Merge branch 'main' into feature/delete_location_note
# Conflicts: # app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt # app/src/main/res/values/strings.xml
9
.gitignore
vendored
@ -7,6 +7,7 @@ build/
|
||||
!*/build/intermediates/
|
||||
local.properties
|
||||
.gradle/
|
||||
.kotlin/
|
||||
captures/
|
||||
.externalNativeBuild/
|
||||
debug_keystore/
|
||||
@ -40,6 +41,11 @@ dependency-reduced-pom.xml
|
||||
# Linters
|
||||
.lint/
|
||||
|
||||
# Python test tooling
|
||||
**/__pycache__/
|
||||
*.py[cod]
|
||||
release-gate-results/
|
||||
|
||||
# Other
|
||||
*.log
|
||||
.cxx/
|
||||
@ -63,3 +69,6 @@ google-services.json
|
||||
# Arti build artifacts (cloned repo and Rust build cache)
|
||||
tools/arti-build/.arti-source/
|
||||
tools/arti-build/target/
|
||||
|
||||
# JVM heap dumps (a Gradle daemon OOM drops these in the repo root)
|
||||
*.hprof
|
||||
|
||||
178
CHANGELOG.md
@ -1,178 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
## [1.4.0] - 2025-10-15
|
||||
### Fixed
|
||||
- fix: Resolve debug settings bottom sheet crash on some devices (Issue #472)
|
||||
- Fixed IllegalFormatConversionException in DebugSettingsSheet.kt when scrolling through debug settings
|
||||
- Corrected string formatting for debug_target_fpr_fmt and debug_derived_p_fmt string resources
|
||||
- Improved string resource parameter handling for numeric values
|
||||
|
||||
## [0.7.2] - 2025-07-20
|
||||
### Fixed
|
||||
- fix: battery optimization screen content scrollable with fixed buttons
|
||||
|
||||
## [0.7.1] - 2025-07-19
|
||||
|
||||
### Added
|
||||
- feat(battery): add battery optimization management for background reliability
|
||||
|
||||
### Fixed
|
||||
- fix: center align toolbar item in ChatHeader - passed modifier.fillmaxHeight so the content inside the row can actually be centered
|
||||
- fix: update sidebar text to use string resources
|
||||
- fix(chat): cursor location and enhance message input with slash command styling
|
||||
|
||||
### Changed
|
||||
- refactor: remove context attribute at ChatViewModel.kt
|
||||
- Refactor: Migrate MainViewModel to use StateFlow
|
||||
|
||||
### Improved
|
||||
- Use HorizontalDivider instead of deprecated Divider
|
||||
- Use contentPadding instead of padding so items remain fully visible
|
||||
|
||||
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.7]
|
||||
|
||||
### Added
|
||||
- Location services check during app startup with educational UI
|
||||
- Message text selection functionality in chat interface
|
||||
- Enhanced RSSI tracking and unread message indicators
|
||||
- Major Bluetooth connection architecture refactoring with dedicated managers
|
||||
|
||||
### Fixed
|
||||
- **Critical**: Android-iOS message fragmentation compatibility issues
|
||||
- Fixed fragment size (500→150 bytes) and ID generation for cross-platform messaging
|
||||
- Ensures Android can properly communicate with iOS devices
|
||||
- DirectMessage notifications and text copying functionality
|
||||
- Smart routing optimizations (no relay loops, targeted delivery)
|
||||
- Build system compilation issues and null pointer exceptions
|
||||
|
||||
### Changed
|
||||
- Comprehensive dependency updates (AGP 8.10.1, Kotlin 2.2.0, Compose 2025.06.01)
|
||||
- Optimized BLE scan intervals for better battery performance
|
||||
- Reduced excessive logging output
|
||||
|
||||
### Improved
|
||||
- Cross-platform compatibility with iOS and Rust implementations
|
||||
- Connection stability through architectural improvements
|
||||
- Battery performance via scan duty cycling
|
||||
- User onboarding with location services education
|
||||
|
||||
## [0.6]
|
||||
|
||||
### Added
|
||||
- Channel password management with `/pass` command for channel owners
|
||||
- Monochrome/themed launcher icon for Android 12+ dynamic theming support
|
||||
- Unit tests package with initial testing infrastructure
|
||||
- Production build optimization with code minification and shrinking
|
||||
- Native back gesture/button handling for all app views
|
||||
|
||||
### Fixed
|
||||
- Favorite peer functionality completely restored and improved
|
||||
- Enhanced favorite system with fallback mechanism for peers without key exchange
|
||||
- Fixed UI state updates for favorite stars in both header and sidebar
|
||||
- Improved favorite persistence across app sessions
|
||||
- `/w` command now displays user nicknames instead of peer IDs
|
||||
- Button styling and layout improvements across the app
|
||||
- Enhanced back button positioning and styling
|
||||
- Improved private chat and channel header button layouts
|
||||
- Fixed button padding and alignment issues
|
||||
- Color scheme consistency updates
|
||||
- Updated orange color throughout the app to match iOS version
|
||||
- Consistent color usage for private messages and UI elements
|
||||
- App startup reliability improvements
|
||||
- Better initialization sequence handling
|
||||
- Fixed null pointer exceptions during startup
|
||||
- Enhanced error handling and logging
|
||||
- Input field styling and behavior improvements
|
||||
- Sidebar user interaction enhancements
|
||||
- Permission explanation screen layout fixes with proper vertical padding
|
||||
|
||||
### Changed
|
||||
- Updated GitHub organization references in project files
|
||||
- Improved README documentation with updated clone URLs
|
||||
- Enhanced logging throughout the application for better debugging
|
||||
|
||||
## [0.5.1] - 2025-07-10
|
||||
|
||||
### Added
|
||||
- Bluetooth startup check with user prompt to enable Bluetooth if disabled
|
||||
|
||||
### Fixed
|
||||
- Improved Bluetooth initialization reliability on first app launch
|
||||
|
||||
## [0.5] - 2025-07-10
|
||||
|
||||
### Added
|
||||
- New user onboarding screen with permission explanations
|
||||
- Educational content explaining why each permission is required
|
||||
- Privacy assurance messaging (no tracking, no servers, local-only data)
|
||||
|
||||
### Fixed
|
||||
- Comprehensive permission validation - ensures all required permissions are granted
|
||||
- Proper Bluetooth stack initialization on first app load
|
||||
- Eliminated need for manual app restart after installation
|
||||
- Enhanced permission request coordination and error handling
|
||||
|
||||
### Changed
|
||||
- Improved first-time user experience with guided setup flow
|
||||
|
||||
## [0.4] - 2025-07-10
|
||||
|
||||
### Added
|
||||
- Push notifications for direct messages
|
||||
- Enhanced notification system with proper click handling and grouping
|
||||
|
||||
### Improved
|
||||
- Direct message (DM) view with better user interface
|
||||
- Enhanced private messaging experience
|
||||
|
||||
### Known Issues
|
||||
- Favorite peer functionality currently broken
|
||||
|
||||
## [0.3] - 2025-07-09
|
||||
|
||||
### Added
|
||||
- Battery-aware scanning policies for improved power management
|
||||
- Dynamic scan behavior based on device battery state
|
||||
|
||||
### Fixed
|
||||
- Android-to-Android Bluetooth Low Energy connections
|
||||
- Peer discovery reliability between Android devices
|
||||
- Connection stability improvements
|
||||
|
||||
## [0.2] - 2025-07-09
|
||||
|
||||
### Added
|
||||
- Initial Android implementation of bitchat protocol
|
||||
- Bluetooth Low Energy mesh networking
|
||||
- End-to-end encryption for private messages
|
||||
- Channel-based messaging with password protection
|
||||
- Store-and-forward message delivery
|
||||
- IRC-style commands (/msg, /join, /clear, etc.)
|
||||
- RSSI-based signal quality indicators
|
||||
|
||||
### Fixed
|
||||
- Various Bluetooth handling improvements
|
||||
- User interface refinements
|
||||
- Connection reliability enhancements
|
||||
|
||||
## [0.1] - 2025-07-08
|
||||
|
||||
### Added
|
||||
- Initial release of bitchat Android client
|
||||
- Basic mesh networking functionality
|
||||
- Core messaging features
|
||||
- Protocol compatibility with iOS bitchat client
|
||||
|
||||
[Unreleased]: https://github.com/permissionlesstech/bitchat-android/compare/0.5.1...HEAD
|
||||
[0.5.1]: https://github.com/permissionlesstech/bitchat-android/compare/0.5...0.5.1
|
||||
[0.5]: https://github.com/permissionlesstech/bitchat-android/compare/0.4...0.5
|
||||
[0.4]: https://github.com/permissionlesstech/bitchat-android/compare/0.3...0.4
|
||||
[0.3]: https://github.com/permissionlesstech/bitchat-android/compare/0.2...0.3
|
||||
[0.2]: https://github.com/permissionlesstech/bitchat-android/compare/0.1...0.2
|
||||
[0.1]: https://github.com/permissionlesstech/bitchat-android/releases/tag/0.1
|
||||
@ -1 +0,0 @@
|
||||
I, callebtc, creator of bitchat for android, owner of the copyright claims, and owner of the official bitchat android repository (https://github.com/permissionlesstech/bitchat-android), hereby authorize Verse Communication PBC to publish bitchat (com.bitchat.doid) on the Google Play Store.
|
||||
@ -1,156 +0,0 @@
|
||||
# bitchat Privacy Policy
|
||||
|
||||
*Last updated: January 2025*
|
||||
|
||||
## Our Commitment
|
||||
|
||||
bitchat is designed with privacy as its foundation. We believe private communication is a fundamental human right. This policy explains how bitchat protects your privacy.
|
||||
|
||||
## 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.
|
||||
- **No tracking** - We have no analytics, telemetry, or user tracking
|
||||
- **Open source** - You can verify these claims by reading our code
|
||||
|
||||
## What Information bitchat Stores
|
||||
|
||||
### On Your Device Only
|
||||
|
||||
1. **Identity Key**
|
||||
- A cryptographic key generated on first launch
|
||||
- Stored locally in your device's secure storage
|
||||
- Allows you to maintain "favorite" relationships across app restarts
|
||||
- Never leaves your device
|
||||
|
||||
2. **Nickname**
|
||||
- The display name you choose (or auto-generated)
|
||||
- Stored only on your device
|
||||
- Shared with peers you communicate with
|
||||
|
||||
3. **Message History** (if enabled)
|
||||
- When room owners enable retention, messages are saved locally
|
||||
- Stored encrypted on your device
|
||||
- You can delete this at any time
|
||||
|
||||
4. **Favorite Peers**
|
||||
- Public keys of peers you mark as favorites
|
||||
- Stored only on your device
|
||||
- Allows you to recognize these peers in future sessions
|
||||
|
||||
### Temporary Session Data
|
||||
|
||||
During each session, bitchat temporarily maintains:
|
||||
- Active peer connections (forgotten when app closes)
|
||||
- Routing information for message delivery
|
||||
- Cached messages for offline peers (12 hours max)
|
||||
|
||||
## What Information is Shared
|
||||
|
||||
### With Other bitchat Users
|
||||
|
||||
When you use bitchat, nearby peers can see:
|
||||
- Your chosen nickname
|
||||
- Your ephemeral public key (changes each session)
|
||||
- Messages you send to public rooms or directly to them
|
||||
- Your approximate Bluetooth signal strength (for connection quality)
|
||||
|
||||
### With Room Members
|
||||
|
||||
When you join a password-protected room:
|
||||
- Your messages are visible to others with the password
|
||||
- Your nickname appears in the member list
|
||||
- Room owners can see you've joined
|
||||
|
||||
## What We DON'T Do
|
||||
|
||||
bitchat **never**:
|
||||
- Collects personal information
|
||||
- Collects location history
|
||||
- Transmits any data to us (the developers)
|
||||
- Stores data on servers
|
||||
- Shares data with third parties
|
||||
- Uses analytics or telemetry
|
||||
- Creates user profiles
|
||||
- Requires registration
|
||||
|
||||
## Encryption
|
||||
|
||||
All private messages use end-to-end encryption:
|
||||
- **X25519** for key exchange
|
||||
- **AES-256-GCM** for message encryption
|
||||
- **Ed25519** for digital signatures
|
||||
- **Argon2id** for password-protected rooms
|
||||
|
||||
## Your Rights
|
||||
|
||||
You have complete control:
|
||||
- **Delete Everything**: Triple-tap the logo to instantly wipe all data
|
||||
- **Leave Anytime**: Close the app and your presence disappears
|
||||
- **No Account**: Nothing to delete from servers because there are none
|
||||
- **Portability**: Your data never leaves your device unless you export it
|
||||
|
||||
## Location Data & Permissions
|
||||
|
||||
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
|
||||
|
||||
bitchat does not knowingly collect information from children. The app has no age verification because it collects no personal information from anyone.
|
||||
|
||||
## Data Retention
|
||||
|
||||
- **Messages**: Deleted from memory when app closes (unless room retention is enabled)
|
||||
- **Identity Key**: Persists until you delete the app
|
||||
- **Favorites**: Persist until you remove them or delete the app
|
||||
- **Everything Else**: Exists only during active sessions
|
||||
|
||||
## Security Measures
|
||||
|
||||
- All communication is encrypted
|
||||
- No data transmitted to servers (there are none)
|
||||
- Open source code for public audit
|
||||
- Regular security updates
|
||||
- Cryptographic signatures prevent tampering
|
||||
|
||||
## Changes to This Policy
|
||||
|
||||
If we update this policy:
|
||||
- The "Last updated" date will change
|
||||
- The updated policy will be included in the app
|
||||
- No retroactive changes can affect data (since we don't collect any)
|
||||
|
||||
## Contact
|
||||
|
||||
bitchat is an open source project. For privacy questions:
|
||||
- Review our code: https://github.com/yourusername/bitchat
|
||||
- Open an issue on GitHub
|
||||
- Join the discussion in public rooms
|
||||
|
||||
## Philosophy
|
||||
|
||||
Privacy isn't just a feature—it's the entire point. bitchat proves that modern communication doesn't require surrendering your privacy. No accounts, no servers, no surveillance. Just people talking freely.
|
||||
|
||||
---
|
||||
|
||||
*This policy is released into the public domain under The Unlicense, just like bitchat itself.*
|
||||
328
README.md
@ -1,308 +1,86 @@
|
||||
<p align="center">
|
||||
<img src="https://github.com/user-attachments/assets/188c42f8-d249-4a72-b27a-e2b4f10a00a8" alt="Bitchat Android Logo" width="480">
|
||||
</p>
|
||||
<img width="256" height="256" alt="icon_128x128@2x" src="https://github.com/user-attachments/assets/90133f83-b4f6-41c6-aab9-25d0859d2a47" />
|
||||
|
||||
> [!WARNING]
|
||||
> This software has not received external security review and may contain vulnerabilities and may not necessarily meet its stated security goals. Do not use it for sensitive use cases, and do not rely on its security until it has been reviewed. Work in progress.
|
||||
## bitchat for Android
|
||||
|
||||
# bitchat for Android
|
||||
A decentralized peer-to-peer messaging app with dual transport architecture: local Bluetooth mesh networks for offline communication and internet-based Nostr protocol for global reach. No accounts, no phone numbers, no central servers.
|
||||
|
||||
A secure, decentralized, peer-to-peer messaging app that works over Bluetooth mesh networks. No internet required for mesh chats, no servers, no phone numbers - just pure encrypted communication. Bitchat also supports geohash channels, which use an internet connection to connect you with others in your geographic area.
|
||||
This is the Android implementation of bitchat, fully protocol-compatible with the [iOS version](https://github.com/permissionlesstech/bitchat) for cross-platform mesh communication.
|
||||
|
||||
This is the **Android port** of the original [bitchat iOS app](https://github.com/jackjackbits/bitchat), maintaining 100% protocol compatibility for cross-platform communication.
|
||||
[bitchat.free](http://bitchat.free)
|
||||
|
||||
## Install bitchat
|
||||
|
||||
You can download the latest version of bitchat for Android from the [GitHub Releases page](https://github.com/permissionlesstech/bitchat-android/releases).
|
||||
|
||||
Or you can:
|
||||
[GitHub Releases](https://github.com/permissionlesstech/bitchat-android/releases)
|
||||
|
||||
[<img alt="Get it on Google Play" height="60" src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png"/>](https://play.google.com/store/apps/details?id=com.bitchat.droid)
|
||||
|
||||
**Instructions:**
|
||||
|
||||
1. **Download the APK:** On your Android device, navigate to the link above and download the latest `.apk` file. Open it.
|
||||
2. **Allow Unknown Sources:** On some devices, before you can install the APK, you may need to enable "Install from unknown sources" in your device's settings. This is typically found under **Settings > Security** or **Settings > Apps & notifications > Special app access**.
|
||||
3. **Install:** Open the downloaded `.apk` file to begin the installation.
|
||||
|
||||
## License
|
||||
|
||||
This project is released into the public domain. See the [LICENSE](LICENSE.md) file for details.
|
||||
|
||||
## Features
|
||||
|
||||
- **✅ Cross-Platform Compatible**: Full protocol compatibility with iOS bitchat
|
||||
- **✅ Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
|
||||
- **✅ End-to-End Encryption**: X25519 key exchange + AES-256-GCM for private messages
|
||||
- **✅ Channel-Based Chats**: Topic-based group messaging with optional password protection
|
||||
- **✅ Store & Forward**: Messages cached for offline peers and delivered when they reconnect
|
||||
- **✅ Privacy First**: No accounts, no phone numbers, no persistent identifiers
|
||||
- **✅ IRC-Style Commands**: Familiar `/join`, `/msg`, `/who` style interface
|
||||
- **✅ Message Retention**: Optional channel-wide message saving controlled by channel owners
|
||||
- **✅ Emergency Wipe**: Triple-tap logo to instantly clear all data
|
||||
- **✅ Modern Android UI**: Jetpack Compose with Material Design 3
|
||||
- **✅ Dark/Light Themes**: Terminal-inspired aesthetic matching iOS version
|
||||
- **✅ Battery Optimization**: Adaptive scanning and power management
|
||||
|
||||
## Android Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Android Studio**: Arctic Fox (2020.3.1) or newer
|
||||
- **Android SDK**: API level 26 (Android 8.0) or higher
|
||||
- **Kotlin**: 1.8.0 or newer
|
||||
- **Gradle**: 7.0 or newer
|
||||
|
||||
### Build Instructions
|
||||
|
||||
1. **Clone the repository:**
|
||||
```bash
|
||||
git clone https://github.com/permissionlesstech/bitchat-android.git
|
||||
cd bitchat-android
|
||||
```
|
||||
|
||||
2. **Open in Android Studio:**
|
||||
```bash
|
||||
# Open Android Studio and select "Open an Existing Project"
|
||||
# Navigate to the bitchat-android directory
|
||||
```
|
||||
|
||||
3. **Build the project:**
|
||||
```bash
|
||||
./gradlew build
|
||||
```
|
||||
|
||||
4. **Install on device:**
|
||||
```bash
|
||||
./gradlew installDebug
|
||||
```
|
||||
|
||||
### Development Build
|
||||
|
||||
For development builds with debugging enabled:
|
||||
|
||||
```bash
|
||||
./gradlew assembleDebug
|
||||
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
### Release Build
|
||||
|
||||
For production releases:
|
||||
|
||||
```bash
|
||||
./gradlew assembleRelease
|
||||
```
|
||||
|
||||
## Android-Specific Requirements
|
||||
|
||||
### Permissions
|
||||
|
||||
The app requires the following permissions (automatically requested):
|
||||
|
||||
- **Bluetooth**: Core BLE functionality
|
||||
- **Location**: Required for BLE scanning on Android
|
||||
- **Network**: Expand your mesh through public internet relays
|
||||
- **Notifications**: Message alerts and background updates
|
||||
|
||||
### Hardware Requirements
|
||||
|
||||
- **Bluetooth LE (BLE)**: Required for mesh networking
|
||||
- **Android 8.0+**: API level 26 minimum
|
||||
- **RAM**: 2GB recommended for optimal performance
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Commands
|
||||
|
||||
- `/j #channel` - Join or create a channel
|
||||
- `/m @name message` - Send a private message
|
||||
- `/w` - List online users
|
||||
- `/channels` - Show all discovered channels
|
||||
- `/block @name` - Block a peer from messaging you
|
||||
- `/block` - List all blocked peers
|
||||
- `/unblock @name` - Unblock a peer
|
||||
- `/clear` - Clear chat messages
|
||||
- `/pass [password]` - Set/change channel password (owner only)
|
||||
- `/transfer @name` - Transfer channel ownership
|
||||
- `/save` - Toggle message retention for channel (owner only)
|
||||
|
||||
### Getting Started
|
||||
|
||||
1. **Install the app** on your Android device (requires Android 8.0+)
|
||||
2. **Grant permissions** for Bluetooth and location when prompted
|
||||
3. **Launch bitchat** - it will auto-start mesh networking
|
||||
4. **Set your nickname** or use the auto-generated one
|
||||
5. **Connect automatically** to nearby iOS and Android bitchat users
|
||||
6. **Join a channel** with `/j #general` or start chatting in public
|
||||
7. **Messages relay** through the mesh network to reach distant peers
|
||||
|
||||
### Android UI Features
|
||||
|
||||
- **Jetpack Compose UI**: Modern Material Design 3 interface
|
||||
- **Dark/Light Themes**: Terminal-inspired aesthetic matching iOS
|
||||
- **Haptic Feedback**: Vibrations for interactions and notifications
|
||||
- **Adaptive Layout**: Optimized for various Android screen sizes
|
||||
- **Message Status**: Real-time delivery and read receipts
|
||||
- **RSSI Indicators**: Signal strength colors for each peer
|
||||
|
||||
### Channel Features
|
||||
|
||||
- **Password Protection**: Channel owners can set passwords with `/pass`
|
||||
- **Message Retention**: Owners can enable mandatory message saving with `/save`
|
||||
- **@ Mentions**: Use `@nickname` to mention users (with autocomplete)
|
||||
- **Ownership Transfer**: Pass control to trusted users with `/transfer`
|
||||
|
||||
## Security & Privacy
|
||||
|
||||
### Encryption
|
||||
- **Private Messages**: X25519 key exchange + AES-256-GCM encryption
|
||||
- **Channel Messages**: Argon2id password derivation + AES-256-GCM
|
||||
- **Digital Signatures**: Ed25519 for message authenticity
|
||||
- **Forward Secrecy**: New key pairs generated each session
|
||||
|
||||
### Privacy Features
|
||||
- **No Registration**: No accounts, emails, or phone numbers required
|
||||
- **Ephemeral by Default**: Messages exist only in device memory
|
||||
- **Cover Traffic**: Random delays and dummy messages prevent traffic analysis
|
||||
- **Emergency Wipe**: Triple-tap logo to instantly clear all data
|
||||
- **Bundled Tor Support**: Built-in Tor network integration for enhanced privacy when internet connectivity is available
|
||||
|
||||
## Performance & Efficiency
|
||||
|
||||
### Message Compression
|
||||
- **LZ4 Compression**: Automatic compression for messages >100 bytes
|
||||
- **30-70% bandwidth savings** on typical text messages
|
||||
- **Smart compression**: Skips already-compressed data
|
||||
|
||||
### Battery Optimization
|
||||
- **Adaptive Power Modes**: Automatically adjusts based on battery level
|
||||
- Performance mode: Full features when charging or >60% battery
|
||||
- Balanced mode: Default operation (30-60% battery)
|
||||
- Power saver: Reduced scanning when <30% battery
|
||||
- Ultra-low power: Emergency mode when <10% battery
|
||||
- **Background efficiency**: Automatic power saving when app backgrounded
|
||||
- **Configurable scanning**: Duty cycle adapts to battery state
|
||||
|
||||
### Network Efficiency
|
||||
- **Optimized Bloom filters**: Faster duplicate detection with less memory
|
||||
- **Message aggregation**: Batches small messages to reduce transmissions
|
||||
- **Adaptive connection limits**: Adjusts peer connections based on power mode
|
||||
- **Dual Transport Architecture**: Bluetooth LE mesh for offline messaging, Nostr relays for internet-based messaging
|
||||
- **Location-Based Channels**: Geographic chat rooms using geohash coordinates over Nostr relays
|
||||
- **Intelligent Message Routing**: Automatically chooses the best transport, with queuing and retry when a peer is unreachable
|
||||
- **End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) (XX pattern, X25519 + ChaCha20-Poly1305) for private messages over the mesh
|
||||
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop relay over Bluetooth LE (max 7 hops)
|
||||
- **Wi-Fi Aware Transport**: Higher-bandwidth local mesh on supported devices
|
||||
- **Channel Chats**: Topic-based group messaging with optional password protection (Argon2id + AES-256-GCM)
|
||||
- **IRC-Style Commands**: Familiar `/join`, `/msg`, `/who` style interface
|
||||
- **Tor Support**: Built-in Tor (Arti) for private internet connectivity
|
||||
- **Emergency Wipe**: Triple-tap to instantly clear all data
|
||||
- **Cross-Platform**: Binary protocol compatible with bitchat on iOS and macOS
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Binary Protocol
|
||||
bitchat uses an efficient binary protocol optimized for Bluetooth LE:
|
||||
- Compact packet format with 1-byte type field
|
||||
- TTL-based message routing (max 7 hops)
|
||||
- Automatic fragmentation for large messages
|
||||
- Message deduplication via unique IDs
|
||||
### Bluetooth Mesh Network (Offline)
|
||||
|
||||
### Mesh Networking
|
||||
- Each device acts as both client and peripheral
|
||||
- Automatic peer discovery and connection management
|
||||
- Store-and-forward for offline message delivery
|
||||
- Adaptive duty cycling for battery optimization
|
||||
- Direct peer-to-peer within Bluetooth range, multi-hop relay through nearby devices
|
||||
- Noise Protocol sessions with forward secrecy; peer identities derived from static keys
|
||||
- Compact binary packet format with fragmentation, TTL routing, and deduplication
|
||||
- Adaptive duty cycling and connection limits for battery efficiency
|
||||
- Foreground service keeps the mesh alive within Android background execution limits
|
||||
|
||||
### Android-Specific Optimizations
|
||||
- **Coroutine Architecture**: Asynchronous operations for mesh networking
|
||||
- **Kotlin Coroutines**: Thread-safe concurrent mesh operations
|
||||
- **EncryptedSharedPreferences**: Secure storage for user settings
|
||||
- **Lifecycle-Aware**: Proper handling of Android app lifecycle
|
||||
- **Battery Optimization**: Foreground service and adaptive scanning
|
||||
### Nostr Protocol (Internet)
|
||||
|
||||
## Android Technical Architecture
|
||||
- Global reach via public relays, geohash-based location channels
|
||||
- Private messages fall back to Nostr for mutual favorites when the mesh is unavailable
|
||||
- Ephemeral keys per geohash area
|
||||
|
||||
### Core Components
|
||||
### Android Stack
|
||||
|
||||
1. **BitchatApplication.kt**: Application-level initialization and dependency injection
|
||||
2. **MainActivity.kt**: Main activity handling permissions and UI hosting
|
||||
3. **ChatViewModel.kt**: MVVM pattern managing app state and business logic
|
||||
4. **BluetoothMeshService.kt**: Core BLE mesh networking (central + peripheral roles)
|
||||
5. **EncryptionService.kt**: Cryptographic operations using BouncyCastle
|
||||
6. **BinaryProtocol.kt**: Binary packet encoding/decoding matching iOS format
|
||||
7. **ChatScreen.kt**: Jetpack Compose UI with Material Design 3
|
||||
- Kotlin, Jetpack Compose (Material 3), MVVM
|
||||
- Coroutines and Flow for all networking and state
|
||||
- Core components: `MeshForegroundService` (persistent connectivity), `BluetoothMeshService` / `WifiAwareMeshService` (transports), `UnifiedMeshService` (transport selection), `NoiseSessionManager` (encryption sessions), `MessageRouter` (mesh/Nostr routing with outbox retry)
|
||||
|
||||
### Dependencies
|
||||
## Building
|
||||
|
||||
- **Jetpack Compose**: Modern declarative UI
|
||||
- **BouncyCastle**: Cryptographic operations (X25519, Ed25519, AES-GCM)
|
||||
- **Nordic BLE Library**: Reliable Bluetooth LE operations
|
||||
- **Kotlin Coroutines**: Asynchronous programming
|
||||
- **LZ4**: Message compression (when enabled)
|
||||
- **EncryptedSharedPreferences**: Secure local storage
|
||||
Requires Android Studio and the Android SDK (API 26+).
|
||||
|
||||
### Binary Protocol Compatibility
|
||||
```bash
|
||||
git clone https://github.com/permissionlesstech/bitchat-android.git
|
||||
cd bitchat-android
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
The Android implementation maintains 100% binary protocol compatibility with iOS:
|
||||
- **Header Format**: Identical 13-byte header structure
|
||||
- **Packet Types**: Same message types and routing logic
|
||||
- **Encryption**: Identical cryptographic algorithms and key exchange
|
||||
- **UUIDs**: Same Bluetooth service and characteristic identifiers
|
||||
- **Fragmentation**: Compatible message fragmentation for large content
|
||||
Install on a connected device:
|
||||
|
||||
## Publishing to Google Play
|
||||
```bash
|
||||
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
### Preparation
|
||||
The app requests Bluetooth, location (required for BLE scanning), and notification permissions at runtime.
|
||||
|
||||
1. **Update version information:**
|
||||
```kotlin
|
||||
// In app/build.gradle.kts
|
||||
defaultConfig {
|
||||
versionCode = 2 // Increment for each release
|
||||
versionName = "1.1.0" // User-visible version
|
||||
}
|
||||
```
|
||||
## Testing
|
||||
|
||||
2. **Create a signed release build:**
|
||||
```bash
|
||||
./gradlew assembleRelease
|
||||
```
|
||||
```bash
|
||||
# Unit tests
|
||||
./gradlew test
|
||||
|
||||
3. **Generate app bundle (recommended for Play Store):**
|
||||
```bash
|
||||
./gradlew bundleRelease
|
||||
```
|
||||
# Lint
|
||||
./gradlew lint
|
||||
|
||||
### Play Store Requirements
|
||||
# Instrumented tests (requires a device or emulator)
|
||||
./gradlew connectedAndroidTest
|
||||
```
|
||||
|
||||
- **Target API**: Latest Android API (currently 34)
|
||||
- **Privacy Policy**: Required for apps requesting sensitive permissions
|
||||
- **App Permissions**: Justify Bluetooth and location usage
|
||||
- **Content Rating**: Complete questionnaire for age-appropriate content
|
||||
|
||||
### Distribution
|
||||
|
||||
- **Google Play Store**: Main distribution channel
|
||||
- **F-Droid**: For open-source distribution
|
||||
- **Direct APK**: For testing and development
|
||||
|
||||
## Cross-Platform Communication
|
||||
|
||||
This Android port enables seamless communication with the original iOS bitchat app:
|
||||
|
||||
- **iPhone ↔ Android**: Full bidirectional messaging
|
||||
- **Mixed Groups**: iOS and Android users in same channels
|
||||
- **Feature Parity**: All commands and encryption work across platforms
|
||||
- **Protocol Sync**: Identical message format and routing behavior
|
||||
|
||||
**iOS Version**: For iPhone/iPad users, get the original bitchat at [github.com/jackjackbits/bitchat](https://github.com/jackjackbits/bitchat)
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Key areas for enhancement:
|
||||
|
||||
1. **Performance**: Battery optimization and connection reliability
|
||||
2. **UI/UX**: Additional Material Design 3 features
|
||||
3. **Security**: Enhanced cryptographic features
|
||||
4. **Testing**: Unit and integration test coverage
|
||||
5. **Documentation**: API documentation and development guides
|
||||
|
||||
## Support & Issues
|
||||
|
||||
- **Bug Reports**: [Create an issue](../../issues) with device info and logs
|
||||
- **Feature Requests**: [Start a discussion](https://github.com/orgs/permissionlesstech/discussions)
|
||||
- **Security Issues**: Email security concerns privately
|
||||
- **iOS Compatibility**: Cross-reference with [original iOS repo](https://github.com/jackjackbits/bitchat)
|
||||
|
||||
For iOS-specific issues, please refer to the [original iOS bitchat repository](https://github.com/jackjackbits/bitchat).
|
||||
Note that BLE mesh behavior is difficult to emulate; protocol and session logic is covered by unit tests, while radio-level behavior needs real devices.
|
||||
|
||||
6
app/src/main/assets/design-icons/README.md
Normal file
@ -0,0 +1,6 @@
|
||||
# Design-spec icons
|
||||
|
||||
These standalone SVGs were extracted from the supplied 393 px Figma screen exports. The matching
|
||||
Android vector resources in `res/drawable/ic_spec_*.xml` are the runtime copies used by Compose.
|
||||
Paths and stroke weights remain faithful to the exports; UI tint and opacity are applied at the
|
||||
call site so selected and disabled states remain theme-aware.
|
||||
3
app/src/main/assets/design-icons/bookmark-filled.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 15L8 11.267L13 15V2.4C13 2.029 12.856 1.673 12.601 1.41C12.345 1.147 11.998 1 11.636 1H4.364C4.002 1 3.655 1.147 3.399 1.41C3.144 1.673 3 2.029 3 2.4V15Z" fill="currentColor" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 370 B |
3
app/src/main/assets/design-icons/bookmark-outline.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 15L8 11.267L13 15V2.4C13 2.029 12.856 1.673 12.601 1.41C12.345 1.147 11.998 1 11.636 1H4.364C4.002 1 3.655 1.147 3.399 1.41C3.144 1.673 3 2.029 3 2.4V15Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 362 B |
6
app/src/main/assets/design-icons/chat-bubbles.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(-16 -370)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M28.5 370.625H18.5C18.0027 370.625 17.5258 370.823 17.1742 371.174C16.8225 371.526 16.625 372.003 16.625 372.5V378.75C16.625 379.247 16.8225 379.724 17.1742 380.076C17.5258 380.427 18.0027 380.625 18.5 380.625H20.375V384.375L24.125 380.625H28.5C28.9973 380.625 29.4742 380.427 29.8258 380.076C30.1775 379.724 30.375 379.247 30.375 378.75V372.5C30.375 372.003 30.1775 371.526 29.8258 371.174C29.4742 370.823 28.9973 370.625 28.5 370.625Z"/>
|
||||
<path d="M24.125 385.625H27.875L31.625 389.375V385.625H33.5C33.9973 385.625 34.4742 385.427 34.8258 385.076C35.1775 384.724 35.375 384.247 35.375 383.75V377.5C35.375 377.003 35.1775 376.526 34.8258 376.174C34.4742 375.823 33.9973 375.625 33.5 375.625H32.875"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 955 B |
6
app/src/main/assets/design-icons/command.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(-16 -407)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M26.625 422.875H35.375"/>
|
||||
<path d="M16.625 407.875L24.125 415.375L16.625 422.875"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 338 B |
6
app/src/main/assets/design-icons/eye-off.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(-16 -682)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16.625 692C16.625 692 20.375 685.125 26 685.125C31.625 685.125 35.375 692 35.375 692C35.375 692 31.625 698.875 26 698.875C20.375 698.875 16.625 692 16.625 692Z"/>
|
||||
<path d="M22.25 692C22.25 689.929 23.9288 688.25 26 688.25M29.75 692C29.75 694.071 28.0712 695.75 26 695.75M17.25 700.75L34.75 683.25"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 555 B |
7
app/src/main/assets/design-icons/globe.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(-16 -278)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M25.9999 297.375C28.2187 297.375 30.0174 293.178 30.0174 288C30.0174 282.822 28.2187 278.625 25.9999 278.625C23.7811 278.625 21.9824 282.822 21.9824 288C21.9824 293.178 23.7811 297.375 25.9999 297.375Z"/>
|
||||
<path d="M16.625 288H35.375"/>
|
||||
<path d="M26 297.375C31.1777 297.375 35.375 293.178 35.375 288C35.375 282.822 31.1777 278.625 26 278.625C20.8223 278.625 16.625 282.822 16.625 288C16.625 293.178 20.8223 297.375 26 297.375Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 687 B |
7
app/src/main/assets/design-icons/lock.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(-16 -310)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M32.25 329.375H19.75C18.715 329.375 17.875 328.535 17.875 327.5V321.25C17.875 320.215 18.715 319.375 19.75 319.375H32.25C33.285 319.375 34.125 320.215 34.125 321.25V327.5C34.125 328.535 33.285 329.375 32.25 329.375Z"/>
|
||||
<path d="M21.625 316.875V315C21.625 312.584 23.5838 310.625 26 310.625C28.4162 310.625 30.375 312.584 30.375 315V316.875"/>
|
||||
<path d="M26 326.25C27.0355 326.25 27.875 325.411 27.875 324.375C27.875 323.339 27.0355 322.5 26 322.5C24.9645 322.5 24.125 323.339 24.125 324.375C24.125 325.411 24.9645 326.25 26 326.25Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 792 B |
6
app/src/main/assets/design-icons/mention.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(-16 -582)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M29.5236 587.888V594.4C29.5236 596.41 32.5761 596.856 34.1749 594.139C35.5299 591.84 35.1974 588.334 33.5049 586.025C31.0149 582.629 25.2574 581.359 21.0736 584.166C17.2311 586.746 15.8624 591.968 17.9861 596.188C20.0874 600.364 25.0199 602.386 29.4861 600.876"/>
|
||||
<path d="M25.8485 595.785C27.8698 595.785 29.5085 594.032 29.5085 591.87C29.5085 589.708 27.8698 587.955 25.8485 587.955C23.8271 587.955 22.1885 589.708 22.1885 591.87C22.1885 594.032 23.8271 595.785 25.8485 595.785Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 738 B |
5
app/src/main/assets/design-icons/on-location-person.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 15.5H14" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 12.5V8.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="8" cy="4.5" r="4" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 395 B |
3
app/src/main/assets/design-icons/panic.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path transform="translate(-16 -786)" d="M17.875 805.375H22.875M20.375 786.625V789.125M20.375 789.125L27.875 796.625M16.625 790.375L22.875 796.625M30.375 789.125L35.375 794.125M25.375 789.125L32.875 796.625M35.375 789.125H16.625V796.625H35.375V789.125ZM20.375 805.375V799.125M29.125 805.375H34.125M31.625 786.625V789.125M31.625 805.375V799.125" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 541 B |
6
app/src/main/assets/design-icons/people.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(-16 -314)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M28.2187 328.116L25.14 327.241C25.0239 327.208 24.9198 327.142 24.8404 327.051C24.761 326.96 24.7096 326.848 24.6925 326.729L24.4338 324.907C25.0869 324.609 25.6407 324.129 26.0291 323.525C26.4176 322.921 26.6244 322.218 26.625 321.5V319.782C26.6402 318.788 26.2704 317.826 25.593 317.098C24.9156 316.37 23.9829 315.932 22.99 315.875C22.488 315.86 21.9879 315.945 21.5196 316.127C21.0513 316.308 20.6242 316.582 20.2637 316.932C19.9032 317.282 19.6166 317.7 19.421 318.163C19.2254 318.625 19.1248 319.123 19.125 319.625V321.5C19.1256 322.218 19.3324 322.921 19.7209 323.525C20.1093 324.129 20.6631 324.609 21.3162 324.907L21.0575 326.724C21.0404 326.843 20.989 326.955 20.9096 327.046C20.8302 327.137 20.7261 327.203 20.61 327.236L17.5313 328.111C17.2702 328.186 17.0406 328.343 16.8771 328.56C16.7136 328.777 16.6251 329.041 16.625 329.312V332.125H29.125V329.317C29.1249 329.046 29.0364 328.782 28.8729 328.565C28.7094 328.348 28.4798 328.191 28.2187 328.116Z"/>
|
||||
<path d="M31.625 332.125H35.375V328.101C35.375 327.823 35.2819 327.552 35.1104 327.332C34.939 327.113 34.6991 326.956 34.4288 326.889L30.7825 325.977C30.6618 325.947 30.5528 325.882 30.4695 325.789C30.3863 325.697 30.3324 325.582 30.315 325.459L30.0587 323.657C30.7119 323.359 31.2657 322.879 31.6541 322.275C32.0426 321.671 32.2494 320.968 32.25 320.25V318.532C32.2652 317.538 31.8954 316.576 31.218 315.848C30.5406 315.12 29.6079 314.682 28.615 314.625C27.9181 314.603 27.229 314.777 26.625 315.125"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
6
app/src/main/assets/design-icons/person.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(-16 -242)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M27.725 252.271L28.1875 255.125H31.48C32.3075 255.125 33.0375 255.669 33.2763 256.461L34.75 261.375H17.25L18.7237 256.461C18.9612 255.669 19.6912 255.125 20.52 255.125H23.8125L24.275 252.271"/>
|
||||
<path d="M30.375 247C30.375 244.584 28.4162 242.625 26 242.625C23.5838 242.625 21.625 244.584 21.625 247V248.25C21.625 250.666 23.5838 252.625 26 252.625C28.4162 252.625 30.375 250.666 30.375 248.25V247Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 655 B |
6
app/src/main/assets/design-icons/range.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(-16 -378)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M25.375 386.125C26.0654 386.125 26.625 385.565 26.625 384.875C26.625 384.185 26.0654 383.625 25.375 383.625C24.6846 383.625 24.125 384.185 24.125 384.875C24.125 385.565 24.6846 386.125 25.375 386.125Z"/>
|
||||
<path d="M25.375 397.375V386.125M21.625 397.375H29.125M28.9102 388.41C29.8475 387.472 30.3741 386.201 30.3741 384.875C30.3741 383.549 29.8475 382.278 28.9102 381.34M31.5625 391.062C32.3751 390.25 33.0197 389.285 33.4595 388.224C33.8993 387.162 34.1256 386.024 34.1256 384.875C34.1256 383.726 33.8993 382.588 33.4595 381.526C33.0197 380.465 32.3751 379.5 31.5625 378.688M21.8399 388.41C20.9026 387.472 20.376 386.201 20.376 384.875C20.376 383.549 20.9026 382.278 21.8399 381.34M19.1876 391.062C18.375 390.25 17.7304 389.285 17.2907 388.224C16.8509 387.162 16.6245 386.024 16.6245 384.875C16.6245 383.726 16.8509 382.588 17.2907 381.526C17.7304 380.465 18.375 379.5 19.1876 378.688"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
3
app/src/main/assets/design-icons/shuffle.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path transform="translate(-16 -734)" d="M16.625 749.625H16.88C18.3088 749.625 19.6688 749.014 20.6175 747.946L27.6338 740.053C28.5825 738.985 29.9425 738.374 31.3712 738.374H35.375M31.625 734.625L35.375 738.375L31.625 742.125M26.3477 746.5L27.6339 747.946C28.5827 749.014 29.9427 749.625 31.3714 749.625H35.3752M16.625 738.375H16.88C18.3088 738.375 19.6688 738.986 20.6175 740.054L21.9025 741.5M31.625 753.375L35.375 749.625L31.625 745.875" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 638 B |
3
app/src/main/assets/design-icons/star-filled.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path transform="translate(-16 -446)" d="M26 446.925L28.1637 453.836H35.375L29.645 458.143L31.8975 465.075L26 460.79L20.1025 465.075L22.355 458.143L16.625 453.836H23.8363L26 446.925Z" fill="currentColor" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 388 B |
3
app/src/main/assets/design-icons/star.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path transform="translate(-16 -446)" d="M26 446.925L28.1637 453.836H35.375L29.645 458.143L31.8975 465.075L26 460.79L20.1025 465.075L22.355 458.143L16.625 453.836H23.8363L26 446.925Z" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 380 B |
5
app/src/main/assets/design-icons/teleport.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="8" cy="8" r="7.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="3 3"/>
|
||||
<circle cx="5" cy="6" r="1" fill="currentColor"/><circle cx="11" cy="6" r="1" fill="currentColor"/>
|
||||
<path d="M5.5 10.5H10.5" stroke="currentColor" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 405 B |
3
app/src/main/assets/design-icons/waveform.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 5H2.5V12.5H0ZM2.5 2.5H5V15H2.5ZM5 0H7.5V15H5ZM7.5 0H10V17.5H7.5ZM10 0H12.5V17.5H10ZM12.5 0H15V20H12.5ZM15 2.5H17.5V15H15ZM17.5 5H20V12.5H17.5Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 271 B |
8
app/src/main/assets/design-icons/wifi-off.svg
Normal file
@ -0,0 +1,8 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(-16 -242)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21.1387 252.139C22.4278 250.851 24.1757 250.127 25.998 250.127C27.8204 250.127 29.5683 250.851 30.8574 252.139"/>
|
||||
<path d="M27.25 255.601C27.4831 255.81 27.6603 256.073 27.7656 256.368C27.8708 256.662 27.9008 256.978 27.8527 257.287C27.8046 257.596 27.68 257.888 27.4902 258.137C27.3004 258.385 27.0514 258.582 26.766 258.71C26.4806 258.838 26.1677 258.892 25.856 258.868C25.5442 258.844 25.2433 258.743 24.9809 258.573C24.7184 258.403 24.5026 258.17 24.3531 257.895C24.2036 257.62 24.1252 257.313 24.125 257"/>
|
||||
<path d="M17.6025 248.604C19.8294 246.378 22.8493 245.127 25.9982 245.127C29.147 245.127 32.1669 246.378 34.3938 248.604"/>
|
||||
<path d="M34.125 243.875L19.125 258.875"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 942 B |
@ -13,6 +13,9 @@ class BitchatApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// Start the single process-wide power policy before transport components are constructed.
|
||||
com.bitchat.android.mesh.PowerManager.getInstance(this).start()
|
||||
|
||||
// Initialize Tor first so any early network goes over Tor
|
||||
try {
|
||||
val torProvider = ArtiTorManager.getInstance()
|
||||
@ -53,6 +56,10 @@ class BitchatApplication : Application() {
|
||||
com.bitchat.android.nostr.GeohashConversationRegistry.initialize(this)
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// Own relay connectivity, selected-channel subscriptions, and presence scheduling at the
|
||||
// process level so closing the Activity does not disconnect Nostr.
|
||||
try { com.bitchat.android.nostr.NostrBackgroundRuntime.initialize(this) } catch (_: Exception) { }
|
||||
|
||||
// Initialize mesh service preferences
|
||||
try { com.bitchat.android.service.MeshServicePreferences.init(this) } catch (_: Exception) { }
|
||||
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
package com.bitchat.android.core.ui.component.button
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@ -12,10 +15,15 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitchat.android.core.ui.icon.BitChatIcon
|
||||
import com.bitchat.android.ui.rememberPressScale
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
@ -30,6 +38,7 @@ fun BitChatBrandButton(
|
||||
contentDescription: String,
|
||||
modifier: Modifier = Modifier,
|
||||
tint: Color = MaterialTheme.colorScheme.primary,
|
||||
iconSize: Dp = 22.dp,
|
||||
) {
|
||||
var tapCount by remember { mutableIntStateOf(0) }
|
||||
var resetJob by remember { mutableStateOf<Job?>(null) }
|
||||
@ -37,33 +46,47 @@ fun BitChatBrandButton(
|
||||
val currentOnClick by rememberUpdatedState(onClick)
|
||||
val currentOnTripleClick by rememberUpdatedState(onTripleClick)
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
tapCount += 1
|
||||
resetJob?.cancel()
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val pressScale = rememberPressScale(interactionSource)
|
||||
|
||||
if (tapCount == 3) {
|
||||
tapCount = 0
|
||||
resetJob = null
|
||||
currentOnTripleClick()
|
||||
} else {
|
||||
resetJob = coroutineScope.launch {
|
||||
delay(MultiClickThreshold)
|
||||
if (tapCount == 1) {
|
||||
currentOnClick()
|
||||
}
|
||||
// A plain Box rather than an IconButton: IconButton insists on drawing a ripple, which was the
|
||||
// only press background left in the header once every other control moved to scale-only
|
||||
// feedback.
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(CircleShape)
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClickLabel = contentDescription
|
||||
) {
|
||||
tapCount += 1
|
||||
resetJob?.cancel()
|
||||
|
||||
if (tapCount == 3) {
|
||||
tapCount = 0
|
||||
resetJob = null
|
||||
currentOnTripleClick()
|
||||
} else {
|
||||
resetJob = coroutineScope.launch {
|
||||
delay(MultiClickThreshold)
|
||||
if (tapCount == 1) {
|
||||
currentOnClick()
|
||||
}
|
||||
tapCount = 0
|
||||
resetJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = BitChatIcon,
|
||||
contentDescription = contentDescription,
|
||||
tint = tint,
|
||||
modifier = Modifier.size(16.dp),
|
||||
modifier = Modifier
|
||||
.size(iconSize)
|
||||
.scale(pressScale),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,34 +1,38 @@
|
||||
package com.bitchat.android.core.ui.component.button
|
||||
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitchat.android.R
|
||||
|
||||
@Composable
|
||||
fun CloseButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier.Companion
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
.size(32.dp),
|
||||
// 44.dp to match every other tap target in the app's chrome.
|
||||
modifier = modifier.size(44.dp),
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
|
||||
containerColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.1f)
|
||||
contentColor = colorScheme.primary,
|
||||
containerColor = Color.Transparent
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Close",
|
||||
modifier = Modifier.Companion.size(18.dp)
|
||||
contentDescription = stringResource(R.string.close_plain),
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,8 +9,26 @@ import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Dismisses the enclosing [BitchatBottomSheet], playing the slide-down first.
|
||||
*
|
||||
* `ModalBottomSheet` only animates itself out when *it* initiates the dismissal — a swipe or a tap
|
||||
* on the scrim. Anything that closes a sheet programmatically (a close button, picking an item from
|
||||
* a list) previously flipped the caller's `isPresented` flag straight to false, which yanks the
|
||||
* composable out of the tree and makes the sheet vanish instantly.
|
||||
*
|
||||
* Anything inside a sheet that wants to close it should prefer this over calling its own
|
||||
* `onDismiss` directly.
|
||||
*/
|
||||
val LocalSheetDismiss = staticCompositionLocalOf<(() -> Unit)?> { null }
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@ -20,6 +38,20 @@ fun BitchatBottomSheet(
|
||||
onDismissRequest: () -> Unit,
|
||||
content: @Composable (ColumnScope.() -> Unit),
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Runs the hide animation to completion, then tells the caller to drop the sheet. `hide()`
|
||||
// throws if the sheet is already on its way out (two rapid taps on a close button), which is
|
||||
// benign — the dismissal still has to go through.
|
||||
val animatedDismiss: () -> Unit = remember(sheetState, onDismissRequest) {
|
||||
{
|
||||
scope.launch {
|
||||
runCatching { sheetState.hide() }
|
||||
onDismissRequest()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
modifier = modifier.statusBarsPadding(),
|
||||
onDismissRequest = onDismissRequest,
|
||||
@ -27,6 +59,9 @@ fun BitchatBottomSheet(
|
||||
dragHandle = null,
|
||||
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
) {
|
||||
CompositionLocalProvider(LocalSheetDismiss provides animatedDismiss) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,9 +10,10 @@ 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 androidx.compose.runtime.CompositionLocalProvider
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.core.ui.component.button.CloseButton
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@ -30,8 +31,9 @@ fun BitchatSheetTopBar(
|
||||
navigationIcon = { navigationIcon?.invoke() },
|
||||
actions = {
|
||||
actions()
|
||||
val dismiss = LocalSheetDismiss.current
|
||||
CloseButton(
|
||||
onClick = onClose,
|
||||
onClick = { dismiss?.invoke() ?: onClose() },
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
},
|
||||
@ -59,8 +61,9 @@ fun BitchatSheetCenterTopBar(
|
||||
navigationIcon = { navigationIcon?.invoke() },
|
||||
actions = {
|
||||
actions()
|
||||
val dismiss = LocalSheetDismiss.current
|
||||
CloseButton(
|
||||
onClick = onClose,
|
||||
onClick = { dismiss?.invoke() ?: onClose() },
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
},
|
||||
@ -80,7 +83,7 @@ fun BitchatSheetTitle(text: String) {
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ -30,12 +30,12 @@ import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.ui.theme.BitchatTheme
|
||||
import com.bitchat.android.util.UniversalApkManager
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
@ -112,7 +112,7 @@ fun HotspotScreen(
|
||||
title = {
|
||||
Text(
|
||||
text = "Share BitChat",
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
@ -398,7 +398,7 @@ fun ActiveHotspotScreen(state: HotspotViewModel.HotspotState.Active) {
|
||||
text = {
|
||||
Text(
|
||||
text = title,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = if (selectedTab == index) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
@ -615,7 +615,7 @@ fun CredentialCard(
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
)
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
/**
|
||||
* Ensures a Noise completion promotes only the BLE connection whose ANNOUNCE started that
|
||||
* authentication attempt.
|
||||
*/
|
||||
internal object AuthenticatedBleLinkPolicy {
|
||||
data class Claim(val deviceAddress: String, val linkID: String)
|
||||
|
||||
fun matches(claim: Claim?, authenticatedAddress: String?, authenticatedLinkID: String?): Boolean =
|
||||
claim != null &&
|
||||
claim.deviceAddress == authenticatedAddress &&
|
||||
claim.linkID == authenticatedLinkID
|
||||
}
|
||||
@ -18,7 +18,7 @@ class BluetoothConnectionManager(
|
||||
private val context: Context,
|
||||
private val myPeerID: String,
|
||||
private val fragmentManager: FragmentManager? = null
|
||||
) : PowerManagerDelegate {
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BluetoothConnectionManager"
|
||||
@ -30,7 +30,7 @@ class BluetoothConnectionManager(
|
||||
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
|
||||
|
||||
// Power management
|
||||
private val powerManager = PowerManager(context.applicationContext)
|
||||
private val powerManager = PowerManager.getInstance(context.applicationContext)
|
||||
|
||||
// Coroutines
|
||||
private val connectionScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
@ -67,8 +67,8 @@ class BluetoothConnectionManager(
|
||||
delegate?.onDeviceConnected(device)
|
||||
}
|
||||
|
||||
override fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?) {
|
||||
delegate?.onDeviceDisconnected(device, linkID)
|
||||
override fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?, peerID: String?) {
|
||||
delegate?.onDeviceDisconnected(device, linkID, peerID)
|
||||
}
|
||||
|
||||
override fun onRSSIUpdated(deviceAddress: String, rssi: Int) {
|
||||
@ -92,8 +92,8 @@ class BluetoothConnectionManager(
|
||||
// Public property for address-peer mapping
|
||||
val addressPeerMap get() = connectionTracker.addressPeerMap
|
||||
|
||||
fun bindPeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean =
|
||||
connectionTracker.bindPeerIfCurrent(deviceAddress, linkID, peerID)
|
||||
fun observePeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean =
|
||||
connectionTracker.observePeerIfCurrent(deviceAddress, linkID, peerID)
|
||||
|
||||
fun getCurrentLinkID(deviceAddress: String): String? =
|
||||
connectionTracker.getCurrentLinkID(deviceAddress)
|
||||
@ -117,7 +117,19 @@ class BluetoothConnectionManager(
|
||||
}
|
||||
|
||||
init {
|
||||
powerManager.delegate = this
|
||||
connectionScope.launch {
|
||||
var previousMode: PowerManager.PowerMode? = null
|
||||
powerManager.profile.collect { profile ->
|
||||
val modeChanged = previousMode != null && previousMode != profile.mode
|
||||
previousMode = profile.mode
|
||||
if (!isActive || !isBleTransportEnabled()) return@collect
|
||||
|
||||
if (modeChanged && isGattServerEnabled()) {
|
||||
serverManager.restartAdvertising()
|
||||
}
|
||||
clientManager.applyPowerProfile(profile)
|
||||
}
|
||||
}
|
||||
// Observe debug settings to enforce role state while active
|
||||
try {
|
||||
val dbg = com.bitchat.android.ui.debug.DebugSettingsManager.getInstance()
|
||||
@ -294,9 +306,6 @@ class BluetoothConnectionManager(
|
||||
clientManager.stop()
|
||||
serverManager.stop()
|
||||
|
||||
// Stop power manager
|
||||
powerManager.stop()
|
||||
|
||||
// Stop connection tracker
|
||||
connectionTracker.stop()
|
||||
|
||||
@ -329,6 +338,16 @@ class BluetoothConnectionManager(
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun broadcastControlPacketAndAwaitAcceptance(routed: RoutedPacket): Boolean {
|
||||
if (!isActive || !isBleTransportEnabled()) return false
|
||||
|
||||
return packetBroadcaster.broadcastControlPacketAndAwaitAcceptance(
|
||||
routed,
|
||||
serverManager.getGattServer(),
|
||||
serverManager.getCharacteristic()
|
||||
)
|
||||
}
|
||||
|
||||
fun sendToPeer(peerID: String, routed: RoutedPacket): Boolean {
|
||||
if (!isActive || !isBleTransportEnabled()) return false
|
||||
return packetBroadcaster.sendToPeer(
|
||||
@ -451,54 +470,6 @@ class BluetoothConnectionManager(
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PowerManagerDelegate Implementation
|
||||
|
||||
override fun onPowerModeChanged(newMode: PowerManager.PowerMode) {
|
||||
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 = isGattServerEnabled()
|
||||
if (serverEnabled) {
|
||||
serverManager.restartAdvertising()
|
||||
} else {
|
||||
serverManager.stop()
|
||||
}
|
||||
|
||||
// Only restart scanning if the duty cycle behavior changed
|
||||
val nowUsingDutyCycle = powerManager.shouldUseDutyCycle()
|
||||
if (wasUsingDutyCycle != nowUsingDutyCycle) {
|
||||
val clientEnabled = isGattClientEnabled()
|
||||
if (clientEnabled) {
|
||||
clientManager.restartScanning()
|
||||
} else {
|
||||
clientManager.stop()
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce connection limits
|
||||
enforceStrictLimits()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onScanStateChanged(shouldScan: Boolean) {
|
||||
if (!isActive || !isBleTransportEnabled()) {
|
||||
clientManager.onScanStateChanged(false)
|
||||
return
|
||||
}
|
||||
clientManager.onScanStateChanged(shouldScan)
|
||||
}
|
||||
|
||||
// MARK: - Private Implementation - All moved to component managers
|
||||
}
|
||||
|
||||
/**
|
||||
@ -512,6 +483,6 @@ interface BluetoothConnectionManagerDelegate {
|
||||
ingressLinkID: String
|
||||
)
|
||||
fun onDeviceConnected(device: BluetoothDevice)
|
||||
fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?)
|
||||
fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?, peerID: String?)
|
||||
fun onRSSIUpdated(deviceAddress: String, rssi: Int)
|
||||
}
|
||||
|
||||
@ -32,7 +32,7 @@ class BluetoothConnectionTracker(
|
||||
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>()
|
||||
private val peerBindingLock = Any()
|
||||
private val connectionStateLock = Any()
|
||||
|
||||
/**
|
||||
* Consolidated device connection information
|
||||
@ -77,9 +77,9 @@ class BluetoothConnectionTracker(
|
||||
*/
|
||||
fun addDeviceConnection(deviceAddress: String, deviceConn: DeviceConnection) {
|
||||
Log.d(TAG, "Tracker: Adding device connection for $deviceAddress (isClient: ${deviceConn.isClient}")
|
||||
synchronized(peerBindingLock) {
|
||||
synchronized(connectionStateLock) {
|
||||
connectedDevices[deviceAddress] = deviceConn
|
||||
// A mapping authenticates a GATT connection, not a reusable Bluetooth address.
|
||||
// A route observation belongs to this GATT generation, not its reusable address.
|
||||
addressPeerMap.remove(deviceAddress)
|
||||
}
|
||||
removePendingConnection(deviceAddress)
|
||||
@ -91,7 +91,7 @@ class BluetoothConnectionTracker(
|
||||
* Update a device connection
|
||||
*/
|
||||
fun updateDeviceConnection(deviceAddress: String, deviceConn: DeviceConnection) {
|
||||
synchronized(peerBindingLock) {
|
||||
synchronized(connectionStateLock) {
|
||||
connectedDevices[deviceAddress] = deviceConn
|
||||
}
|
||||
}
|
||||
@ -100,7 +100,7 @@ class BluetoothConnectionTracker(
|
||||
deviceAddress: String,
|
||||
linkID: String,
|
||||
update: (DeviceConnection) -> DeviceConnection
|
||||
): Boolean = synchronized(peerBindingLock) {
|
||||
): Boolean = synchronized(connectionStateLock) {
|
||||
val current = connectedDevices[deviceAddress] ?: return@synchronized false
|
||||
if (current.linkID != linkID) return@synchronized false
|
||||
connectedDevices[deviceAddress] = update(current)
|
||||
@ -117,10 +117,16 @@ class BluetoothConnectionTracker(
|
||||
fun getCurrentLinkID(deviceAddress: String): String? =
|
||||
connectedDevices[deviceAddress]?.linkID
|
||||
|
||||
fun bindPeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean =
|
||||
synchronized(peerBindingLock) {
|
||||
/**
|
||||
* Records that the current link delivered a validated, non-relayed ANNOUNCE for [peerID].
|
||||
*
|
||||
* A peer may be reachable over more than one link, so observing one link must not discard the
|
||||
* other observations. The link generation check prevents a late packet from an old GATT
|
||||
* connection from being applied to a replacement connection that reused the same address.
|
||||
*/
|
||||
fun observePeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean =
|
||||
synchronized(connectionStateLock) {
|
||||
if (connectedDevices[deviceAddress]?.linkID != linkID) return@synchronized false
|
||||
addressPeerMap.entries.removeIf { it.value == peerID && it.key != deviceAddress }
|
||||
addressPeerMap[deviceAddress] = peerID
|
||||
true
|
||||
}
|
||||
@ -265,7 +271,7 @@ class BluetoothConnectionTracker(
|
||||
* Clean up a specific device connection
|
||||
*/
|
||||
fun cleanupDeviceConnection(deviceAddress: String) {
|
||||
synchronized(peerBindingLock) {
|
||||
synchronized(connectionStateLock) {
|
||||
connectedDevices.remove(deviceAddress)
|
||||
subscribedDevices.removeAll { it.address == deviceAddress }
|
||||
addressPeerMap.remove(deviceAddress)
|
||||
@ -277,7 +283,7 @@ class BluetoothConnectionTracker(
|
||||
fun cleanupDeviceConnectionIfCurrent(
|
||||
deviceAddress: String,
|
||||
expectedLinkID: String
|
||||
): Boolean = synchronized(peerBindingLock) {
|
||||
): Boolean = synchronized(connectionStateLock) {
|
||||
val current = connectedDevices[deviceAddress] ?: return@synchronized false
|
||||
if (current.linkID != expectedLinkID) {
|
||||
return@synchronized false
|
||||
|
||||
@ -93,6 +93,7 @@ class BluetoothGattClientManager(
|
||||
@Volatile private var lastScanResultTime = 0L
|
||||
private var scanRetryCount = 0
|
||||
private var scanWatchdogJob: Job? = null
|
||||
private var scanDutyCycleJob: Job? = null
|
||||
|
||||
// RSSI monitoring state
|
||||
private var rssiMonitoringJob: Job? = null
|
||||
@ -131,18 +132,9 @@ class BluetoothGattClientManager(
|
||||
isActive = true
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
applyPowerProfile(powerManager.profile.value)
|
||||
// Start RSSI monitoring
|
||||
startRSSIMonitoring()
|
||||
// Start the scan watchdog so a silently-dead or wedged scanner self-heals.
|
||||
startScanWatchdog()
|
||||
}
|
||||
|
||||
return true
|
||||
@ -153,6 +145,8 @@ class BluetoothGattClientManager(
|
||||
*/
|
||||
fun stop() {
|
||||
scanningDesired = false
|
||||
scanDutyCycleJob?.cancel()
|
||||
scanDutyCycleJob = null
|
||||
stopScanWatchdog()
|
||||
if (!isActive) {
|
||||
// Idempotent stop
|
||||
@ -208,10 +202,10 @@ class BluetoothGattClientManager(
|
||||
Log.d(TAG, "Failed to request RSSI from ${deviceConn.device.address}: ${e.message}")
|
||||
}
|
||||
}
|
||||
delay(AppConstants.Mesh.RSSI_UPDATE_INTERVAL_MS)
|
||||
delay(powerManager.profile.value.ble.rssiPollIntervalMs)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error in RSSI monitoring: ${e.message}")
|
||||
delay(AppConstants.Mesh.RSSI_UPDATE_INTERVAL_MS)
|
||||
delay(powerManager.profile.value.ble.rssiPollIntervalMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -527,10 +521,12 @@ class BluetoothGattClientManager(
|
||||
} else {
|
||||
Log.i(TAG, "Disconnected from $deviceAddress (client)")
|
||||
}
|
||||
// Capture the observed peer before cleanup drops the address mapping.
|
||||
val disconnectedPeerID = connectionTracker.addressPeerMap[deviceAddress]
|
||||
connectionTracker.cleanupDeviceConnectionIfCurrent(deviceAddress, linkID)
|
||||
|
||||
// Notify higher layers about device disconnection to update direct flags
|
||||
delegate?.onDeviceDisconnected(gatt.device, linkID)
|
||||
delegate?.onDeviceDisconnected(gatt.device, linkID, disconnectedPeerID)
|
||||
|
||||
connectionScope.launch {
|
||||
delay(500) // CLEANUP_DELAY
|
||||
@ -659,13 +655,37 @@ class BluetoothGattClientManager(
|
||||
connectionScope.launch {
|
||||
stopScanning()
|
||||
delay(1000) // Extra delay to avoid rate limiting
|
||||
|
||||
if (powerManager.shouldUseDutyCycle()) {
|
||||
Log.i(TAG, "Switching to duty cycle scanning mode")
|
||||
// Duty cycle will handle scanning
|
||||
} else {
|
||||
Log.i(TAG, "Switching to continuous scanning mode")
|
||||
startScanning()
|
||||
applyPowerProfile(powerManager.profile.value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the current process-wide profile without ever disabling background discovery.
|
||||
*/
|
||||
fun applyPowerProfile(profile: PowerManager.RuntimePerformanceProfile) {
|
||||
scanDutyCycleJob?.cancel()
|
||||
scanDutyCycleJob = null
|
||||
if (!isActive || !isClientRoleEnabled()) {
|
||||
onScanStateChanged(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (profile.ble.continuousScan) {
|
||||
startScanWatchdog()
|
||||
onScanStateChanged(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Duty-cycled scans are re-armed every window, so the continuous-scan watchdog would only
|
||||
// create background wakeups during intentional OFF periods.
|
||||
stopScanWatchdog()
|
||||
scanDutyCycleJob = connectionScope.launch {
|
||||
while (isActive && isClientRoleEnabled()) {
|
||||
onScanStateChanged(true)
|
||||
delay(profile.ble.scanOnMs)
|
||||
if (!isActive || !isClientRoleEnabled()) break
|
||||
onScanStateChanged(false)
|
||||
delay(profile.ble.scanOffMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -203,11 +203,13 @@ class BluetoothGattServerManager(
|
||||
BluetoothProfile.STATE_DISCONNECTED -> {
|
||||
Log.i(TAG, "Disconnected from ${device.address} (server)")
|
||||
val linkID = serverLinkIDs.remove(device.address)
|
||||
// Capture the observed peer before cleanup drops the address mapping.
|
||||
val disconnectedPeerID = connectionTracker.addressPeerMap[device.address]
|
||||
if (linkID != null) {
|
||||
connectionTracker.cleanupDeviceConnectionIfCurrent(device.address, linkID)
|
||||
}
|
||||
// Notify delegate about device disconnection so higher layers can update direct flags
|
||||
delegate?.onDeviceDisconnected(device, linkID)
|
||||
delegate?.onDeviceDisconnected(device, linkID, disconnectedPeerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,7 +21,6 @@ import com.bitchat.android.services.VerificationService
|
||||
import com.bitchat.android.service.TransportBridgeService
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.math.sign
|
||||
import kotlin.random.Random
|
||||
|
||||
@ -43,8 +42,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BluetoothMeshService"
|
||||
private const val BLE_AUTHENTICATION_TIMEOUT_MS = 20_000L
|
||||
private val MAX_TTL: UByte = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
|
||||
private const val PEER_DISCONNECT_GRACE_MS = com.bitchat.android.util.AppConstants.Mesh.PEER_DISCONNECT_GRACE_MS
|
||||
}
|
||||
|
||||
// Core components - each handling specific responsibilities
|
||||
@ -55,6 +54,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
private val peerManager = PeerManager()
|
||||
private val fragmentManager = FragmentManager()
|
||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val readReceiptRetrySender = RetryingControlPacketSender(serviceScope)
|
||||
private val authenticatedPeerStateStore = SecureAuthenticatedPeerStateStore(context)
|
||||
private val authenticatedPeerState by lazy {
|
||||
AuthenticatedPeerStateCoordinator(
|
||||
@ -126,11 +126,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
var delegate: BluetoothMeshDelegate? = null
|
||||
|
||||
// Coroutines
|
||||
private var announceJob: Job? = null
|
||||
// Tracks whether this instance has been terminated via stopServices()
|
||||
private var terminated = false
|
||||
private val provisionalBleClaims =
|
||||
ConcurrentHashMap<String, AuthenticatedBleLinkPolicy.Claim>()
|
||||
|
||||
init {
|
||||
Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID")
|
||||
@ -139,6 +136,17 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
messageHandler.packetProcessor = packetProcessor
|
||||
//startPeriodicDebugLogging()
|
||||
|
||||
// Flush queued private messages as soon as a BLE Noise session authenticates,
|
||||
// instead of relying on the foreground-only UI poll.
|
||||
encryptionService.onSessionEstablished = { peerID ->
|
||||
Log.d(TAG, "BLE Noise session established with ${peerID.take(8)}")
|
||||
try {
|
||||
com.bitchat.android.services.MessageRouter
|
||||
.tryGetInstance()
|
||||
?.onSessionEstablished(peerID)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
// Initialize sync manager (needs serviceScope)
|
||||
gossipSyncManager = GossipSyncManager(
|
||||
myPeerID = myPeerID,
|
||||
@ -177,6 +185,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
connectionManager.broadcastPacket(packet)
|
||||
}
|
||||
|
||||
override suspend fun sendAndReport(packet: RoutedPacket): Boolean {
|
||||
if (!isBleTransportEnabled()) return false
|
||||
return connectionManager.broadcastControlPacketAndAwaitAcceptance(packet)
|
||||
}
|
||||
|
||||
override fun sendToPeer(peerID: String, packet: BitchatPacket) {
|
||||
if (!isBleTransportEnabled()) return
|
||||
connectionManager.sendPacketToPeer(peerID, packet)
|
||||
@ -190,6 +203,15 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
return true
|
||||
}
|
||||
|
||||
private suspend fun broadcastRoutedPacketAndReport(routed: RoutedPacket): Boolean {
|
||||
if (!isBleTransportEnabled()) return false
|
||||
val acceptedByBle =
|
||||
connectionManager.broadcastControlPacketAndAwaitAcceptance(routed)
|
||||
val acceptedByBridgedTransport =
|
||||
TransportBridgeService.broadcastAndReport("BLE", routed)
|
||||
return acceptedByBle || acceptedByBridgedTransport
|
||||
}
|
||||
|
||||
private fun isBleTransportEnabled(): Boolean {
|
||||
return try {
|
||||
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value
|
||||
@ -217,23 +239,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send broadcast announcement every 30 seconds
|
||||
*/
|
||||
private fun sendPeriodicBroadcastAnnounce() {
|
||||
announceJob?.cancel()
|
||||
announceJob = serviceScope.launch {
|
||||
while (isActive) {
|
||||
try {
|
||||
delay(30000) // 30 seconds
|
||||
sendBroadcastAnnounce()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error in periodic broadcast announce: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup delegate connections between components
|
||||
*/
|
||||
@ -253,7 +258,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
delegate?.didUpdatePeerList(peerIDs)
|
||||
}
|
||||
override fun onPeerRemoved(peerID: String) {
|
||||
provisionalBleClaims.remove(peerID)
|
||||
authenticatedPeerState.clear(peerID)
|
||||
try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { }
|
||||
// Remove from mesh graph topology to prevent routing through stale peers
|
||||
@ -282,22 +286,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
authenticatedRemoteStaticKey,
|
||||
authenticatedSessionToken
|
||||
)
|
||||
val expectedClaim = provisionalBleClaims.remove(peerID)
|
||||
if (AuthenticatedBleLinkPolicy.matches(expectedClaim, directRelayAddress, ingressLinkID)) {
|
||||
val authenticatedClaim = checkNotNull(expectedClaim)
|
||||
if (connectionManager.bindPeerIfCurrent(
|
||||
authenticatedClaim.deviceAddress,
|
||||
authenticatedClaim.linkID,
|
||||
peerID
|
||||
)
|
||||
) {
|
||||
Log.i(TAG, "Authenticated BLE link $directRelayAddress as $peerID")
|
||||
try { peerManager.refreshPeerList() } catch (_: Exception) { }
|
||||
try { gossipSyncManager.scheduleInitialSyncToPeer(peerID, 1_000) } catch (_: Exception) { }
|
||||
} else {
|
||||
Log.w(TAG, "Ignoring Noise completion for stale BLE link $directRelayAddress")
|
||||
}
|
||||
}
|
||||
// Send announcement and cached messages after key exchange
|
||||
serviceScope.launch {
|
||||
delay(100)
|
||||
@ -431,6 +419,14 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
override fun hasNoiseSession(peerID: String): Boolean {
|
||||
return encryptionService.hasEstablishedSession(peerID)
|
||||
}
|
||||
|
||||
override fun removeNoiseSession(peerID: String) {
|
||||
try {
|
||||
encryptionService.removePeer(peerID)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to remove Noise session for $peerID: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun initiateNoiseHandshake(peerID: String) {
|
||||
try {
|
||||
@ -503,7 +499,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
delegate?.didReceiveMessage(message)
|
||||
|
||||
// If no UI delegate attached (app closed), show DM notification via service manager
|
||||
if (delegate == null && message.isPrivate) {
|
||||
if (delegate == null && message.isPrivate && message.sender != "system") {
|
||||
try {
|
||||
val senderPeerID = message.senderPeerID
|
||||
if (senderPeerID != null) {
|
||||
@ -521,10 +517,24 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
|
||||
override fun onDeliveryAckReceived(messageID: String, peerID: String) {
|
||||
// Status events can arrive while MainActivity has detached the UI delegate.
|
||||
// Persist first so the next UI collector observes the advancement.
|
||||
try {
|
||||
com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(
|
||||
messageID,
|
||||
com.bitchat.android.model.DeliveryStatus.Delivered(peerID, Date())
|
||||
)
|
||||
} catch (_: Exception) { }
|
||||
delegate?.didReceiveDeliveryAck(messageID, peerID)
|
||||
}
|
||||
|
||||
override fun onReadReceiptReceived(messageID: String, peerID: String) {
|
||||
try {
|
||||
com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(
|
||||
messageID,
|
||||
com.bitchat.android.model.DeliveryStatus.Read(peerID, Date())
|
||||
)
|
||||
} catch (_: Exception) { }
|
||||
delegate?.didReceiveReadReceipt(messageID, peerID)
|
||||
}
|
||||
|
||||
@ -564,48 +574,31 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
return runBlocking { securityManager.handleNoiseHandshake(routed) }
|
||||
}
|
||||
|
||||
override fun handleNoiseEncrypted(routed: RoutedPacket) {
|
||||
serviceScope.launch { messageHandler.handleNoiseEncrypted(routed) }
|
||||
override fun handleNoiseEncrypted(routed: RoutedPacket): Boolean {
|
||||
return runBlocking { messageHandler.handleNoiseEncrypted(routed) }
|
||||
}
|
||||
|
||||
override suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
|
||||
val result = messageHandler.handleAnnounceWithResult(routed)
|
||||
if (result !is AnnounceHandlingResult.Accepted) return false
|
||||
|
||||
val deviceAddress = routed.relayAddress
|
||||
val pid = routed.peerID
|
||||
val linkID = routed.ingressLinkID
|
||||
val isDirect = routed.packet.ttl == com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
|
||||
val alreadyAuthenticated = deviceAddress != null &&
|
||||
pid != null &&
|
||||
connectionManager.addressPeerMap[deviceAddress] == pid
|
||||
if (deviceAddress != null && linkID != null && pid != null && isDirect && !alreadyAuthenticated) {
|
||||
try {
|
||||
val claim = AuthenticatedBleLinkPolicy.Claim(deviceAddress, linkID)
|
||||
registerProvisionalBleClaim(pid, claim)
|
||||
val handshakeData = encryptionService.initiateHandshake(pid, replaceEstablished = true)
|
||||
if (handshakeData != null) {
|
||||
val handshake = signPacketBeforeBroadcast(
|
||||
BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_HANDSHAKE.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
recipientID = hexStringToByteArray(pid),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = handshakeData,
|
||||
ttl = MAX_TTL
|
||||
)
|
||||
)
|
||||
if (!connectionManager.sendPacketToLink(deviceAddress, linkID, handshake)) {
|
||||
provisionalBleClaims.remove(pid, claim)
|
||||
Log.w(TAG, "Could not send Noise handshake on BLE link $deviceAddress")
|
||||
}
|
||||
} else {
|
||||
provisionalBleClaims.remove(pid, claim)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
provisionalBleClaims.remove(pid, AuthenticatedBleLinkPolicy.Claim(deviceAddress, linkID))
|
||||
Log.w(TAG, "Could not authenticate provisional BLE claim for $pid: ${e.message}")
|
||||
DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL)?.let { observation ->
|
||||
if (connectionManager.observePeerIfCurrent(
|
||||
observation.relayAddress,
|
||||
observation.ingressLinkID,
|
||||
observation.peerID
|
||||
)
|
||||
) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"Observed direct BLE route ${observation.relayAddress} to ${observation.peerID}"
|
||||
)
|
||||
try { peerManager.refreshPeerList() } catch (_: Exception) { }
|
||||
try {
|
||||
gossipSyncManager.scheduleInitialSyncToPeer(observation.peerID, 1_000)
|
||||
} catch (_: Exception) { }
|
||||
} else {
|
||||
Log.d(TAG, "Ignoring ANNOUNCE from stale BLE link ${observation.relayAddress}")
|
||||
}
|
||||
}
|
||||
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
|
||||
@ -707,18 +700,41 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
|
||||
override fun onDeviceDisconnected(
|
||||
device: android.bluetooth.BluetoothDevice,
|
||||
linkID: String?
|
||||
linkID: String?,
|
||||
peerID: String?
|
||||
) {
|
||||
Log.i(TAG, "Device disconnected: ${device.address}")
|
||||
val addr = device.address
|
||||
clearProvisionalBleClaimsForLink(addr, linkID)
|
||||
Log.i(TAG, "Device disconnected: ${device.address} (peerID: $peerID)")
|
||||
|
||||
// refresh peer list on disconnect.
|
||||
// refresh peer list on disconnect.
|
||||
try { peerManager.refreshPeerList() } catch (_: Exception) { }
|
||||
|
||||
// ConnectionTracker already removes an authenticated mapping only when this exact
|
||||
// ConnectionTracker already removes an observed mapping only when this exact
|
||||
// link is still current. Do not remove by reusable address here: this may be a late
|
||||
// disconnect callback from a replaced GATT connection.
|
||||
|
||||
// If the peer that used this link does not come back within a short grace
|
||||
// period (no other link, no traffic), tear down their Noise session instead of
|
||||
// waiting for the 3-minute stale-peer sweep.
|
||||
if (peerID != null) {
|
||||
val deviceAddress = device.address
|
||||
val disconnectedAt = System.currentTimeMillis()
|
||||
serviceScope.launch {
|
||||
delay(PEER_DISCONNECT_GRACE_MS)
|
||||
try {
|
||||
val linkBack =
|
||||
connectionManager.addressPeerMap.containsKey(deviceAddress) ||
|
||||
connectionManager.addressPeerMap.containsValue(peerID)
|
||||
val lastSeen = peerManager.getPeerInfo(peerID)?.lastSeen ?: 0L
|
||||
val seenAfterDisconnect = lastSeen > disconnectedAt
|
||||
if (!linkBack && !seenAfterDisconnect) {
|
||||
Log.i(TAG, "Peer $peerID did not return after disconnect; removing peer and Noise session")
|
||||
peerManager.removePeer(peerID)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Disconnect grace check failed for $peerID: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRSSIUpdated(deviceAddress: String, rssi: Int) {
|
||||
@ -730,24 +746,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerProvisionalBleClaim(
|
||||
peerID: String,
|
||||
claim: AuthenticatedBleLinkPolicy.Claim
|
||||
) {
|
||||
provisionalBleClaims[peerID] = claim
|
||||
serviceScope.launch {
|
||||
delay(BLE_AUTHENTICATION_TIMEOUT_MS)
|
||||
provisionalBleClaims.remove(peerID, claim)
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearProvisionalBleClaimsForLink(deviceAddress: String, linkID: String?) {
|
||||
if (linkID == null) return
|
||||
provisionalBleClaims.entries.removeIf { (_, claim) ->
|
||||
claim.deviceAddress == deviceAddress && claim.linkID == linkID
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the mesh service
|
||||
*/
|
||||
@ -778,8 +776,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
isActive = true
|
||||
TransportBridgeService.register("BLE", this)
|
||||
|
||||
// Start periodic announcements for peer discovery and connectivity
|
||||
sendPeriodicBroadcastAnnounce()
|
||||
// Start periodic syncs
|
||||
com.bitchat.android.service.MeshServiceHolder.startSharedGossip("BLE")
|
||||
} else {
|
||||
@ -801,8 +797,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
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) { }
|
||||
@ -822,8 +816,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
|
||||
Log.i(TAG, "Stopping Bluetooth mesh service")
|
||||
isActive = false
|
||||
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) { }
|
||||
@ -1023,7 +1015,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
*/
|
||||
fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) {
|
||||
if (content.isEmpty() || recipientPeerID.isEmpty()) return
|
||||
if (recipientNickname.isEmpty()) return
|
||||
// Nicknames are presentation metadata. Routing and encryption are bound to the peer ID,
|
||||
// so a temporarily unresolved nickname must never suppress a private message.
|
||||
|
||||
serviceScope.launch {
|
||||
val finalMessageID = messageID ?: java.util.UUID.randomUUID().toString()
|
||||
@ -1100,12 +1093,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
|
||||
try {
|
||||
// Avoid duplicate read receipts: check persistent store first
|
||||
val seenStore = try { com.bitchat.android.services.SeenMessageStore.getInstance(context.applicationContext) } catch (_: Exception) { null }
|
||||
if (seenStore?.hasRead(messageID) == true) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Create read receipt payload using NoisePayloadType exactly like iOS
|
||||
val readReceiptPayload = com.bitchat.android.model.NoisePayload(
|
||||
type = com.bitchat.android.model.NoisePayloadType.READ_RECEIPT,
|
||||
@ -1129,10 +1116,31 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
|
||||
// Sign the packet before broadcasting
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||
|
||||
// Persist as read after successful send
|
||||
try { seenStore?.markRead(messageID) } catch (_: Exception) { }
|
||||
val retryKey = "$recipientPeerID:$messageID"
|
||||
readReceiptRetrySender.enqueue(
|
||||
key = retryKey,
|
||||
sendAttempt = { attempt ->
|
||||
// Keep the addressed packet on the normal broadcaster actor so receipt
|
||||
// attempts are ordered with other BLE traffic and can use mesh routing.
|
||||
val accepted =
|
||||
broadcastRoutedPacketAndReport(RoutedPacket(signedPacket))
|
||||
Log.d(
|
||||
TAG,
|
||||
"Read receipt attempt $attempt accepted=$accepted " +
|
||||
"peer=${recipientPeerID.take(8)} message=${messageID.take(8)}"
|
||||
)
|
||||
accepted
|
||||
},
|
||||
onComplete = { accepted ->
|
||||
if (accepted) {
|
||||
try {
|
||||
com.bitchat.android.services.SeenMessageStore
|
||||
.getInstance(context.applicationContext)
|
||||
.markReadReceiptSent(messageID)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send read receipt to $recipientPeerID: ${e.message}")
|
||||
|
||||
@ -10,6 +10,8 @@ import com.bitchat.android.protocol.SpecialRecipients
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import com.bitchat.android.util.toHexString
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@ -110,7 +112,8 @@ class BluetoothPacketBroadcaster(
|
||||
private data class BroadcastRequest(
|
||||
val routed: RoutedPacket,
|
||||
val gattServer: BluetoothGattServer?,
|
||||
val characteristic: BluetoothGattCharacteristic?
|
||||
val characteristic: BluetoothGattCharacteristic?,
|
||||
val accepted: CompletableDeferred<Boolean>? = null
|
||||
)
|
||||
|
||||
// Actor scope for the broadcaster
|
||||
@ -123,7 +126,17 @@ class BluetoothPacketBroadcaster(
|
||||
capacity = Channel.UNLIMITED
|
||||
) {
|
||||
for (request in channel) {
|
||||
broadcastSinglePacketInternal(request.routed, request.gattServer, request.characteristic)
|
||||
val accepted = try {
|
||||
broadcastSinglePacketInternal(
|
||||
request.routed,
|
||||
request.gattServer,
|
||||
request.characteristic
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Broadcast request failed: ${e.message}")
|
||||
false
|
||||
}
|
||||
request.accepted?.complete(accepted)
|
||||
}
|
||||
}
|
||||
|
||||
@ -242,6 +255,29 @@ class BluetoothPacketBroadcaster(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a small control packet with normal BLE traffic and waits for the platform write
|
||||
* API to accept at least one notification/write.
|
||||
*/
|
||||
suspend fun broadcastControlPacketAndAwaitAcceptance(
|
||||
routed: RoutedPacket,
|
||||
gattServer: BluetoothGattServer?,
|
||||
characteristic: BluetoothGattCharacteristic?
|
||||
): Boolean {
|
||||
val accepted = CompletableDeferred<Boolean>()
|
||||
return try {
|
||||
broadcasterActor.send(
|
||||
BroadcastRequest(routed, gattServer, characteristic, accepted)
|
||||
)
|
||||
accepted.await()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to queue control packet: ${e.message}")
|
||||
broadcastSinglePacketInternal(routed, gattServer, characteristic)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Targeted send to a specific peer (by peerID) if directly connected.
|
||||
* Returns true if sent to at least one matching connection.
|
||||
@ -274,11 +310,11 @@ class BluetoothPacketBroadcaster(
|
||||
routed: RoutedPacket,
|
||||
gattServer: BluetoothGattServer?,
|
||||
characteristic: BluetoothGattCharacteristic?
|
||||
) {
|
||||
): Boolean {
|
||||
val packet = routed.packet
|
||||
// iOS-compatible: Use selective padding policy for BLE
|
||||
val padForBLE = BLEPacketPaddingPolicy.shouldPadForBLE(packet.type)
|
||||
val data = packet.toBinaryData(padding = padForBLE) ?: return
|
||||
val data = packet.toBinaryData(padding = padForBLE) ?: return false
|
||||
val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString()
|
||||
val senderPeerID = routed.peerID ?: packet.senderID.toHexString()
|
||||
val incomingAddr = routed.relayAddress
|
||||
@ -320,7 +356,7 @@ class BluetoothPacketBroadcaster(
|
||||
}
|
||||
}
|
||||
|
||||
if (sent) return
|
||||
if (sent) return true
|
||||
|
||||
Log.d(TAG, "Source Routing: First hop $firstHop not connected. Falling back to standard broadcast logic.")
|
||||
}
|
||||
@ -337,7 +373,7 @@ class BluetoothPacketBroadcaster(
|
||||
if (notifyDevice(targetDevice, data, gattServer, characteristic)) {
|
||||
val toPeer = connectionTracker.addressPeerMap[targetDevice.address]
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDevice.address, packet.ttl, packet.version, routeInfo)
|
||||
return // Sent, no need to continue
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@ -350,7 +386,7 @@ class BluetoothPacketBroadcaster(
|
||||
if (writeToDeviceConn(targetDeviceConn, data)) {
|
||||
val toPeer = connectionTracker.addressPeerMap[targetDeviceConn.device.address]
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDeviceConn.device.address, packet.ttl, packet.version, routeInfo)
|
||||
return // Sent, no need to continue
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -360,6 +396,7 @@ class BluetoothPacketBroadcaster(
|
||||
val connectedDevices = connectionTracker.getConnectedDevices()
|
||||
|
||||
val senderID = packet.senderID.toHexString()
|
||||
var accepted = false
|
||||
|
||||
// Send to server connections (devices connected to our GATT server)
|
||||
subscribedDevices.forEach { device ->
|
||||
@ -371,6 +408,7 @@ class BluetoothPacketBroadcaster(
|
||||
}
|
||||
val sent = notifyDevice(device, data, gattServer, characteristic)
|
||||
if (sent) {
|
||||
accepted = true
|
||||
val toPeer = connectionTracker.addressPeerMap[device.address]
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, device.address, packet.ttl, packet.version, routeInfo)
|
||||
}
|
||||
@ -387,11 +425,13 @@ class BluetoothPacketBroadcaster(
|
||||
}
|
||||
val sent = writeToDeviceConn(deviceConn, data)
|
||||
if (sent) {
|
||||
accepted = true
|
||||
val toPeer = connectionTracker.addressPeerMap[deviceConn.device.address]
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, deviceConn.device.address, packet.ttl, packet.version, routeInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
return accepted
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
|
||||
/**
|
||||
* Describes transport reachability learned from an already-validated ANNOUNCE.
|
||||
*
|
||||
* This is deliberately only a routing observation. Noise authenticates the peer independently and
|
||||
* must not be restarted merely to associate the current transport link with that peer.
|
||||
*/
|
||||
internal object DirectLinkAnnouncementPolicy {
|
||||
data class Observation(
|
||||
val peerID: String,
|
||||
val relayAddress: String,
|
||||
val ingressLinkID: String
|
||||
)
|
||||
|
||||
fun observationFor(routed: RoutedPacket, maxTtl: UByte): Observation? {
|
||||
if (routed.packet.ttl != maxTtl) return null
|
||||
return Observation(
|
||||
peerID = routed.peerID ?: return null,
|
||||
relayAddress = routed.relayAddress ?: return null,
|
||||
ingressLinkID = routed.ingressLinkID ?: return null
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -20,7 +20,6 @@ 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
|
||||
@ -44,7 +43,6 @@ class MeshCore(
|
||||
data class Hooks(
|
||||
val onMessageReceived: ((BitchatMessage) -> Unit)? = null,
|
||||
val onAnnounceProcessed: ((RoutedPacket, Boolean) -> Unit)? = null,
|
||||
val onDirectNoiseAuthenticated: ((String, String, String, ByteArray) -> Unit)? = null,
|
||||
val readReceiptInterceptor: ((String, String) -> Boolean)? = null,
|
||||
val onReadReceiptSent: ((String) -> Unit)? = null,
|
||||
val announcementNicknameProvider: (() -> String?)? = null,
|
||||
@ -53,6 +51,7 @@ class MeshCore(
|
||||
|
||||
private val peerManager = PeerManager()
|
||||
val fragmentManager = FragmentManager()
|
||||
private val readReceiptRetrySender = RetryingControlPacketSender(scope)
|
||||
private val authenticatedPeerStateStore = SecureAuthenticatedPeerStateStore(context)
|
||||
private val authenticatedPeerState by lazy {
|
||||
AuthenticatedPeerStateCoordinator(
|
||||
@ -116,7 +115,6 @@ class MeshCore(
|
||||
|
||||
var delegate: MeshDelegate? = null
|
||||
|
||||
private var announceJob: Job? = null
|
||||
private var isActive = false
|
||||
|
||||
init {
|
||||
@ -145,7 +143,6 @@ class MeshCore(
|
||||
fun startCore() {
|
||||
if (isActive) return
|
||||
isActive = true
|
||||
startPeriodicBroadcastAnnounce()
|
||||
if (ownsGossipManager) {
|
||||
gossipSyncManager.start()
|
||||
}
|
||||
@ -154,14 +151,14 @@ class MeshCore(
|
||||
fun stopCore() {
|
||||
if (!isActive) return
|
||||
isActive = false
|
||||
announceJob?.cancel()
|
||||
announceJob = null
|
||||
directPeers.clear()
|
||||
if (ownsGossipManager) {
|
||||
gossipSyncManager.stop()
|
||||
}
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
directPeers.clear()
|
||||
peerManager.shutdown()
|
||||
fragmentManager.shutdown()
|
||||
securityManager.shutdown()
|
||||
@ -190,21 +187,20 @@ class MeshCore(
|
||||
transport.broadcastPacket(packet)
|
||||
}
|
||||
|
||||
fun sendFromBridgeAndReport(packet: RoutedPacket): Boolean {
|
||||
return 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 suspend fun dispatchGlobalAndReport(routed: RoutedPacket): Boolean {
|
||||
val acceptedByLocalTransport = transport.broadcastPacket(routed)
|
||||
val acceptedByBridgedTransport =
|
||||
TransportBridgeService.broadcastAndReport(transport.id, routed)
|
||||
return acceptedByLocalTransport || acceptedByBridgedTransport
|
||||
}
|
||||
|
||||
private fun setupDelegates() {
|
||||
@ -215,6 +211,7 @@ class MeshCore(
|
||||
}
|
||||
|
||||
override fun onPeerRemoved(peerID: String) {
|
||||
directPeers.remove(peerID)
|
||||
authenticatedPeerState.clear(peerID)
|
||||
try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { }
|
||||
try { encryptionService.removePeer(peerID) } catch (_: Exception) { }
|
||||
@ -235,14 +232,6 @@ class MeshCore(
|
||||
authenticatedRemoteStaticKey,
|
||||
authenticatedSessionToken
|
||||
)
|
||||
if (directRelayAddress != null && ingressLinkID != null) {
|
||||
hooks.onDirectNoiseAuthenticated?.invoke(
|
||||
peerID,
|
||||
directRelayAddress,
|
||||
ingressLinkID,
|
||||
authenticatedRemoteStaticKey
|
||||
)
|
||||
}
|
||||
scope.launch {
|
||||
delay(100)
|
||||
sendAnnouncementToPeer(peerID)
|
||||
@ -370,6 +359,14 @@ class MeshCore(
|
||||
return encryptionService.hasEstablishedSession(peerID)
|
||||
}
|
||||
|
||||
override fun removeNoiseSession(peerID: String) {
|
||||
try {
|
||||
encryptionService.removePeer(peerID)
|
||||
} catch (e: Exception) {
|
||||
Log.w("MeshCore", "Failed to remove Noise session for $peerID: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun initiateNoiseHandshake(peerID: String) {
|
||||
this@MeshCore.initiateNoiseHandshake(peerID)
|
||||
}
|
||||
@ -404,10 +401,22 @@ class MeshCore(
|
||||
}
|
||||
|
||||
override fun onDeliveryAckReceived(messageID: String, peerID: String) {
|
||||
try {
|
||||
com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(
|
||||
messageID,
|
||||
com.bitchat.android.model.DeliveryStatus.Delivered(peerID, java.util.Date())
|
||||
)
|
||||
} catch (_: Exception) { }
|
||||
delegate?.didReceiveDeliveryAck(messageID, peerID)
|
||||
}
|
||||
|
||||
override fun onReadReceiptReceived(messageID: String, peerID: String) {
|
||||
try {
|
||||
com.bitchat.android.services.AppStateStore.updatePrivateMessageStatus(
|
||||
messageID,
|
||||
com.bitchat.android.model.DeliveryStatus.Read(peerID, java.util.Date())
|
||||
)
|
||||
} catch (_: Exception) { }
|
||||
delegate?.didReceiveReadReceipt(messageID, peerID)
|
||||
}
|
||||
|
||||
@ -445,8 +454,8 @@ class MeshCore(
|
||||
return runBlocking { securityManager.handleNoiseHandshake(routed) }
|
||||
}
|
||||
|
||||
override fun handleNoiseEncrypted(routed: RoutedPacket) {
|
||||
scope.launch { messageHandler.handleNoiseEncrypted(routed) }
|
||||
override fun handleNoiseEncrypted(routed: RoutedPacket): Boolean {
|
||||
return runBlocking { messageHandler.handleNoiseEncrypted(routed) }
|
||||
}
|
||||
|
||||
override suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
|
||||
@ -697,8 +706,24 @@ class MeshCore(
|
||||
signature = null,
|
||||
ttl = maxTtl
|
||||
)
|
||||
dispatchGlobal(RoutedPacket(signPacketBeforeBroadcast(packet)))
|
||||
hooks.onReadReceiptSent?.invoke(messageID)
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
val retryKey = "$recipientPeerID:$messageID"
|
||||
readReceiptRetrySender.enqueue(
|
||||
key = retryKey,
|
||||
sendAttempt = {
|
||||
dispatchGlobalAndReport(RoutedPacket(signedPacket))
|
||||
},
|
||||
onComplete = { accepted ->
|
||||
if (accepted) {
|
||||
try {
|
||||
com.bitchat.android.services.SeenMessageStore
|
||||
.getInstance(context.applicationContext)
|
||||
.markReadReceiptSent(messageID)
|
||||
} catch (_: Exception) { }
|
||||
hooks.onReadReceiptSent?.invoke(messageID)
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e("MeshCore", "Failed to send read receipt: ${e.message}")
|
||||
}
|
||||
@ -845,6 +870,7 @@ class MeshCore(
|
||||
}
|
||||
|
||||
fun removePeer(peerID: String) {
|
||||
directPeers.remove(peerID)
|
||||
peerManager.removePeer(peerID)
|
||||
}
|
||||
|
||||
@ -898,44 +924,6 @@ class MeshCore(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a fresh replacement handshake on one exact direct transport generation.
|
||||
* This authenticates provisional transport claims without broadcasting the challenge or
|
||||
* accidentally sending it through a socket that later reused the same alias.
|
||||
*/
|
||||
fun initiateNoiseHandshakeOnLink(
|
||||
peerID: String,
|
||||
relayAddress: String,
|
||||
ingressLinkID: String
|
||||
): Boolean {
|
||||
return try {
|
||||
val handshakeData = encryptionService.initiateHandshake(
|
||||
peerID,
|
||||
replaceEstablished = true
|
||||
) ?: return false
|
||||
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
|
||||
)
|
||||
transport.sendPacketToLink(
|
||||
relayAddress,
|
||||
ingressLinkID,
|
||||
signPacketBeforeBroadcast(packet)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
"MeshCore",
|
||||
"Failed to initiate link-bound Noise handshake with $peerID: ${e.message}"
|
||||
)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun getPeerFingerprint(peerID: String): String? = peerManager.getFingerprintForPeer(peerID)
|
||||
|
||||
fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID)
|
||||
@ -989,6 +977,7 @@ class MeshCore(
|
||||
}
|
||||
|
||||
fun clearAllInternalData() {
|
||||
directPeers.clear()
|
||||
fragmentManager.clearAllFragments()
|
||||
storeForwardManager.clearAllCache()
|
||||
securityManager.clearAllData()
|
||||
|
||||
@ -9,7 +9,10 @@ import com.bitchat.android.protocol.BitchatPacket
|
||||
interface MeshTransport {
|
||||
val id: String
|
||||
|
||||
fun broadcastPacket(routed: RoutedPacket)
|
||||
/**
|
||||
* Broadcasts a packet and reports whether at least one concrete transport write was accepted.
|
||||
*/
|
||||
fun broadcastPacket(routed: RoutedPacket): Boolean
|
||||
|
||||
fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean
|
||||
|
||||
|
||||
@ -27,52 +27,62 @@ 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
|
||||
private const val MAX_CONSECUTIVE_DECRYPT_FAILURES = 3
|
||||
}
|
||||
|
||||
|
||||
// Delegate for callbacks
|
||||
var delegate: MessageHandlerDelegate? = null
|
||||
|
||||
|
||||
// Reference to PacketProcessor for recursive packet handling
|
||||
var packetProcessor: PacketProcessor? = null
|
||||
|
||||
|
||||
// Coroutines
|
||||
private val handlerScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
|
||||
// Consecutive decrypt failures per peer; only signature-verified packets reach this path,
|
||||
// so repeated failures mean the established session is stale (peer re-handshaked elsewhere).
|
||||
private val consecutiveDecryptFailures = java.util.concurrent.ConcurrentHashMap<String, Int>()
|
||||
|
||||
/**
|
||||
* Handle Noise encrypted transport message - SIMPLIFIED iOS-compatible version
|
||||
* Uses NoisePayloadType system exactly like iOS SimplifiedBluetoothService
|
||||
*
|
||||
* Returns false when the payload could not be decrypted, so callers can treat the
|
||||
* packet as not liveness-proving (no lastSeen refresh, no relay).
|
||||
*/
|
||||
suspend fun handleNoiseEncrypted(routed: RoutedPacket) {
|
||||
suspend fun handleNoiseEncrypted(routed: RoutedPacket): Boolean {
|
||||
val packet = routed.packet
|
||||
val peerID = routed.peerID ?: "unknown"
|
||||
|
||||
|
||||
// Skip our own messages
|
||||
if (peerID == myPeerID) return
|
||||
|
||||
if (peerID == myPeerID) return true
|
||||
|
||||
// Check if this message is for us
|
||||
val recipientID = packet.recipientID?.toHexString()
|
||||
if (recipientID != myPeerID) {
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// Decrypt the message using the Noise service
|
||||
val decryption = delegate?.decryptFromPeer(packet.payload, peerID)
|
||||
if (decryption == null) {
|
||||
Log.w(TAG, "Failed to decrypt Noise message from $peerID - may need handshake")
|
||||
return
|
||||
registerDecryptFailure(peerID)
|
||||
return false
|
||||
}
|
||||
consecutiveDecryptFailures.remove(peerID)
|
||||
val decryptedData = decryption.plaintext
|
||||
|
||||
|
||||
if (decryptedData.isEmpty()) {
|
||||
Log.w(TAG, "Decrypted data is empty from $peerID")
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
val noisePayload = com.bitchat.android.model.NoisePayload.decode(decryptedData)
|
||||
if (noisePayload == null) {
|
||||
Log.w(TAG, "Failed to parse NoisePayload from $peerID")
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
when (noisePayload.type) {
|
||||
@ -86,7 +96,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
handleFavoriteNotificationFromMesh(pmContent, peerID)
|
||||
// Acknowledge delivery for UX parity
|
||||
sendDeliveryAck(privateMessage.messageID, peerID)
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
// Create BitchatMessage - preserve source packet timestamp
|
||||
@ -180,6 +190,31 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error processing Noise encrypted message from $peerID: ${e.message}")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Count consecutive decrypt failures from a signature-verified peer that we still hold an
|
||||
* established session for. After repeated failures the session is stale (the peer completed
|
||||
* a new handshake elsewhere), so destroy it and start a fresh handshake; the peer's side
|
||||
* will finish via the responder-candidate path and evict its own stale session.
|
||||
*/
|
||||
private fun registerDecryptFailure(peerID: String) {
|
||||
if (peerID == "unknown" || peerID == myPeerID) return
|
||||
if (delegate?.hasNoiseSession(peerID) != true) return
|
||||
val failures = (consecutiveDecryptFailures[peerID] ?: 0) + 1
|
||||
if (failures >= MAX_CONSECUTIVE_DECRYPT_FAILURES) {
|
||||
consecutiveDecryptFailures.remove(peerID)
|
||||
Log.w(TAG, "Noise session with $peerID stale after $failures decrypt failures; resetting and re-handshaking")
|
||||
try { delegate?.removeNoiseSession(peerID) } catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to reset Noise session for $peerID: ${e.message}")
|
||||
}
|
||||
try { delegate?.initiateNoiseHandshake(peerID) } catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to re-initiate handshake with $peerID: ${e.message}")
|
||||
}
|
||||
} else {
|
||||
consecutiveDecryptFailures[peerID] = failures
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -575,13 +610,31 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
}
|
||||
|
||||
val action = if (control.isFavorite) "favorited" else "unfavorited"
|
||||
val notice = "${peerInfo.nickname} $action you$guidance"
|
||||
val sys = com.bitchat.android.model.BitchatMessage(
|
||||
sender = "system",
|
||||
content = "${peerInfo.nickname} $action you$guidance",
|
||||
content = notice,
|
||||
timestamp = java.util.Date(),
|
||||
isRelay = false
|
||||
)
|
||||
delegate?.onMessageReceived(sys)
|
||||
|
||||
// Mirror the notice into the private conversation so it's visible while chatting
|
||||
try {
|
||||
val conversationID = com.bitchat.android.services.ContactDirectory
|
||||
.canonicalConversationId(fromPeerID)
|
||||
val sysPrivate = com.bitchat.android.model.BitchatMessage(
|
||||
sender = "system",
|
||||
content = notice,
|
||||
timestamp = java.util.Date(),
|
||||
isRelay = false,
|
||||
isPrivate = true,
|
||||
senderPeerID = conversationID
|
||||
)
|
||||
delegate?.onMessageReceived(sysPrivate)
|
||||
} catch (_: Exception) {
|
||||
// Best-effort; public notice already delivered
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Best-effort; ignore errors
|
||||
@ -628,6 +681,7 @@ interface MessageHandlerDelegate {
|
||||
// Noise protocol operations
|
||||
fun hasNoiseSession(peerID: String): Boolean
|
||||
fun initiateNoiseHandshake(peerID: String)
|
||||
fun removeNoiseSession(peerID: String) {}
|
||||
fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray?
|
||||
fun onAuthenticatedPeerStateReceived(
|
||||
peerID: String,
|
||||
|
||||
@ -144,7 +144,7 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
if (packetRelayManager.isPacketAddressedToMe(packet)) {
|
||||
when (messageType) {
|
||||
MessageType.NOISE_HANDSHAKE -> validPacket = handleNoiseHandshake(routed)
|
||||
MessageType.NOISE_ENCRYPTED -> handleNoiseEncrypted(routed)
|
||||
MessageType.NOISE_ENCRYPTED -> validPacket = handleNoiseEncrypted(routed)
|
||||
MessageType.FILE_TRANSFER -> handleMessage(routed)
|
||||
else -> {
|
||||
validPacket = false
|
||||
@ -175,9 +175,10 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
|
||||
/**
|
||||
* Handle Noise encrypted transport message
|
||||
* Returns false when decryption fails so undecryptable packets do not prove liveness.
|
||||
*/
|
||||
private suspend fun handleNoiseEncrypted(routed: RoutedPacket) {
|
||||
delegate?.handleNoiseEncrypted(routed)
|
||||
private suspend fun handleNoiseEncrypted(routed: RoutedPacket): Boolean {
|
||||
return delegate?.handleNoiseEncrypted(routed) ?: false
|
||||
}
|
||||
|
||||
/**
|
||||
@ -292,7 +293,7 @@ interface PacketProcessorDelegate {
|
||||
|
||||
// Message type handlers
|
||||
fun handleNoiseHandshake(routed: RoutedPacket): Boolean
|
||||
fun handleNoiseEncrypted(routed: RoutedPacket)
|
||||
fun handleNoiseEncrypted(routed: RoutedPacket): Boolean
|
||||
suspend fun handleAnnounce(routed: RoutedPacket): Boolean
|
||||
fun handleMessage(routed: RoutedPacket)
|
||||
fun handleLeave(routed: RoutedPacket)
|
||||
|
||||
@ -14,327 +14,261 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import kotlinx.coroutines.*
|
||||
import kotlin.math.max
|
||||
import com.bitchat.android.services.AppStateStore
|
||||
import com.bitchat.android.util.AppConstants
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Power-aware Bluetooth management for bitchat
|
||||
* Adjusts scanning, advertising, and connection behavior based on battery state
|
||||
* Process-wide source of truth for background and battery-aware scheduling.
|
||||
*
|
||||
* The manager deliberately exposes policy only. Consumers retain ownership of their hardware and
|
||||
* network jobs, so changing profile never implies disconnecting a transport or suspending Tor.
|
||||
*/
|
||||
class PowerManager(private val context: Context) : LifecycleEventObserver {
|
||||
|
||||
class PowerManager private constructor(context: Context) : LifecycleEventObserver {
|
||||
|
||||
enum class PowerMode {
|
||||
PERFORMANCE,
|
||||
BALANCED,
|
||||
POWER_SAVER,
|
||||
ULTRA_LOW_POWER
|
||||
}
|
||||
|
||||
enum class BatteryBand { NORMAL, LOW, CRITICAL }
|
||||
|
||||
data class BleSchedule(
|
||||
val scanOnMs: Long,
|
||||
val scanOffMs: Long,
|
||||
val continuousScan: Boolean,
|
||||
val rssiPollIntervalMs: Long,
|
||||
val maxConnections: Int = 8
|
||||
)
|
||||
|
||||
data class WifiAwareSchedule(
|
||||
val tcpKeepAliveMs: Long,
|
||||
val discoveryKeepAliveMs: Long,
|
||||
val connectionMaintenanceMs: Long,
|
||||
val discoveryIdleRefreshMs: Long,
|
||||
val discoverySessionRefreshMinMs: Long,
|
||||
val discoveryStaleMs: Long
|
||||
)
|
||||
|
||||
data class NostrSchedule(
|
||||
val presenceHeartbeatMinMs: Long,
|
||||
val presenceHeartbeatMaxMs: Long,
|
||||
val subscriptionValidationMs: Long
|
||||
)
|
||||
|
||||
data class RuntimePerformanceProfile(
|
||||
val mode: PowerMode,
|
||||
val batteryBand: BatteryBand,
|
||||
val isBackground: Boolean,
|
||||
val isCharging: Boolean,
|
||||
val hasDirectPeers: Boolean,
|
||||
val ble: BleSchedule,
|
||||
val meshAnnouncementIntervalMs: Long,
|
||||
val wifiAware: WifiAwareSchedule,
|
||||
val nostr: NostrSchedule
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PowerManager"
|
||||
|
||||
// Battery thresholds
|
||||
private const val CRITICAL_BATTERY = com.bitchat.android.util.AppConstants.Power.CRITICAL_BATTERY_PERCENT
|
||||
private const val LOW_BATTERY = com.bitchat.android.util.AppConstants.Power.LOW_BATTERY_PERCENT
|
||||
private const val MEDIUM_BATTERY = com.bitchat.android.util.AppConstants.Power.MEDIUM_BATTERY_PERCENT
|
||||
|
||||
// Scan duty cycle periods (ms)
|
||||
private const val SCAN_ON_DURATION_NORMAL = com.bitchat.android.util.AppConstants.Power.SCAN_ON_DURATION_NORMAL_MS // 8 seconds on
|
||||
private const val SCAN_OFF_DURATION_NORMAL = com.bitchat.android.util.AppConstants.Power.SCAN_OFF_DURATION_NORMAL_MS // 2 seconds off
|
||||
private const val SCAN_ON_DURATION_POWER_SAVE = com.bitchat.android.util.AppConstants.Power.SCAN_ON_DURATION_POWER_SAVE_MS // 2 seconds on
|
||||
private const val SCAN_OFF_DURATION_POWER_SAVE = com.bitchat.android.util.AppConstants.Power.SCAN_OFF_DURATION_POWER_SAVE_MS // 8 seconds off
|
||||
private const val SCAN_ON_DURATION_ULTRA_LOW = com.bitchat.android.util.AppConstants.Power.SCAN_ON_DURATION_ULTRA_LOW_MS // 1 second on
|
||||
private const val SCAN_OFF_DURATION_ULTRA_LOW = com.bitchat.android.util.AppConstants.Power.SCAN_OFF_DURATION_ULTRA_LOW_MS // 10 seconds off
|
||||
|
||||
// Connection limits
|
||||
private const val MAX_CONNECTIONS_NORMAL = com.bitchat.android.util.AppConstants.Power.MAX_CONNECTIONS_NORMAL
|
||||
private const val MAX_CONNECTIONS_POWER_SAVE = com.bitchat.android.util.AppConstants.Power.MAX_CONNECTIONS_POWER_SAVE
|
||||
private const val MAX_CONNECTIONS_ULTRA_LOW = com.bitchat.android.util.AppConstants.Power.MAX_CONNECTIONS_ULTRA_LOW
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: PowerManager? = null
|
||||
|
||||
fun getInstance(context: Context): PowerManager =
|
||||
INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: PowerManager(context.applicationContext).also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
|
||||
enum class PowerMode {
|
||||
PERFORMANCE, // Full power, no restrictions
|
||||
BALANCED, // Moderate power saving
|
||||
POWER_SAVER, // Aggressive power saving
|
||||
ULTRA_LOW_POWER // Minimal operations only
|
||||
}
|
||||
|
||||
private var currentMode = PowerMode.BALANCED
|
||||
private var isCharging = false
|
||||
private var batteryLevel = 100
|
||||
private var isAppInBackground = true
|
||||
|
||||
private val powerScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var dutyCycleJob: Job? = null
|
||||
|
||||
var delegate: PowerManagerDelegate? = null
|
||||
|
||||
// Battery monitoring
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
@Volatile private var batteryLevel = 100
|
||||
@Volatile private var isCharging = false
|
||||
@Volatile private var isAppInBackground = true
|
||||
@Volatile private var hasDirectPeers = false
|
||||
@Volatile private var shutdown = false
|
||||
|
||||
private val _profile = MutableStateFlow(
|
||||
PowerProfileResolver.resolve(
|
||||
batteryLevel = batteryLevel,
|
||||
isCharging = isCharging,
|
||||
isBackground = isAppInBackground,
|
||||
hasDirectPeers = hasDirectPeers
|
||||
)
|
||||
)
|
||||
val profile: StateFlow<RuntimePerformanceProfile> = _profile.asStateFlow()
|
||||
|
||||
private val batteryReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
Intent.ACTION_BATTERY_CHANGED -> {
|
||||
val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
|
||||
val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
|
||||
if (level != -1 && scale != -1) {
|
||||
batteryLevel = (level * 100) / scale
|
||||
}
|
||||
|
||||
if (level >= 0 && scale > 0) batteryLevel = (level * 100) / scale
|
||||
|
||||
val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
|
||||
isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
|
||||
status == BatteryManager.BATTERY_STATUS_FULL
|
||||
|
||||
updatePowerMode()
|
||||
status == BatteryManager.BATTERY_STATUS_FULL
|
||||
refreshProfile()
|
||||
}
|
||||
Intent.ACTION_POWER_CONNECTED -> {
|
||||
isCharging = true
|
||||
updatePowerMode()
|
||||
refreshProfile()
|
||||
}
|
||||
Intent.ACTION_POWER_DISCONNECTED -> {
|
||||
isCharging = false
|
||||
updatePowerMode()
|
||||
refreshProfile()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
init {
|
||||
registerBatteryReceiver()
|
||||
|
||||
// Register for process lifecycle events on the main thread
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
try {
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||
isAppInBackground = !ProcessLifecycleOwner.get().lifecycle.currentState
|
||||
.isAtLeast(Lifecycle.State.STARTED)
|
||||
refreshProfile()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to register lifecycle observer: ${e.message}")
|
||||
Log.e(TAG, "Failed to register process lifecycle observer: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
updatePowerMode()
|
||||
scope.launch {
|
||||
AppStateStore.directPeers
|
||||
.collect { peers ->
|
||||
hasDirectPeers = peers.isNotEmpty()
|
||||
refreshProfile()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun start() {
|
||||
Log.i(TAG, "Starting power management")
|
||||
startDutyCycle()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
Log.i(TAG, "Stopping power management")
|
||||
powerScope.cancel()
|
||||
unregisterBatteryReceiver()
|
||||
|
||||
// Unregister lifecycle observer
|
||||
|
||||
/** Kept for existing callers; initialization is eager and idempotent. */
|
||||
fun start() = Unit
|
||||
|
||||
/**
|
||||
* Process-level teardown for explicit full application shutdown only.
|
||||
* Transport stop/restart paths must not call this.
|
||||
*/
|
||||
fun shutdown() {
|
||||
if (shutdown) return
|
||||
shutdown = true
|
||||
scope.cancel()
|
||||
try { appContext.unregisterReceiver(batteryReceiver) } catch (_: Exception) { }
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
try {
|
||||
ProcessLifecycleOwner.get().lifecycle.removeObserver(this)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to remove lifecycle observer: ${e.message}")
|
||||
}
|
||||
try { ProcessLifecycleOwner.get().lifecycle.removeObserver(this) } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
refreshProfile()
|
||||
}
|
||||
Lifecycle.Event.ON_STOP -> {
|
||||
Log.d(TAG, "Process lifecycle: ON_STOP (App going to background)")
|
||||
isAppInBackground = true
|
||||
updatePowerMode()
|
||||
refreshProfile()
|
||||
}
|
||||
else -> {}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scan settings optimized for current power mode
|
||||
*/
|
||||
|
||||
fun getScanSettings(): ScanSettings {
|
||||
val current = profile.value
|
||||
val builder = ScanSettings.Builder()
|
||||
.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
|
||||
|
||||
when (currentMode) {
|
||||
when (current.mode) {
|
||||
PowerMode.PERFORMANCE -> builder
|
||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||||
.setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
|
||||
.setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT)
|
||||
|
||||
PowerMode.BALANCED -> builder
|
||||
.setScanMode(ScanSettings.SCAN_MODE_BALANCED)
|
||||
.setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
|
||||
.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
|
||||
|
||||
PowerMode.POWER_SAVER -> builder
|
||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
|
||||
.setMatchMode(ScanSettings.MATCH_MODE_STICKY)
|
||||
.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
|
||||
|
||||
PowerMode.POWER_SAVER,
|
||||
PowerMode.ULTRA_LOW_POWER -> builder
|
||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
|
||||
.setMatchMode(ScanSettings.MATCH_MODE_STICKY)
|
||||
.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
|
||||
}
|
||||
|
||||
return builder.setReportDelay(0).build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get advertising settings optimized for current power mode
|
||||
*/
|
||||
fun getAdvertiseSettings(): AdvertiseSettings {
|
||||
return when (currentMode) {
|
||||
PowerMode.PERFORMANCE -> AdvertiseSettings.Builder()
|
||||
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
|
||||
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
|
||||
.setConnectable(true)
|
||||
.setTimeout(0)
|
||||
.build()
|
||||
|
||||
PowerMode.BALANCED -> AdvertiseSettings.Builder()
|
||||
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED)
|
||||
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM)
|
||||
.setConnectable(true)
|
||||
.setTimeout(0)
|
||||
.build()
|
||||
|
||||
PowerMode.POWER_SAVER -> AdvertiseSettings.Builder()
|
||||
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_POWER)
|
||||
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_LOW)
|
||||
.setConnectable(true)
|
||||
.setTimeout(0)
|
||||
.build()
|
||||
|
||||
PowerMode.ULTRA_LOW_POWER -> AdvertiseSettings.Builder()
|
||||
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_POWER)
|
||||
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_ULTRA_LOW)
|
||||
.setConnectable(true)
|
||||
.setTimeout(0)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun getAdvertiseSettings(): AdvertiseSettings = when (profile.value.mode) {
|
||||
PowerMode.PERFORMANCE -> AdvertiseSettings.Builder()
|
||||
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
|
||||
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
|
||||
.setConnectable(true).setTimeout(0).build()
|
||||
PowerMode.BALANCED -> AdvertiseSettings.Builder()
|
||||
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED)
|
||||
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM)
|
||||
.setConnectable(true).setTimeout(0).build()
|
||||
PowerMode.POWER_SAVER -> AdvertiseSettings.Builder()
|
||||
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_POWER)
|
||||
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_LOW)
|
||||
.setConnectable(true).setTimeout(0).build()
|
||||
PowerMode.ULTRA_LOW_POWER -> AdvertiseSettings.Builder()
|
||||
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_POWER)
|
||||
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_ULTRA_LOW)
|
||||
.setConnectable(true).setTimeout(0).build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maximum allowed connections for current power mode
|
||||
*/
|
||||
fun getMaxConnections(): Int {
|
||||
return when (currentMode) {
|
||||
PowerMode.PERFORMANCE -> MAX_CONNECTIONS_NORMAL
|
||||
PowerMode.BALANCED -> MAX_CONNECTIONS_NORMAL
|
||||
PowerMode.POWER_SAVER -> MAX_CONNECTIONS_POWER_SAVE
|
||||
PowerMode.ULTRA_LOW_POWER -> MAX_CONNECTIONS_ULTRA_LOW
|
||||
}
|
||||
|
||||
fun getMaxConnections(): Int = profile.value.ble.maxConnections
|
||||
|
||||
fun getRSSIThreshold(): Int = when (profile.value.mode) {
|
||||
PowerMode.PERFORMANCE -> -95
|
||||
PowerMode.BALANCED -> -85
|
||||
PowerMode.POWER_SAVER -> -75
|
||||
PowerMode.ULTRA_LOW_POWER -> -65
|
||||
}
|
||||
|
||||
/**
|
||||
* Get RSSI filter threshold for current power mode
|
||||
*/
|
||||
fun getRSSIThreshold(): Int {
|
||||
return when (currentMode) {
|
||||
PowerMode.PERFORMANCE -> -95
|
||||
PowerMode.BALANCED -> -85
|
||||
PowerMode.POWER_SAVER -> -75
|
||||
PowerMode.ULTRA_LOW_POWER -> -65
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if duty cycling should be used
|
||||
*/
|
||||
fun shouldUseDutyCycle(): Boolean {
|
||||
return currentMode != PowerMode.PERFORMANCE
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current power mode information
|
||||
*/
|
||||
fun getPowerInfo(): String {
|
||||
return buildString {
|
||||
|
||||
fun shouldUseDutyCycle(): Boolean = !profile.value.ble.continuousScan
|
||||
|
||||
fun getPowerInfo(): String = profile.value.let { current ->
|
||||
buildString {
|
||||
appendLine("=== Power Manager Status ===")
|
||||
appendLine("Current Mode: $currentMode")
|
||||
appendLine("Battery Level: $batteryLevel%")
|
||||
appendLine("Is Charging: $isCharging")
|
||||
appendLine("App In Background: $isAppInBackground")
|
||||
appendLine("Max Connections: ${getMaxConnections()}")
|
||||
appendLine("RSSI Threshold: ${getRSSIThreshold()} dBm")
|
||||
appendLine("Use Duty Cycle: ${shouldUseDutyCycle()}")
|
||||
appendLine("Current Mode: ${current.mode}")
|
||||
appendLine("Battery Band: ${current.batteryBand} ($batteryLevel%)")
|
||||
appendLine("Is Charging: ${current.isCharging}")
|
||||
appendLine("App In Background: ${current.isBackground}")
|
||||
appendLine("Has Direct Peers: ${current.hasDirectPeers}")
|
||||
appendLine("BLE Scan: ${current.ble.scanOnMs}ms ON / ${current.ble.scanOffMs}ms OFF")
|
||||
appendLine("Max Connections: ${current.ble.maxConnections}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePowerMode() {
|
||||
// Determine the base mode from battery/charging state only
|
||||
val baseMode = when {
|
||||
// Charging in foreground may use performance
|
||||
isCharging && !isAppInBackground -> PowerMode.PERFORMANCE
|
||||
|
||||
// Critical battery - force ultra low power regardless of foreground/background
|
||||
batteryLevel <= CRITICAL_BATTERY -> PowerMode.ULTRA_LOW_POWER
|
||||
|
||||
// Low battery - prefer power saver
|
||||
batteryLevel <= LOW_BATTERY -> PowerMode.POWER_SAVER
|
||||
|
||||
// Otherwise balanced
|
||||
else -> PowerMode.BALANCED
|
||||
}
|
||||
|
||||
// If app is in background (including when running as a foreground service),
|
||||
// cap the power mode to at least POWER_SAVER. Preserve ULTRA_LOW_POWER.
|
||||
val newMode = if (isAppInBackground) {
|
||||
if (baseMode == PowerMode.ULTRA_LOW_POWER) PowerMode.ULTRA_LOW_POWER else PowerMode.POWER_SAVER
|
||||
} else {
|
||||
baseMode
|
||||
}
|
||||
|
||||
if (newMode != currentMode) {
|
||||
val oldMode = currentMode
|
||||
currentMode = newMode
|
||||
Log.i(TAG, "Power mode changed: $oldMode → $newMode (battery: $batteryLevel%, charging: $isCharging, background: $isAppInBackground)")
|
||||
|
||||
delegate?.onPowerModeChanged(currentMode)
|
||||
|
||||
// Restart duty cycle with new parameters
|
||||
if (shouldUseDutyCycle()) {
|
||||
startDutyCycle()
|
||||
} else {
|
||||
stopDutyCycle()
|
||||
}
|
||||
private fun refreshProfile() {
|
||||
if (shutdown) return
|
||||
val next = PowerProfileResolver.resolve(
|
||||
batteryLevel = batteryLevel,
|
||||
isCharging = isCharging,
|
||||
isBackground = isAppInBackground,
|
||||
hasDirectPeers = hasDirectPeers
|
||||
)
|
||||
if (_profile.value != next) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Profile changed: mode=${next.mode}, battery=${next.batteryBand}, " +
|
||||
"background=${next.isBackground}, directPeers=${next.hasDirectPeers}"
|
||||
)
|
||||
_profile.value = next
|
||||
}
|
||||
}
|
||||
|
||||
private fun startDutyCycle() {
|
||||
stopDutyCycle()
|
||||
|
||||
if (!shouldUseDutyCycle()) {
|
||||
delegate?.onScanStateChanged(true) // Always scan in performance mode
|
||||
return
|
||||
}
|
||||
|
||||
val (onDuration, offDuration) = when (currentMode) {
|
||||
PowerMode.BALANCED -> SCAN_ON_DURATION_NORMAL to SCAN_OFF_DURATION_NORMAL
|
||||
PowerMode.POWER_SAVER -> SCAN_ON_DURATION_POWER_SAVE to SCAN_OFF_DURATION_POWER_SAVE
|
||||
PowerMode.ULTRA_LOW_POWER -> SCAN_ON_DURATION_ULTRA_LOW to SCAN_OFF_DURATION_ULTRA_LOW
|
||||
PowerMode.PERFORMANCE -> return // No duty cycle
|
||||
}
|
||||
|
||||
dutyCycleJob = powerScope.launch {
|
||||
while (isActive && shouldUseDutyCycle()) {
|
||||
// Scan ON period
|
||||
Log.d(TAG, "Duty cycle: Scan ON for ${onDuration}ms")
|
||||
delegate?.onScanStateChanged(true)
|
||||
delay(onDuration)
|
||||
|
||||
// Scan OFF period (keep advertising active)
|
||||
if (isActive && shouldUseDutyCycle()) {
|
||||
Log.d(TAG, "Duty cycle: Scan OFF for ${offDuration}ms")
|
||||
delegate?.onScanStateChanged(false)
|
||||
delay(offDuration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.i(TAG, "Started duty cycle: ${onDuration}ms ON, ${offDuration}ms OFF")
|
||||
}
|
||||
|
||||
private fun stopDutyCycle() {
|
||||
dutyCycleJob?.cancel()
|
||||
dutyCycleJob = null
|
||||
}
|
||||
|
||||
|
||||
private fun registerBatteryReceiver() {
|
||||
try {
|
||||
val filter = IntentFilter().apply {
|
||||
@ -342,25 +276,141 @@ class PowerManager(private val context: Context) : LifecycleEventObserver {
|
||||
addAction(Intent.ACTION_POWER_CONNECTED)
|
||||
addAction(Intent.ACTION_POWER_DISCONNECTED)
|
||||
}
|
||||
context.registerReceiver(batteryReceiver, filter)
|
||||
appContext.registerReceiver(batteryReceiver, filter)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to register battery receiver: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun unregisterBatteryReceiver() {
|
||||
try {
|
||||
context.unregisterReceiver(batteryReceiver)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to unregister battery receiver: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegate interface for power management callbacks
|
||||
* Pure policy resolver so cadence and battery-boundary behavior can be tested without Android.
|
||||
*/
|
||||
interface PowerManagerDelegate {
|
||||
fun onPowerModeChanged(newMode: PowerManager.PowerMode)
|
||||
fun onScanStateChanged(shouldScan: Boolean)
|
||||
internal object PowerProfileResolver {
|
||||
fun resolve(
|
||||
batteryLevel: Int,
|
||||
isCharging: Boolean,
|
||||
isBackground: Boolean,
|
||||
hasDirectPeers: Boolean
|
||||
): PowerManager.RuntimePerformanceProfile {
|
||||
val batteryBand = when {
|
||||
batteryLevel <= AppConstants.Power.CRITICAL_BATTERY_PERCENT ->
|
||||
PowerManager.BatteryBand.CRITICAL
|
||||
batteryLevel <= AppConstants.Power.LOW_BATTERY_PERCENT ->
|
||||
PowerManager.BatteryBand.LOW
|
||||
else -> PowerManager.BatteryBand.NORMAL
|
||||
}
|
||||
|
||||
val mode = when {
|
||||
isBackground && batteryBand == PowerManager.BatteryBand.CRITICAL ->
|
||||
PowerManager.PowerMode.ULTRA_LOW_POWER
|
||||
isBackground -> PowerManager.PowerMode.POWER_SAVER
|
||||
isCharging -> PowerManager.PowerMode.PERFORMANCE
|
||||
batteryBand == PowerManager.BatteryBand.CRITICAL ->
|
||||
PowerManager.PowerMode.ULTRA_LOW_POWER
|
||||
batteryBand == PowerManager.BatteryBand.LOW ->
|
||||
PowerManager.PowerMode.POWER_SAVER
|
||||
else -> PowerManager.PowerMode.BALANCED
|
||||
}
|
||||
|
||||
val ble = when {
|
||||
isBackground && hasDirectPeers ->
|
||||
PowerManager.BleSchedule(
|
||||
scanOnMs = 1_000L,
|
||||
scanOffMs = 29_000L,
|
||||
continuousScan = false,
|
||||
rssiPollIntervalMs = backgroundRssiInterval(batteryBand)
|
||||
)
|
||||
isBackground ->
|
||||
PowerManager.BleSchedule(
|
||||
scanOnMs = 1_000L,
|
||||
scanOffMs = 59_000L,
|
||||
continuousScan = false,
|
||||
rssiPollIntervalMs = backgroundRssiInterval(batteryBand)
|
||||
)
|
||||
mode == PowerManager.PowerMode.PERFORMANCE ->
|
||||
PowerManager.BleSchedule(Long.MAX_VALUE, 0L, true, 5_000L)
|
||||
mode == PowerManager.PowerMode.BALANCED ->
|
||||
PowerManager.BleSchedule(8_000L, 2_000L, false, 10_000L)
|
||||
mode == PowerManager.PowerMode.POWER_SAVER ->
|
||||
PowerManager.BleSchedule(2_000L, 28_000L, false, 30_000L)
|
||||
else ->
|
||||
PowerManager.BleSchedule(1_000L, 29_000L, false, 60_000L)
|
||||
}
|
||||
|
||||
val announcementInterval = when {
|
||||
isBackground && batteryBand == PowerManager.BatteryBand.NORMAL -> 60_000L
|
||||
isBackground && batteryBand == PowerManager.BatteryBand.LOW -> 120_000L
|
||||
isBackground -> 300_000L
|
||||
mode == PowerManager.PowerMode.POWER_SAVER -> 60_000L
|
||||
mode == PowerManager.PowerMode.ULTRA_LOW_POWER -> 120_000L
|
||||
else -> 30_000L
|
||||
}
|
||||
|
||||
val wifi = when {
|
||||
isBackground && batteryBand == PowerManager.BatteryBand.NORMAL ->
|
||||
wifi(10, 60, 60, 5, 5, 10)
|
||||
isBackground && batteryBand == PowerManager.BatteryBand.LOW ->
|
||||
wifi(20, 120, 120, 10, 10, 15)
|
||||
isBackground ->
|
||||
wifi(30, 180, 180, 15, 15, 20)
|
||||
mode == PowerManager.PowerMode.PERFORMANCE ->
|
||||
PowerManager.WifiAwareSchedule(2_000L, 20_000L, 15_000L, 120_000L, 90_000L, 300_000L)
|
||||
mode == PowerManager.PowerMode.BALANCED ->
|
||||
PowerManager.WifiAwareSchedule(5_000L, 30_000L, 30_000L, 180_000L, 120_000L, 300_000L)
|
||||
mode == PowerManager.PowerMode.POWER_SAVER ->
|
||||
PowerManager.WifiAwareSchedule(10_000L, 60_000L, 60_000L, 300_000L, 180_000L, 600_000L)
|
||||
else ->
|
||||
PowerManager.WifiAwareSchedule(15_000L, 90_000L, 90_000L, 600_000L, 300_000L, 900_000L)
|
||||
}
|
||||
|
||||
val nostr = when {
|
||||
isBackground && batteryBand == PowerManager.BatteryBand.NORMAL ->
|
||||
PowerManager.NostrSchedule(300_000L, 330_000L, 300_000L)
|
||||
isBackground && batteryBand == PowerManager.BatteryBand.LOW ->
|
||||
PowerManager.NostrSchedule(600_000L, 660_000L, 600_000L)
|
||||
isBackground ->
|
||||
PowerManager.NostrSchedule(900_000L, 990_000L, 900_000L)
|
||||
mode == PowerManager.PowerMode.POWER_SAVER ->
|
||||
PowerManager.NostrSchedule(120_000L, 132_000L, 60_000L)
|
||||
mode == PowerManager.PowerMode.ULTRA_LOW_POWER ->
|
||||
PowerManager.NostrSchedule(300_000L, 330_000L, 120_000L)
|
||||
else ->
|
||||
PowerManager.NostrSchedule(40_000L, 80_000L, 30_000L)
|
||||
}
|
||||
|
||||
return PowerManager.RuntimePerformanceProfile(
|
||||
mode = mode,
|
||||
batteryBand = batteryBand,
|
||||
isBackground = isBackground,
|
||||
isCharging = isCharging,
|
||||
hasDirectPeers = hasDirectPeers,
|
||||
ble = ble,
|
||||
meshAnnouncementIntervalMs = announcementInterval,
|
||||
wifiAware = wifi,
|
||||
nostr = nostr
|
||||
)
|
||||
}
|
||||
|
||||
private fun wifi(
|
||||
tcpSeconds: Long,
|
||||
discoverySeconds: Long,
|
||||
maintenanceSeconds: Long,
|
||||
idleMinutes: Long,
|
||||
minRefreshMinutes: Long,
|
||||
staleMinutes: Long
|
||||
) = PowerManager.WifiAwareSchedule(
|
||||
tcpKeepAliveMs = tcpSeconds * 1_000L,
|
||||
discoveryKeepAliveMs = discoverySeconds * 1_000L,
|
||||
connectionMaintenanceMs = maintenanceSeconds * 1_000L,
|
||||
discoveryIdleRefreshMs = idleMinutes * 60_000L,
|
||||
discoverySessionRefreshMinMs = minRefreshMinutes * 60_000L,
|
||||
discoveryStaleMs = staleMinutes * 60_000L
|
||||
)
|
||||
|
||||
private fun backgroundRssiInterval(band: PowerManager.BatteryBand): Long = when (band) {
|
||||
PowerManager.BatteryBand.NORMAL -> 60_000L
|
||||
PowerManager.BatteryBand.LOW -> 120_000L
|
||||
PowerManager.BatteryBand.CRITICAL -> 300_000L
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,82 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
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 kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* Sends small idempotent control packets redundantly while serializing the attempts submitted
|
||||
* through this sender. Android BLE only reports that a GATT write/notification was accepted;
|
||||
* it does not prove that the remote application processed the packet. Reusing the exact encoded
|
||||
* packet makes retries safe: the receiver's packet/replay protection drops copies it already saw.
|
||||
*/
|
||||
internal class RetryingControlPacketSender(
|
||||
private val scope: CoroutineScope,
|
||||
private val maxAttempts: Int = 3,
|
||||
private val retryDelayMs: Long = 750L,
|
||||
private val interSendDelayMs: Long = 75L
|
||||
) {
|
||||
private val sendMutex = Mutex()
|
||||
private val jobsLock = Any()
|
||||
private val activeJobs = mutableMapOf<String, Job>()
|
||||
|
||||
init {
|
||||
require(maxAttempts > 0)
|
||||
require(retryDelayMs >= 0)
|
||||
require(interSendDelayMs >= 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Coalesces concurrent requests for the same logical packet. Once the retry window finishes,
|
||||
* a later user action may enqueue the packet again; duplicate receipt processing is idempotent.
|
||||
*/
|
||||
fun enqueue(
|
||||
key: String,
|
||||
sendAttempt: suspend (attempt: Int) -> Boolean,
|
||||
onComplete: (acceptedAtLeastOnce: Boolean) -> Unit = {}
|
||||
) {
|
||||
val job = synchronized(jobsLock) {
|
||||
if (activeJobs[key]?.isActive == true) return
|
||||
|
||||
scope.launch(start = CoroutineStart.LAZY) {
|
||||
var acceptedAtLeastOnce = false
|
||||
var completed = false
|
||||
try {
|
||||
repeat(maxAttempts) { index ->
|
||||
if (!isActive) return@launch
|
||||
val attempt = index + 1
|
||||
sendMutex.withLock {
|
||||
try {
|
||||
val accepted = try {
|
||||
sendAttempt(attempt)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
acceptedAtLeastOnce = accepted || acceptedAtLeastOnce
|
||||
} finally {
|
||||
if (interSendDelayMs > 0) delay(interSendDelayMs)
|
||||
}
|
||||
}
|
||||
if (attempt < maxAttempts && retryDelayMs > 0) {
|
||||
delay(retryDelayMs)
|
||||
}
|
||||
}
|
||||
completed = true
|
||||
} finally {
|
||||
synchronized(jobsLock) {
|
||||
activeJobs.remove(key)
|
||||
}
|
||||
if (completed) {
|
||||
try { onComplete(acceptedAtLeastOnce) } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
}.also { activeJobs[key] = it }
|
||||
}
|
||||
job.start()
|
||||
}
|
||||
}
|
||||
@ -25,12 +25,14 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
private const val CLEANUP_INTERVAL = com.bitchat.android.util.AppConstants.Security.CLEANUP_INTERVAL_MS // 5 minutes
|
||||
private const val MAX_PROCESSED_MESSAGES = com.bitchat.android.util.AppConstants.Security.MAX_PROCESSED_MESSAGES
|
||||
private const val MAX_PROCESSED_KEY_EXCHANGES = com.bitchat.android.util.AppConstants.Security.MAX_PROCESSED_KEY_EXCHANGES
|
||||
private const val KEY_EXCHANGE_DEDUP_TIMEOUT = com.bitchat.android.util.AppConstants.Security.KEY_EXCHANGE_DEDUP_TIMEOUT_MS
|
||||
}
|
||||
|
||||
// Security tracking
|
||||
private val processedMessages = Collections.synchronizedSet(mutableSetOf<String>())
|
||||
private val processedKeyExchanges = Collections.synchronizedSet(mutableSetOf<String>())
|
||||
private val messageTimestamps = Collections.synchronizedMap(mutableMapOf<String, Long>())
|
||||
private val keyExchangeTimestamps = Collections.synchronizedMap(mutableMapOf<String, Long>())
|
||||
|
||||
// Delegate for callbacks
|
||||
var delegate: SecurityManagerDelegate? = null
|
||||
@ -76,7 +78,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
|
||||
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,
|
||||
// This ensures we observe the same peer on a new direct transport connection,
|
||||
// while still dropping looped/relayed duplicates.
|
||||
val isFreshAnnounce = messageType == MessageType.ANNOUNCE &&
|
||||
packet.ttl >= com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
|
||||
@ -134,6 +136,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
// from ambient session state: a rejected replacement may leave the old session active.
|
||||
val result = encryptionService.processHandshakeMessageWithResult(packet.payload, peerID)
|
||||
processedKeyExchanges.add(exchangeKey)
|
||||
keyExchangeTimestamps[exchangeKey] = System.currentTimeMillis()
|
||||
|
||||
if (result.response != null) {
|
||||
// Send handshake response through delegate
|
||||
@ -393,8 +396,8 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
/**
|
||||
* Clean up old processed messages and timestamps
|
||||
*/
|
||||
private fun cleanupOldData() {
|
||||
val cutoffTime = System.currentTimeMillis() - MESSAGE_TIMEOUT
|
||||
internal fun cleanupOldData(nowMs: Long = System.currentTimeMillis()) {
|
||||
val cutoffTime = nowMs - MESSAGE_TIMEOUT
|
||||
|
||||
// Clean up old message timestamps and corresponding processed messages
|
||||
val messagesToRemove = messageTimestamps.entries.filter { (_, timestamp) ->
|
||||
@ -413,12 +416,25 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
processedMessages.removeAll(toRemove.toSet())
|
||||
removeFromMessageTimestamps(toRemove)
|
||||
}
|
||||
|
||||
|
||||
// Expire handshake dedup entries by time so a delayed same-ephemeral delivery
|
||||
// (e.g. a re-handshake retry after a failed attempt) is not blocked forever.
|
||||
val keyExchangeCutoff = nowMs - KEY_EXCHANGE_DEDUP_TIMEOUT
|
||||
val keyExchangesToRemove = keyExchangeTimestamps.entries.filter { (_, timestamp) ->
|
||||
timestamp < keyExchangeCutoff
|
||||
}.map { it.key }
|
||||
|
||||
keyExchangesToRemove.forEach { exchangeKey ->
|
||||
keyExchangeTimestamps.remove(exchangeKey)
|
||||
processedKeyExchanges.remove(exchangeKey)
|
||||
}
|
||||
|
||||
// Limit the size of processed key exchanges set
|
||||
if (processedKeyExchanges.size > MAX_PROCESSED_KEY_EXCHANGES) {
|
||||
val excess = processedKeyExchanges.size - MAX_PROCESSED_KEY_EXCHANGES
|
||||
val toRemove = processedKeyExchanges.take(excess)
|
||||
processedKeyExchanges.removeAll(toRemove.toSet())
|
||||
toRemove.forEach { keyExchangeTimestamps.remove(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@ -438,6 +454,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
processedMessages.clear()
|
||||
processedKeyExchanges.clear()
|
||||
messageTimestamps.clear()
|
||||
keyExchangeTimestamps.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -7,6 +7,16 @@ import com.bitchat.android.model.BitchatFilePacket
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.noise.NoiseSession
|
||||
import com.bitchat.android.wifiaware.WifiAwareController
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Feature-facing mesh service that hides local transport selection from the rest of the app.
|
||||
@ -24,6 +34,10 @@ class UnifiedMeshService(
|
||||
private const val TAG = "UnifiedMeshService"
|
||||
}
|
||||
|
||||
private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
private val powerManager = PowerManager.getInstance(context.applicationContext)
|
||||
private var announcementJob: Job? = null
|
||||
|
||||
override val myPeerID: String
|
||||
get() = bluetooth.myPeerID
|
||||
|
||||
@ -49,14 +63,37 @@ class UnifiedMeshService(
|
||||
try { WifiAwareController.startIfPossible() } catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to start Wi-Fi Aware transport: ${e.message}")
|
||||
}
|
||||
startAnnouncementScheduler()
|
||||
refreshDelegates()
|
||||
}
|
||||
|
||||
override fun stopServices() {
|
||||
announcementJob?.cancel()
|
||||
announcementJob = null
|
||||
try { bluetooth.stopServices() } catch (_: Exception) { }
|
||||
try { WifiAwareController.stop() } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
private fun startAnnouncementScheduler() {
|
||||
if (announcementJob?.isActive == true) return
|
||||
announcementJob = serviceScope.launch {
|
||||
powerManager.profile
|
||||
.map { profile ->
|
||||
profile.meshAnnouncementIntervalMs to profile.hasDirectPeers
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { (intervalMs, hasRecipients) ->
|
||||
if (!hasRecipients) return@collectLatest
|
||||
// Connection-specific paths already send an immediate announce. Begin the
|
||||
// periodic cadence after the configured interval to avoid a transition burst.
|
||||
while (isActive) {
|
||||
delay(intervalMs)
|
||||
if (powerManager.profile.value.hasDirectPeers) sendBroadcastAnnounce()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendMessage(content: String, mentions: List<String>, channel: String?) {
|
||||
when {
|
||||
isBleEnabled() -> bluetooth.sendMessage(content, mentions, channel)
|
||||
|
||||
@ -69,7 +69,15 @@ data class BitchatMessage(
|
||||
val encryptedContent: ByteArray? = null,
|
||||
val isEncrypted: Boolean = false,
|
||||
val deliveryStatus: DeliveryStatus? = null,
|
||||
val powDifficulty: Int? = null
|
||||
val powDifficulty: Int? = null,
|
||||
/**
|
||||
* Full canonical Nostr public key supplied by the local Nostr bridge.
|
||||
*
|
||||
* This is local identity metadata, not part of the Bitchat binary wire format. It lets UI
|
||||
* surfaces color the sender by the same stable key while [senderPeerID] remains available for
|
||||
* mesh IDs and private-chat routing aliases.
|
||||
*/
|
||||
val senderNostrPubkey: String? = null
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
@ -355,4 +363,3 @@ data class BitchatMessage(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -133,6 +133,9 @@ class ArtiTorManager private constructor() {
|
||||
lastLogTime.set(System.currentTimeMillis())
|
||||
_statusFlow.update { it.copy(lastLogLine = s) }
|
||||
handleArtiLogLine(s)
|
||||
if (_statusFlow.value.bootstrapPercent < 100) {
|
||||
armBootstrapInactivityWatchdog()
|
||||
}
|
||||
}
|
||||
|
||||
artiProxy = ArtiProxy.Builder(application)
|
||||
@ -374,29 +377,30 @@ class ArtiTorManager private constructor() {
|
||||
}
|
||||
|
||||
private fun startInactivityMonitoring() {
|
||||
inactivityJob?.cancel()
|
||||
inactivityJob = appScope.launch {
|
||||
while (true) {
|
||||
delay(INACTIVITY_TIMEOUT_MS)
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val lastActivity = lastLogTime.get()
|
||||
val timeSinceLastActivity = currentTime - lastActivity
|
||||
armBootstrapInactivityWatchdog()
|
||||
}
|
||||
|
||||
if (timeSinceLastActivity > INACTIVITY_TIMEOUT_MS) {
|
||||
val currentMode = _statusFlow.value.mode
|
||||
if (currentMode == TorMode.ON) {
|
||||
val bootstrapPercent = _statusFlow.value.bootstrapPercent
|
||||
if (bootstrapPercent < 100) {
|
||||
Log.w(TAG, "Inactivity detected (${timeSinceLastActivity}ms), restarting Arti")
|
||||
currentApplication?.let { app ->
|
||||
appScope.launch {
|
||||
restartArti(app)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* One-shot startup watchdog. Arti log activity re-arms it and successful bootstrap cancels it,
|
||||
* so a healthy background Tor process has no permanent five-second polling coroutine.
|
||||
*/
|
||||
private fun armBootstrapInactivityWatchdog() {
|
||||
inactivityJob?.cancel()
|
||||
if (lifecycleState != LifecycleState.RUNNING || _statusFlow.value.bootstrapPercent >= 100) {
|
||||
inactivityJob = null
|
||||
return
|
||||
}
|
||||
inactivityJob = appScope.launch {
|
||||
delay(INACTIVITY_TIMEOUT_MS)
|
||||
val timeSinceLastActivity = System.currentTimeMillis() - lastLogTime.get()
|
||||
if (
|
||||
timeSinceLastActivity >= INACTIVITY_TIMEOUT_MS &&
|
||||
_statusFlow.value.mode == TorMode.ON &&
|
||||
_statusFlow.value.bootstrapPercent < 100 &&
|
||||
lifecycleState == LifecycleState.RUNNING
|
||||
) {
|
||||
Log.w(TAG, "Bootstrap inactivity detected (${timeSinceLastActivity}ms), restarting Arti")
|
||||
currentApplication?.let { restartArti(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -494,6 +498,7 @@ class ArtiTorManager private constructor() {
|
||||
running = true
|
||||
)
|
||||
}
|
||||
stopInactivityMonitoring()
|
||||
completeWaitersIf(TorState.RUNNING)
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,8 @@ package com.bitchat.android.noise
|
||||
|
||||
import android.util.Log
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
data class NoiseHandshakeProcessingResult(
|
||||
val response: ByteArray?,
|
||||
@ -50,15 +52,30 @@ class NoiseSessionManager(
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NoiseSessionManager"
|
||||
private const val HANDSHAKE_TIMEOUT_MS = 20_000L
|
||||
private const val HANDSHAKE_TIMEOUT_MS = 10_000L
|
||||
private const val HANDSHAKE_SWEEP_INTERVAL_MS = 2_000L
|
||||
private const val HANDSHAKE_MESSAGE_1_SIZE = 32
|
||||
private const val SESSION_TOKEN_SIZE = 32
|
||||
}
|
||||
|
||||
|
||||
private val sessions = ConcurrentHashMap<String, NoiseSession>()
|
||||
// An inbound replacement handshake must prove its authenticated static-key binding before it
|
||||
// can evict a working transport session. Keep responder candidates outside the active map.
|
||||
private val responderCandidates = ConcurrentHashMap<String, NoiseSession>()
|
||||
|
||||
private val sweepScheduler = Executors.newSingleThreadScheduledExecutor { runnable ->
|
||||
Thread(runnable, "NoiseHandshakeSweeper").apply { isDaemon = true }
|
||||
}
|
||||
|
||||
init {
|
||||
sweepScheduler.scheduleWithFixedDelay({
|
||||
try {
|
||||
cleanupStaleHandshakes(System.currentTimeMillis())
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Handshake sweep failed: ${e.message}")
|
||||
}
|
||||
}, HANDSHAKE_SWEEP_INTERVAL_MS, HANDSHAKE_SWEEP_INTERVAL_MS, TimeUnit.MILLISECONDS)
|
||||
}
|
||||
|
||||
// Callbacks
|
||||
var onSessionEstablished: ((String, ByteArray) -> Unit)? = null
|
||||
@ -289,6 +306,31 @@ class NoiseSessionManager(
|
||||
if (lastActivity == null) return false
|
||||
return (nowMs - lastActivity) > HANDSHAKE_TIMEOUT_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* Actively expire handshakes that stopped progressing (lost response, abandoned
|
||||
* responder candidates). Established sessions are never touched here.
|
||||
*/
|
||||
@Synchronized
|
||||
fun cleanupStaleHandshakes(nowMs: Long) {
|
||||
sessions.entries.toList().forEach { (peerID, session) ->
|
||||
if (session.isHandshaking() && isHandshakeStale(session, nowMs)) {
|
||||
Log.d(TAG, "Expiring stale handshake with $peerID")
|
||||
if (sessions.remove(peerID, session)) {
|
||||
session.destroy()
|
||||
runCatching { onSessionFailed?.invoke(peerID, NoiseSessionError.HandshakeTimeout) }
|
||||
}
|
||||
}
|
||||
}
|
||||
responderCandidates.entries.toList().forEach { (peerID, session) ->
|
||||
if (session.isHandshaking() && isHandshakeStale(session, nowMs)) {
|
||||
Log.d(TAG, "Expiring stale responder candidate for $peerID")
|
||||
if (responderCandidates.remove(peerID, session)) {
|
||||
session.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SIMPLIFIED: Encrypt data
|
||||
@ -427,6 +469,7 @@ class NoiseSessionManager(
|
||||
*/
|
||||
@Synchronized
|
||||
fun shutdown() {
|
||||
sweepScheduler.shutdownNow()
|
||||
sessions.values.forEach { it.destroy() }
|
||||
responderCandidates.values.forEach { it.destroy() }
|
||||
sessions.clear()
|
||||
@ -443,6 +486,7 @@ sealed class NoiseSessionError(message: String, cause: Throwable? = null) : Exce
|
||||
object SessionNotEstablished : NoiseSessionError("Session not established")
|
||||
object InvalidState : NoiseSessionError("Session in invalid state")
|
||||
object HandshakeFailed : NoiseSessionError("Handshake failed")
|
||||
object HandshakeTimeout : NoiseSessionError("Handshake timed out")
|
||||
object AlreadyEstablished : NoiseSessionError("Session already established")
|
||||
object SessionGenerationChanged : NoiseSessionError("Noise session generation changed")
|
||||
class PeerIdentityMismatch(claimedPeerID: String, derivedPeerID: String?) : NoiseSessionError(
|
||||
|
||||
@ -3,8 +3,6 @@ package com.bitchat.android.nostr
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.ui.ChatState
|
||||
import com.bitchat.android.ui.MessageManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Date
|
||||
@ -17,11 +15,10 @@ import java.util.Date
|
||||
*/
|
||||
class GeohashMessageHandler(
|
||||
private val application: Application,
|
||||
private val state: ChatState,
|
||||
private val messageManager: MessageManager,
|
||||
private val repo: GeohashRepository,
|
||||
private val scope: CoroutineScope,
|
||||
private val dataManager: com.bitchat.android.ui.DataManager
|
||||
private val dataManager: com.bitchat.android.ui.DataManager,
|
||||
private val addChannelMessage: (String, BitchatMessage) -> Unit
|
||||
) {
|
||||
companion object { private const val TAG = "GeohashMessageHandler" }
|
||||
|
||||
@ -96,13 +93,14 @@ class GeohashMessageHandler(
|
||||
isRelay = false,
|
||||
originalSender = repo.displayNameForNostrPubkey(pubkey),
|
||||
senderPeerID = "nostr:${pubkey.take(8)}",
|
||||
senderNostrPubkey = pubkey,
|
||||
mentions = null,
|
||||
channel = "#$subscribedGeohash",
|
||||
powDifficulty = try {
|
||||
if (hasNonce) NostrProofOfWork.calculateDifficulty(event.id).takeIf { it > 0 } else null
|
||||
} catch (_: Exception) { null }
|
||||
)
|
||||
messageManager.addChannelMessage("geo:$subscribedGeohash", msg)
|
||||
addChannelMessage("geo:$subscribedGeohash", msg)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "onEvent error: ${e.message}")
|
||||
}
|
||||
|
||||
@ -28,14 +28,17 @@ class GeohashRepository(
|
||||
// conversation key (e.g., "nostr_<pub16>") -> source geohash it belongs to
|
||||
private val conversationGeohash: MutableMap<String, String> = mutableMapOf()
|
||||
|
||||
@Synchronized
|
||||
fun setConversationGeohash(convKey: String, geohash: String) {
|
||||
if (geohash.isNotEmpty()) {
|
||||
conversationGeohash[convKey] = geohash
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getConversationGeohash(convKey: String): String? = conversationGeohash[convKey]
|
||||
|
||||
@Synchronized
|
||||
fun findPubkeyByNickname(targetNickname: String): String? {
|
||||
return geoNicknames.entries.firstOrNull { (_, nickname) ->
|
||||
val base = nickname.split("#").firstOrNull() ?: nickname
|
||||
@ -43,6 +46,7 @@ class GeohashRepository(
|
||||
}?.key
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun findPubkeyByShortId(shortId: String): String? {
|
||||
// First check cached nicknames (fastest)
|
||||
var found = geoNicknames.keys.firstOrNull { it.startsWith(shortId, ignoreCase = true) }
|
||||
@ -66,6 +70,7 @@ class GeohashRepository(
|
||||
fun setCurrentGeohash(geo: String?) { currentGeohash = geo }
|
||||
fun getCurrentGeohash(): String? = currentGeohash
|
||||
|
||||
@Synchronized
|
||||
fun clearAll() {
|
||||
geohashParticipants.clear()
|
||||
geoNicknames.clear()
|
||||
@ -76,6 +81,7 @@ class GeohashRepository(
|
||||
currentGeohash = null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun cacheNickname(pubkeyHex: String, nickname: String) {
|
||||
val lower = pubkeyHex.lowercase()
|
||||
val previous = geoNicknames[lower]
|
||||
@ -85,8 +91,10 @@ class GeohashRepository(
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getCachedNickname(pubkeyHex: String): String? = geoNicknames[pubkeyHex.lowercase()]
|
||||
|
||||
@Synchronized
|
||||
fun markTeleported(pubkeyHex: String) {
|
||||
val set = state.getTeleportedGeoValue().toMutableSet()
|
||||
val key = pubkeyHex.lowercase()
|
||||
@ -97,10 +105,12 @@ class GeohashRepository(
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun isPersonTeleported(pubkeyHex: String): Boolean {
|
||||
return state.getTeleportedGeoValue().contains(pubkeyHex.lowercase())
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun updateParticipant(geohash: String, participantId: String, lastSeen: Date) {
|
||||
val participants = geohashParticipants.getOrPut(geohash) { mutableMapOf() }
|
||||
// Cap to now: prevents future-timestamped events (clock skew / malicious created_at)
|
||||
@ -117,6 +127,7 @@ class GeohashRepository(
|
||||
updateReactiveParticipantCounts()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun geohashParticipantCount(geohash: String): Int {
|
||||
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
|
||||
val participants = geohashParticipants[geohash] ?: return 0
|
||||
@ -130,6 +141,7 @@ class GeohashRepository(
|
||||
return participants.keys.count { !dataManager.isGeohashUserBlocked(it) }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun refreshGeohashPeople() {
|
||||
val geohash = currentGeohash
|
||||
if (geohash == null) {
|
||||
@ -168,6 +180,7 @@ class GeohashRepository(
|
||||
state.setGeohashPeople(people)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun updateReactiveParticipantCounts() {
|
||||
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
|
||||
val counts = mutableMapOf<String, Int>()
|
||||
@ -180,12 +193,15 @@ class GeohashRepository(
|
||||
state.setGeohashParticipantCounts(counts)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun putNostrKeyMapping(tempKeyOrPeer: String, pubkeyHex: String) {
|
||||
nostrKeyMapping[tempKeyOrPeer] = pubkeyHex
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getNostrKeyMapping(): Map<String, String> = nostrKeyMapping.toMap()
|
||||
|
||||
@Synchronized
|
||||
fun displayNameForNostrPubkey(pubkeyHex: String): String {
|
||||
val suffix = pubkeyHex.takeLast(4)
|
||||
val lower = pubkeyHex.lowercase()
|
||||
@ -203,6 +219,7 @@ class GeohashRepository(
|
||||
return "$nick#$suffix"
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun displayNameForNostrPubkeyUI(pubkeyHex: String): String {
|
||||
val lower = pubkeyHex.lowercase()
|
||||
val suffix = pubkeyHex.takeLast(4)
|
||||
@ -234,6 +251,7 @@ class GeohashRepository(
|
||||
/**
|
||||
* Get display name for any geohash (not just current one) for header titles
|
||||
*/
|
||||
@Synchronized
|
||||
fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String {
|
||||
val lower = pubkeyHex.lowercase()
|
||||
val suffix = pubkeyHex.takeLast(4)
|
||||
|
||||
@ -0,0 +1,115 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.app.Application
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import com.bitchat.android.services.AppStateStore
|
||||
import com.bitchat.android.ui.ChatState
|
||||
import com.bitchat.android.ui.DataManager
|
||||
import com.bitchat.android.ui.MessageManager
|
||||
import com.bitchat.android.ui.NoiseSessionDelegate
|
||||
import com.bitchat.android.ui.PrivateChatManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Process-owned Nostr event processing.
|
||||
*
|
||||
* Relay subscriptions must remain useful when no Activity exists, so their handlers cannot be
|
||||
* borrowed from a ViewModel. This processor owns only application-scoped collaborators and writes
|
||||
* messages to [AppStateStore], which the next UI instance hydrates from.
|
||||
*/
|
||||
internal class NostrBackgroundEventProcessor(
|
||||
application: Application,
|
||||
parentScope: CoroutineScope
|
||||
) {
|
||||
private val scope = CoroutineScope(
|
||||
parentScope.coroutineContext + Dispatchers.IO.limitedParallelism(1)
|
||||
)
|
||||
private val state = ChatState(scope)
|
||||
private val dataManager = DataManager(application.applicationContext).apply {
|
||||
state.setNickname(loadNickname())
|
||||
loadBlockedUsers()
|
||||
loadGeohashBlockedUsers()
|
||||
}
|
||||
private val messageManager = MessageManager(state)
|
||||
private val geohashRepository = GeohashRepository(application, state, dataManager)
|
||||
private val privateChatManager = PrivateChatManager(
|
||||
state = state,
|
||||
messageManager = messageManager,
|
||||
dataManager = dataManager,
|
||||
noiseSessionDelegate = object : NoiseSessionDelegate {
|
||||
override fun hasEstablishedSession(peerID: String): Boolean = false
|
||||
override fun initiateHandshake(peerID: String) = Unit
|
||||
override fun getMyPeerID(): String = ""
|
||||
},
|
||||
trackUnreadMessages = false
|
||||
)
|
||||
private val geohashMessageHandler = GeohashMessageHandler(
|
||||
application = application,
|
||||
repo = geohashRepository,
|
||||
scope = scope,
|
||||
dataManager = dataManager,
|
||||
addChannelMessage = AppStateStore::addChannelMessage
|
||||
)
|
||||
private val directMessageHandler = NostrDirectMessageHandler(
|
||||
application = application,
|
||||
state = state,
|
||||
privateChatManager = privateChatManager,
|
||||
updateDeliveryStatus = ::updateDeliveryStatus,
|
||||
scope = scope,
|
||||
repo = geohashRepository,
|
||||
dataManager = dataManager
|
||||
)
|
||||
|
||||
init {
|
||||
// Keep the headless state aligned with messages sent or received through other transports.
|
||||
// This preserves duplicate detection and focused-conversation behavior without retaining UI.
|
||||
scope.launch {
|
||||
AppStateStore.privateMessages.collect(state::setPrivateChats)
|
||||
}
|
||||
scope.launch {
|
||||
AppStateStore.nickname.collect(state::setNickname)
|
||||
}
|
||||
scope.launch {
|
||||
AppStateStore.selectedPrivateChatPeer.collect(state::setSelectedPrivateChatPeer)
|
||||
}
|
||||
}
|
||||
|
||||
fun onAccountDm(event: NostrEvent, identity: NostrIdentity) {
|
||||
refreshBlockLists()
|
||||
directMessageHandler.onGiftWrap(event, "", identity)
|
||||
}
|
||||
|
||||
fun onGeohashMessage(event: NostrEvent, geohash: String) {
|
||||
refreshBlockLists()
|
||||
geohashMessageHandler.onEvent(event, geohash)
|
||||
}
|
||||
|
||||
fun onGeohashDm(event: NostrEvent, geohash: String, identity: NostrIdentity) {
|
||||
refreshBlockLists()
|
||||
directMessageHandler.onGiftWrap(event, geohash, identity)
|
||||
}
|
||||
|
||||
fun conversationGeohash(conversationKey: String): String? =
|
||||
geohashRepository.getConversationGeohash(conversationKey)
|
||||
?: GeohashConversationRegistry.get(conversationKey)
|
||||
|
||||
fun displayNameForNostrPubkey(pubkeyHex: String): String =
|
||||
geohashRepository.displayNameForNostrPubkeyUI(pubkeyHex)
|
||||
|
||||
fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String =
|
||||
geohashRepository.displayNameForGeohashConversation(pubkeyHex, sourceGeohash)
|
||||
|
||||
private fun updateDeliveryStatus(messageId: String, status: DeliveryStatus) {
|
||||
messageManager.updateMessageDeliveryStatus(messageId, status)
|
||||
// The headless state may not yet contain a just-sent UI message. Update the process store
|
||||
// unconditionally so a delivery/read receipt can never be lost during Activity handoff.
|
||||
AppStateStore.updatePrivateMessageStatus(messageId, status)
|
||||
}
|
||||
|
||||
private fun refreshBlockLists() {
|
||||
dataManager.loadBlockedUsers()
|
||||
dataManager.loadGeohashBlockedUsers()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,272 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import com.bitchat.android.geohash.ChannelID
|
||||
import com.bitchat.android.geohash.GeohashNostrPrivacyPolicy
|
||||
import com.bitchat.android.geohash.LiveLocationPrivacyGate
|
||||
import com.bitchat.android.geohash.LocationChannelManager
|
||||
import com.bitchat.android.mesh.PowerManager
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import java.security.SecureRandom
|
||||
import kotlin.random.asKotlinRandom
|
||||
|
||||
/**
|
||||
* Process-owned Nostr connectivity and low-volume background subscriptions.
|
||||
*
|
||||
* Stable subscriptions dispatch directly to a process-owned event processor. The UI hydrates from
|
||||
* the process state store, so relay events remain useful without retaining a cleared ViewModel.
|
||||
*/
|
||||
object NostrBackgroundRuntime {
|
||||
private const val TAG = "NostrBackground"
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val random = SecureRandom().asKotlinRandom()
|
||||
private val lock = Any()
|
||||
|
||||
@Volatile private var initialized = false
|
||||
@Volatile private var activeGeohash: String? = null
|
||||
@Volatile private var activeGeohashLiveToken: Long? = null
|
||||
@Volatile private var conversationGeohash: String? = null
|
||||
private lateinit var application: Application
|
||||
private lateinit var subscriptions: NostrSubscriptionManager
|
||||
private lateinit var locationChannels: LocationChannelManager
|
||||
private lateinit var eventProcessor: NostrBackgroundEventProcessor
|
||||
|
||||
fun initialize(app: Application) {
|
||||
synchronized(lock) {
|
||||
if (initialized) return
|
||||
application = app
|
||||
locationChannels = LocationChannelManager.getInstance(app)
|
||||
eventProcessor = NostrBackgroundEventProcessor(app, scope)
|
||||
subscriptions = NostrSubscriptionManager(
|
||||
app,
|
||||
owner = NostrRelayManager.OWNER_BACKGROUND
|
||||
)
|
||||
// Publish readiness only after every process-owned dependency is available.
|
||||
initialized = true
|
||||
}
|
||||
|
||||
subscriptions.connect()
|
||||
subscribeAccountDm()
|
||||
observeSelectedChannel()
|
||||
startPresenceScheduler()
|
||||
}
|
||||
|
||||
fun resetSubscriptions() {
|
||||
if (!initialized) return
|
||||
subscriptions.unsubscribeAllOwned()
|
||||
scope.launch {
|
||||
// Let CLOSE frames be queued before replacing the deterministic IDs.
|
||||
delay(100)
|
||||
subscribeAccountDm()
|
||||
activeGeohash?.let { geohash ->
|
||||
subscribeSelectedGeohash(geohash, activeGeohashLiveToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun ensureConversationDm(geohash: String) {
|
||||
if (!initialized || geohash == conversationGeohash) return
|
||||
val selectedLiveToken = activeGeohashLiveToken
|
||||
val selectedChannelSubscriptionIsUsable =
|
||||
geohash == activeGeohash &&
|
||||
(selectedLiveToken == null ||
|
||||
LiveLocationPrivacyGate.accepts(selectedLiveToken))
|
||||
if (selectedChannelSubscriptionIsUsable) return
|
||||
conversationGeohash?.let { subscriptions.unsubscribe("geo-dm-conversation-$it") }
|
||||
conversationGeohash = geohash
|
||||
subscribeGeohashDm(
|
||||
geohash = geohash,
|
||||
subscriptionId = "geo-dm-conversation-$geohash",
|
||||
liveLocationToken = null
|
||||
)
|
||||
}
|
||||
|
||||
private fun subscribeAccountDm() {
|
||||
val identity = NostrIdentityBridge.getCurrentNostrIdentity(application) ?: return
|
||||
subscriptions.subscribeGiftWraps(
|
||||
pubkey = identity.publicKeyHex,
|
||||
sinceMs = System.currentTimeMillis() - 172_800_000L,
|
||||
id = "chat-messages",
|
||||
handler = { event ->
|
||||
eventProcessor.onAccountDm(event, identity)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun observeSelectedChannel() {
|
||||
scope.launch {
|
||||
locationChannels.selectedChannel.collectLatest { channel ->
|
||||
val locationChannel = channel as? ChannelID.Location
|
||||
val next = locationChannel?.channel?.geohash
|
||||
val nextToken = locationChannel?.let {
|
||||
locationChannels.liveLocationTokenForSelectedChannel(it.channel)
|
||||
}
|
||||
val previous = activeGeohash
|
||||
if (previous == next && activeGeohashLiveToken == nextToken) {
|
||||
return@collectLatest
|
||||
}
|
||||
|
||||
previous?.let {
|
||||
subscriptions.unsubscribe("geohash-$it")
|
||||
subscriptions.unsubscribe("geo-dm-$it")
|
||||
}
|
||||
activeGeohash = next
|
||||
activeGeohashLiveToken = nextToken
|
||||
if (conversationGeohash == next) {
|
||||
subscriptions.unsubscribe("geo-dm-conversation-$next")
|
||||
conversationGeohash = null
|
||||
}
|
||||
next?.let { geohash ->
|
||||
val isLiveDerived =
|
||||
locationChannels.isSelectedChannelLiveDerived(locationChannel.channel)
|
||||
if (!isLiveDerived || nextToken != null) {
|
||||
subscribeSelectedGeohash(geohash, nextToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun subscribeSelectedGeohash(
|
||||
geohash: String,
|
||||
liveLocationToken: Long?
|
||||
) {
|
||||
subscriptions.subscribeGeohashMessages(
|
||||
geohash = geohash,
|
||||
sinceMs = System.currentTimeMillis() - 3_600_000L,
|
||||
limit = 200,
|
||||
id = "geohash-$geohash",
|
||||
handler = { event -> eventProcessor.onGeohashMessage(event, geohash) },
|
||||
liveLocationToken = liveLocationToken
|
||||
)
|
||||
subscribeGeohashDm(geohash, "geo-dm-$geohash", liveLocationToken)
|
||||
}
|
||||
|
||||
private fun subscribeGeohashDm(
|
||||
geohash: String,
|
||||
subscriptionId: String,
|
||||
liveLocationToken: Long?
|
||||
) {
|
||||
scope.launch {
|
||||
val subscribe = {
|
||||
val identity = NostrIdentityBridge.deriveIdentity(geohash, application)
|
||||
subscriptions.subscribeGiftWraps(
|
||||
pubkey = identity.publicKeyHex,
|
||||
sinceMs = System.currentTimeMillis() - 172_800_000L,
|
||||
id = subscriptionId,
|
||||
handler = { event ->
|
||||
eventProcessor.onGeohashDm(event, geohash, identity)
|
||||
},
|
||||
liveLocationToken = liveLocationToken
|
||||
)
|
||||
GeohashAliasRegistry.put(
|
||||
"nostr_${identity.publicKeyHex.take(16)}",
|
||||
identity.publicKeyHex
|
||||
)
|
||||
}
|
||||
|
||||
if (liveLocationToken == null) {
|
||||
subscribe()
|
||||
} else {
|
||||
LiveLocationPrivacyGate.runIfAllowed(liveLocationToken, subscribe)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startPresenceScheduler() {
|
||||
val powerManager = PowerManager.getInstance(application)
|
||||
scope.launch {
|
||||
val nostrSchedule = powerManager.profile
|
||||
.map { it.nostr }
|
||||
.distinctUntilChanged()
|
||||
combine(
|
||||
locationChannels.availableChannels,
|
||||
LiveLocationPrivacyGate.enabled,
|
||||
nostrSchedule
|
||||
) { channels, liveLocationEnabled, schedule ->
|
||||
val targets = GeohashNostrPrivacyPolicy.livePresenceTargets(
|
||||
availableChannels = channels,
|
||||
liveLocationEnabled = liveLocationEnabled
|
||||
)
|
||||
targets to schedule
|
||||
}.collectLatest { (targets, schedule) ->
|
||||
if (targets.isEmpty()) return@collectLatest
|
||||
while (true) {
|
||||
val waitMs = if (schedule.presenceHeartbeatMaxMs > schedule.presenceHeartbeatMinMs) {
|
||||
random.nextLong(
|
||||
schedule.presenceHeartbeatMinMs,
|
||||
schedule.presenceHeartbeatMaxMs + 1
|
||||
)
|
||||
} else {
|
||||
schedule.presenceHeartbeatMinMs
|
||||
}
|
||||
delay(waitMs)
|
||||
|
||||
// Send every target in one wake window; do not spread the batch over seconds.
|
||||
targets.forEach { geohash ->
|
||||
try {
|
||||
val token = LiveLocationPrivacyGate.captureToken()
|
||||
?: return@forEach
|
||||
if (geohash !in GeohashNostrPrivacyPolicy.livePresenceTargets(
|
||||
availableChannels = locationChannels.availableChannels.value,
|
||||
liveLocationEnabled = true
|
||||
)
|
||||
) return@forEach
|
||||
|
||||
var identity: NostrIdentity? = null
|
||||
LiveLocationPrivacyGate.runIfAllowed(token) {
|
||||
identity = NostrIdentityBridge.deriveIdentity(geohash, application)
|
||||
}
|
||||
val preparedIdentity = identity ?: return@forEach
|
||||
if (!LiveLocationPrivacyGate.accepts(token)) return@forEach
|
||||
val event = NostrProtocol.createGeohashPresenceEvent(
|
||||
geohash,
|
||||
preparedIdentity
|
||||
)
|
||||
LiveLocationPrivacyGate.runIfAllowed(token) {
|
||||
NostrRelayManager.getInstance(application).sendEventToGeohash(
|
||||
event = event,
|
||||
geohash = geohash,
|
||||
includeDefaults = false,
|
||||
nRelays = 5,
|
||||
liveLocationToken = token
|
||||
)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Presence heartbeat failed for $geohash: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun conversationGeohash(conversationKey: String): String? =
|
||||
if (initialized) eventProcessor.conversationGeohash(conversationKey)
|
||||
else GeohashConversationRegistry.get(conversationKey)
|
||||
|
||||
fun displayNameForNostrPubkey(pubkeyHex: String): String? =
|
||||
if (initialized) eventProcessor.displayNameForNostrPubkey(pubkeyHex) else null
|
||||
|
||||
fun displayNameForGeohashConversation(
|
||||
pubkeyHex: String,
|
||||
sourceGeohash: String
|
||||
): String? = if (initialized) {
|
||||
eventProcessor.displayNameForGeohashConversation(pubkeyHex, sourceGeohash)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
@ -15,7 +15,6 @@ import com.bitchat.android.services.ContactDirectory
|
||||
import com.bitchat.android.services.ContactIdentityResolver
|
||||
import com.bitchat.android.services.SeenMessageStore
|
||||
import com.bitchat.android.ui.ChatState
|
||||
import com.bitchat.android.ui.MeshDelegateHandler
|
||||
import com.bitchat.android.ui.PrivateChatManager
|
||||
import com.bitchat.android.ui.PrivateMessageOrigin
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@ -28,14 +27,17 @@ class NostrDirectMessageHandler(
|
||||
private val application: Application,
|
||||
private val state: ChatState,
|
||||
private val privateChatManager: PrivateChatManager,
|
||||
private val meshDelegateHandler: MeshDelegateHandler,
|
||||
private val updateDeliveryStatus: (String, DeliveryStatus) -> Unit,
|
||||
private val scope: CoroutineScope,
|
||||
private val repo: GeohashRepository,
|
||||
private val dataManager: com.bitchat.android.ui.DataManager
|
||||
private val dataManager: com.bitchat.android.ui.DataManager,
|
||||
private val seenStoreProvider: () -> SeenMessageStore = {
|
||||
SeenMessageStore.getInstance(application)
|
||||
}
|
||||
) {
|
||||
companion object { private const val TAG = "NostrDirectMessageHandler" }
|
||||
|
||||
private val seenStore by lazy { SeenMessageStore.getInstance(application) }
|
||||
private val seenStore by lazy(seenStoreProvider)
|
||||
|
||||
// Simple event deduplication
|
||||
private val processedIds = ArrayDeque<String>()
|
||||
@ -54,7 +56,7 @@ class NostrDirectMessageHandler(
|
||||
}
|
||||
|
||||
fun onGiftWrap(giftWrap: NostrEvent, geohash: String, identity: NostrIdentity) {
|
||||
scope.launch(Dispatchers.Default) {
|
||||
scope.launch {
|
||||
try {
|
||||
if (dedupe(giftWrap.id)) return@launch
|
||||
|
||||
@ -82,7 +84,7 @@ class NostrDirectMessageHandler(
|
||||
if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return@launch
|
||||
|
||||
val noisePayload = NoisePayload.decode(packet.payload) ?: return@launch
|
||||
val messageTimestamp = Date(giftWrap.createdAt * 1000L)
|
||||
val messageTimestamp = Date(rumorTimestamp * 1000L)
|
||||
val convKey = "nostr_${senderPubkey.take(16)}"
|
||||
repo.putNostrKeyMapping(convKey, senderPubkey)
|
||||
com.bitchat.android.nostr.GeohashAliasRegistry.put(convKey, senderPubkey)
|
||||
@ -148,11 +150,12 @@ class NostrDirectMessageHandler(
|
||||
isPrivate = true,
|
||||
recipientNickname = state.getNicknameValue(),
|
||||
senderPeerID = conversationID,
|
||||
senderNostrPubkey = senderPubkey,
|
||||
deliveryStatus = DeliveryStatus.Delivered(to = state.getNicknameValue() ?: "Unknown", at = Date())
|
||||
)
|
||||
|
||||
val isViewing = state.getSelectedPrivateChatPeerValue() == conversationID
|
||||
val suppressUnread = seenStore.hasRead(pm.messageID)
|
||||
val suppressUnread = seenStore.hasBeenReadLocally(pm.messageID)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
privateChatManager.handleIncomingPrivateMessage(
|
||||
@ -171,19 +174,26 @@ class NostrDirectMessageHandler(
|
||||
if (isViewing && !suppressUnread) {
|
||||
val nostrTransport = NostrTransport.getInstance(application)
|
||||
nostrTransport.sendReadReceiptGeohash(pm.messageID, senderPubkey, recipientIdentity)
|
||||
seenStore.markRead(pm.messageID)
|
||||
seenStore.markReadLocally(pm.messageID)
|
||||
seenStore.markReadReceiptSent(pm.messageID)
|
||||
}
|
||||
}
|
||||
NoisePayloadType.DELIVERED -> {
|
||||
val messageId = String(payload.data, Charsets.UTF_8)
|
||||
withContext(Dispatchers.Main) {
|
||||
meshDelegateHandler.didReceiveDeliveryAck(messageId, conversationID)
|
||||
updateDeliveryStatus(
|
||||
messageId,
|
||||
DeliveryStatus.Delivered(conversationID, Date())
|
||||
)
|
||||
}
|
||||
}
|
||||
NoisePayloadType.READ_RECEIPT -> {
|
||||
val messageId = String(payload.data, Charsets.UTF_8)
|
||||
withContext(Dispatchers.Main) {
|
||||
meshDelegateHandler.didReceiveReadReceipt(messageId, conversationID)
|
||||
updateDeliveryStatus(
|
||||
messageId,
|
||||
DeliveryStatus.Read(conversationID, Date())
|
||||
)
|
||||
}
|
||||
}
|
||||
NoisePayloadType.FILE_TRANSFER -> {
|
||||
@ -201,7 +211,8 @@ class NostrDirectMessageHandler(
|
||||
isRelay = false,
|
||||
isPrivate = true,
|
||||
recipientNickname = state.getNicknameValue(),
|
||||
senderPeerID = conversationID
|
||||
senderPeerID = conversationID,
|
||||
senderNostrPubkey = senderPubkey
|
||||
)
|
||||
Log.d(TAG, "📄 Saved Nostr encrypted incoming file to $savedPath (msgId=$uniqueMsgId)")
|
||||
withContext(Dispatchers.Main) {
|
||||
|
||||
@ -0,0 +1,90 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
/**
|
||||
* Thread-safe bounded queue of relay deliveries awaiting a usable WebSocket.
|
||||
*
|
||||
* Queue entries have a local ID rather than using the Nostr event ID: the same signed event may be
|
||||
* intentionally published more than once with different relay sets or privacy provenance.
|
||||
*/
|
||||
internal class NostrPendingEventQueue(
|
||||
private val capacity: Int
|
||||
) {
|
||||
init {
|
||||
require(capacity > 0)
|
||||
}
|
||||
|
||||
data class Delivery(
|
||||
val queueId: Long,
|
||||
val event: NostrEvent,
|
||||
val liveLocationToken: Long?
|
||||
)
|
||||
|
||||
private data class Entry(
|
||||
val queueId: Long,
|
||||
val event: NostrEvent,
|
||||
val pendingRelayUrls: MutableSet<String>,
|
||||
val liveLocationToken: Long?
|
||||
)
|
||||
|
||||
private val lock = Any()
|
||||
private val entries = ArrayDeque<Entry>()
|
||||
private var nextQueueId = 1L
|
||||
|
||||
fun enqueue(
|
||||
event: NostrEvent,
|
||||
relayUrls: Collection<String>,
|
||||
liveLocationToken: Long?
|
||||
): Long? {
|
||||
val pendingRelays = relayUrls.filterTo(linkedSetOf()) { it.isNotBlank() }
|
||||
if (pendingRelays.isEmpty()) return null
|
||||
|
||||
return synchronized(lock) {
|
||||
if (entries.size >= capacity) entries.removeFirst()
|
||||
val queueId = nextQueueId++
|
||||
entries.addLast(
|
||||
Entry(
|
||||
queueId = queueId,
|
||||
event = event,
|
||||
pendingRelayUrls = pendingRelays,
|
||||
liveLocationToken = liveLocationToken
|
||||
)
|
||||
)
|
||||
queueId
|
||||
}
|
||||
}
|
||||
|
||||
fun pendingForRelay(relayUrl: String): List<Delivery> = synchronized(lock) {
|
||||
entries
|
||||
.asSequence()
|
||||
.filter { relayUrl in it.pendingRelayUrls }
|
||||
.map { Delivery(it.queueId, it.event, it.liveLocationToken) }
|
||||
.toList()
|
||||
}
|
||||
|
||||
fun markDelivered(queueId: Long, relayUrl: String) {
|
||||
synchronized(lock) {
|
||||
val iterator = entries.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val entry = iterator.next()
|
||||
if (entry.queueId != queueId) continue
|
||||
entry.pendingRelayUrls.remove(relayUrl)
|
||||
if (entry.pendingRelayUrls.isEmpty()) iterator.remove()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeLiveLocationEvents() {
|
||||
synchronized(lock) {
|
||||
entries.removeAll { it.liveLocationToken != null }
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
synchronized(lock) {
|
||||
entries.clear()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun size(): Int = synchronized(lock) { entries.size }
|
||||
}
|
||||
@ -2,17 +2,18 @@ package com.bitchat.android.nostr
|
||||
|
||||
import android.util.Log
|
||||
import com.bitchat.android.geohash.LiveLocationPrivacyGate
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import com.google.gson.JsonArray
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import com.google.gson.JsonParser
|
||||
import kotlinx.coroutines.*
|
||||
import okhttp3.*
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
|
||||
@ -27,11 +28,15 @@ class NostrRelayManager private constructor() {
|
||||
val shared = NostrRelayManager()
|
||||
|
||||
private const val TAG = "NostrRelayManager"
|
||||
private const val MAX_QUEUED_EVENTS = 500
|
||||
const val OWNER_LEGACY = "legacy"
|
||||
const val OWNER_BACKGROUND = "background"
|
||||
|
||||
/**
|
||||
* Get instance for Android compatibility (context-aware calls)
|
||||
*/
|
||||
fun getInstance(context: android.content.Context): NostrRelayManager {
|
||||
shared.appContext = context.applicationContext
|
||||
return shared
|
||||
}
|
||||
|
||||
@ -84,6 +89,8 @@ class NostrRelayManager private constructor() {
|
||||
// Internal state
|
||||
private val relaysList = mutableListOf<Relay>()
|
||||
private val connections = ConcurrentHashMap<String, WebSocket>()
|
||||
private val reconnectJobs = ConcurrentHashMap<String, Job>()
|
||||
private val desiredConnected = AtomicBoolean(false)
|
||||
private val subscriptions = ConcurrentHashMap<String, Set<String>>() // relay URL -> subscription IDs
|
||||
private val messageHandlers = ConcurrentHashMap<String, (NostrEvent) -> Unit>()
|
||||
|
||||
@ -99,28 +106,25 @@ class NostrRelayManager private constructor() {
|
||||
val handler: (NostrEvent) -> Unit,
|
||||
val targetRelayUrls: Set<String>? = null, // null means all relays
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
val originGeohash: String? = null,
|
||||
val owner: String = OWNER_LEGACY,
|
||||
val liveLocationToken: Long? = null
|
||||
)
|
||||
|
||||
// Event deduplication system
|
||||
private val eventDeduplicator = NostrEventDeduplicator.getInstance()
|
||||
|
||||
// Message queue for reliability
|
||||
private data class QueuedEvent(
|
||||
val event: NostrEvent,
|
||||
val targetRelays: List<String>,
|
||||
val liveLocationToken: Long? = null
|
||||
)
|
||||
|
||||
private val messageQueue = mutableListOf<QueuedEvent>()
|
||||
private val messageQueueLock = Any()
|
||||
// Bounded per-relay delivery queue for reconnect reliability.
|
||||
private val messageQueue = NostrPendingEventQueue(MAX_QUEUED_EVENTS)
|
||||
|
||||
// Coroutine scope for background operations
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
// Subscription validation timer
|
||||
private var subscriptionValidationJob: Job? = null
|
||||
private val SUBSCRIPTION_VALIDATION_INTERVAL = com.bitchat.android.util.AppConstants.Nostr.SUBSCRIPTION_VALIDATION_INTERVAL_MS // 30 seconds
|
||||
private val powerManager: com.bitchat.android.mesh.PowerManager?
|
||||
get() = appContext?.let { com.bitchat.android.mesh.PowerManager.getInstance(it) }
|
||||
@Volatile private var appContext: android.content.Context? = null
|
||||
|
||||
// OkHttp client for WebSocket connections (via provider to honor Tor)
|
||||
private val httpClient: OkHttpClient
|
||||
@ -193,6 +197,7 @@ class NostrRelayManager private constructor() {
|
||||
handler: (NostrEvent) -> Unit,
|
||||
includeDefaults: Boolean = false,
|
||||
nRelays: Int = 5,
|
||||
owner: String = OWNER_LEGACY,
|
||||
liveLocationToken: Long? = null
|
||||
): String {
|
||||
if (!isNetworkActionAllowed(liveLocationToken)) return id
|
||||
@ -209,8 +214,14 @@ class NostrRelayManager private constructor() {
|
||||
id = id,
|
||||
handler = handler,
|
||||
targetRelayUrls = relayUrls,
|
||||
owner = owner,
|
||||
liveLocationToken = liveLocationToken
|
||||
)
|
||||
).also { subscriptionId ->
|
||||
activeSubscriptions[subscriptionId]?.let { subscription ->
|
||||
activeSubscriptions[subscriptionId] =
|
||||
subscription.copy(originGeohash = geohash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -269,14 +280,24 @@ class NostrRelayManager private constructor() {
|
||||
)
|
||||
closeTargets.forEach { (relayUrl, relaySubscriptionIds) ->
|
||||
val webSocket = connections[relayUrl] ?: return@forEach
|
||||
relaySubscriptionIds.forEach { subscriptionId ->
|
||||
for (subscriptionId in relaySubscriptionIds) {
|
||||
val request = NostrRequest.Close(subscriptionId)
|
||||
val message = gson.toJson(request, NostrRequest::class.java)
|
||||
val closeQueued = runCatching { webSocket.send(message) }
|
||||
.getOrDefault(false)
|
||||
if (!closeQueued) {
|
||||
connections.remove(relayUrl, webSocket)
|
||||
subscriptions.remove(relayUrl)
|
||||
webSocket.cancel()
|
||||
updateRelayStatus(
|
||||
relayUrl,
|
||||
isConnected = false,
|
||||
error = IllegalStateException("Failed to close revoked subscription")
|
||||
)
|
||||
if (desiredConnected.get() && relayUrl in nonLiveRelayUrls) {
|
||||
scope.launch { connectToRelay(relayUrl, liveLocationToken = null) }
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -296,9 +317,7 @@ class NostrRelayManager private constructor() {
|
||||
}
|
||||
subscriptions.replaceAll { _, ids -> ids - liveSubscriptionIds }
|
||||
|
||||
synchronized(messageQueueLock) {
|
||||
messageQueue.removeAll { it.liveLocationToken != null }
|
||||
}
|
||||
messageQueue.removeLiveLocationEvents()
|
||||
|
||||
liveGeohashTokens.keys.forEach(geohashToRelays::remove)
|
||||
liveGeohashTokens.clear()
|
||||
@ -307,6 +326,8 @@ class NostrRelayManager private constructor() {
|
||||
.filterNotTo(mutableSetOf()) { it in nonLiveRelayUrls }
|
||||
liveOnlyRelayUrls.forEach { relayUrl ->
|
||||
connections.remove(relayUrl)?.cancel()
|
||||
subscriptions.remove(relayUrl)
|
||||
reconnectJobs.remove(relayUrl)?.cancel()
|
||||
}
|
||||
synchronized(relaysList) {
|
||||
relaysList.removeAll { it.url in liveOnlyRelayUrls }
|
||||
@ -329,11 +350,15 @@ class NostrRelayManager private constructor() {
|
||||
}
|
||||
updateRelaysList()
|
||||
|
||||
if (!desiredConnected.get()) return
|
||||
val job = scope.launch {
|
||||
if (!isNetworkActionAllowed(liveLocationToken)) return@launch
|
||||
if (!desiredConnected.get() ||
|
||||
!isNetworkActionAllowed(liveLocationToken)
|
||||
) return@launch
|
||||
relayUrls.forEach { relayUrl ->
|
||||
launch {
|
||||
if (!connections.containsKey(relayUrl) &&
|
||||
if (desiredConnected.get() &&
|
||||
!connections.containsKey(relayUrl) &&
|
||||
isNetworkActionAllowed(liveLocationToken)
|
||||
) {
|
||||
connectToRelay(relayUrl, liveLocationToken)
|
||||
@ -373,6 +398,8 @@ class NostrRelayManager private constructor() {
|
||||
* Connect to all configured relays
|
||||
*/
|
||||
fun connect() {
|
||||
desiredConnected.set(true)
|
||||
Log.i(TAG, "Connecting to ${relaysList.size} Nostr relays")
|
||||
scope.launch {
|
||||
relaysList.forEach { relay ->
|
||||
launch {
|
||||
@ -393,17 +420,29 @@ class NostrRelayManager private constructor() {
|
||||
* Disconnect from all relays
|
||||
*/
|
||||
fun disconnect() {
|
||||
Log.i(TAG, "Disconnecting from all Nostr relays")
|
||||
desiredConnected.set(false)
|
||||
|
||||
// Stop subscription validation
|
||||
stopSubscriptionValidation()
|
||||
|
||||
connections.values.forEach { webSocket ->
|
||||
reconnectJobs.values.forEach(Job::cancel)
|
||||
reconnectJobs.clear()
|
||||
liveLocationConnectionJobs.forEach(Job::cancel)
|
||||
liveLocationConnectionJobs.clear()
|
||||
|
||||
val sockets = connections.values.toList()
|
||||
connections.clear()
|
||||
sockets.forEach { webSocket ->
|
||||
webSocket.close(1000, "Manual disconnect")
|
||||
}
|
||||
connections.clear()
|
||||
|
||||
// Clear subscriptions
|
||||
|
||||
// Preserve logical subscriptions for controlled resets, but forget per-socket state.
|
||||
subscriptions.clear()
|
||||
|
||||
relaysList.forEach {
|
||||
it.isConnected = false
|
||||
it.nextReconnectTime = null
|
||||
}
|
||||
updateRelaysList()
|
||||
updateConnectionStatus()
|
||||
}
|
||||
|
||||
@ -415,18 +454,25 @@ class NostrRelayManager private constructor() {
|
||||
relayUrls: List<String>? = null,
|
||||
liveLocationToken: Long? = null
|
||||
) {
|
||||
val targetRelays = relayUrls ?: relaysList.map { it.url }
|
||||
val targetRelays = (relayUrls ?: relaysList.map { it.url })
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
if (targetRelays.isEmpty()) return
|
||||
|
||||
val queued = runNetworkAction(liveLocationToken) {
|
||||
synchronized(messageQueueLock) {
|
||||
messageQueue.add(QueuedEvent(event, targetRelays, liveLocationToken))
|
||||
}
|
||||
val queueId = messageQueue.enqueue(
|
||||
event = event,
|
||||
relayUrls = targetRelays,
|
||||
liveLocationToken = liveLocationToken
|
||||
) ?: return@runNetworkAction
|
||||
scope.launch {
|
||||
if (!isNetworkActionAllowed(liveLocationToken)) return@launch
|
||||
targetRelays.forEach { relayUrl ->
|
||||
val webSocket = connections[relayUrl]
|
||||
if (webSocket != null) {
|
||||
sendToRelay(event, webSocket, relayUrl, liveLocationToken)
|
||||
if (sendToRelay(event, webSocket, relayUrl, liveLocationToken)) {
|
||||
messageQueue.markDelivered(queueId, relayUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -443,6 +489,7 @@ class NostrRelayManager private constructor() {
|
||||
id: String = generateSubscriptionId(),
|
||||
handler: (NostrEvent) -> Unit,
|
||||
targetRelayUrls: List<String>? = null,
|
||||
owner: String = OWNER_LEGACY,
|
||||
liveLocationToken: Long? = null
|
||||
): String {
|
||||
val subscriptionInfo = SubscriptionInfo(
|
||||
@ -450,6 +497,7 @@ class NostrRelayManager private constructor() {
|
||||
filter = filter,
|
||||
handler = handler,
|
||||
targetRelayUrls = targetRelayUrls?.toSet(),
|
||||
owner = owner,
|
||||
liveLocationToken = liveLocationToken
|
||||
)
|
||||
|
||||
@ -546,12 +594,20 @@ class NostrRelayManager private constructor() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun unsubscribeOwner(owner: String) {
|
||||
activeSubscriptions.values
|
||||
.filter { it.owner == owner }
|
||||
.map { it.id }
|
||||
.forEach(::unsubscribe)
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually retry connection to a specific relay
|
||||
*/
|
||||
fun retryConnection(relayUrl: String) {
|
||||
val relay = relaysList.find { it.url == relayUrl } ?: return
|
||||
desiredConnected.set(true)
|
||||
val liveToken = liveLocationRelayTokens[relayUrl]
|
||||
?.takeIf { relayUrl !in nonLiveRelayUrls }
|
||||
if (!isNetworkActionAllowed(liveToken)) return
|
||||
@ -561,8 +617,8 @@ class NostrRelayManager private constructor() {
|
||||
relay.nextReconnectTime = null
|
||||
|
||||
// Disconnect if connected
|
||||
connections[relayUrl]?.close(1000, "Manual retry")
|
||||
connections.remove(relayUrl)
|
||||
reconnectJobs.remove(relayUrl)?.cancel()
|
||||
connections.remove(relayUrl)?.close(1000, "Manual retry")
|
||||
|
||||
// Attempt immediate reconnection
|
||||
scope.launch {
|
||||
@ -575,6 +631,7 @@ class NostrRelayManager private constructor() {
|
||||
* This will automatically restore all subscriptions when reconnected
|
||||
*/
|
||||
fun resetAllConnections() {
|
||||
val shouldReconnect = desiredConnected.get()
|
||||
disconnect()
|
||||
|
||||
// Reset all relay states
|
||||
@ -584,8 +641,8 @@ class NostrRelayManager private constructor() {
|
||||
relay.lastError = null
|
||||
}
|
||||
|
||||
// Reconnect - subscriptions will be automatically restored in onOpen
|
||||
connect()
|
||||
// Reconnect only when connectivity was desired before the controlled reset.
|
||||
if (shouldReconnect) connect()
|
||||
}
|
||||
|
||||
/**
|
||||
@ -615,9 +672,7 @@ class NostrRelayManager private constructor() {
|
||||
geohashToRelays.clear()
|
||||
|
||||
// Clear any queued messages waiting to be sent
|
||||
synchronized(messageQueueLock) {
|
||||
messageQueue.clear()
|
||||
}
|
||||
messageQueue.clear()
|
||||
|
||||
Log.i(TAG, "Cleared all Nostr subscriptions and routing caches")
|
||||
} catch (e: Exception) {
|
||||
@ -709,35 +764,52 @@ class NostrRelayManager private constructor() {
|
||||
stopSubscriptionValidation() // Stop any existing validation
|
||||
|
||||
subscriptionValidationJob = scope.launch {
|
||||
while (isActive) {
|
||||
delay(SUBSCRIPTION_VALIDATION_INTERVAL)
|
||||
|
||||
try {
|
||||
val report = validateSubscriptionConsistency()
|
||||
if (!report.isConsistent && report.connectedRelayCount > 0) {
|
||||
Log.w(TAG, "Nostr subscription inconsistencies detected")
|
||||
|
||||
// Auto-repair: re-establish subscriptions for relays with missing ones
|
||||
connections.forEach { (relayUrl, webSocket) ->
|
||||
val currentSubs = subscriptions[relayUrl] ?: emptySet()
|
||||
val expectedSubs = activeSubscriptions.keys.filter { subId ->
|
||||
val subInfo = activeSubscriptions[subId]
|
||||
subInfo?.targetRelayUrls == null || subInfo.targetRelayUrls.contains(relayUrl)
|
||||
}.toSet()
|
||||
|
||||
val missingSubs = expectedSubs - currentSubs
|
||||
if (missingSubs.isNotEmpty()) {
|
||||
Log.i(TAG, "Auto-repairing ${missingSubs.size} missing subscriptions")
|
||||
restoreSubscriptionsForRelay(relayUrl, webSocket)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error during subscription validation: ${e.message}")
|
||||
val manager = powerManager
|
||||
if (manager == null) {
|
||||
runSubscriptionValidationLoop(
|
||||
com.bitchat.android.util.AppConstants.Nostr
|
||||
.SUBSCRIPTION_VALIDATION_INTERVAL_MS
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
manager.profile
|
||||
.map { it.nostr.subscriptionValidationMs }
|
||||
.distinctUntilChanged()
|
||||
.collectLatest(::runSubscriptionValidationLoop)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runSubscriptionValidationLoop(intervalMs: Long) {
|
||||
while (currentCoroutineContext().isActive && desiredConnected.get()) {
|
||||
delay(intervalMs)
|
||||
if (!desiredConnected.get()) break
|
||||
validateAndRepairSubscriptions()
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateAndRepairSubscriptions() {
|
||||
try {
|
||||
val report = validateSubscriptionConsistency()
|
||||
if (report.isConsistent || report.connectedRelayCount == 0) return
|
||||
|
||||
Log.w(TAG, "Nostr subscription inconsistencies detected")
|
||||
connections.forEach { (relayUrl, webSocket) ->
|
||||
val currentSubs = subscriptions[relayUrl] ?: emptySet()
|
||||
val expectedSubs = activeSubscriptions.keys.filter { subId ->
|
||||
val subInfo = activeSubscriptions[subId]
|
||||
subInfo?.targetRelayUrls == null ||
|
||||
subInfo.targetRelayUrls.contains(relayUrl)
|
||||
}.toSet()
|
||||
|
||||
if ((expectedSubs - currentSubs).isNotEmpty()) {
|
||||
Log.i(TAG, "Auto-repairing missing subscriptions")
|
||||
restoreSubscriptionsForRelay(relayUrl, webSocket)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error during subscription validation: ${e.message}")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@ -754,6 +826,7 @@ class NostrRelayManager private constructor() {
|
||||
urlString: String,
|
||||
liveLocationToken: Long? = null
|
||||
) {
|
||||
if (!desiredConnected.get()) return
|
||||
val connectionToken = liveLocationToken
|
||||
?.takeIf { urlString !in nonLiveRelayUrls }
|
||||
if (!isNetworkActionAllowed(connectionToken)) return
|
||||
@ -767,17 +840,25 @@ class NostrRelayManager private constructor() {
|
||||
.url(urlString)
|
||||
.build()
|
||||
|
||||
runNetworkAction(connectionToken) {
|
||||
val started = runNetworkAction(connectionToken) {
|
||||
val webSocket = httpClient.newWebSocket(
|
||||
request,
|
||||
RelayWebSocketListener(urlString, connectionToken)
|
||||
)
|
||||
connections[urlString] = webSocket
|
||||
val existing = connections.putIfAbsent(urlString, webSocket)
|
||||
when {
|
||||
existing != null -> webSocket.close(1000, "Duplicate connection")
|
||||
!desiredConnected.get() -> {
|
||||
connections.remove(urlString, webSocket)
|
||||
webSocket.close(1000, "Connection no longer desired")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!started) return
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to create WebSocket connection")
|
||||
handleDisconnection(urlString, e, liveLocationToken)
|
||||
handleConnectionCreationFailure(urlString, e, connectionToken)
|
||||
}
|
||||
}
|
||||
|
||||
@ -786,9 +867,9 @@ class NostrRelayManager private constructor() {
|
||||
webSocket: WebSocket,
|
||||
relayUrl: String,
|
||||
liveLocationToken: Long? = null
|
||||
) {
|
||||
if (!isNetworkActionAllowed(liveLocationToken)) return
|
||||
try {
|
||||
): Boolean {
|
||||
if (!isNetworkActionAllowed(liveLocationToken)) return false
|
||||
return try {
|
||||
val request = NostrRequest.Event(event)
|
||||
val message = gson.toJson(request, NostrRequest::class.java)
|
||||
|
||||
@ -798,17 +879,21 @@ class NostrRelayManager private constructor() {
|
||||
}
|
||||
if (success) {
|
||||
// Update relay stats
|
||||
val relay = relaysList.find { it.url == relayUrl }
|
||||
relay?.messagesSent = (relay?.messagesSent ?: 0) + 1
|
||||
relaysList.find { it.url == relayUrl }?.let { relay ->
|
||||
relay.messagesSent += 1
|
||||
}
|
||||
updateRelaysList()
|
||||
true
|
||||
} else {
|
||||
Log.e(TAG, "Failed to send event: WebSocket send failed")
|
||||
false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send event")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun handleMessage(message: String, relayUrl: String) {
|
||||
try {
|
||||
val jsonElement = JsonParser.parseString(message)
|
||||
@ -822,8 +907,9 @@ class NostrRelayManager private constructor() {
|
||||
when (response) {
|
||||
is NostrResponse.Event -> {
|
||||
// Update relay stats
|
||||
val relay = relaysList.find { it.url == relayUrl }
|
||||
relay?.messagesReceived = (relay?.messagesReceived ?: 0) + 1
|
||||
relaysList.find { it.url == relayUrl }?.let { relay ->
|
||||
relay.messagesReceived += 1
|
||||
}
|
||||
updateRelaysList()
|
||||
|
||||
// CLIENT-SIDE FILTER ENFORCEMENT: Ensure this event matches the subscription's filter
|
||||
@ -882,17 +968,38 @@ class NostrRelayManager private constructor() {
|
||||
|
||||
private fun handleDisconnection(
|
||||
relayUrl: String,
|
||||
webSocket: WebSocket,
|
||||
error: Throwable,
|
||||
liveLocationToken: Long? = null
|
||||
) {
|
||||
// Ignore callbacks from intentionally closed or replaced sockets. They must not remove a
|
||||
// newer socket or schedule a reconnect after a controlled disconnect/privacy revocation.
|
||||
if (!connections.remove(relayUrl, webSocket)) return
|
||||
subscriptions.remove(relayUrl)
|
||||
handleCurrentDisconnection(relayUrl, error, liveLocationToken)
|
||||
}
|
||||
|
||||
private fun handleConnectionCreationFailure(
|
||||
relayUrl: String,
|
||||
error: Throwable,
|
||||
liveLocationToken: Long?
|
||||
) {
|
||||
if (!desiredConnected.get()) return
|
||||
handleCurrentDisconnection(relayUrl, error, liveLocationToken)
|
||||
}
|
||||
|
||||
private fun handleCurrentDisconnection(
|
||||
relayUrl: String,
|
||||
error: Throwable,
|
||||
liveLocationToken: Long?
|
||||
) {
|
||||
val connectionToken = liveLocationToken
|
||||
?.takeIf { relayUrl !in nonLiveRelayUrls }
|
||||
connections.remove(relayUrl)
|
||||
// NOTE: Don't remove subscriptions here - keep them for restoration on reconnection
|
||||
// subscriptions.remove(relayUrl) // REMOVED - this was causing subscription loss
|
||||
|
||||
|
||||
updateRelayStatus(relayUrl, false, error)
|
||||
if (!isNetworkActionAllowed(connectionToken)) return
|
||||
if (!desiredConnected.get() ||
|
||||
!isNetworkActionAllowed(connectionToken)
|
||||
) return
|
||||
|
||||
// Check if this is a DNS error
|
||||
val errorMessage = error.message?.lowercase() ?: ""
|
||||
@ -927,13 +1034,19 @@ class NostrRelayManager private constructor() {
|
||||
|
||||
Log.d(TAG, "Scheduling Nostr relay reconnection")
|
||||
|
||||
// Schedule reconnection
|
||||
scope.launch {
|
||||
reconnectJobs.remove(relayUrl)?.cancel()
|
||||
val reconnectJob = scope.launch {
|
||||
delay(backoffInterval)
|
||||
if (isNetworkActionAllowed(connectionToken)) {
|
||||
if (desiredConnected.get() &&
|
||||
isNetworkActionAllowed(connectionToken)
|
||||
) {
|
||||
connectToRelay(relayUrl, connectionToken)
|
||||
}
|
||||
}
|
||||
reconnectJobs[relayUrl] = reconnectJob
|
||||
reconnectJob.invokeOnCompletion {
|
||||
reconnectJobs.remove(relayUrl, reconnectJob)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateRelayStatus(url: String, isConnected: Boolean, error: Throwable? = null) {
|
||||
@ -1014,36 +1127,38 @@ class NostrRelayManager private constructor() {
|
||||
) : WebSocketListener() {
|
||||
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
if (!isNetworkActionAllowed(liveLocationToken)) {
|
||||
connections.remove(relayUrl)
|
||||
webSocket.cancel()
|
||||
if (!desiredConnected.get() ||
|
||||
connections[relayUrl] !== webSocket ||
|
||||
!isNetworkActionAllowed(liveLocationToken)
|
||||
) {
|
||||
connections.remove(relayUrl, webSocket)
|
||||
webSocket.close(1000, "Stale connection")
|
||||
return
|
||||
}
|
||||
reconnectJobs.remove(relayUrl)?.cancel()
|
||||
updateRelayStatus(relayUrl, true)
|
||||
|
||||
// Restore all active subscriptions for this relay
|
||||
restoreSubscriptionsForRelay(relayUrl, webSocket)
|
||||
|
||||
// Process any queued messages for this relay
|
||||
synchronized(messageQueueLock) {
|
||||
val iterator = messageQueue.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val queued = iterator.next()
|
||||
if (relayUrl in queued.targetRelays &&
|
||||
isNetworkActionAllowed(queued.liveLocationToken)
|
||||
) {
|
||||
sendToRelay(
|
||||
queued.event,
|
||||
webSocket,
|
||||
relayUrl,
|
||||
queued.liveLocationToken
|
||||
)
|
||||
}
|
||||
// Process only events still pending for this relay, outside the queue lock.
|
||||
val queuedForRelay = messageQueue.pendingForRelay(relayUrl)
|
||||
.filter { isNetworkActionAllowed(it.liveLocationToken) }
|
||||
queuedForRelay.forEach { delivery ->
|
||||
if (sendToRelay(
|
||||
delivery.event,
|
||||
webSocket,
|
||||
relayUrl,
|
||||
delivery.liveLocationToken
|
||||
)
|
||||
) {
|
||||
messageQueue.markDelivered(delivery.queueId, relayUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
if (connections[relayUrl] !== webSocket) return
|
||||
handleMessage(text, relayUrl)
|
||||
}
|
||||
|
||||
@ -1053,12 +1168,12 @@ class NostrRelayManager private constructor() {
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
val error = Exception("WebSocket closed: $code $reason")
|
||||
handleDisconnection(relayUrl, error, liveLocationToken)
|
||||
handleDisconnection(relayUrl, webSocket, error, liveLocationToken)
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
Log.e(TAG, "Nostr WebSocket failure")
|
||||
handleDisconnection(relayUrl, t, liveLocationToken)
|
||||
handleDisconnection(relayUrl, webSocket, t, liveLocationToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,29 +3,49 @@ package com.bitchat.android.nostr
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import com.bitchat.android.geohash.LiveLocationPrivacyGate
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* NostrSubscriptionManager
|
||||
* - Encapsulates subscription lifecycle with NostrRelayManager
|
||||
* - Encapsulates ordered subscription lifecycle with NostrRelayManager.
|
||||
*
|
||||
* Relay-manager operations are already non-blocking and schedule network I/O on their own scope.
|
||||
* Keeping this facade synchronous prevents lifecycle cancellation from dropping unsubscribe work
|
||||
* and guarantees that a channel switch closes the old subscription before opening the new one.
|
||||
*/
|
||||
class NostrSubscriptionManager(
|
||||
private val application: Application,
|
||||
private val scope: CoroutineScope
|
||||
private val owner: String = NostrRelayManager.OWNER_LEGACY
|
||||
) {
|
||||
companion object { private const val TAG = "NostrSubscriptionManager" }
|
||||
|
||||
private val relayManager get() = NostrRelayManager.getInstance(application)
|
||||
|
||||
fun connect() = scope.launch { runCatching { relayManager.connect() }.onFailure { Log.e(TAG, "connect failed: ${it.message}") } }
|
||||
fun disconnect() = scope.launch { runCatching { relayManager.disconnect() }.onFailure { Log.e(TAG, "disconnect failed: ${it.message}") } }
|
||||
fun connect() {
|
||||
runCatching { relayManager.connect() }
|
||||
.onFailure { Log.e(TAG, "connect failed: ${it.message}") }
|
||||
}
|
||||
|
||||
fun subscribeGiftWraps(pubkey: String, sinceMs: Long, id: String, handler: (NostrEvent) -> Unit) {
|
||||
scope.launch {
|
||||
val filter = NostrFilter.giftWrapsFor(pubkey, sinceMs)
|
||||
relayManager.subscribe(filter, id, handler)
|
||||
}
|
||||
fun disconnect() {
|
||||
runCatching { relayManager.disconnect() }
|
||||
.onFailure { Log.e(TAG, "disconnect failed: ${it.message}") }
|
||||
}
|
||||
|
||||
fun subscribeGiftWraps(
|
||||
pubkey: String,
|
||||
sinceMs: Long,
|
||||
id: String,
|
||||
handler: (NostrEvent) -> Unit,
|
||||
liveLocationToken: Long? = null
|
||||
) {
|
||||
if (!isAllowed(liveLocationToken)) return
|
||||
val filter = NostrFilter.giftWrapsFor(pubkey, sinceMs)
|
||||
relayManager.subscribe(
|
||||
filter = filter,
|
||||
id = id,
|
||||
handler = handler,
|
||||
owner = owner,
|
||||
liveLocationToken = liveLocationToken
|
||||
)
|
||||
}
|
||||
|
||||
/** Subscribe to geohash chat messages only (kind 20000) — low-volume, kept alive in background. */
|
||||
@ -37,21 +57,18 @@ class NostrSubscriptionManager(
|
||||
handler: (NostrEvent) -> Unit,
|
||||
liveLocationToken: Long? = null
|
||||
) {
|
||||
scope.launch {
|
||||
if (liveLocationToken != null &&
|
||||
!LiveLocationPrivacyGate.accepts(liveLocationToken)
|
||||
) return@launch
|
||||
val filter = NostrFilter.geohashMessages(geohash, sinceMs, limit)
|
||||
relayManager.subscribeForGeohash(
|
||||
geohash,
|
||||
filter,
|
||||
id,
|
||||
handler,
|
||||
includeDefaults = false,
|
||||
nRelays = 5,
|
||||
liveLocationToken = liveLocationToken
|
||||
)
|
||||
}
|
||||
if (!isAllowed(liveLocationToken)) return
|
||||
val filter = NostrFilter.geohashMessages(geohash, sinceMs, limit)
|
||||
relayManager.subscribeForGeohash(
|
||||
geohash,
|
||||
filter,
|
||||
id,
|
||||
handler,
|
||||
includeDefaults = false,
|
||||
nRelays = 5,
|
||||
owner = owner,
|
||||
liveLocationToken = liveLocationToken
|
||||
)
|
||||
}
|
||||
|
||||
/** Subscribe to geohash presence heartbeats only (kind 20001) — high-volume, paused in background. */
|
||||
@ -63,22 +80,28 @@ class NostrSubscriptionManager(
|
||||
handler: (NostrEvent) -> Unit,
|
||||
liveLocationToken: Long? = null
|
||||
) {
|
||||
scope.launch {
|
||||
if (liveLocationToken != null &&
|
||||
!LiveLocationPrivacyGate.accepts(liveLocationToken)
|
||||
) return@launch
|
||||
val filter = NostrFilter.geohashPresence(geohash, sinceMs, limit)
|
||||
relayManager.subscribeForGeohash(
|
||||
geohash,
|
||||
filter,
|
||||
id,
|
||||
handler,
|
||||
includeDefaults = false,
|
||||
nRelays = 5,
|
||||
liveLocationToken = liveLocationToken
|
||||
)
|
||||
}
|
||||
if (!isAllowed(liveLocationToken)) return
|
||||
val filter = NostrFilter.geohashPresence(geohash, sinceMs, limit)
|
||||
relayManager.subscribeForGeohash(
|
||||
geohash,
|
||||
filter,
|
||||
id,
|
||||
handler,
|
||||
includeDefaults = false,
|
||||
nRelays = 5,
|
||||
owner = owner,
|
||||
liveLocationToken = liveLocationToken
|
||||
)
|
||||
}
|
||||
|
||||
fun unsubscribe(id: String) { scope.launch { runCatching { relayManager.unsubscribe(id) } } }
|
||||
fun unsubscribe(id: String) {
|
||||
runCatching { relayManager.unsubscribe(id) }
|
||||
}
|
||||
|
||||
fun unsubscribeAllOwned() {
|
||||
runCatching { relayManager.unsubscribeOwner(owner) }
|
||||
}
|
||||
|
||||
private fun isAllowed(liveLocationToken: Long?): Boolean =
|
||||
liveLocationToken == null || LiveLocationPrivacyGate.accepts(liveLocationToken)
|
||||
}
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
package com.bitchat.android.onboarding
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.LocationOn
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@ -13,9 +16,6 @@ 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
|
||||
@ -28,15 +28,18 @@ 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.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.R
|
||||
|
||||
/**
|
||||
* Explanation screen shown before requesting background location permission.
|
||||
*
|
||||
* Bottom action layout matches the battery optimization skip screen so the
|
||||
* primary / check-again / skip buttons stay pixel-aligned across both screens.
|
||||
*/
|
||||
@Composable
|
||||
fun BackgroundLocationPermissionScreen(
|
||||
@ -46,132 +49,125 @@ fun BackgroundLocationPermissionScreen(
|
||||
onSkip: () -> Unit
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Box(modifier = modifier) {
|
||||
Box(
|
||||
modifier = modifier.padding(24.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
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
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
// Scrollable content area
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.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 = BitchatFontFamily
|
||||
),
|
||||
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 = BitchatFontFamily,
|
||||
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 = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Medium
|
||||
),
|
||||
color = colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed buttons at the bottom — same structure/spacing as battery optimization
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Button(
|
||||
onClick = onContinue,
|
||||
@ -183,10 +179,9 @@ fun BackgroundLocationPermissionScreen(
|
||||
Text(
|
||||
text = stringResource(R.string.grant_background_location),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ -201,7 +196,7 @@ fun BackgroundLocationPermissionScreen(
|
||||
Text(
|
||||
text = stringResource(R.string.check_again),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -213,7 +208,7 @@ fun BackgroundLocationPermissionScreen(
|
||||
Text(
|
||||
text = stringResource(R.string.battery_optimization_skip),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -234,7 +229,7 @@ private fun HeaderSection(colorScheme: ColorScheme) {
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 32.sp
|
||||
),
|
||||
@ -244,7 +239,7 @@ private fun HeaderSection(colorScheme: ColorScheme) {
|
||||
Text(
|
||||
text = stringResource(R.string.background_location_required_subtitle),
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
package com.bitchat.android.onboarding
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
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.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@ -15,11 +15,11 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.R
|
||||
|
||||
/**
|
||||
@ -105,7 +105,7 @@ private fun BatteryOptimizationEnabledContent(
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 32.sp
|
||||
),
|
||||
@ -115,7 +115,7 @@ private fun BatteryOptimizationEnabledContent(
|
||||
Text(
|
||||
text = stringResource(R.string.battery_optimization_detected_title),
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
@ -225,7 +225,7 @@ private fun BatteryOptimizationEnabledContent(
|
||||
Text(
|
||||
text = stringResource(R.string.disable_battery_optimization),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
)
|
||||
@ -243,7 +243,7 @@ private fun BatteryOptimizationEnabledContent(
|
||||
Text(
|
||||
text = stringResource(R.string.check_again),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -259,7 +259,7 @@ private fun BatteryOptimizationEnabledContent(
|
||||
Text(
|
||||
text = stringResource(R.string.battery_optimization_skip),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -284,7 +284,7 @@ private fun BatteryOptimizationCheckingContent(
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 32.sp
|
||||
),
|
||||
@ -294,7 +294,7 @@ private fun BatteryOptimizationCheckingContent(
|
||||
Text(
|
||||
text = stringResource(R.string.battery_optimization_disabled_title),
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
@ -322,7 +322,7 @@ private fun BatteryOptimizationCheckingContent(
|
||||
Text(
|
||||
text = stringResource(R.string.battery_optimization_success_message),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.8f)
|
||||
),
|
||||
textAlign = TextAlign.Center
|
||||
@ -347,7 +347,7 @@ private fun BatteryOptimizationNotSupportedContent(
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 32.sp
|
||||
),
|
||||
@ -357,7 +357,7 @@ private fun BatteryOptimizationNotSupportedContent(
|
||||
Text(
|
||||
text = stringResource(R.string.battery_optimization_not_required),
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
@ -372,7 +372,7 @@ private fun BatteryOptimizationNotSupportedContent(
|
||||
Text(
|
||||
text = stringResource(R.string.battery_optimization_not_supported_message),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.8f)
|
||||
),
|
||||
textAlign = TextAlign.Center
|
||||
@ -388,7 +388,7 @@ private fun BatteryOptimizationNotSupportedContent(
|
||||
Text(
|
||||
text = stringResource(R.string.continue_btn),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
)
|
||||
|
||||
@ -1,20 +1,20 @@
|
||||
package com.bitchat.android.onboarding
|
||||
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.R
|
||||
|
||||
/**
|
||||
@ -83,7 +83,7 @@ private fun BluetoothDisabledContent(
|
||||
Text(
|
||||
text = stringResource(R.string.bluetooth_recommended),
|
||||
style = MaterialTheme.typography.headlineSmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = colorScheme.primary
|
||||
),
|
||||
@ -114,7 +114,7 @@ private fun BluetoothDisabledContent(
|
||||
Text(
|
||||
text = stringResource(R.string.bluetooth_needs_bullets),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.8f)
|
||||
)
|
||||
)
|
||||
@ -138,7 +138,7 @@ private fun BluetoothDisabledContent(
|
||||
Text(
|
||||
text = stringResource(R.string.enable_bluetooth),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
@ -187,7 +187,7 @@ private fun BluetoothNotSupportedContent(
|
||||
Text(
|
||||
text = stringResource(R.string.bluetooth_not_supported),
|
||||
style = MaterialTheme.typography.headlineSmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = colorScheme.error
|
||||
),
|
||||
@ -204,7 +204,7 @@ private fun BluetoothNotSupportedContent(
|
||||
Text(
|
||||
text = stringResource(R.string.bluetooth_unsupported_explanation),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onSurface
|
||||
),
|
||||
modifier = Modifier.padding(16.dp),
|
||||
@ -235,7 +235,7 @@ private fun BluetoothCheckingContent(
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = colorScheme.primary
|
||||
),
|
||||
@ -247,7 +247,7 @@ private fun BluetoothCheckingContent(
|
||||
Text(
|
||||
text = stringResource(R.string.checking_bluetooth_status),
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
)
|
||||
|
||||
@ -8,11 +8,11 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.R
|
||||
|
||||
/**
|
||||
@ -63,7 +63,7 @@ fun InitializingScreen(modifier: Modifier) {
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = colorScheme.primary
|
||||
),
|
||||
@ -92,7 +92,7 @@ fun InitializingScreen(modifier: Modifier) {
|
||||
Text(
|
||||
text = stringResource(R.string.initializing_mesh_network),
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
)
|
||||
@ -102,7 +102,7 @@ fun InitializingScreen(modifier: Modifier) {
|
||||
Text(
|
||||
text = stringResource(R.string.dot),
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onSurface.copy(alpha = alpha)
|
||||
)
|
||||
)
|
||||
@ -127,7 +127,7 @@ fun InitializingScreen(modifier: Modifier) {
|
||||
Text(
|
||||
text = stringResource(R.string.setting_up_bluetooth),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.8f)
|
||||
),
|
||||
textAlign = TextAlign.Center
|
||||
@ -136,7 +136,7 @@ fun InitializingScreen(modifier: Modifier) {
|
||||
Text(
|
||||
text = stringResource(R.string.should_take_seconds),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
),
|
||||
textAlign = TextAlign.Center
|
||||
@ -184,7 +184,7 @@ fun InitializationErrorScreen(
|
||||
Text(
|
||||
text = stringResource(R.string.setup_not_complete),
|
||||
style = MaterialTheme.typography.headlineSmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = colorScheme.error
|
||||
),
|
||||
@ -201,7 +201,7 @@ fun InitializationErrorScreen(
|
||||
Text(
|
||||
text = errorMessage,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onSurface
|
||||
),
|
||||
modifier = Modifier.padding(16.dp),
|
||||
@ -220,7 +220,7 @@ fun InitializationErrorScreen(
|
||||
Text(
|
||||
text = stringResource(R.string.try_again),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
@ -234,7 +234,7 @@ fun InitializationErrorScreen(
|
||||
Text(
|
||||
text = stringResource(R.string.open_settings),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = BitchatFontFamily
|
||||
),
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
)
|
||||
|
||||
@ -1,21 +1,21 @@
|
||||
package com.bitchat.android.onboarding
|
||||
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.R
|
||||
|
||||
/**
|
||||
@ -80,7 +80,7 @@ private fun LocationDisabledContent(
|
||||
Text(
|
||||
text = stringResource(R.string.location_services_required),
|
||||
style = MaterialTheme.typography.headlineSmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = colorScheme.primary
|
||||
),
|
||||
@ -122,7 +122,7 @@ private fun LocationDisabledContent(
|
||||
Text(
|
||||
text = stringResource(R.string.location_explanation),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.8f)
|
||||
)
|
||||
)
|
||||
@ -142,7 +142,7 @@ private fun LocationDisabledContent(
|
||||
Text(
|
||||
text = stringResource(R.string.location_needs_bullets),
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.8f)
|
||||
)
|
||||
)
|
||||
@ -166,7 +166,7 @@ private fun LocationDisabledContent(
|
||||
Text(
|
||||
text = stringResource(R.string.open_location_settings),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
@ -180,7 +180,7 @@ private fun LocationDisabledContent(
|
||||
Text(
|
||||
text = stringResource(R.string.check_again),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = BitchatFontFamily
|
||||
),
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
)
|
||||
@ -209,7 +209,7 @@ private fun LocationNotAvailableContent(
|
||||
Text(
|
||||
text = stringResource(R.string.location_services_unavailable),
|
||||
style = MaterialTheme.typography.headlineSmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = colorScheme.error
|
||||
),
|
||||
@ -226,7 +226,7 @@ private fun LocationNotAvailableContent(
|
||||
Text(
|
||||
text = stringResource(R.string.location_unavailable_explanation),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onSurface
|
||||
),
|
||||
modifier = Modifier.padding(16.dp),
|
||||
@ -247,7 +247,7 @@ private fun LocationCheckingContent(
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = colorScheme.primary
|
||||
),
|
||||
@ -259,7 +259,7 @@ private fun LocationCheckingContent(
|
||||
Text(
|
||||
text = stringResource(R.string.checking_location_services),
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
)
|
||||
|
||||
@ -1,9 +1,5 @@
|
||||
package com.bitchat.android.onboarding
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
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.Bluetooth
|
||||
import androidx.compose.material.icons.filled.LocationOn
|
||||
@ -13,17 +9,21 @@ 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.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.R
|
||||
|
||||
/**
|
||||
@ -68,7 +68,7 @@ fun PermissionExplanationScreen(
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.headlineLarge.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 32.sp
|
||||
),
|
||||
@ -79,7 +79,7 @@ fun PermissionExplanationScreen(
|
||||
Text(
|
||||
text = stringResource(R.string.about_tagline),
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
@ -117,7 +117,7 @@ fun PermissionExplanationScreen(
|
||||
Text(
|
||||
text = stringResource(R.string.privacy_bullets),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.8f)
|
||||
)
|
||||
}
|
||||
@ -164,7 +164,7 @@ fun PermissionExplanationScreen(
|
||||
Text(
|
||||
text = stringResource(R.string.grant_permissions),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
|
||||
@ -183,7 +183,6 @@ object BinaryProtocol {
|
||||
private const val SENDER_ID_SIZE = 8
|
||||
private const val RECIPIENT_ID_SIZE = 8
|
||||
private const val SIGNATURE_SIZE = 64
|
||||
|
||||
object Flags {
|
||||
const val HAS_RECIPIENT: UByte = 0x01u
|
||||
const val HAS_SIGNATURE: UByte = 0x02u
|
||||
@ -200,6 +199,15 @@ object BinaryProtocol {
|
||||
|
||||
fun encode(packet: BitchatPacket, padding: Boolean = true): ByteArray? {
|
||||
try {
|
||||
val maxPayloadLength = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
|
||||
if (packet.payload.size > maxPayloadLength) {
|
||||
Log.w(
|
||||
"BinaryProtocol",
|
||||
"Cannot encode payload ${packet.payload.size} above receiver limit $maxPayloadLength"
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
// Try to compress payload if beneficial
|
||||
var payload = packet.payload
|
||||
var originalPayloadSize: Int? = null
|
||||
@ -324,21 +332,36 @@ object BinaryProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
fun decode(data: ByteArray): BitchatPacket? {
|
||||
fun decode(data: ByteArray): BitchatPacket? =
|
||||
decode(data, CompressionUtil::decompressWithResourcesReserved)
|
||||
|
||||
/** Test seam used to prove rejected expansion sizes never reach inflation. */
|
||||
internal fun decodeForTesting(
|
||||
data: ByteArray,
|
||||
decompress: (ByteArray, Int) -> ByteArray?
|
||||
): BitchatPacket? = decode(data, decompress)
|
||||
|
||||
private fun decode(
|
||||
data: ByteArray,
|
||||
decompress: (ByteArray, Int) -> ByteArray?
|
||||
): BitchatPacket? {
|
||||
// Try decode as-is first (robust when padding wasn't applied) - iOS fix
|
||||
decodeCore(data)?.let { return it }
|
||||
decodeCore(data, decompress)?.let { return it }
|
||||
|
||||
// If that fails, try after removing padding
|
||||
val unpadded = MessagePadding.unpad(data)
|
||||
if (unpadded.contentEquals(data)) return null // No padding was removed, already failed
|
||||
|
||||
return decodeCore(unpadded)
|
||||
return decodeCore(unpadded, decompress)
|
||||
}
|
||||
|
||||
/**
|
||||
* Core decoding implementation used by decode() with and without padding removal - iOS fix
|
||||
*/
|
||||
private fun decodeCore(raw: ByteArray): BitchatPacket? {
|
||||
private fun decodeCore(
|
||||
raw: ByteArray,
|
||||
decompress: (ByteArray, Int) -> ByteArray?
|
||||
): BitchatPacket? {
|
||||
try {
|
||||
if (raw.size < HEADER_SIZE_V1 + SENDER_ID_SIZE) return null
|
||||
|
||||
@ -435,23 +458,45 @@ object BinaryProtocol {
|
||||
} else {
|
||||
buffer.getShort().toUShort().toInt()
|
||||
}
|
||||
|
||||
val maxExpandedSize = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
|
||||
if (originalSize <= 0 || originalSize > maxExpandedSize) {
|
||||
Log.w(
|
||||
"BinaryProtocol",
|
||||
"Expanded payload size $originalSize is outside the allowed range 1..$maxExpandedSize"
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
// Compressed payload
|
||||
val compressedSize = payloadLength.toInt() - lengthFieldBytes
|
||||
val compressedPayload = ByteArray(compressedSize)
|
||||
buffer.get(compressedPayload)
|
||||
|
||||
if (compressedSize == 0) {
|
||||
Log.w("BinaryProtocol", "Compressed payload has no deflate bytes")
|
||||
return null
|
||||
}
|
||||
// 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
|
||||
}
|
||||
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
|
||||
// Reserve the compressed copy plus expanded output before either allocation.
|
||||
// Small packets share the memory pool; packets wait only while its budget is full.
|
||||
val resourceBytes = compressedSize.toLong() + originalSize.toLong()
|
||||
val expandedPayload = CompressionUtil.withDecompressionResources(resourceBytes) {
|
||||
val compressedPayload = ByteArray(compressedSize)
|
||||
buffer.get(compressedPayload)
|
||||
decompress(compressedPayload, originalSize)
|
||||
} ?: return null
|
||||
if (expandedPayload.size != originalSize) {
|
||||
Log.w(
|
||||
"BinaryProtocol",
|
||||
"Expanded payload size ${expandedPayload.size} did not match declared size $originalSize"
|
||||
)
|
||||
return null
|
||||
}
|
||||
expandedPayload
|
||||
} else {
|
||||
val payloadBytes = ByteArray(payloadLength.toInt())
|
||||
buffer.get(payloadBytes)
|
||||
@ -477,7 +522,7 @@ object BinaryProtocol {
|
||||
route = route
|
||||
)
|
||||
|
||||
} catch (e: Throwable) {
|
||||
} catch (e: Exception) {
|
||||
Log.e("BinaryProtocol", "Error decoding packet: ${e.message}")
|
||||
return null
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package com.bitchat.android.protocol
|
||||
|
||||
import android.util.Log
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.zip.DataFormatException
|
||||
import java.util.zip.Deflater
|
||||
import java.util.zip.Inflater
|
||||
|
||||
@ -11,6 +12,8 @@ import java.util.zip.Inflater
|
||||
*/
|
||||
object CompressionUtil {
|
||||
private const val COMPRESSION_THRESHOLD = com.bitchat.android.util.AppConstants.Protocol.COMPRESSION_THRESHOLD_BYTES // bytes - same as iOS
|
||||
|
||||
private val decompressionPool = DecompressionResourcePool.forRuntime()
|
||||
|
||||
/**
|
||||
* Helper to check if compression is worth it - exact same logic as iOS
|
||||
@ -73,47 +76,124 @@ object CompressionUtil {
|
||||
* iOS COMPRESSION_ZLIB produces raw deflate data (no headers)
|
||||
*/
|
||||
fun decompress(compressedData: ByteArray, originalSize: Int): ByteArray? {
|
||||
// iOS COMPRESSION_ZLIB produces raw deflate format (no headers)
|
||||
try {
|
||||
val inflater = Inflater(true) // true = raw deflate, no headers
|
||||
inflater.setInput(compressedData)
|
||||
|
||||
val decompressedBuffer = ByteArray(originalSize)
|
||||
val actualSize = inflater.inflate(decompressedBuffer)
|
||||
inflater.end()
|
||||
|
||||
// Verify decompressed size matches expected (same validation as iOS)
|
||||
return if (actualSize == originalSize) {
|
||||
decompressedBuffer
|
||||
} else if (actualSize > 0) {
|
||||
// Handle case where actual size is different
|
||||
decompressedBuffer.copyOfRange(0, actualSize)
|
||||
} else {
|
||||
if (!isValidRequest(compressedData, originalSize)) return null
|
||||
return withDecompressionResources(originalSize.toLong()) {
|
||||
decompressWithResourcesReserved(compressedData, originalSize)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun <T> withDecompressionResources(bytes: Long, block: () -> T): T? =
|
||||
decompressionPool.withReservation(bytes, block)
|
||||
|
||||
/**
|
||||
* Inflate after the caller has reserved all packet-specific allocations.
|
||||
* This avoids nested acquisition when BinaryProtocol reserves both its input copy and output.
|
||||
*/
|
||||
internal fun decompressWithResourcesReserved(
|
||||
compressedData: ByteArray,
|
||||
originalSize: Int
|
||||
): ByteArray? {
|
||||
if (!isValidRequest(compressedData, originalSize)) return null
|
||||
return decompressExact(compressedData, originalSize)
|
||||
}
|
||||
|
||||
private fun isValidRequest(compressedData: ByteArray, originalSize: Int): Boolean {
|
||||
val maxExpandedSize = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
|
||||
if (compressedData.isEmpty()) {
|
||||
Log.w("CompressionUtil", "Refusing an empty compressed payload")
|
||||
return false
|
||||
}
|
||||
if (originalSize <= 0 || originalSize > maxExpandedSize) {
|
||||
Log.w(
|
||||
"CompressionUtil",
|
||||
"Refusing expanded payload size $originalSize outside 1..$maxExpandedSize"
|
||||
)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun decompressExact(compressedData: ByteArray, originalSize: Int): ByteArray? {
|
||||
return if (looksLikeZlib(compressedData)) {
|
||||
// A raw stream can coincidentally begin with a valid-looking zlib header. The
|
||||
// header therefore only determines which format to try first; any non-exact zlib
|
||||
// result must still fall back to raw under the same size/completion bounds.
|
||||
val zlibResult = try {
|
||||
inflateExact(compressedData, originalSize, nowrap = false)
|
||||
} catch (zlibException: DataFormatException) {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d("CompressionUtil", "Raw deflate decompression failed: ${e.message}, trying with zlib headers...")
|
||||
|
||||
// Fallback: try with zlib headers in case of mixed usage
|
||||
try {
|
||||
val inflater = Inflater(false) // false = expect zlib headers
|
||||
inflater.setInput(compressedData)
|
||||
|
||||
val decompressedBuffer = ByteArray(originalSize)
|
||||
val actualSize = inflater.inflate(decompressedBuffer)
|
||||
inflater.end()
|
||||
|
||||
return if (actualSize == originalSize) {
|
||||
decompressedBuffer
|
||||
} else if (actualSize > 0) {
|
||||
decompressedBuffer.copyOfRange(0, actualSize)
|
||||
} else {
|
||||
if (zlibResult != null) {
|
||||
zlibResult
|
||||
} else {
|
||||
try {
|
||||
inflateExact(compressedData, originalSize, nowrap = true)
|
||||
} catch (rawException: DataFormatException) {
|
||||
Log.d("CompressionUtil", "Invalid zlib/raw deflate stream")
|
||||
null
|
||||
}
|
||||
} catch (fallbackException: Exception) {
|
||||
Log.e("CompressionUtil", "Both raw deflate and zlib decompression failed: ${fallbackException.message}")
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
inflateExact(compressedData, originalSize, nowrap = true)
|
||||
} catch (rawException: DataFormatException) {
|
||||
Log.d("CompressionUtil", "Invalid raw deflate stream")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** RFC 1950 header check used to avoid speculative double inflation. */
|
||||
private fun looksLikeZlib(data: ByteArray): Boolean {
|
||||
if (data.size < 2) return false
|
||||
val cmf = data[0].toInt() and 0xFF
|
||||
val flg = data[1].toInt() and 0xFF
|
||||
return (cmf and 0x0F) == 8 &&
|
||||
(cmf ushr 4) <= 7 &&
|
||||
((cmf shl 8) or flg) % 31 == 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Inflate one complete stream into exactly [originalSize] bytes.
|
||||
*
|
||||
* A full output buffer alone is not success: an attacker can under-declare a larger stream so
|
||||
* the first inflate call fills the buffer while [Inflater.finished] remains false. Conversely,
|
||||
* a truncated or over-declared stream can produce a non-empty prefix. Both forms are rejected,
|
||||
* as are trailing bytes after the compressed stream.
|
||||
*
|
||||
* [DataFormatException] is deliberately allowed to escape so the caller can try the legacy
|
||||
* zlib-wrapped format. Size/completion mismatches return null; the fallback must then prove the
|
||||
* same bytes are a complete, exact-sized zlib stream before they can be accepted.
|
||||
*/
|
||||
@Throws(DataFormatException::class)
|
||||
private fun inflateExact(
|
||||
compressedData: ByteArray,
|
||||
originalSize: Int,
|
||||
nowrap: Boolean
|
||||
): ByteArray? {
|
||||
val inflater = Inflater(nowrap)
|
||||
return try {
|
||||
inflater.setInput(compressedData)
|
||||
val output = ByteArray(originalSize)
|
||||
var written = 0
|
||||
|
||||
while (written < originalSize) {
|
||||
val count = inflater.inflate(output, written, originalSize - written)
|
||||
if (count == 0) break
|
||||
written += count
|
||||
}
|
||||
|
||||
if (written != originalSize) return null
|
||||
|
||||
// Give Inflater one byte of room to consume the end marker. Any produced byte proves
|
||||
// the declared size was smaller than the actual expansion.
|
||||
val overflowProbe = ByteArray(1)
|
||||
if (inflater.inflate(overflowProbe) != 0) return null
|
||||
|
||||
if (!inflater.finished() || inflater.remaining != 0) return null
|
||||
output
|
||||
} finally {
|
||||
inflater.end()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
package com.bitchat.android.protocol
|
||||
|
||||
import java.util.concurrent.Semaphore
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.ceil
|
||||
|
||||
/**
|
||||
* Fair, weighted admission control for decompression allocations.
|
||||
*
|
||||
* Permits represent memory rather than workers: small packets can proceed concurrently while
|
||||
* near-limit packets consume most of the budget. Callers must reserve before allocating any
|
||||
* packet-specific compressed copy or expanded output.
|
||||
*/
|
||||
internal class DecompressionResourcePool(
|
||||
budgetBytes: Long,
|
||||
private val unitBytes: Int,
|
||||
private val waitTimeoutMs: Long
|
||||
) {
|
||||
private val totalPermits = (budgetBytes / unitBytes).toInt().coerceAtLeast(1)
|
||||
private val permits = Semaphore(totalPermits, true)
|
||||
|
||||
fun <T> withReservation(bytes: Long, block: () -> T): T? {
|
||||
val requiredPermits = permitsFor(bytes)
|
||||
val acquired = try {
|
||||
permits.tryAcquire(requiredPermits, waitTimeoutMs, TimeUnit.MILLISECONDS)
|
||||
} catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
false
|
||||
}
|
||||
if (!acquired) return null
|
||||
|
||||
return try {
|
||||
block()
|
||||
} finally {
|
||||
permits.release(requiredPermits)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun permitsFor(bytes: Long): Int =
|
||||
ceil(bytes.coerceAtLeast(1).toDouble() / unitBytes.toDouble())
|
||||
.toInt()
|
||||
.coerceAtMost(totalPermits)
|
||||
|
||||
internal val availablePermits: Int
|
||||
get() = permits.availablePermits()
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_UNIT_BYTES = 256 * 1024
|
||||
private const val DEFAULT_WAIT_TIMEOUT_MS = 1_000L
|
||||
private const val HEAP_BUDGET_DIVISOR = 8L
|
||||
private const val MAX_BUDGET_BYTES = 64L * 1024 * 1024
|
||||
|
||||
fun forRuntime(
|
||||
maxHeapBytes: Long = Runtime.getRuntime().maxMemory(),
|
||||
maxPacketResourceBytes: Long =
|
||||
2L * com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
|
||||
): DecompressionResourcePool {
|
||||
val budget = recommendedBudgetBytes(maxHeapBytes, maxPacketResourceBytes)
|
||||
return DecompressionResourcePool(
|
||||
budgetBytes = budget,
|
||||
unitBytes = DEFAULT_UNIT_BYTES,
|
||||
waitTimeoutMs = DEFAULT_WAIT_TIMEOUT_MS
|
||||
)
|
||||
}
|
||||
|
||||
internal fun recommendedBudgetBytes(
|
||||
maxHeapBytes: Long,
|
||||
maxPacketResourceBytes: Long
|
||||
): Long = (maxHeapBytes / HEAP_BUDGET_DIVISOR)
|
||||
.coerceAtLeast(maxPacketResourceBytes)
|
||||
.coerceAtMost(MAX_BUDGET_BYTES.coerceAtLeast(maxPacketResourceBytes))
|
||||
}
|
||||
}
|
||||
@ -56,6 +56,8 @@ object AppShutdownCoordinator {
|
||||
|
||||
// Stop mesh (best-effort)
|
||||
try { mesh?.stopServices() } catch (_: Exception) { }
|
||||
try { com.bitchat.android.nostr.NostrRelayManager.shared.disconnect() } catch (_: Exception) { }
|
||||
try { com.bitchat.android.mesh.PowerManager.getInstance(app).shutdown() } catch (_: Exception) { }
|
||||
|
||||
// Stop Tor temporarily (do not change user setting)
|
||||
val torProvider = ArtiTorManager.getInstance()
|
||||
|
||||
@ -19,8 +19,8 @@ import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MeshForegroundService : Service() {
|
||||
@ -112,9 +112,11 @@ class MeshForegroundService : Service() {
|
||||
private val unifiedMeshService: com.bitchat.android.mesh.MeshService?
|
||||
get() = MeshServiceHolder.unifiedMeshService
|
||||
private val serviceJob = Job()
|
||||
private val scope = CoroutineScope(Dispatchers.Default + serviceJob)
|
||||
// Service lifecycle callbacks and notification state are main-thread confined.
|
||||
private val scope = CoroutineScope(Dispatchers.Main.immediate + serviceJob)
|
||||
private var isInForeground: Boolean = false
|
||||
private var isShuttingDown: Boolean = false
|
||||
private var lastNotifiedPeerCount: Int? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
@ -131,6 +133,16 @@ class MeshForegroundService : Service() {
|
||||
MeshServiceHolder.attach(created)
|
||||
}
|
||||
MeshServiceHolder.getUnifiedOrCreate(applicationContext)
|
||||
|
||||
// Notification content is driven by peer-state changes, not a permanent timer.
|
||||
updateJob = scope.launch {
|
||||
com.bitchat.android.services.AppStateStore.peers
|
||||
.map { peers -> peers.distinct().size }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
if (isInForeground) updateNotification(force = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
@ -144,6 +156,7 @@ class MeshForegroundService : Service() {
|
||||
when (intent?.action) {
|
||||
ACTION_STOP -> {
|
||||
// Stop FGS and mesh cleanly
|
||||
try { com.bitchat.android.services.MessageRouter.tryGetInstance()?.stopOutboxScheduler() } catch (_: Exception) { }
|
||||
try { unifiedMeshService?.stopServices() ?: meshService?.stopServices() } catch (_: Exception) { }
|
||||
try { MeshServiceHolder.clear() } catch (_: Exception) { }
|
||||
try { stopForeground(true) } catch (_: Exception) { }
|
||||
@ -175,9 +188,11 @@ 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(getUnifiedActivePeerCount())
|
||||
val count = getUnifiedActivePeerCount()
|
||||
val n = buildNotification(count)
|
||||
startForegroundCompat(n)
|
||||
isInForeground = true
|
||||
lastNotifiedPeerCount = count
|
||||
} else {
|
||||
updateNotification(force = true)
|
||||
}
|
||||
@ -190,32 +205,11 @@ class MeshForegroundService : Service() {
|
||||
|
||||
// Promote exactly once when eligible, otherwise stay background (or stop)
|
||||
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions() && !isInForeground) {
|
||||
val notification = buildNotification(getUnifiedActivePeerCount())
|
||||
val count = getUnifiedActivePeerCount()
|
||||
val notification = buildNotification(count)
|
||||
startForegroundCompat(notification)
|
||||
isInForeground = true
|
||||
}
|
||||
|
||||
// Periodically refresh the notification with live network size
|
||||
if (updateJob == null) {
|
||||
updateJob = scope.launch {
|
||||
while (isActive) {
|
||||
// Retry enabling mesh/foreground once permissions become available
|
||||
ensureMeshStarted()
|
||||
val eligible = MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()
|
||||
if (eligible) {
|
||||
// Only update the notification; do not re-call startForeground()
|
||||
updateNotification(force = false)
|
||||
} else {
|
||||
// If disabled or perms missing, ensure we are not in foreground and clear notif
|
||||
if (isInForeground) {
|
||||
try { stopForeground(false) } catch (_: Exception) { }
|
||||
isInForeground = false
|
||||
}
|
||||
notificationManager.cancel(NOTIFICATION_ID)
|
||||
}
|
||||
delay(5000)
|
||||
}
|
||||
}
|
||||
lastNotifiedPeerCount = count
|
||||
}
|
||||
|
||||
return START_STICKY
|
||||
@ -229,19 +223,9 @@ class MeshForegroundService : Service() {
|
||||
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")
|
||||
val service = MeshServiceHolder.getOrCreate(applicationContext)
|
||||
val service = MeshServiceHolder.getUnifiedOrCreate(applicationContext)
|
||||
service.startServices()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("MeshForegroundService", "Failed to start mesh service: ${e.message}")
|
||||
@ -254,14 +238,17 @@ class MeshForegroundService : Service() {
|
||||
return
|
||||
}
|
||||
val count = getUnifiedActivePeerCount()
|
||||
val notification = buildNotification(count)
|
||||
if (MeshServicePreferences.isBackgroundEnabled(true) && hasAllRequiredPermissions()) {
|
||||
notificationManager.notify(NOTIFICATION_ID, notification)
|
||||
if (lastNotifiedPeerCount != count) {
|
||||
notificationManager.notify(NOTIFICATION_ID, buildNotification(count))
|
||||
lastNotifiedPeerCount = count
|
||||
}
|
||||
} else if (force) {
|
||||
// If disabled and forced, make sure to remove any prior foreground state
|
||||
try { stopForeground(false) } catch (_: Exception) { }
|
||||
notificationManager.cancel(NOTIFICATION_ID)
|
||||
isInForeground = false
|
||||
lastNotifiedPeerCount = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ import android.util.Log
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.util.toHexString
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import java.security.MessageDigest
|
||||
import java.util.Collections
|
||||
import java.util.LinkedHashMap
|
||||
@ -31,6 +32,14 @@ object TransportBridgeService {
|
||||
*/
|
||||
fun send(packet: RoutedPacket)
|
||||
|
||||
/**
|
||||
* Send a packet and report whether at least one concrete transport write was accepted.
|
||||
*
|
||||
* Receipt retries use this path so a registered-but-disconnected transport cannot be
|
||||
* mistaken for a successful send.
|
||||
*/
|
||||
suspend fun sendAndReport(packet: RoutedPacket): Boolean = false
|
||||
|
||||
/**
|
||||
* Send a packet to a specific peer via this transport (optional).
|
||||
*/
|
||||
@ -45,6 +54,11 @@ object TransportBridgeService {
|
||||
}
|
||||
}
|
||||
)
|
||||
private data class PreparedForward(
|
||||
val packet: BitchatPacket,
|
||||
val seenKey: String,
|
||||
val reservedAtMs: Long
|
||||
)
|
||||
|
||||
/**
|
||||
* Register a transport layer to receive bridged packets.
|
||||
@ -74,7 +88,8 @@ 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 prepared = prepareForwardedPacket("broadcast", packet.packet) ?: return
|
||||
val forwardedPacket = prepared.packet
|
||||
// Prepared private-media fragments must remain the admitted plan when
|
||||
// crossing transports, but relay TTL still has to advance on every
|
||||
// hop. TTL is excluded from the signature and does not affect size.
|
||||
@ -96,13 +111,51 @@ object TransportBridgeService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts through every other active transport and reports whether any concrete write was
|
||||
* accepted. Failed attempts release their duplicate-suppression reservation so a later retry
|
||||
* can use a transport that reconnects during the retry window.
|
||||
*/
|
||||
suspend fun broadcastAndReport(sourceId: String, packet: RoutedPacket): Boolean {
|
||||
val targets = transports.filterKeys { it != sourceId }
|
||||
if (targets.isEmpty()) return false
|
||||
val kind = "broadcast"
|
||||
val prepared = prepareForwardedPacket(kind, packet.packet) ?: return false
|
||||
val forwardedPacket = prepared.packet
|
||||
val forwarded = packet.copy(
|
||||
packet = forwardedPacket,
|
||||
preparedPackets = packet.preparedPackets?.map { prepared ->
|
||||
prepared.copy(ttl = forwardedPacket.ttl)
|
||||
}
|
||||
)
|
||||
|
||||
var accepted = false
|
||||
targets.forEach { (id, layer) ->
|
||||
val targetAccepted = try {
|
||||
layer.sendAndReport(forwarded)
|
||||
} catch (e: CancellationException) {
|
||||
releaseSeenPacket(prepared)
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to bridge packet to $id: ${e.message}")
|
||||
false
|
||||
}
|
||||
accepted = targetAccepted || accepted
|
||||
}
|
||||
if (!accepted) {
|
||||
releaseSeenPacket(prepared)
|
||||
}
|
||||
return accepted
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
val forwardedPacket =
|
||||
prepareForwardedPacket("peer:$peerID", packet)?.packet ?: return
|
||||
|
||||
targets.forEach { (id, layer) ->
|
||||
try {
|
||||
@ -147,7 +200,7 @@ object TransportBridgeService {
|
||||
}
|
||||
}
|
||||
|
||||
private fun prepareForwardedPacket(kind: String, packet: BitchatPacket): BitchatPacket? {
|
||||
private fun prepareForwardedPacket(kind: String, packet: BitchatPacket): PreparedForward? {
|
||||
if (packet.ttl == 0u.toUByte()) {
|
||||
Log.d(TAG, "Dropping bridged packet type ${packet.type}: TTL expired")
|
||||
return null
|
||||
@ -165,7 +218,19 @@ object TransportBridgeService {
|
||||
seenPackets[key] = now
|
||||
}
|
||||
|
||||
return packet.copy(ttl = (packet.ttl - 1u).toUByte())
|
||||
return PreparedForward(
|
||||
packet = packet.copy(ttl = (packet.ttl - 1u).toUByte()),
|
||||
seenKey = key,
|
||||
reservedAtMs = now
|
||||
)
|
||||
}
|
||||
|
||||
private fun releaseSeenPacket(prepared: PreparedForward) {
|
||||
synchronized(seenPackets) {
|
||||
if (seenPackets[prepared.seenKey] == prepared.reservedAtMs) {
|
||||
seenPackets.remove(prepared.seenKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun pruneSeen(now: Long) {
|
||||
|
||||
@ -17,6 +17,8 @@ object AppStateStore {
|
||||
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>>()
|
||||
private val _directPeers = MutableStateFlow<Set<String>>(emptySet())
|
||||
val directPeers: StateFlow<Set<String>> = _directPeers.asStateFlow()
|
||||
// Connected peer IDs (mesh ephemeral IDs)
|
||||
private val _peers = MutableStateFlow<List<String>>(emptyList())
|
||||
val peers: StateFlow<List<String>> = _peers.asStateFlow()
|
||||
@ -29,6 +31,12 @@ object AppStateStore {
|
||||
private val _privateMessages = MutableStateFlow<Map<String, List<BitchatMessage>>>(emptyMap())
|
||||
val privateMessages: StateFlow<Map<String, List<BitchatMessage>>> = _privateMessages.asStateFlow()
|
||||
|
||||
private val _nickname = MutableStateFlow("")
|
||||
val nickname: StateFlow<String> = _nickname.asStateFlow()
|
||||
|
||||
private val _selectedPrivateChatPeer = MutableStateFlow<String?>(null)
|
||||
val selectedPrivateChatPeer: StateFlow<String?> = _selectedPrivateChatPeer.asStateFlow()
|
||||
|
||||
// Channel messages by channel name
|
||||
private val _channelMessages = MutableStateFlow<Map<String, List<BitchatMessage>>>(emptyMap())
|
||||
val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = _channelMessages.asStateFlow()
|
||||
@ -39,6 +47,14 @@ object AppStateStore {
|
||||
}
|
||||
}
|
||||
|
||||
fun setNickname(nickname: String) {
|
||||
_nickname.value = nickname
|
||||
}
|
||||
|
||||
fun setSelectedPrivateChatPeer(peerID: String?) {
|
||||
_selectedPrivateChatPeer.value = peerID
|
||||
}
|
||||
|
||||
fun setTransportPeers(transportId: String, ids: List<String>) {
|
||||
synchronized(this) {
|
||||
peerIdsByTransport[transportId] = ids.toSet()
|
||||
@ -69,20 +85,25 @@ object AppStateStore {
|
||||
fun setTransportDirectPeers(transportId: String, ids: Collection<String>) {
|
||||
synchronized(this) {
|
||||
directPeerIdsByTransport[transportId] = ids.toSet()
|
||||
publishDirectPeersLocked()
|
||||
}
|
||||
}
|
||||
|
||||
fun clearTransportDirectPeers(transportId: String) {
|
||||
synchronized(this) {
|
||||
directPeerIdsByTransport.remove(transportId)
|
||||
publishDirectPeersLocked()
|
||||
}
|
||||
}
|
||||
|
||||
/** Union of direct peers across all transports. */
|
||||
fun getDirectPeers(): Set<String> {
|
||||
synchronized(this) {
|
||||
return directPeerIdsByTransport.values.flatten().toSet()
|
||||
}
|
||||
fun getDirectPeers(): Set<String> = _directPeers.value
|
||||
|
||||
private fun publishDirectPeersLocked() {
|
||||
_directPeers.value = directPeerIdsByTransport.values
|
||||
.asSequence()
|
||||
.flatten()
|
||||
.toSet()
|
||||
}
|
||||
|
||||
fun addPublicMessage(msg: BitchatMessage) {
|
||||
@ -99,6 +120,7 @@ object AppStateStore {
|
||||
synchronized(this) {
|
||||
if (seenMessageIds.contains(msg.id)) return
|
||||
seenMessageIds.add(msg.id)
|
||||
PrivateMessageArrivalOrder.record(msg.id)
|
||||
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
||||
val map = _privateMessages.value.toMutableMap()
|
||||
val list = (map[conversationID] ?: emptyList()) + msg
|
||||
@ -207,12 +229,16 @@ object AppStateStore {
|
||||
synchronized(this) {
|
||||
seenMessageIds.clear()
|
||||
seenPublicMessageKeys.clear()
|
||||
PrivateMessageArrivalOrder.clear()
|
||||
peerIdsByTransport.clear()
|
||||
directPeerIdsByTransport.clear()
|
||||
_peers.value = emptyList()
|
||||
_directPeers.value = emptySet()
|
||||
_publicMessages.value = emptyList()
|
||||
_privateMessages.value = emptyMap()
|
||||
_channelMessages.value = emptyMap()
|
||||
_nickname.value = ""
|
||||
_selectedPrivateChatPeer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -26,6 +26,10 @@ object ContactDirectory {
|
||||
@Volatile
|
||||
private var meshProvider: (() -> MeshService?)? = null
|
||||
|
||||
@Volatile
|
||||
internal var identityManagerProvider: (Context) -> SecureIdentityStateManager =
|
||||
{ SecureIdentityStateManager(it) }
|
||||
|
||||
fun initialize(context: Context, meshProvider: () -> MeshService?) {
|
||||
appContext = context.applicationContext
|
||||
this.meshProvider = meshProvider
|
||||
@ -79,7 +83,8 @@ object ContactDirectory {
|
||||
noisePublicKey = noiseKey ?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.noisePublicKey },
|
||||
nostrPubkey = favorite?.peerNostrPublicKey,
|
||||
displayName = favorite?.peerNickname?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
|
||||
?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.nickname },
|
||||
?: liveMeshPeerID?.let { meshProvider?.invoke()?.getPeerInfo(it)?.nickname }
|
||||
?: contactFingerprint?.let { cachedFingerprintNickname(it) },
|
||||
isMutualFavorite = favorite?.isMutual == true
|
||||
)
|
||||
}
|
||||
@ -97,9 +102,10 @@ object ContactDirectory {
|
||||
}
|
||||
|
||||
return merged.mapValues { (_, messages) ->
|
||||
messages
|
||||
.distinctBy { it.id }
|
||||
.sortedWith(compareBy<BitchatMessage> { it.timestamp.time }.thenBy { it.id })
|
||||
// A private message's timestamp comes from the sender and is not a reliable ordering
|
||||
// signal when peers' clocks differ. Use the local receipt sequence so interleaved
|
||||
// alias lists can be merged back into their global arrival order.
|
||||
PrivateMessageArrivalOrder.order(messages.distinctBy { it.id })
|
||||
}
|
||||
}
|
||||
|
||||
@ -132,7 +138,7 @@ object ContactDirectory {
|
||||
private fun cachedNoiseKey(peerID: String): ByteArray? {
|
||||
val context = appContext ?: return null
|
||||
return try {
|
||||
SecureIdentityStateManager(context)
|
||||
identityManagerProvider(context)
|
||||
.getCachedNoiseKey(peerID)
|
||||
?.let { ContactIdentityResolver.bytesFromHex(it) }
|
||||
} catch (_: Exception) {
|
||||
@ -140,6 +146,17 @@ object ContactDirectory {
|
||||
}
|
||||
}
|
||||
|
||||
private fun cachedFingerprintNickname(fingerprint: String): String? {
|
||||
val context = appContext ?: return null
|
||||
return try {
|
||||
identityManagerProvider(context)
|
||||
.getCachedFingerprintNickname(fingerprint)
|
||||
?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun favoriteForMeshPeerID(peerID: String): FavoriteRelationship? =
|
||||
try {
|
||||
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
|
||||
|
||||
@ -65,7 +65,6 @@ object ConversationAliasResolver {
|
||||
if (didMerge) {
|
||||
currentChats[targetConversationID] = targetList
|
||||
.distinctBy { it.id }
|
||||
.sortedBy { it.timestamp.time }
|
||||
state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentChats))
|
||||
|
||||
// Move unread flags
|
||||
|
||||
@ -6,6 +6,15 @@ import com.bitchat.android.favorites.FavoriteControlMessage
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.ReadReceipt
|
||||
import com.bitchat.android.nostr.NostrTransport
|
||||
import com.bitchat.android.util.AppConstants
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Routes messages between local mesh transports and Nostr, matching iOS behavior.
|
||||
@ -22,9 +31,27 @@ class MessageRouter private constructor(
|
||||
DROPPED
|
||||
}
|
||||
|
||||
private data class QueuedMessage(
|
||||
val content: String,
|
||||
val nickname: String,
|
||||
val messageID: String,
|
||||
val enqueuedAtMs: Long
|
||||
)
|
||||
|
||||
private data class ConversationRetry(
|
||||
val handshakeAttempts: Int,
|
||||
val nextHandshakeAttemptAtMs: Long
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MessageRouter"
|
||||
private const val OUTBOX_TICK_MS = AppConstants.Router.OUTBOX_TICK_MS
|
||||
private const val OUTBOX_MESSAGE_TTL_MS = AppConstants.Router.OUTBOX_MESSAGE_TTL_MS
|
||||
private const val OUTBOX_MAX_PER_PEER = AppConstants.Router.OUTBOX_MAX_PER_PEER
|
||||
private val HANDSHAKE_RETRY_BACKOFF_MS = AppConstants.Router.HANDSHAKE_RETRY_BACKOFF_MS
|
||||
|
||||
@Volatile private var INSTANCE: MessageRouter? = null
|
||||
internal var disableSchedulerForTesting = false
|
||||
fun tryGetInstance(): MessageRouter? = INSTANCE
|
||||
fun getInstance(context: Context, mesh: MeshService): MessageRouter {
|
||||
val instance = INSTANCE ?: synchronized(this) {
|
||||
@ -39,15 +66,38 @@ class MessageRouter private constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
// Always update mesh reference and sync peer ID
|
||||
// Always update mesh reference and sync peer ID, and make sure the retry
|
||||
// scheduler is running (it is stopped together with MeshForegroundService).
|
||||
instance.mesh = mesh
|
||||
instance.nostr.senderPeerID = mesh.myPeerID
|
||||
instance.startOutboxScheduler()
|
||||
return instance
|
||||
}
|
||||
|
||||
internal fun resetForTesting() {
|
||||
INSTANCE?.schedulerScope?.cancel()
|
||||
INSTANCE = null
|
||||
}
|
||||
}
|
||||
|
||||
// Outbox: peerID -> queued (content, nickname, messageID)
|
||||
private val outbox = mutableMapOf<String, MutableList<Triple<String, String, String>>>()
|
||||
// Outbox: conversationID -> queued messages, oldest first
|
||||
private val outbox = ConcurrentHashMap<String, MutableList<QueuedMessage>>()
|
||||
|
||||
// Per-conversation handshake retry state for queued messages
|
||||
private val retryState = ConcurrentHashMap<String, ConversationRetry>()
|
||||
|
||||
private val schedulerScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
private var schedulerJob: kotlinx.coroutines.Job? = null
|
||||
|
||||
// Injectable clock for tests
|
||||
internal var clock: () -> Long = { System.currentTimeMillis() }
|
||||
|
||||
// Called with the messageID of queued messages that expired or were evicted
|
||||
var onMessageExpired: ((String) -> Unit)? = null
|
||||
|
||||
init {
|
||||
startOutboxScheduler()
|
||||
}
|
||||
|
||||
// Listener for favorites changes to flush outbox when npub mapping appears/changes
|
||||
private val favoriteListener = object: com.bitchat.android.favorites.FavoritesChangeListener {
|
||||
@ -88,10 +138,9 @@ class MessageRouter private constructor(
|
||||
return RouteResult.NOSTR
|
||||
} else {
|
||||
Log.d(TAG, "Queued PM for ${conversationID} (no mesh, no Nostr mapping) msg_id=${messageID.take(8)}…")
|
||||
val q = outbox.getOrPut(conversationID) { mutableListOf() }
|
||||
q.add(Triple(content, recipientNickname, messageID))
|
||||
enqueue(conversationID, QueuedMessage(content, recipientNickname, messageID, clock()))
|
||||
Log.d(TAG, "Initiating noise handshake after queueing PM for ${conversationID.take(16)}…")
|
||||
if (hasMesh) meshTarget?.let { mesh.initiateNoiseHandshake(it) }
|
||||
if (hasMesh) meshTarget?.let { kickHandshake(conversationID, it, immediate = true) }
|
||||
return RouteResult.QUEUED
|
||||
}
|
||||
}
|
||||
@ -139,7 +188,10 @@ class MessageRouter private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
// Flush any queued messages for a specific peerID
|
||||
// Flush any queued messages for a specific peerID.
|
||||
// All outbox mutations happen under the router monitor so a concurrent enqueue cannot
|
||||
// be lost between the empty check and the map removal.
|
||||
@Synchronized
|
||||
fun flushOutboxFor(peerID: String) {
|
||||
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
||||
val queued = outbox[conversationID] ?: outbox[peerID] ?: return
|
||||
@ -147,21 +199,23 @@ class MessageRouter private constructor(
|
||||
Log.d(TAG, "Flushing outbox for ${conversationID.take(16)}… count=${queued.size}")
|
||||
val iterator = queued.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val (content, nickname, messageID) = iterator.next()
|
||||
val entry = iterator.next()
|
||||
val resolution = ContactDirectory.resolve(conversationID)
|
||||
val meshTarget = resolution.meshPeerID
|
||||
val nostrTarget = resolution.noiseKeyHex ?: conversationID
|
||||
if (meshTarget != null && isReady(mesh, meshTarget)) {
|
||||
mesh.sendPrivateMessage(content, meshTarget, nickname, messageID)
|
||||
mesh.sendPrivateMessage(entry.content, meshTarget, entry.nickname, entry.messageID)
|
||||
iterator.remove()
|
||||
} else if (canSendViaNostr(nostrTarget)) {
|
||||
nostr.sendPrivateMessage(content, nostrTarget, nickname, messageID)
|
||||
nostr.sendPrivateMessage(entry.content, nostrTarget, entry.nickname, entry.messageID)
|
||||
iterator.remove()
|
||||
}
|
||||
}
|
||||
if (queued.isEmpty()) {
|
||||
outbox.remove(conversationID)
|
||||
outbox.remove(peerID)
|
||||
outbox.remove(conversationID, queued)
|
||||
outbox.remove(peerID, queued)
|
||||
retryState.remove(conversationID)
|
||||
retryState.remove(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
@ -170,6 +224,116 @@ class MessageRouter private constructor(
|
||||
outbox.keys.toList().forEach { flushOutboxFor(it) }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun enqueue(conversationID: String, entry: QueuedMessage) {
|
||||
val queue = outbox.getOrPut(conversationID) { mutableListOf() }
|
||||
queue.add(entry)
|
||||
while (queue.size > OUTBOX_MAX_PER_PEER) {
|
||||
val evicted = queue.removeAt(0)
|
||||
Log.w(TAG, "Outbox full for ${conversationID.take(16)}…; evicting oldest msg_id=${evicted.messageID.take(8)}…")
|
||||
notifyExpired(evicted.messageID)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notifyExpired(messageID: String) {
|
||||
try { onMessageExpired?.invoke(messageID) } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate a Noise handshake for a conversation with queued messages, applying
|
||||
* exponential backoff between attempts. [immediate] resets the backoff (peer just
|
||||
* appeared or a new message was queued). Kicks are suppressed while a previous
|
||||
* attempt is still inside its backoff window, so alias duplicates and frequent
|
||||
* peer-list updates cannot spam handshakes.
|
||||
*/
|
||||
@Synchronized
|
||||
private fun kickHandshake(conversationID: String, meshTarget: String, immediate: Boolean) {
|
||||
val now = clock()
|
||||
val current = retryState[conversationID]
|
||||
if (current != null && now < current.nextHandshakeAttemptAtMs) return
|
||||
val attempts = if (immediate) 0 else (current?.handshakeAttempts ?: 0)
|
||||
try { mesh.initiateNoiseHandshake(meshTarget) } catch (_: Exception) { }
|
||||
val backoff = HANDSHAKE_RETRY_BACKOFF_MS[attempts.coerceAtMost(HANDSHAKE_RETRY_BACKOFF_MS.size - 1)]
|
||||
retryState[conversationID] = ConversationRetry(
|
||||
handshakeAttempts = attempts + 1,
|
||||
nextHandshakeAttemptAtMs = now + backoff
|
||||
)
|
||||
Log.d(TAG, "Handshake attempt ${attempts + 1} for ${conversationID.take(16)}…, next retry in ${backoff}ms")
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun startOutboxScheduler() {
|
||||
if (disableSchedulerForTesting) return
|
||||
if (schedulerJob?.isActive == true) return
|
||||
schedulerJob = schedulerScope.launch {
|
||||
while (isActive) {
|
||||
delay(OUTBOX_TICK_MS)
|
||||
try { tickOutbox() } catch (e: Exception) {
|
||||
Log.w(TAG, "Outbox scheduler tick failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop retrying while the mesh transports are down. Persistent network work must
|
||||
* follow the MeshForegroundService lifecycle; getInstance restarts the scheduler
|
||||
* and rebinds the mesh reference when the service comes back.
|
||||
*/
|
||||
fun stopOutboxScheduler() {
|
||||
schedulerJob?.cancel()
|
||||
schedulerJob = null
|
||||
}
|
||||
|
||||
internal val isSchedulerRunning: Boolean get() = schedulerJob?.isActive == true
|
||||
|
||||
/**
|
||||
* One scheduler pass over the outbox: expire old entries, flush what can be sent,
|
||||
* and re-initiate handshakes (with backoff) for peers that are connected but have
|
||||
* no established session yet.
|
||||
*/
|
||||
@Synchronized
|
||||
internal fun tickOutbox(nowMs: Long = clock()) {
|
||||
outbox.keys.toList().forEach { conversationID ->
|
||||
expireOldEntries(conversationID, nowMs)
|
||||
val queued = outbox[conversationID] ?: return@forEach
|
||||
if (queued.isEmpty()) return@forEach
|
||||
|
||||
val resolution = ContactDirectory.resolve(conversationID)
|
||||
val meshTarget = resolution.meshPeerID
|
||||
|
||||
if (meshTarget != null && isReady(mesh, meshTarget)) {
|
||||
flushOutboxFor(conversationID)
|
||||
return@forEach
|
||||
}
|
||||
if (canSendViaNostr(resolution.noiseKeyHex ?: conversationID)) {
|
||||
flushOutboxFor(conversationID)
|
||||
return@forEach
|
||||
}
|
||||
// Peer visible but no session: retry the handshake with backoff.
|
||||
if (meshTarget != null && isConnected(mesh, meshTarget)) {
|
||||
kickHandshake(conversationID, meshTarget, immediate = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun expireOldEntries(conversationID: String, nowMs: Long) {
|
||||
val queued = outbox[conversationID] ?: return
|
||||
val iterator = queued.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val entry = iterator.next()
|
||||
if (nowMs - entry.enqueuedAtMs > OUTBOX_MESSAGE_TTL_MS) {
|
||||
Log.w(TAG, "Expiring queued PM for ${conversationID.take(16)}… msg_id=${entry.messageID.take(8)}…")
|
||||
iterator.remove()
|
||||
notifyExpired(entry.messageID)
|
||||
}
|
||||
}
|
||||
if (queued.isEmpty()) {
|
||||
outbox.remove(conversationID, queued)
|
||||
retryState.remove(conversationID)
|
||||
}
|
||||
}
|
||||
|
||||
private fun canSendViaNostr(peerID: String): Boolean {
|
||||
return try {
|
||||
val resolution = ContactDirectory.resolve(peerID)
|
||||
@ -208,20 +372,51 @@ class MessageRouter private constructor(
|
||||
// Called when mesh peer list changes; attempt to flush any matching outbox entries
|
||||
fun onPeersUpdated(peers: List<String>) {
|
||||
peers.forEach { pid ->
|
||||
kickHandshakeIfPending(pid)
|
||||
flushOutboxFor(pid)
|
||||
val noiseHex = try {
|
||||
mesh.getPeerInfo(pid)?.noisePublicKey?.let { ContactIdentityResolver.noiseKeyHex(it) }
|
||||
} catch (_: Exception) { null }
|
||||
noiseHex?.let { flushOutboxFor(it) }
|
||||
noiseHex?.let {
|
||||
kickHandshakeIfPending(it)
|
||||
flushOutboxFor(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Called when a Noise session becomes established; flush both the mesh peerID and its noiseHex alias
|
||||
fun onSessionEstablished(peerID: String) {
|
||||
resetRetry(peerID)
|
||||
flushOutboxFor(peerID)
|
||||
val noiseHex = try {
|
||||
mesh.getPeerInfo(peerID)?.noisePublicKey?.let { ContactIdentityResolver.noiseKeyHex(it) }
|
||||
} catch (_: Exception) { null }
|
||||
noiseHex?.let { flushOutboxFor(it) }
|
||||
noiseHex?.let {
|
||||
resetRetry(it)
|
||||
flushOutboxFor(it)
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset handshake backoff for a conversation whose session just came up. */
|
||||
private fun resetRetry(peerID: String) {
|
||||
retryState.remove(ContactDirectory.canonicalConversationId(peerID))
|
||||
retryState.remove(peerID)
|
||||
}
|
||||
|
||||
/**
|
||||
* A peer (re)appeared: if we still owe them queued messages and there is no working
|
||||
* session yet, restart the handshake immediately instead of waiting for the backoff.
|
||||
*/
|
||||
@Synchronized
|
||||
private fun kickHandshakeIfPending(peerID: String) {
|
||||
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
||||
val queued = outbox[conversationID] ?: outbox[peerID] ?: return
|
||||
if (queued.isEmpty()) return
|
||||
val resolution = ContactDirectory.resolve(conversationID)
|
||||
val meshTarget = resolution.meshPeerID ?: return
|
||||
if (isReady(mesh, meshTarget)) return
|
||||
if (!isConnected(mesh, meshTarget)) return
|
||||
Log.d(TAG, "Peer ${meshTarget.take(8)}… reappeared with ${queued.size} queued PM(s); re-initiating handshake")
|
||||
kickHandshake(conversationID, meshTarget, immediate = true)
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,39 @@
|
||||
package com.bitchat.android.services
|
||||
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
|
||||
/**
|
||||
* Process-local receipt sequence for private messages.
|
||||
*
|
||||
* Peer-provided timestamps cannot safely order a conversation because clocks can differ. This
|
||||
* registry follows the lifetime of [AppStateStore] and lets alias merges reconstruct the order in
|
||||
* which messages entered the app, even when they were initially stored under different keys.
|
||||
*/
|
||||
internal object PrivateMessageArrivalOrder {
|
||||
private val sequenceByMessageID = mutableMapOf<String, Long>()
|
||||
private var nextSequence = 0L
|
||||
|
||||
fun record(messageID: String) {
|
||||
synchronized(this) {
|
||||
if (messageID !in sequenceByMessageID) {
|
||||
sequenceByMessageID[messageID] = nextSequence++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun order(messages: List<BitchatMessage>): List<BitchatMessage> {
|
||||
synchronized(this) {
|
||||
if (messages.size < 2 || messages.any { it.id !in sequenceByMessageID }) {
|
||||
return messages
|
||||
}
|
||||
return messages.sortedBy { sequenceByMessageID.getValue(it.id) }
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
synchronized(this) {
|
||||
sequenceByMessageID.clear()
|
||||
nextSequence = 0L
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -4,9 +4,14 @@ import android.content.Context
|
||||
import android.util.Log
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
/**
|
||||
* Persistent store for message IDs we've already acknowledged (DELIVERED) or READ.
|
||||
* Persistent store for message IDs we've already acknowledged as delivered, read locally, or
|
||||
* admitted to a completed read-receipt send window.
|
||||
*
|
||||
* Local read state must not be used as proof that a read-receipt packet reached the sender.
|
||||
* Transport delivery is best-effort and retryable, while local read state drives unread UI.
|
||||
* Limits to last MAX_IDS entries per set to avoid memory bloat.
|
||||
*/
|
||||
class SeenMessageStore private constructor(private val context: Context) {
|
||||
@ -27,12 +32,14 @@ class SeenMessageStore private constructor(private val context: Context) {
|
||||
private val secure = SecureIdentityStateManager(context)
|
||||
|
||||
private val delivered = LinkedHashSet<String>(MAX_IDS)
|
||||
private val read = LinkedHashSet<String>(MAX_IDS)
|
||||
private val locallyRead = LinkedHashSet<String>(MAX_IDS)
|
||||
private val readReceiptsSent = LinkedHashSet<String>(MAX_IDS)
|
||||
|
||||
init { load() }
|
||||
|
||||
@Synchronized fun hasDelivered(id: String) = delivered.contains(id)
|
||||
@Synchronized fun hasRead(id: String) = read.contains(id)
|
||||
@Synchronized fun hasBeenReadLocally(id: String) = locallyRead.contains(id)
|
||||
@Synchronized fun hasReadReceiptBeenSent(id: String) = readReceiptsSent.contains(id)
|
||||
|
||||
@Synchronized fun markDelivered(id: String) {
|
||||
if (delivered.remove(id)) delivered.add(id) else {
|
||||
@ -42,17 +49,26 @@ class SeenMessageStore private constructor(private val context: Context) {
|
||||
persist()
|
||||
}
|
||||
|
||||
@Synchronized fun markRead(id: String) {
|
||||
if (read.remove(id)) read.add(id) else {
|
||||
read.add(id)
|
||||
trim(read)
|
||||
@Synchronized fun markReadLocally(id: String) {
|
||||
if (locallyRead.remove(id)) locallyRead.add(id) else {
|
||||
locallyRead.add(id)
|
||||
trim(locallyRead)
|
||||
}
|
||||
persist()
|
||||
}
|
||||
|
||||
@Synchronized fun markReadReceiptSent(id: String) {
|
||||
if (readReceiptsSent.remove(id)) readReceiptsSent.add(id) else {
|
||||
readReceiptsSent.add(id)
|
||||
trim(readReceiptsSent)
|
||||
}
|
||||
persist()
|
||||
}
|
||||
|
||||
@Synchronized fun clear() {
|
||||
delivered.clear()
|
||||
read.clear()
|
||||
locallyRead.clear()
|
||||
readReceiptsSent.clear()
|
||||
persist()
|
||||
}
|
||||
|
||||
@ -68,10 +84,19 @@ class SeenMessageStore private constructor(private val context: Context) {
|
||||
try {
|
||||
val json = secure.getSecureValue(STORAGE_KEY) ?: return
|
||||
val data = gson.fromJson(json, StorePayload::class.java) ?: return
|
||||
delivered.clear(); read.clear()
|
||||
delivered.clear(); locallyRead.clear(); readReceiptsSent.clear()
|
||||
data.delivered.takeLast(MAX_IDS).forEach { delivered.add(it) }
|
||||
data.read.takeLast(MAX_IDS).forEach { read.add(it) }
|
||||
Log.d(TAG, "Loaded delivered=${delivered.size}, read=${read.size}")
|
||||
data.locallyRead.takeLast(MAX_IDS).forEach { locallyRead.add(it) }
|
||||
// Older payloads used the local-read set to suppress receipt sends. Seed the new
|
||||
// explicit set once during migration to avoid replaying an entire chat history.
|
||||
(data.readReceiptsSent ?: data.locallyRead)
|
||||
.takeLast(MAX_IDS)
|
||||
.forEach { readReceiptsSent.add(it) }
|
||||
Log.d(
|
||||
TAG,
|
||||
"Loaded delivered=${delivered.size}, locallyRead=${locallyRead.size}, " +
|
||||
"readReceiptsSent=${readReceiptsSent.size}"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load SeenMessageStore: ${e.message}")
|
||||
}
|
||||
@ -79,7 +104,11 @@ class SeenMessageStore private constructor(private val context: Context) {
|
||||
|
||||
@Synchronized private fun persist() {
|
||||
try {
|
||||
val payload = StorePayload(delivered.toList(), read.toList())
|
||||
val payload = StorePayload(
|
||||
delivered = delivered.toList(),
|
||||
locallyRead = locallyRead.toList(),
|
||||
readReceiptsSent = readReceiptsSent.toList()
|
||||
)
|
||||
val json = gson.toJson(payload)
|
||||
secure.storeSecureValue(STORAGE_KEY, json)
|
||||
} catch (e: Exception) {
|
||||
@ -89,6 +118,10 @@ class SeenMessageStore private constructor(private val context: Context) {
|
||||
|
||||
private data class StorePayload(
|
||||
val delivered: List<String> = emptyList(),
|
||||
val read: List<String> = emptyList()
|
||||
// Keep the existing JSON field name for backward-compatible secure-store migration.
|
||||
@SerializedName("read")
|
||||
val locallyRead: List<String> = emptyList(),
|
||||
@SerializedName("read_receipts_sent")
|
||||
val readReceiptsSent: List<String>? = null
|
||||
)
|
||||
}
|
||||
|
||||
605
app/src/main/java/com/bitchat/android/ui/AboutSections.kt
Normal file
@ -0,0 +1,605 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.core.ui.icon.BitChatIcon
|
||||
import com.bitchat.android.ui.theme.BitchatMotion
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
|
||||
/**
|
||||
* Building blocks for the redesigned About sheet.
|
||||
*
|
||||
* Kept in a separate file from [AboutSheet] because the sheet itself is mostly wiring for
|
||||
* preferences, whereas these are pure presentation.
|
||||
*/
|
||||
|
||||
/** Horizontal inset shared by every About section, so cards and labels align to one grid. */
|
||||
internal val AboutHorizontalPadding = 20.dp
|
||||
|
||||
/** Card corner radius for grouped rows. */
|
||||
internal val AboutCardShape = RoundedCornerShape(16.dp)
|
||||
|
||||
/** Leading icon column in settings-style sheet rows. */
|
||||
internal val SheetRowLeadingSlot = 22.dp
|
||||
internal val SheetRowLeadingGutter = 16.dp
|
||||
internal val SheetRowHorizontal = 16.dp
|
||||
internal val SheetRowVertical = 13.dp
|
||||
|
||||
/**
|
||||
* Exact height of a people-list row.
|
||||
*
|
||||
* Fixed rather than derived from content: these lists reorder themselves constantly, and a row
|
||||
* whose height depends on its content makes the whole card change height every time the order
|
||||
* changes. Equals the leading glyph plus [SheetRowVertical] above and below.
|
||||
*/
|
||||
internal val SheetRowHeight = SheetRowLeadingSlot + SheetRowVertical * 2
|
||||
internal val SheetRowDividerInset = SheetRowHorizontal + SheetRowLeadingSlot + SheetRowLeadingGutter
|
||||
/** Selection indicator sized for [SheetRowLeadingSlot]. */
|
||||
internal val SheetRowSelectedDot = 12.dp
|
||||
|
||||
/**
|
||||
* Two top-level views of the sheet: what the app is and how to drive it, versus the knobs.
|
||||
*/
|
||||
enum class AboutTab {
|
||||
Info,
|
||||
Settings,
|
||||
}
|
||||
|
||||
/**
|
||||
* Small uppercase section label, e.g. `SETTINGS`.
|
||||
*
|
||||
* Uppercasing happens here rather than in the string resource so translators supply natural
|
||||
* sentence case and locales without a case distinction are unaffected.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AboutSectionLabel(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
letterSpacing = 0.8.sp,
|
||||
color = palette.textTertiary,
|
||||
modifier = modifier.padding(start = AboutHorizontalPadding, top = 24.dp, bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon + title on one line, optional short subtitle beneath. Used by location / network sheets.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SheetIconSectionHeader(
|
||||
@DrawableRes iconRes: Int,
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(iconRes),
|
||||
contentDescription = null,
|
||||
tint = colorScheme.primary,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 17.sp,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = colorScheme.primary
|
||||
)
|
||||
}
|
||||
if (!subtitle.isNullOrBlank()) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 17.sp,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Inset divider used inside grouped sheet cards (aligns with text column after the leading slot). */
|
||||
@Composable
|
||||
internal fun SheetCardDivider() {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = SheetRowDividerInset),
|
||||
thickness = 1.dp,
|
||||
color = colorScheme.outlineVariant
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Centered app identity block: logo, wordmark, tagline, version.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AboutHero(
|
||||
versionName: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val palette = LocalBitchatPalette.current
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = BitChatIcon,
|
||||
contentDescription = null,
|
||||
tint = colorScheme.primary,
|
||||
modifier = Modifier.size(64.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 40.sp,
|
||||
// Monospace at display size leaves too much air between glyphs; pull it in slightly
|
||||
// so the wordmark reads as a single unit.
|
||||
letterSpacing = (-0.5).sp,
|
||||
color = colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.about_tagline),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 16.sp,
|
||||
color = colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.version_prefix, versionName),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = palette.textTertiary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-up tab bar with a sliding underline indicator.
|
||||
*
|
||||
* The indicator animates its offset rather than cross-fading two static bars, which is what
|
||||
* makes the switch feel physically connected to the tap.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AboutTabBar(
|
||||
selected: AboutTab,
|
||||
onSelect: (AboutTab) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val density = LocalDensity.current
|
||||
|
||||
var rowWidth by remember { mutableStateOf(0.dp) }
|
||||
val tabWidth = rowWidth / 2
|
||||
val indicatorOffset by animateDpAsState(
|
||||
targetValue = if (selected == AboutTab.Info) 0.dp else tabWidth,
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
label = "aboutTabIndicator"
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding)
|
||||
.onSizeChanged { size ->
|
||||
rowWidth = with(density) { size.width.toDp() }
|
||||
}
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
AboutTabLabel(
|
||||
text = stringResource(R.string.about_tab_info),
|
||||
isSelected = selected == AboutTab.Info,
|
||||
onClick = { onSelect(AboutTab.Info) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
AboutTabLabel(
|
||||
text = stringResource(R.string.about_tab_settings),
|
||||
isSelected = selected == AboutTab.Settings,
|
||||
onClick = { onSelect(AboutTab.Settings) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
// Lambda overload: the offset is animated every frame, and the non-lambda
|
||||
// version would invalidate composition rather than just layout.
|
||||
.offset { IntOffset(x = indicatorOffset.roundToPx(), y = 0) }
|
||||
.width(tabWidth)
|
||||
.height(2.dp)
|
||||
.background(colorScheme.primary)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AboutTabLabel(
|
||||
text: String,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
val color by animateColorAsState(
|
||||
targetValue = if (isSelected) colorScheme.primary else colorScheme.onSurfaceVariant,
|
||||
animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing),
|
||||
label = "aboutTabLabelColor"
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(44.dp)
|
||||
.clickable(onClickLabel = text) { onClick() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
letterSpacing = 0.8.sp,
|
||||
color = color
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One line of the "How To Use" list: an icon plus a single instruction. */
|
||||
@Composable
|
||||
private fun AboutInstructionRow(
|
||||
@DrawableRes iconRes: Int,
|
||||
text: String
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(iconRes),
|
||||
contentDescription = null,
|
||||
tint = colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(top = 1.dp)
|
||||
.size(22.dp)
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
color = colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The "How To Use" tab: a short, scannable list of the gestures that are not self-evident.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AboutHowToUseSection(modifier: Modifier = Modifier) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(R.string.about_how_to_use_heading),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = colorScheme.primary,
|
||||
modifier = Modifier.padding(
|
||||
start = AboutHorizontalPadding,
|
||||
top = 20.dp,
|
||||
bottom = 8.dp
|
||||
)
|
||||
)
|
||||
|
||||
AboutInstructionRow(
|
||||
iconRes = R.drawable.ic_spec_person,
|
||||
text = stringResource(R.string.about_howto_nickname)
|
||||
)
|
||||
AboutInstructionRow(
|
||||
iconRes = R.drawable.ic_spec_globe,
|
||||
text = stringResource(R.string.about_howto_channels)
|
||||
)
|
||||
AboutInstructionRow(
|
||||
iconRes = R.drawable.ic_spec_people,
|
||||
text = stringResource(R.string.about_howto_people)
|
||||
)
|
||||
AboutInstructionRow(
|
||||
iconRes = R.drawable.ic_spec_bookmark_outline,
|
||||
text = stringResource(R.string.about_howto_bookmark)
|
||||
)
|
||||
AboutInstructionRow(
|
||||
iconRes = R.drawable.ic_spec_mention,
|
||||
text = stringResource(R.string.about_howto_mention)
|
||||
)
|
||||
AboutInstructionRow(
|
||||
iconRes = R.drawable.ic_spec_command,
|
||||
text = stringResource(R.string.about_howto_commands)
|
||||
)
|
||||
AboutInstructionRow(
|
||||
iconRes = R.drawable.ic_spec_waveform,
|
||||
text = stringResource(R.string.about_howto_panic)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capability list, laid out like [AboutHowToUseSection]: flat rows, no card surface or dividers.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AboutFeatureCard(modifier: Modifier = Modifier) {
|
||||
val features = listOf(
|
||||
Triple(
|
||||
R.drawable.ic_spec_wifi_off,
|
||||
R.string.about_offline_mesh_title,
|
||||
R.string.about_offline_mesh_desc
|
||||
),
|
||||
Triple(
|
||||
R.drawable.ic_spec_lock,
|
||||
R.string.about_e2e_title,
|
||||
R.string.about_e2e_desc
|
||||
),
|
||||
Triple(
|
||||
R.drawable.ic_spec_globe,
|
||||
R.string.about_online_geohash_title,
|
||||
R.string.about_online_geohash_desc
|
||||
),
|
||||
Triple(
|
||||
R.drawable.ic_spec_eye_off,
|
||||
R.string.about_no_tracking_title,
|
||||
R.string.about_no_tracking_desc
|
||||
),
|
||||
)
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
features.forEach { (icon, titleRes, descRes) ->
|
||||
AboutFeatureRow(
|
||||
iconRes = icon,
|
||||
title = stringResource(titleRes),
|
||||
subtitle = stringResource(descRes)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AboutFeatureRow(
|
||||
@DrawableRes iconRes: Int,
|
||||
title: String,
|
||||
subtitle: String
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(iconRes),
|
||||
contentDescription = null,
|
||||
tint = colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(top = 1.dp)
|
||||
.size(22.dp)
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
lineHeight = 20.sp,
|
||||
color = colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 17.sp,
|
||||
color = colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Small uppercase pill, e.g. the `RECOMMENDED` badge beside the Tor routing toggle.
|
||||
*/
|
||||
@Composable
|
||||
internal fun BitchatBadge(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(colorScheme.primary.copy(alpha = 0.15f), RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 5.dp, vertical = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 0.5.sp,
|
||||
color = colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared bottom-sheet primitives
|
||||
|
||||
/** Horizontal inset for sheet content. Cards inset from here; labels align to the same edge. */
|
||||
internal val SheetHorizontalPadding = 16.dp
|
||||
|
||||
/**
|
||||
* Section divider label used across the sheets, e.g. `BOOKMARKED`, `NEARBY`, `ON LOCATION`.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SheetSectionLabel(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
letterSpacing = 0.8.sp,
|
||||
color = palette.textTertiary,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
start = SheetHorizontalPadding + 14.dp,
|
||||
end = SheetHorizontalPadding,
|
||||
top = 20.dp,
|
||||
bottom = 6.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Circular tinted badge holding a section's icon, used at the top of the sheets.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SheetHeaderBadge(
|
||||
icon: ImageVector,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(44.dp)
|
||||
.background(colorScheme.surface, androidx.compose.foundation.shape.CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = colorScheme.primary,
|
||||
modifier = Modifier.size(26.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-width destructive action, e.g. `REMOVE LOCATION ACCESS`.
|
||||
*
|
||||
* Tinted fill plus an outline rather than a solid red button: the action is legitimate but
|
||||
* rarely wanted, and a solid red block would dominate the sheet.
|
||||
*/
|
||||
@Composable
|
||||
internal fun SheetDestructiveButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
isDestructive: Boolean = true
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val accent = if (isDestructive) colorScheme.error else colorScheme.primary
|
||||
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(44.dp),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = accent.copy(alpha = 0.10f),
|
||||
border = BorderStroke(1.dp, accent.copy(alpha = 0.30f))
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
letterSpacing = 0.8.sp,
|
||||
color = accent
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,11 +3,19 @@ package com.bitchat.android.ui
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bluetooth
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material.icons.filled.Speed
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@ -26,43 +34,17 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bluetooth
|
||||
import androidx.compose.material.icons.filled.ChevronRight
|
||||
import androidx.compose.material.icons.filled.CloudDownload
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Public
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.Speed
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material.icons.filled.Wifi
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@ -70,14 +52,15 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
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.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.core.ui.component.button.CloseButton
|
||||
import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
|
||||
import com.bitchat.android.hotspot.HotspotActivity
|
||||
import com.bitchat.android.net.ArtiTorManager
|
||||
@ -85,51 +68,10 @@ import com.bitchat.android.net.TorMode
|
||||
import com.bitchat.android.net.TorPreferenceManager
|
||||
import com.bitchat.android.nostr.NostrProofOfWork
|
||||
import com.bitchat.android.nostr.PoWPreferenceManager
|
||||
import com.bitchat.android.ui.theme.BitchatMotion
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
import com.bitchat.android.util.UniversalApkManager
|
||||
|
||||
/**
|
||||
* Feature row for displaying app capabilities
|
||||
*/
|
||||
@Composable
|
||||
private fun FeatureRow(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(top = 2.dp)
|
||||
.size(22.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(14.dp))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme selection chip with Apple-like styling
|
||||
*/
|
||||
@ -141,17 +83,25 @@ private fun ThemeChip(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
|
||||
|
||||
// Cross-fade the chip so switching theme does not read as two separate flashes (the chip
|
||||
// recolouring plus the whole app recolouring underneath it).
|
||||
val containerColor by animateColorAsState(
|
||||
targetValue = if (selected) colorScheme.primary else colorScheme.surfaceVariant,
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
label = "themeChipContainer"
|
||||
)
|
||||
val labelColor by animateColorAsState(
|
||||
targetValue = if (selected) Color.White else colorScheme.onSurfaceVariant,
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
label = "themeChipLabel"
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = if (selected) {
|
||||
if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
|
||||
} else {
|
||||
colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
}
|
||||
color = containerColor
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@ -161,9 +111,10 @@ private fun ThemeChip(
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
|
||||
color = if (selected) Color.White else colorScheme.onSurface.copy(alpha = 0.8f)
|
||||
color = labelColor
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -184,23 +135,49 @@ private fun SettingsToggleRow(
|
||||
statusIndicator: (@Composable () -> Unit)? = null
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
|
||||
val palette = LocalBitchatPalette.current
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
// Colours cross-fade so a row becoming available (Tor finishing bootstrap) eases in rather
|
||||
// than popping.
|
||||
val iconTint by animateColorAsState(
|
||||
targetValue = if (enabled) colorScheme.primary else palette.textTertiary,
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
label = "settingsRowIcon"
|
||||
)
|
||||
val titleColor by animateColorAsState(
|
||||
targetValue = if (enabled) colorScheme.onSurface else palette.textTertiary,
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
label = "settingsRowTitle"
|
||||
)
|
||||
val subtitleColor by animateColorAsState(
|
||||
targetValue = if (enabled) colorScheme.onSurfaceVariant else palette.textTertiary,
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
label = "settingsRowSubtitle"
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
// The whole row toggles, not just the switch: a 14.dp-tall switch is a poor target
|
||||
// when there is a full-width row sitting right next to it.
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
enabled = enabled
|
||||
) { onCheckedChange(!checked) }
|
||||
.padding(horizontal = 16.dp, vertical = 13.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = if (enabled) colorScheme.primary else colorScheme.onSurface.copy(alpha = 0.3f),
|
||||
tint = iconTint,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(14.dp))
|
||||
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
@ -211,29 +188,32 @@ private fun SettingsToggleRow(
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (enabled) colorScheme.onSurface else colorScheme.onSurface.copy(alpha = 0.4f)
|
||||
color = titleColor
|
||||
)
|
||||
statusIndicator?.invoke()
|
||||
}
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = colorScheme.onSurface.copy(alpha = if (enabled) 0.6f else 0.3f),
|
||||
lineHeight = 16.sp
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = subtitleColor,
|
||||
lineHeight = 17.sp
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = { if (enabled) onCheckedChange(it) },
|
||||
enabled = enabled,
|
||||
interactionSource = interactionSource,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = Color.White,
|
||||
checkedTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D),
|
||||
checkedTrackColor = colorScheme.primary,
|
||||
uncheckedThumbColor = Color.White,
|
||||
uncheckedTrackColor = colorScheme.surfaceVariant
|
||||
)
|
||||
@ -272,12 +252,14 @@ fun AboutSheet(
|
||||
}
|
||||
val topBarAlpha by animateFloatAsState(
|
||||
targetValue = if (isScrolled) 0.98f else 0f,
|
||||
animationSpec = tween(BitchatMotion.EMPHASIZED_MS, easing = FastOutSlowInEasing),
|
||||
label = "topBarAlpha"
|
||||
)
|
||||
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
|
||||
val palette = LocalBitchatPalette.current
|
||||
var selectedTab by remember { mutableStateOf(AboutTab.Info) }
|
||||
|
||||
if (isPresented) {
|
||||
BitchatBottomSheet(
|
||||
modifier = modifier,
|
||||
@ -287,103 +269,49 @@ fun AboutSheet(
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(top = 80.dp, bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||
contentPadding = PaddingValues(top = 72.dp, bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp)
|
||||
) {
|
||||
// Header Section - App Identity
|
||||
item(key = "header") {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 28.sp,
|
||||
letterSpacing = 1.sp
|
||||
),
|
||||
color = colorScheme.onBackground
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.version_prefix, versionName ?: ""),
|
||||
fontSize = 13.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.5f)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.about_tagline),
|
||||
fontSize = 13.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.6f),
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
item(key = "hero") {
|
||||
AboutHero(versionName = versionName ?: "")
|
||||
}
|
||||
|
||||
// Features Section - Grouped Card
|
||||
item(key = "features") {
|
||||
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.about_appearance).uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.5f),
|
||||
letterSpacing = 0.5.sp,
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp)
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = colorScheme.surface,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column {
|
||||
FeatureRow(
|
||||
icon = Icons.Filled.Bluetooth,
|
||||
title = stringResource(R.string.about_offline_mesh_title),
|
||||
subtitle = stringResource(R.string.about_offline_mesh_desc)
|
||||
)
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = 56.dp),
|
||||
color = colorScheme.outline.copy(alpha = 0.12f)
|
||||
)
|
||||
FeatureRow(
|
||||
icon = Icons.Default.Public,
|
||||
title = stringResource(R.string.about_online_geohash_title),
|
||||
subtitle = stringResource(R.string.about_online_geohash_desc)
|
||||
)
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = 56.dp),
|
||||
color = colorScheme.outline.copy(alpha = 0.12f)
|
||||
)
|
||||
FeatureRow(
|
||||
icon = Icons.Default.Lock,
|
||||
title = stringResource(R.string.about_e2e_title),
|
||||
subtitle = stringResource(R.string.about_e2e_desc)
|
||||
)
|
||||
}
|
||||
item(key = "tabs") {
|
||||
AboutTabBar(
|
||||
selected = selectedTab,
|
||||
onSelect = { selectedTab = it },
|
||||
modifier = Modifier.padding(top = 24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedTab == AboutTab.Info) {
|
||||
// What the app is, then how to drive it. Both are reference material a
|
||||
// new user reads once, so they belong on the same tab.
|
||||
item(key = "features") {
|
||||
Column {
|
||||
AboutSectionLabel(text = stringResource(R.string.about_section_about))
|
||||
AboutFeatureCard()
|
||||
}
|
||||
}
|
||||
|
||||
item(key = "how_to_use") {
|
||||
AboutHowToUseSection()
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedTab == AboutTab.Settings) {
|
||||
// Appearance Section
|
||||
item(key = "appearance") {
|
||||
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
|
||||
Text(
|
||||
text = "THEME",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.5f),
|
||||
letterSpacing = 0.5.sp,
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp)
|
||||
)
|
||||
Column {
|
||||
AboutSectionLabel(text = stringResource(R.string.about_section_theme))
|
||||
val themePref by com.bitchat.android.ui.theme.ThemePreferenceManager.themeFlow.collectAsState()
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding),
|
||||
color = colorScheme.surface,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
shape = AboutCardShape
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
@ -425,18 +353,14 @@ fun AboutSheet(
|
||||
val torStatus by torProvider.statusFlow.collectAsState()
|
||||
val torAvailable = remember { torProvider.isTorAvailable() }
|
||||
|
||||
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
|
||||
Text(
|
||||
text = "SETTINGS",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.5f),
|
||||
letterSpacing = 0.5.sp,
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp)
|
||||
)
|
||||
Column {
|
||||
AboutSectionLabel(text = stringResource(R.string.about_section_settings))
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding),
|
||||
color = colorScheme.surface,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
shape = AboutCardShape
|
||||
) {
|
||||
Column {
|
||||
// Background Mode Toggle
|
||||
@ -455,12 +379,13 @@ fun AboutSheet(
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = 56.dp),
|
||||
color = colorScheme.outline.copy(alpha = 0.12f)
|
||||
modifier = Modifier.padding(start = 54.dp),
|
||||
thickness = 1.dp,
|
||||
color = colorScheme.outlineVariant
|
||||
)
|
||||
|
||||
|
||||
// Proof of Work Toggle
|
||||
SettingsToggleRow(
|
||||
icon = Icons.Filled.Speed,
|
||||
@ -469,16 +394,17 @@ fun AboutSheet(
|
||||
checked = powEnabled,
|
||||
onCheckedChange = { PoWPreferenceManager.setPowEnabled(it) }
|
||||
)
|
||||
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = 56.dp),
|
||||
color = colorScheme.outline.copy(alpha = 0.12f)
|
||||
modifier = Modifier.padding(start = 54.dp),
|
||||
thickness = 1.dp,
|
||||
color = colorScheme.outlineVariant
|
||||
)
|
||||
|
||||
|
||||
// Tor Toggle
|
||||
SettingsToggleRow(
|
||||
icon = Icons.Filled.Security,
|
||||
title = "Tor Network",
|
||||
title = stringResource(R.string.about_tor_title),
|
||||
subtitle = stringResource(R.string.about_tor_route),
|
||||
checked = torMode.value == TorMode.ON,
|
||||
onCheckedChange = { enabled ->
|
||||
@ -491,9 +417,9 @@ fun AboutSheet(
|
||||
statusIndicator = if (torMode.value == TorMode.ON) {
|
||||
{
|
||||
val statusColor = when {
|
||||
torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
|
||||
torStatus.running -> Color(0xFFFF9500)
|
||||
else -> Color(0xFFFF3B30)
|
||||
torStatus.running && torStatus.bootstrapPercent >= 100 -> colorScheme.primary
|
||||
torStatus.running -> palette.accentOrange
|
||||
else -> colorScheme.error
|
||||
}
|
||||
Surface(
|
||||
color = statusColor,
|
||||
@ -857,15 +783,18 @@ fun AboutSheet(
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Tor unavailable hint
|
||||
if (!torAvailable) {
|
||||
Text(
|
||||
text = stringResource(R.string.tor_not_available_in_this_build),
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.5f),
|
||||
modifier = Modifier.padding(start = 16.dp, top = 8.dp)
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = palette.textTertiary,
|
||||
modifier = Modifier.padding(
|
||||
start = AboutHorizontalPadding + 16.dp,
|
||||
top = 8.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -875,13 +804,15 @@ fun AboutSheet(
|
||||
item(key = "pow_slider") {
|
||||
val powEnabled by PoWPreferenceManager.powEnabled.collectAsState()
|
||||
val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState()
|
||||
|
||||
|
||||
if (powEnabled) {
|
||||
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
|
||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding),
|
||||
color = colorScheme.surface,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
shape = AboutCardShape
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
@ -893,31 +824,39 @@ fun AboutSheet(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Difficulty",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
text = stringResource(R.string.about_difficulty),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = "$powDifficulty bits • ${NostrProofOfWork.estimateMiningTime(powDifficulty)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
AnimatedCountLabel(
|
||||
count = powDifficulty,
|
||||
text = stringResource(
|
||||
R.string.about_difficulty_value,
|
||||
powDifficulty,
|
||||
NostrProofOfWork.estimateMiningTime(powDifficulty)
|
||||
),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Slider(
|
||||
value = powDifficulty.toFloat(),
|
||||
onValueChange = { PoWPreferenceManager.setPowDifficulty(it.toInt()) },
|
||||
valueRange = 0f..32f,
|
||||
steps = 31,
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D),
|
||||
activeTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
|
||||
thumbColor = colorScheme.primary,
|
||||
activeTrackColor = colorScheme.primary,
|
||||
inactiveTrackColor = colorScheme.surfaceVariant
|
||||
)
|
||||
)
|
||||
|
||||
Text(
|
||||
|
||||
AnimatedCountLabel(
|
||||
count = powDifficulty,
|
||||
text = when {
|
||||
powDifficulty == 0 -> stringResource(R.string.about_pow_desc_none)
|
||||
powDifficulty <= 8 -> stringResource(R.string.about_pow_desc_very_low)
|
||||
@ -928,8 +867,8 @@ fun AboutSheet(
|
||||
else -> stringResource(R.string.about_pow_desc_extreme)
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.5f)
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = palette.textTertiary
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -942,13 +881,15 @@ fun AboutSheet(
|
||||
val torMode = remember { mutableStateOf(TorPreferenceManager.get(context)) }
|
||||
val torProvider = remember { ArtiTorManager.getInstance() }
|
||||
val torStatus by torProvider.statusFlow.collectAsState()
|
||||
|
||||
|
||||
if (torMode.value == TorMode.ON) {
|
||||
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
|
||||
Column(modifier = Modifier.padding(top = 12.dp)) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding),
|
||||
color = colorScheme.surface,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
shape = AboutCardShape
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
@ -959,14 +900,19 @@ fun AboutSheet(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
val statusColor = when {
|
||||
torStatus.running && torStatus.bootstrapPercent >= 100 -> if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
|
||||
torStatus.running -> Color(0xFFFF9500)
|
||||
else -> Color(0xFFFF3B30)
|
||||
torStatus.running && torStatus.bootstrapPercent >= 100 -> colorScheme.primary
|
||||
torStatus.running -> palette.accentOrange
|
||||
else -> colorScheme.error
|
||||
}
|
||||
Surface(color = statusColor, shape = CircleShape, modifier = Modifier.size(10.dp)) {}
|
||||
Text(
|
||||
text = if (torStatus.running) "Connected (${torStatus.bootstrapPercent}%)" else "Disconnected",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
text = if (torStatus.running) {
|
||||
stringResource(R.string.about_tor_connected, torStatus.bootstrapPercent)
|
||||
} else {
|
||||
stringResource(R.string.about_tor_disconnected)
|
||||
},
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = colorScheme.onSurface
|
||||
)
|
||||
@ -975,8 +921,8 @@ fun AboutSheet(
|
||||
Text(
|
||||
text = torStatus.lastLogLine.take(120),
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.5f),
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = palette.textTertiary,
|
||||
maxLines = 2
|
||||
)
|
||||
}
|
||||
@ -986,67 +932,36 @@ fun AboutSheet(
|
||||
}
|
||||
}
|
||||
|
||||
// Emergency Warning
|
||||
item(key = "warning") {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 20.dp)
|
||||
.fillMaxWidth(),
|
||||
color = colorScheme.error.copy(alpha = 0.1f),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
tint = colorScheme.error,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.about_emergency_title),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = colorScheme.error
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.about_emergency_tip),
|
||||
fontSize = 13.sp,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} // end Settings tab
|
||||
|
||||
// Footer
|
||||
item(key = "footer") {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp),
|
||||
.padding(
|
||||
start = AboutHorizontalPadding,
|
||||
end = AboutHorizontalPadding,
|
||||
top = 24.dp
|
||||
),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (onShowDebug != null) {
|
||||
if (selectedTab == AboutTab.Settings && onShowDebug != null) {
|
||||
TextButton(onClick = onShowDebug) {
|
||||
Text(
|
||||
text = stringResource(R.string.about_debug_settings),
|
||||
fontSize = 13.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.about_footer),
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.4f)
|
||||
fontSize = 11.sp,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = palette.textTertiary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
@ -1059,10 +974,11 @@ fun AboutSheet(
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.height(64.dp)
|
||||
.background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha))
|
||||
.background(colorScheme.background.copy(alpha = topBarAlpha))
|
||||
) {
|
||||
val dismiss = LocalSheetDismiss.current
|
||||
CloseButton(
|
||||
onClick = onDismiss,
|
||||
onClick = { dismiss?.invoke() ?: onDismiss() },
|
||||
modifier = modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.padding(horizontal = 16.dp),
|
||||
@ -1112,7 +1028,7 @@ fun PasswordPromptDialog(
|
||||
onValueChange = onPasswordChange,
|
||||
label = { Text(stringResource(R.string.pwd_label), style = MaterialTheme.typography.bodyMedium) },
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = BitchatFontFamily
|
||||
),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = colorScheme.primary,
|
||||
|
||||
117
app/src/main/java/com/bitchat/android/ui/AnimatedCount.kt
Normal file
@ -0,0 +1,117 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.SizeTransform
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import com.bitchat.android.ui.theme.BitchatMotion
|
||||
|
||||
/**
|
||||
* Counts that roll to their new value instead of snapping.
|
||||
*
|
||||
* Peer counts change on their own, without the user doing anything, so a hard digit swap is easy
|
||||
* to miss and looks like a rendering fault when it is noticed. Sliding the digits in the
|
||||
* direction the number moved — up when someone joins, down when someone leaves — conveys the
|
||||
* change without needing a separate indicator.
|
||||
*/
|
||||
@Composable
|
||||
fun AnimatedCount(
|
||||
count: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
style: TextStyle = LocalTextStyle.current,
|
||||
color: Color = Color.Unspecified,
|
||||
fontSize: TextUnit = TextUnit.Unspecified,
|
||||
fontWeight: FontWeight? = null,
|
||||
fontFamily: FontFamily? = null,
|
||||
prefix: String = "",
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = count,
|
||||
transitionSpec = {
|
||||
val goingUp = targetState > initialState
|
||||
(
|
||||
slideInVertically(
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
initialOffsetY = { height -> if (goingUp) height else -height }
|
||||
) + fadeIn(tween(BitchatMotion.STANDARD_MS))
|
||||
).togetherWith(
|
||||
slideOutVertically(
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
targetOffsetY = { height -> if (goingUp) -height else height }
|
||||
) + fadeOut(tween(BitchatMotion.QUICK_MS))
|
||||
// Clip so the outgoing digit cannot bleed past the text bounds mid-transition.
|
||||
) using SizeTransform(clip = true)
|
||||
},
|
||||
modifier = modifier,
|
||||
label = "animatedCount"
|
||||
) { value ->
|
||||
Text(
|
||||
text = "$prefix$value",
|
||||
style = style,
|
||||
color = color,
|
||||
fontSize = fontSize,
|
||||
fontWeight = fontWeight,
|
||||
fontFamily = fontFamily,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-fades a label whose text embeds a count, e.g. `People (7)` or `3 people`.
|
||||
*
|
||||
* Used where the number is not isolated in its own composable and cannot be rolled on its own.
|
||||
* The transition is keyed on [count] rather than on [text], so a label changing for some other
|
||||
* reason — a locale switch, say — does not animate.
|
||||
*/
|
||||
@Composable
|
||||
fun AnimatedCountLabel(
|
||||
count: Int,
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
style: TextStyle = LocalTextStyle.current,
|
||||
color: Color = Color.Unspecified,
|
||||
fontSize: TextUnit = TextUnit.Unspecified,
|
||||
fontWeight: FontWeight? = null,
|
||||
fontFamily: FontFamily? = null,
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = count,
|
||||
transitionSpec = {
|
||||
fadeIn(tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing))
|
||||
.togetherWith(fadeOut(tween(BitchatMotion.QUICK_MS)))
|
||||
.using(SizeTransform(clip = false))
|
||||
},
|
||||
modifier = modifier,
|
||||
label = "animatedCountLabel"
|
||||
) { state ->
|
||||
// Captured per state, so the outgoing copy keeps rendering the label it entered with.
|
||||
// Reading `text` directly would show the *new* label on both sides of the cross-fade,
|
||||
// turning the transition into a flicker between two identical strings.
|
||||
val stateText = remember(state) { text }
|
||||
Text(
|
||||
text = stateText,
|
||||
style = style,
|
||||
color = color,
|
||||
fontSize = fontSize,
|
||||
fontWeight = fontWeight,
|
||||
fontFamily = fontFamily,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,85 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.animation.BoundsTransform
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.animateBounds
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.VisibilityThreshold
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.LookaheadScope
|
||||
|
||||
/**
|
||||
* Motion for a row moving to a new position, or resizing in place.
|
||||
*
|
||||
* People lists reorder themselves constantly and without user input — someone sends a DM and jumps
|
||||
* to the top, a peer drops off the mesh, a favourite comes online. Rows teleporting between
|
||||
* positions makes the list feel unreliable and costs the reader their place in it.
|
||||
*/
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
private val RowBoundsTransform = BoundsTransform { _, _ ->
|
||||
spring(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow,
|
||||
visibilityThreshold = Rect.VisibilityThreshold
|
||||
)
|
||||
}
|
||||
|
||||
/** Entry fade for a row that was not previously in the list. */
|
||||
private val RowEnterSpec: FiniteAnimationSpec<Float> =
|
||||
spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = 900f)
|
||||
|
||||
/**
|
||||
* A vertical list whose rows animate when they are added, removed, or reordered.
|
||||
*
|
||||
* Deliberately not a `LazyColumn`: both people lists live *inside* an outer `LazyColumn` item, where
|
||||
* nesting another lazy list is not possible. [LookaheadScope] plus [Modifier.animateBounds] gives
|
||||
* the same reorder-and-resize animation that `LazyItemScope.animateItem` provides, without the list
|
||||
* needing to be lazy. These lists are bounded (and the geohash one is explicitly capped), so
|
||||
* nothing is lost by composing every row.
|
||||
*
|
||||
* Rows are keyed so identity survives reordering. Without stable keys a row that moved would look
|
||||
* like a different row appearing, and would fade instead of sliding.
|
||||
*/
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun <T> AnimatedRowColumn(
|
||||
items: List<T>,
|
||||
key: (T) -> Any,
|
||||
modifier: Modifier = Modifier,
|
||||
row: @Composable (index: Int, item: T) -> Unit
|
||||
) {
|
||||
LookaheadScope {
|
||||
Column(modifier = modifier) {
|
||||
items.forEachIndexed { index, item ->
|
||||
key(key(item)) {
|
||||
// Fades the row in on the composition it first appears, then never again —
|
||||
// reordering an existing row must slide, not blink.
|
||||
val enter = remember { Animatable(0f) }
|
||||
LaunchedEffect(Unit) { enter.animateTo(1f, RowEnterSpec) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.animateBounds(
|
||||
lookaheadScope = this@LookaheadScope,
|
||||
boundsTransform = RowBoundsTransform
|
||||
)
|
||||
.graphicsLayer { alpha = enter.value }
|
||||
) {
|
||||
row(index, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,33 +1,36 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
// [Goose] Bridge file share events to ViewModel via dispatcher is installed in ChatScreen composition
|
||||
|
||||
// [Goose] Installing FileShareDispatcher handler in ChatScreen to forward file sends to ViewModel
|
||||
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDownward
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDownward
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.Lifecycle
|
||||
@ -42,6 +45,7 @@ import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.nostr.LocationNotesManager
|
||||
import com.bitchat.android.nostr.NearbyNotesController
|
||||
import com.bitchat.android.ui.media.FullScreenImageViewer
|
||||
import com.bitchat.android.ui.theme.BitchatMotion
|
||||
|
||||
/**
|
||||
* Main ChatScreen - REFACTORED to use component-based architecture
|
||||
@ -58,6 +62,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val messages by viewModel.messages.collectAsStateWithLifecycle()
|
||||
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
|
||||
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
|
||||
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
||||
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
|
||||
val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle()
|
||||
@ -179,6 +185,47 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
// Identity of the timeline on screen, derived exactly like displayMessages above. Drives the
|
||||
// per-conversation scroll position and animation state in MessagesList.
|
||||
val conversationKey = when {
|
||||
currentChannel != null -> "channel:$currentChannel"
|
||||
else -> {
|
||||
val locationChannel = selectedLocationChannel
|
||||
if (locationChannel is com.bitchat.android.geohash.ChannelID.Location) {
|
||||
"geo:${locationChannel.channel.geohash}"
|
||||
} else {
|
||||
"mesh"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val mentionPeerIdentities = remember(
|
||||
displayMessages,
|
||||
currentChannel,
|
||||
selectedLocationChannel,
|
||||
connectedPeers,
|
||||
peerNicknames,
|
||||
geohashPeople,
|
||||
) {
|
||||
val knownPeers = if (
|
||||
currentChannel == null && selectedLocationChannel is ChannelID.Location
|
||||
) {
|
||||
val duplicateNames = duplicateGeohashBaseNames(geohashPeople)
|
||||
geohashPeople.mapNotNull { person ->
|
||||
if (isUnannouncedNickname(person.displayName)) return@mapNotNull null
|
||||
val displayName = disambiguatedGeohashDisplayName(person, duplicateNames)
|
||||
displayName to PeerIdentity.nostr(person.id)
|
||||
}
|
||||
} else {
|
||||
connectedPeers.mapNotNull { peerID ->
|
||||
peerNicknames[peerID]?.let { displayName ->
|
||||
displayName to PeerIdentity.mesh(peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
buildMentionPeerIdentityMap(displayMessages, knownPeers)
|
||||
}
|
||||
|
||||
// Determine whether to show media buttons (only hide in geohash location chats)
|
||||
val showMediaButtons = when {
|
||||
currentChannel != null -> true
|
||||
@ -191,8 +238,15 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
.fillMaxSize()
|
||||
.background(colorScheme.background) // Extend background to fill entire screen including status bar
|
||||
) {
|
||||
val headerHeight = 42.dp
|
||||
|
||||
val headerHeight = ChatHeaderHeight
|
||||
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
|
||||
// Both bars are translucent and the conversation scrolls underneath them, so their
|
||||
// heights are reserved as list padding instead of as layout space. The composer's height
|
||||
// varies (suggestion rows, wrapped lines), so it is measured rather than assumed.
|
||||
var composerHeight by remember { mutableStateOf(0.dp) }
|
||||
val density = LocalDensity.current
|
||||
|
||||
// Main content area that responds to keyboard/window insets
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@ -200,100 +254,92 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
.windowInsetsPadding(WindowInsets.ime) // This handles keyboard insets
|
||||
.windowInsetsPadding(WindowInsets.navigationBars) // Add bottom padding when keyboard is not expanded
|
||||
) {
|
||||
// Header spacer - creates exact space for the floating header (status bar + compact header)
|
||||
Spacer(
|
||||
modifier = Modifier
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
.height(headerHeight)
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
// Messages area - takes up available space, will compress when keyboard appears
|
||||
// Nearby-notes strip and the reveal hint both live in this Box alongside the
|
||||
// list, rather than in a Column above it, because the conversation has to scroll
|
||||
// underneath the translucent bars. Their heights are reserved as list padding.
|
||||
var notesStripHeight by remember { mutableStateOf(0.dp) }
|
||||
val showNotesStrip =
|
||||
isMeshTimeline && nearbyNotesRevealed && nearbyNotes.isNotEmpty()
|
||||
|
||||
MessagesList(
|
||||
messages = displayMessages,
|
||||
currentUserNickname = nickname,
|
||||
meshService = viewModel.meshServiceFacade,
|
||||
mentionPeerIdentities = mentionPeerIdentities,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
conversationKey = conversationKey,
|
||||
contentPadding = PaddingValues(
|
||||
top = statusBarHeight + headerHeight +
|
||||
(if (showNotesStrip) notesStripHeight else 0.dp),
|
||||
bottom = composerHeight
|
||||
),
|
||||
forceScrollToBottom = forceScrollToBottom,
|
||||
onScrolledUpChanged = { isUp -> isScrolledUp = isUp },
|
||||
onNicknameClick = { fullSenderName ->
|
||||
// Single click - mention user in text input
|
||||
val currentText = messageText.text
|
||||
|
||||
// Extract base nickname and hash suffix from full sender name
|
||||
val (baseName, hashSuffix) = splitSuffix(fullSenderName)
|
||||
|
||||
// Check if we're in a geohash channel to include hash suffix
|
||||
val selectedLocationChannel = viewModel.selectedLocationChannel.value
|
||||
val mentionText = if (
|
||||
selectedLocationChannel is ChannelID.Location &&
|
||||
hashSuffix.isNotEmpty()
|
||||
) {
|
||||
// In geohash chat - include the hash suffix from the full display name
|
||||
"@$baseName$hashSuffix"
|
||||
} else {
|
||||
// Regular chat - just the base nickname
|
||||
"@$baseName"
|
||||
}
|
||||
|
||||
val newText = when {
|
||||
currentText.isEmpty() -> "$mentionText "
|
||||
currentText.endsWith(" ") -> "$currentText$mentionText "
|
||||
else -> "$currentText $mentionText "
|
||||
}
|
||||
|
||||
messageText = TextFieldValue(
|
||||
text = newText,
|
||||
selection = TextRange(newText.length)
|
||||
)
|
||||
},
|
||||
onMessageLongPress = { message ->
|
||||
// Message long press - open user action sheet with message context
|
||||
// Extract base nickname from message sender (contains all necessary info)
|
||||
val (baseName, _) = splitSuffix(message.sender)
|
||||
selectedUserForSheet = baseName
|
||||
selectedMessageForSheet = message
|
||||
showUserSheet = true
|
||||
},
|
||||
onCancelTransfer = { msg ->
|
||||
viewModel.cancelMediaSend(msg.id)
|
||||
},
|
||||
onImageClick = { currentPath, allImagePaths, initialIndex ->
|
||||
viewerImagePaths = allImagePaths
|
||||
initialViewerIndex = initialIndex
|
||||
showFullScreenImageViewer = true
|
||||
}
|
||||
)
|
||||
|
||||
// Messages area - takes up available space, will compress when keyboard appears
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
if (isMeshTimeline && nearbyNotesRevealed && nearbyNotes.isNotEmpty()) {
|
||||
NearbyNotesStrip(
|
||||
noteCount = nearbyNotes.size,
|
||||
onClick = { showLocationNotesSheet = true },
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
if (showNotesStrip) {
|
||||
NearbyNotesStrip(
|
||||
noteCount = nearbyNotes.size,
|
||||
onClick = { showLocationNotesSheet = true },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
MessagesList(
|
||||
messages = displayMessages,
|
||||
currentUserNickname = nickname,
|
||||
meshService = viewModel.meshServiceFacade,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
forceScrollToBottom = forceScrollToBottom,
|
||||
onScrolledUpChanged = { isUp -> isScrolledUp = isUp },
|
||||
onNicknameClick = { fullSenderName ->
|
||||
// Single click - mention user in text input
|
||||
val currentText = messageText.text
|
||||
|
||||
// Extract base nickname and hash suffix from full sender name
|
||||
val (baseName, hashSuffix) = splitSuffix(fullSenderName)
|
||||
|
||||
// Check if we're in a geohash channel to include hash suffix
|
||||
val selectedLocationChannel = viewModel.selectedLocationChannel.value
|
||||
val mentionText = if (
|
||||
selectedLocationChannel is ChannelID.Location &&
|
||||
hashSuffix.isNotEmpty()
|
||||
) {
|
||||
// In geohash chat - include the hash suffix from the full display name
|
||||
"@$baseName$hashSuffix"
|
||||
} else {
|
||||
// Regular chat - just the base nickname
|
||||
"@$baseName"
|
||||
}
|
||||
|
||||
val newText = when {
|
||||
currentText.isEmpty() -> "$mentionText "
|
||||
currentText.endsWith(" ") -> "$currentText$mentionText "
|
||||
else -> "$currentText $mentionText "
|
||||
}
|
||||
|
||||
messageText = TextFieldValue(
|
||||
text = newText,
|
||||
selection = TextRange(newText.length),
|
||||
)
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = statusBarHeight + headerHeight)
|
||||
.onSizeChanged { size ->
|
||||
notesStripHeight = with(density) { size.height.toDp() }
|
||||
},
|
||||
onMessageLongPress = { message ->
|
||||
// Message long press - open user action sheet with message context
|
||||
// Extract base nickname from message sender (contains all necessary info)
|
||||
val (baseName, _) = splitSuffix(message.sender)
|
||||
selectedUserForSheet = baseName
|
||||
selectedMessageForSheet = message
|
||||
showUserSheet = true
|
||||
},
|
||||
onCancelTransfer = { msg ->
|
||||
viewModel.cancelMediaSend(msg.id)
|
||||
},
|
||||
onImageClick = { currentPath, allImagePaths, initialIndex ->
|
||||
viewerImagePaths = allImagePaths
|
||||
initialViewerIndex = initialIndex
|
||||
showFullScreenImageViewer = true
|
||||
},
|
||||
)
|
||||
|
||||
if (
|
||||
displayMessages.isEmpty() &&
|
||||
isMeshTimeline &&
|
||||
!nearbyNotesRevealed &&
|
||||
locationEnabled &&
|
||||
locationPermissionState ==
|
||||
LocationChannelManager.PermissionState.AUTHORIZED &&
|
||||
buildingGeohash != null
|
||||
) {
|
||||
NearbyNotesRevealHint(
|
||||
onClick = nearbyNotesController::reveal,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
// Input area - stays at bottom
|
||||
|
||||
// Input area - overlays the bottom of the conversation
|
||||
// Bridge file share from lower-level input to ViewModel
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||
com.bitchat.android.ui.events.FileShareDispatcher.setHandler { peer, channel, path ->
|
||||
@ -302,6 +348,11 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
}
|
||||
|
||||
ChatInputSection(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.onSizeChanged { size ->
|
||||
composerHeight = with(density) { size.height.toDp() }
|
||||
},
|
||||
messageText = messageText,
|
||||
onMessageTextChange = { newText: TextFieldValue ->
|
||||
messageText = newText
|
||||
@ -329,6 +380,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
commandSuggestions = commandSuggestions,
|
||||
showMentionSuggestions = showMentionSuggestions,
|
||||
mentionSuggestions = mentionSuggestions,
|
||||
mentionPeerIdentities = mentionPeerIdentities,
|
||||
onCommandSuggestionClick = { suggestion: CommandSuggestion ->
|
||||
val commandText = viewModel.selectCommandSuggestion(suggestion)
|
||||
messageText = TextFieldValue(
|
||||
@ -349,11 +401,11 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
colorScheme = colorScheme,
|
||||
showMediaButtons = showMediaButtons
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Floating header - positioned absolutely at top, ignores keyboard
|
||||
ChatFloatingHeader(
|
||||
headerHeight = headerHeight,
|
||||
selectedPrivatePeer = null,
|
||||
currentChannel = currentChannel,
|
||||
nickname = nickname,
|
||||
@ -369,40 +421,39 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
}
|
||||
)
|
||||
|
||||
// Divider under header - positioned after status bar + header height
|
||||
HorizontalDivider(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.statusBars)
|
||||
.offset(y = headerHeight)
|
||||
.zIndex(1f),
|
||||
color = colorScheme.outline.copy(alpha = 0.3f)
|
||||
)
|
||||
|
||||
// Scroll-to-bottom floating button
|
||||
AnimatedVisibility(
|
||||
visible = isScrolledUp,
|
||||
enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
|
||||
exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(),
|
||||
// Short and eased: the button appears mid-scroll, so a slow entrance draws the eye
|
||||
// away from the messages the user is actually reading.
|
||||
enter = slideInVertically(
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
|
||||
initialOffsetY = { it / 2 }
|
||||
) + fadeIn(tween(BitchatMotion.STANDARD_MS)),
|
||||
exit = slideOutVertically(
|
||||
animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing),
|
||||
targetOffsetY = { it / 2 }
|
||||
) + fadeOut(tween(BitchatMotion.QUICK_MS)),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 16.dp, bottom = 64.dp)
|
||||
.padding(end = 16.dp, bottom = composerHeight + 8.dp)
|
||||
.zIndex(1.5f)
|
||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||
.windowInsetsPadding(WindowInsets.ime)
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = colorScheme.background,
|
||||
color = colorScheme.surface,
|
||||
tonalElevation = 3.dp,
|
||||
shadowElevation = 6.dp,
|
||||
border = BorderStroke(2.dp, Color(0xFF00C851))
|
||||
border = BorderStroke(1.dp, colorScheme.primary)
|
||||
) {
|
||||
IconButton(onClick = { forceScrollToBottom = !forceScrollToBottom }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowDownward,
|
||||
contentDescription = stringResource(com.bitchat.android.R.string.cd_scroll_to_bottom),
|
||||
tint = Color(0xFF00C851)
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -487,30 +538,6 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NearbyNotesRevealHint(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val actionLabel = stringResource(R.string.nearby_notes_reveal)
|
||||
TextButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp)
|
||||
.padding(horizontal = 24.dp)
|
||||
.semantics { contentDescription = actionLabel },
|
||||
) {
|
||||
Text(
|
||||
text = "📍 $actionLabel",
|
||||
modifier = Modifier.clearAndSetSemantics { },
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NearbyNotesStrip(
|
||||
noteCount: Int,
|
||||
@ -537,7 +564,7 @@ private fun NearbyNotesStrip(
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
Text(
|
||||
@ -561,58 +588,115 @@ fun ChatInputSection(
|
||||
commandSuggestions: List<CommandSuggestion>,
|
||||
showMentionSuggestions: Boolean,
|
||||
mentionSuggestions: List<String>,
|
||||
mentionPeerIdentities: Map<String, PeerIdentity> = emptyMap(),
|
||||
onCommandSuggestionClick: (CommandSuggestion) -> Unit,
|
||||
onMentionSuggestionClick: (String) -> Unit,
|
||||
selectedPrivatePeer: String?,
|
||||
currentChannel: String?,
|
||||
nickname: String,
|
||||
colorScheme: ColorScheme,
|
||||
showMediaButtons: Boolean
|
||||
showMediaButtons: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = colorScheme.background
|
||||
Column(
|
||||
// Flat, slightly translucent screen background — the same treatment as the top bar, so the
|
||||
// two bars are visibly the same kind of surface. No gradient: a soft ramp here just looked
|
||||
// like a smudge above a crisp hairline. The rule is inside the background so the whole bar
|
||||
// is one surface with a top border, rather than a line floating over the conversation.
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(colorScheme.background.copy(alpha = BarBackgroundAlpha))
|
||||
) {
|
||||
Column {
|
||||
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f))
|
||||
// Command suggestions box
|
||||
if (showCommandSuggestions && commandSuggestions.isNotEmpty()) {
|
||||
CommandSuggestionsBox(
|
||||
suggestions = commandSuggestions,
|
||||
onSuggestionClick = onCommandSuggestionClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.2f))
|
||||
// Hairline marking where chrome begins. Faint on purpose — it is a hint, not a border.
|
||||
HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant)
|
||||
|
||||
// Command suggestions box
|
||||
if (showCommandSuggestions && commandSuggestions.isNotEmpty()) {
|
||||
CommandSuggestionsBox(
|
||||
suggestions = commandSuggestions,
|
||||
onSuggestionClick = onCommandSuggestionClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant)
|
||||
}
|
||||
// Retain the final populated list while the picker exits. The state layer clears
|
||||
// suggestions together with visibility; without this snapshot the panel would empty and
|
||||
// snap shut before its shrink/fade animation had a frame to run.
|
||||
var retainedMentionSuggestions by remember { mutableStateOf(emptyList<String>()) }
|
||||
LaunchedEffect(mentionSuggestions) {
|
||||
if (mentionSuggestions.isNotEmpty()) {
|
||||
retainedMentionSuggestions = mentionSuggestions
|
||||
}
|
||||
// Mention suggestions box
|
||||
if (showMentionSuggestions && mentionSuggestions.isNotEmpty()) {
|
||||
}
|
||||
val mentionPickerVisible = showMentionSuggestions && mentionSuggestions.isNotEmpty()
|
||||
val displayedMentionSuggestions = mentionSuggestions.ifEmpty {
|
||||
retainedMentionSuggestions
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = mentionPickerVisible,
|
||||
enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) +
|
||||
expandVertically(
|
||||
animationSpec = tween(
|
||||
BitchatMotion.STANDARD_MS,
|
||||
easing = FastOutSlowInEasing
|
||||
),
|
||||
expandFrom = Alignment.Bottom
|
||||
),
|
||||
exit = fadeOut(tween(BitchatMotion.QUICK_MS)) +
|
||||
shrinkVertically(
|
||||
animationSpec = tween(
|
||||
BitchatMotion.QUICK_MS,
|
||||
easing = FastOutSlowInEasing
|
||||
),
|
||||
shrinkTowards = Alignment.Bottom
|
||||
)
|
||||
) {
|
||||
Column {
|
||||
MentionSuggestionsBox(
|
||||
suggestions = mentionSuggestions,
|
||||
suggestions = displayedMentionSuggestions,
|
||||
mentionPeerIdentities = mentionPeerIdentities,
|
||||
onSuggestionClick = onMentionSuggestionClick,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.2f))
|
||||
HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant)
|
||||
}
|
||||
MessageInput(
|
||||
value = messageText,
|
||||
onValueChange = onMessageTextChange,
|
||||
onSend = onSend,
|
||||
onSendVoiceNote = onSendVoiceNote,
|
||||
onSendImageNote = onSendImageNote,
|
||||
onSendFileNote = onSendFileNote,
|
||||
selectedPrivatePeer = selectedPrivatePeer,
|
||||
currentChannel = currentChannel,
|
||||
nickname = nickname,
|
||||
showMediaButtons = showMediaButtons,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
MessageInput(
|
||||
value = messageText,
|
||||
onValueChange = onMessageTextChange,
|
||||
onSend = onSend,
|
||||
onSendVoiceNote = onSendVoiceNote,
|
||||
onSendImageNote = onSendImageNote,
|
||||
onSendFileNote = onSendFileNote,
|
||||
selectedPrivatePeer = selectedPrivatePeer,
|
||||
currentChannel = currentChannel,
|
||||
nickname = nickname,
|
||||
showMediaButtons = showMediaButtons,
|
||||
mentionPeerIdentities = mentionPeerIdentities,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
/**
|
||||
* Opacity shared by both bars.
|
||||
*
|
||||
* Slight, so the conversation scrolling underneath stays faintly perceptible and the chrome reads
|
||||
* as sitting over the content rather than boxing it in — without ever costing legibility.
|
||||
*/
|
||||
private const val BarBackgroundAlpha = 0.88f
|
||||
|
||||
/**
|
||||
* Fraction of the header that stays fully opaque, measured from the top.
|
||||
*
|
||||
* The header is the one place a gradient earns its keep: the status bar is transparent, so the
|
||||
* header has to be the true background colour where the two meet or the system bar stops looking
|
||||
* like part of the app. Everything below that stop matches the composer's flat translucency.
|
||||
*/
|
||||
private const val HeaderOpaqueStop = 0.72f
|
||||
@Composable
|
||||
private fun ChatFloatingHeader(
|
||||
headerHeight: Dp,
|
||||
selectedPrivatePeer: String?,
|
||||
currentChannel: String?,
|
||||
nickname: String,
|
||||
@ -626,42 +710,48 @@ private fun ChatFloatingHeader(
|
||||
) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val locationManager = remember { com.bitchat.android.geohash.LocationChannelManager.getInstance(context) }
|
||||
|
||||
Surface(
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.zIndex(1f)
|
||||
.windowInsetsPadding(WindowInsets.statusBars), // Extend into status bar area
|
||||
color = colorScheme.background // Solid background color extending into status bar
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
ChatHeaderContent(
|
||||
selectedPrivatePeer = selectedPrivatePeer,
|
||||
currentChannel = currentChannel,
|
||||
nickname = nickname,
|
||||
viewModel = viewModel,
|
||||
onBackClick = {
|
||||
when {
|
||||
selectedPrivatePeer != null -> viewModel.endPrivateChat()
|
||||
currentChannel != null -> viewModel.switchToChannel(null)
|
||||
}
|
||||
},
|
||||
onSidebarClick = onSidebarToggle,
|
||||
onTripleClick = onPanicClear,
|
||||
onShowAppInfo = onShowAppInfo,
|
||||
onLocationChannelsClick = onLocationChannelsClick,
|
||||
onLocationNotesClick = {
|
||||
// Ensure location is loaded before showing sheet
|
||||
locationManager.refreshChannels()
|
||||
onLocationNotesClick()
|
||||
}
|
||||
// Fully opaque where it meets the system status bar, fading to translucent at its
|
||||
// lower edge. The status bar itself is transparent, so anything less than opaque at
|
||||
// the top would let the wallpaper or a light system-bar scrim bleed through and the
|
||||
// header would stop reading as part of the app.
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0f to colorScheme.background,
|
||||
HeaderOpaqueStop to colorScheme.background,
|
||||
1f to colorScheme.background.copy(alpha = BarBackgroundAlpha)
|
||||
)
|
||||
)
|
||||
.windowInsetsPadding(WindowInsets.statusBars) // Extend into status bar area
|
||||
) {
|
||||
// No TopAppBar: it silently injects a 4.dp horizontal pad plus a 12.dp title inset and
|
||||
// applies its own minimum heights, which made the header's spacing impossible to specify
|
||||
// exactly. Height and edge insets belong to each header variant, so that a conversation
|
||||
// header rendered here and one rendered in a sheet are laid out identically.
|
||||
ChatHeaderContent(
|
||||
selectedPrivatePeer = selectedPrivatePeer,
|
||||
currentChannel = currentChannel,
|
||||
nickname = nickname,
|
||||
viewModel = viewModel,
|
||||
onBackClick = {
|
||||
when {
|
||||
selectedPrivatePeer != null -> viewModel.endPrivateChat()
|
||||
currentChannel != null -> viewModel.switchToChannel(null)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent
|
||||
),
|
||||
modifier = Modifier.height(headerHeight) // Ensure compact header height
|
||||
onSidebarClick = onSidebarToggle,
|
||||
onTripleClick = onPanicClear,
|
||||
onShowAppInfo = onShowAppInfo,
|
||||
onLocationChannelsClick = onLocationChannelsClick,
|
||||
onLocationNotesClick = {
|
||||
// Ensure location is loaded before showing sheet
|
||||
locationManager.refreshChannels()
|
||||
onLocationNotesClick()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -94,6 +94,10 @@ class ChatState(
|
||||
// Favorites
|
||||
private val _favoritePeers = MutableStateFlow<Set<String>>(emptySet())
|
||||
val favoritePeers: StateFlow<Set<String>> = _favoritePeers.asStateFlow()
|
||||
|
||||
// Fingerprints of peers who favorited us (drives "favorited you" UI celebrations)
|
||||
private val _peerFavoritedUs = MutableStateFlow<Set<String>>(emptySet())
|
||||
val peerFavoritedUs: StateFlow<Set<String>> = _peerFavoritedUs.asStateFlow()
|
||||
|
||||
// Noise session states for peers (for reactive UI updates)
|
||||
private val _peerSessionStates = MutableStateFlow<Map<String, String>>(emptyMap())
|
||||
@ -174,6 +178,7 @@ class ChatState(
|
||||
fun getSelectedPrivateChatPeerValue() = _selectedPrivateChatPeer.value
|
||||
fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value
|
||||
fun getJoinedChannelsValue() = _joinedChannels.value
|
||||
fun getPeerFavoritedUsValue() = _peerFavoritedUs.value
|
||||
fun getCurrentChannelValue() = _currentChannel.value
|
||||
fun getChannelMessagesValue() = _channelMessages.value
|
||||
fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value
|
||||
@ -211,6 +216,7 @@ class ChatState(
|
||||
|
||||
fun setNickname(nickname: String) {
|
||||
_nickname.value = nickname
|
||||
com.bitchat.android.services.AppStateStore.setNickname(nickname)
|
||||
}
|
||||
|
||||
fun setIsConnected(connected: Boolean) {
|
||||
@ -223,6 +229,7 @@ class ChatState(
|
||||
|
||||
fun setSelectedPrivateChatPeer(peerID: String?) {
|
||||
_selectedPrivateChatPeer.value = peerID
|
||||
com.bitchat.android.services.AppStateStore.setSelectedPrivateChatPeer(peerID)
|
||||
}
|
||||
|
||||
fun setUnreadPrivateMessages(unread: Set<String>) {
|
||||
@ -285,6 +292,10 @@ class ChatState(
|
||||
|
||||
Log.d("ChatState", "StateFlow value after set: ${_favoritePeers.value}")
|
||||
}
|
||||
|
||||
fun setPeerFavoritedUs(fingerprints: Set<String>) {
|
||||
_peerFavoritedUs.value = fingerprints
|
||||
}
|
||||
|
||||
fun setPeerSessionStates(states: Map<String, String>) {
|
||||
_peerSessionStates.value = states
|
||||
|
||||
@ -7,9 +7,10 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import com.bitchat.android.ui.theme.BitchatPalette
|
||||
import com.bitchat.android.ui.theme.ChatVisualTokens
|
||||
import com.bitchat.android.ui.theme.colorForPeer
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
@ -18,6 +19,27 @@ import java.util.*
|
||||
* Extracted from ChatScreen.kt for better organization
|
||||
*/
|
||||
|
||||
/** Opacity applied to the `#abcd` disambiguation suffix so the readable name dominates. */
|
||||
internal const val SUFFIX_ALPHA = ChatVisualTokens.SenderSuffixAlpha
|
||||
|
||||
/** Compact transcript timestamp; seconds add noise without helping conversation scanning. */
|
||||
internal const val CHAT_TIMESTAMP_PATTERN = "HH:mm"
|
||||
|
||||
/** Background opacity for a mention chip referring to somebody else. */
|
||||
internal const val MENTION_CHIP_ALPHA = ChatVisualTokens.HighlightAlpha
|
||||
|
||||
/** Background opacity for a mention chip referring to you. Slightly stronger to catch the eye. */
|
||||
internal const val MENTION_CHIP_ALPHA_SELF = ChatVisualTokens.HighlightAlpha
|
||||
|
||||
/**
|
||||
* Mention token grammar shared by rendered messages and the composer.
|
||||
*
|
||||
* The optional `#abcd` suffix is part of the mention because it disambiguates peers that use the
|
||||
* same nickname. Keeping one regex prevents the composer from styling only `@name` while the
|
||||
* rendered transcript styles the complete token.
|
||||
*/
|
||||
internal val MENTION_TOKEN_REGEX = Regex("@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)")
|
||||
|
||||
/**
|
||||
* Get RSSI-based color for signal strength visualization
|
||||
*/
|
||||
@ -32,152 +54,31 @@ fun getRSSIColor(rssi: Int): Color {
|
||||
}
|
||||
|
||||
/**
|
||||
* Format message as annotated string with iOS-style formatting
|
||||
* Timestamp at END, peer colors, hashtag suffix handling
|
||||
*/
|
||||
fun formatMessageAsAnnotatedString(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: MeshService,
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||
): AnnotatedString {
|
||||
val builder = AnnotatedString.Builder()
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
|
||||
// Determine if this message was sent by self
|
||||
val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID)
|
||||
|
||||
if (message.sender != "system") {
|
||||
// Get base color for this peer (iOS-style color assignment)
|
||||
val baseColor = if (isSelf) {
|
||||
Color(0xFFFF9500) // Orange for self (iOS orange)
|
||||
} else {
|
||||
getPeerColor(message, isDark)
|
||||
}
|
||||
|
||||
// Split sender into base name and hashtag suffix
|
||||
val (baseName, suffix) = splitSuffix(message.sender)
|
||||
|
||||
// Sender prefix "<@"
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
builder.append("<@")
|
||||
builder.pop()
|
||||
|
||||
// Base name (clickable)
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
val nicknameStart = builder.length
|
||||
val truncatedBase = truncateNickname(baseName)
|
||||
builder.append(truncatedBase)
|
||||
val nicknameEnd = builder.length
|
||||
|
||||
// Add click annotation for nickname (store canonical sender name with hash if available)
|
||||
if (!isSelf) {
|
||||
builder.addStringAnnotation(
|
||||
tag = "nickname_click",
|
||||
annotation = (message.originalSender ?: message.sender),
|
||||
start = nicknameStart,
|
||||
end = nicknameEnd
|
||||
)
|
||||
}
|
||||
builder.pop()
|
||||
|
||||
// Hashtag suffix in lighter color (iOS style)
|
||||
if (suffix.isNotEmpty()) {
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor.copy(alpha = 0.6f),
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
builder.append(suffix)
|
||||
builder.pop()
|
||||
}
|
||||
|
||||
// Sender suffix "> "
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
builder.append("> ")
|
||||
builder.pop()
|
||||
|
||||
// Message content with iOS-style hashtag and mention highlighting
|
||||
appendIOSFormattedContent(
|
||||
builder,
|
||||
message.content,
|
||||
message.mentions,
|
||||
currentUserNickname,
|
||||
baseColor,
|
||||
isSelf,
|
||||
)
|
||||
|
||||
// iOS-style timestamp at the END (smaller, grey)
|
||||
// Timestamp (and optional PoW badge)
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color.Gray.copy(alpha = 0.7f),
|
||||
fontSize = (BASE_FONT_SIZE - 4).sp
|
||||
))
|
||||
builder.append(" [${timeFormatter.format(message.timestamp)}]")
|
||||
// If message has valid PoW difficulty, append bits immediately after timestamp with minimal spacing
|
||||
message.powDifficulty?.let { bits ->
|
||||
if (bits > 0) {
|
||||
builder.append(" ⛨${bits}b")
|
||||
}
|
||||
}
|
||||
builder.pop()
|
||||
|
||||
} else {
|
||||
// System message - iOS style
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color.Gray,
|
||||
fontSize = (BASE_FONT_SIZE - 2).sp,
|
||||
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
|
||||
))
|
||||
builder.append("* ${message.content} *")
|
||||
builder.pop()
|
||||
|
||||
// Timestamp for system messages too
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color.Gray.copy(alpha = 0.5f),
|
||||
fontSize = (BASE_FONT_SIZE - 4).sp
|
||||
))
|
||||
builder.append(" [${timeFormatter.format(message.timestamp)}]")
|
||||
builder.pop()
|
||||
}
|
||||
|
||||
return builder.toAnnotatedString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the sender label used by the two-row text-message layout.
|
||||
* Build the sender label shown above the first message of a group.
|
||||
*
|
||||
* Renders `@name` plus a dimmed `#abcd` suffix. The name carries a `nickname_click`
|
||||
* annotation for everyone except yourself.
|
||||
*/
|
||||
fun formatTextMessageSender(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: MeshService,
|
||||
colorScheme: ColorScheme
|
||||
myPeerID: String,
|
||||
palette: BitchatPalette
|
||||
): AnnotatedString {
|
||||
val builder = AnnotatedString.Builder()
|
||||
val isDark =
|
||||
colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID)
|
||||
val senderColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark)
|
||||
val senderWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
val isSelf = message.isFromSelf(currentUserNickname, myPeerID)
|
||||
val senderColor = if (isSelf) {
|
||||
palette.accentOrange
|
||||
} else {
|
||||
colorForPeer(peerIdentityForMessage(message), palette)
|
||||
}
|
||||
val senderWeight = FontWeight.SemiBold
|
||||
val (baseName, suffix) = splitSuffix(message.sender)
|
||||
|
||||
builder.pushStyle(
|
||||
SpanStyle(
|
||||
color = senderColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontSize = ChatVisualTokens.SenderFontSize,
|
||||
fontWeight = senderWeight
|
||||
)
|
||||
)
|
||||
@ -198,9 +99,9 @@ fun formatTextMessageSender(
|
||||
if (suffix.isNotEmpty()) {
|
||||
builder.pushStyle(
|
||||
SpanStyle(
|
||||
color = senderColor.copy(alpha = 0.6f),
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = senderWeight
|
||||
color = senderColor.copy(alpha = SUFFIX_ALPHA),
|
||||
fontSize = ChatVisualTokens.SenderFontSize,
|
||||
fontWeight = FontWeight.Normal
|
||||
)
|
||||
)
|
||||
builder.append(suffix)
|
||||
@ -212,10 +113,13 @@ fun formatTextMessageSender(
|
||||
|
||||
/**
|
||||
* Build the compact timestamp and optional proof-of-work label.
|
||||
*
|
||||
* Used standalone by media rows; text messages get the same span appended inline to the end of
|
||||
* their body via [appendBodyTimestamp].
|
||||
*/
|
||||
fun formatTextMessageMetadata(
|
||||
message: BitchatMessage,
|
||||
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||
timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault())
|
||||
): AnnotatedString {
|
||||
val builder = AnnotatedString.Builder()
|
||||
builder.pushStyle(
|
||||
@ -233,66 +137,159 @@ fun formatTextMessageMetadata(
|
||||
}
|
||||
|
||||
/**
|
||||
* Build only the message body while retaining mention, URL and geohash styling.
|
||||
* Append the timestamp (and optional PoW difficulty) directly after the message body so it
|
||||
* trails the final words rather than occupying its own column.
|
||||
*
|
||||
* Deliberately carries no click annotation: the timestamp is decoration, and making it
|
||||
* tappable would create dead zones inside the message body.
|
||||
*/
|
||||
private fun appendTimestampText(
|
||||
builder: AnnotatedString.Builder,
|
||||
message: BitchatMessage,
|
||||
timeFormatter: SimpleDateFormat
|
||||
) {
|
||||
builder.append(" ")
|
||||
builder.append(timeFormatter.format(message.timestamp))
|
||||
message.powDifficulty?.takeIf { it > 0 }?.let { bits ->
|
||||
builder.append(" ⛨${bits}b")
|
||||
}
|
||||
}
|
||||
|
||||
private fun appendBodyTimestamp(
|
||||
builder: AnnotatedString.Builder,
|
||||
message: BitchatMessage,
|
||||
palette: BitchatPalette,
|
||||
timeFormatter: SimpleDateFormat,
|
||||
) {
|
||||
builder.pushStyle(
|
||||
SpanStyle(
|
||||
color = palette.textTertiary,
|
||||
fontSize = ChatVisualTokens.SystemTimeFontSize,
|
||||
fontWeight = FontWeight.Normal,
|
||||
)
|
||||
)
|
||||
appendTimestampText(builder, message, timeFormatter)
|
||||
builder.pop()
|
||||
}
|
||||
|
||||
private fun appendMutedTimestamp(
|
||||
builder: AnnotatedString.Builder,
|
||||
message: BitchatMessage,
|
||||
contentColor: Color,
|
||||
timeFormatter: SimpleDateFormat,
|
||||
) {
|
||||
builder.pushStyle(
|
||||
SpanStyle(
|
||||
color = contentColor.copy(alpha = ChatVisualTokens.MutedTextAlpha),
|
||||
fontSize = ChatVisualTokens.SystemTimeFontSize,
|
||||
fontWeight = FontWeight.Normal,
|
||||
)
|
||||
)
|
||||
appendTimestampText(builder, message, timeFormatter)
|
||||
builder.pop()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the message body: neutral text with mention/URL/geohash accents, followed by an inline
|
||||
* trailing timestamp.
|
||||
*
|
||||
* Body text is intentionally neutral rather than peer-colored. Colour is reserved for
|
||||
* `@names`, which is what makes a busy channel scannable.
|
||||
*/
|
||||
fun formatTextMessageBody(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: MeshService,
|
||||
colorScheme: ColorScheme
|
||||
palette: BitchatPalette,
|
||||
contentColor: Color,
|
||||
linkColor: Color,
|
||||
mentionPeerIdentities: Map<String, PeerIdentity> = emptyMap(),
|
||||
timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()),
|
||||
includeTimestamp: Boolean = true
|
||||
): AnnotatedString {
|
||||
val builder = AnnotatedString.Builder()
|
||||
val isDark =
|
||||
colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID)
|
||||
val accentColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark)
|
||||
|
||||
appendIOSFormattedContent(
|
||||
builder = builder,
|
||||
content = message.content,
|
||||
mentions = message.mentions,
|
||||
currentUserNickname = currentUserNickname,
|
||||
baseColor = accentColor,
|
||||
isSelf = isSelf,
|
||||
contentColor = colorScheme.onSurface
|
||||
palette = palette,
|
||||
contentColor = contentColor,
|
||||
linkColor = linkColor,
|
||||
mentionPeerIdentities = mentionPeerIdentities,
|
||||
)
|
||||
|
||||
if (includeTimestamp) {
|
||||
appendBodyTimestamp(builder, message, palette, timeFormatter)
|
||||
}
|
||||
return builder.toAnnotatedString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build only the nickname + timestamp header line for a message, matching styles of normal messages.
|
||||
* Build a system / background-action line, e.g. `// Tor started. Routing all chats… 11:09:56`.
|
||||
*
|
||||
* The `//` prefix reads as machine narration in a monospace context and is far quieter than
|
||||
* the previous `* italic asterisk *` treatment, which competed with real messages.
|
||||
*/
|
||||
fun formatSystemMessage(
|
||||
message: BitchatMessage,
|
||||
contentColor: Color,
|
||||
timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault())
|
||||
): AnnotatedString {
|
||||
val builder = AnnotatedString.Builder()
|
||||
builder.pushStyle(
|
||||
SpanStyle(
|
||||
color = contentColor.copy(alpha = ChatVisualTokens.MutedTextAlpha),
|
||||
fontSize = ChatVisualTokens.SystemActionFontSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
)
|
||||
builder.append("// ")
|
||||
builder.append(message.content)
|
||||
builder.pop()
|
||||
|
||||
appendMutedTimestamp(builder, message, contentColor, timeFormatter)
|
||||
return builder.toAnnotatedString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Header line for media (image / audio / file) rows.
|
||||
*
|
||||
* Matches the text-message treatment: `@name#abcd` with no angle brackets, followed by an
|
||||
* inline trailing timestamp. Media rows have no body text to trail, so the timestamp sits on
|
||||
* the same line as the name.
|
||||
*/
|
||||
fun formatMessageHeaderAnnotatedString(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: MeshService,
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||
myPeerID: String,
|
||||
palette: BitchatPalette,
|
||||
contentColor: Color,
|
||||
timeFormatter: SimpleDateFormat = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()),
|
||||
includeSender: Boolean = true
|
||||
): AnnotatedString {
|
||||
val builder = AnnotatedString.Builder()
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
val isSelf = message.isFromSelf(currentUserNickname, myPeerID)
|
||||
|
||||
val isSelf = message.isFromSelf(currentUserNickname, meshService.myPeerID)
|
||||
if (message.sender == "system") {
|
||||
return formatSystemMessage(message, contentColor, timeFormatter)
|
||||
}
|
||||
|
||||
if (message.sender != "system") {
|
||||
val baseColor = if (isSelf) Color(0xFFFF9500) else getPeerColor(message, isDark)
|
||||
if (includeSender) {
|
||||
val baseColor = if (isSelf) {
|
||||
palette.accentOrange
|
||||
} else {
|
||||
colorForPeer(peerIdentityForMessage(message), palette)
|
||||
}
|
||||
val (baseName, suffix) = splitSuffix(message.sender)
|
||||
|
||||
// "<@"
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
builder.append("<@")
|
||||
builder.pop()
|
||||
|
||||
// Base name (clickable when not self)
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
builder.pushStyle(
|
||||
SpanStyle(
|
||||
color = baseColor,
|
||||
fontSize = ChatVisualTokens.SenderFontSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
)
|
||||
builder.append("@")
|
||||
val nicknameStart = builder.length
|
||||
builder.append(truncateNickname(baseName))
|
||||
val nicknameEnd = builder.length
|
||||
@ -306,112 +303,23 @@ fun formatMessageHeaderAnnotatedString(
|
||||
}
|
||||
builder.pop()
|
||||
|
||||
// Hashtag suffix
|
||||
if (suffix.isNotEmpty()) {
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor.copy(alpha = 0.6f),
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
builder.pushStyle(
|
||||
SpanStyle(
|
||||
color = baseColor.copy(alpha = SUFFIX_ALPHA),
|
||||
fontSize = ChatVisualTokens.SenderFontSize,
|
||||
fontWeight = FontWeight.Normal,
|
||||
)
|
||||
)
|
||||
builder.append(suffix)
|
||||
builder.pop()
|
||||
}
|
||||
|
||||
// Sender suffix ">"
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
builder.append(">")
|
||||
builder.pop()
|
||||
|
||||
// Timestamp and optional PoW bits, matching normal message appearance
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color.Gray.copy(alpha = 0.7f),
|
||||
fontSize = (BASE_FONT_SIZE - 4).sp
|
||||
))
|
||||
builder.append(" [${timeFormatter.format(message.timestamp)}]")
|
||||
message.powDifficulty?.let { bits ->
|
||||
if (bits > 0) builder.append(" ⛨${bits}b")
|
||||
}
|
||||
builder.pop()
|
||||
} else {
|
||||
// System message header (should rarely apply to voice)
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color.Gray,
|
||||
fontSize = (BASE_FONT_SIZE - 2).sp,
|
||||
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
|
||||
))
|
||||
builder.append("* ${message.content} *")
|
||||
builder.pop()
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color.Gray.copy(alpha = 0.5f),
|
||||
fontSize = (BASE_FONT_SIZE - 4).sp
|
||||
))
|
||||
builder.append(" [${timeFormatter.format(message.timestamp)}]")
|
||||
builder.pop()
|
||||
}
|
||||
|
||||
appendMutedTimestamp(builder, message, contentColor, timeFormatter)
|
||||
return builder.toAnnotatedString()
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS-style peer color assignment using djb2 hash algorithm
|
||||
* Avoids orange (~30°) reserved for self messages
|
||||
*/
|
||||
fun getPeerColor(message: BitchatMessage, isDark: Boolean): Color {
|
||||
// Create seed from peer identifier (prioritizing stable keys)
|
||||
val seed = when {
|
||||
message.senderPeerID?.startsWith("nostr:") == true || message.senderPeerID?.startsWith("nostr_") == true -> {
|
||||
// For Nostr peers, use the full key if available, otherwise the peer ID
|
||||
"nostr:${message.senderPeerID.lowercase()}"
|
||||
}
|
||||
message.senderPeerID?.length == 16 -> {
|
||||
// For ephemeral peer IDs, try to get stable Noise key, fallback to peer ID
|
||||
"noise:${message.senderPeerID.lowercase()}"
|
||||
}
|
||||
message.senderPeerID?.length == 64 -> {
|
||||
// This is already a stable Noise key
|
||||
"noise:${message.senderPeerID.lowercase()}"
|
||||
}
|
||||
else -> {
|
||||
// Fallback to sender name
|
||||
message.sender.lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
return colorForPeerSeed(seed, isDark)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate consistent peer color using djb2 hash (matches iOS algorithm exactly)
|
||||
*/
|
||||
fun colorForPeerSeed(seed: String, isDark: Boolean): Color {
|
||||
// djb2 hash algorithm (matches iOS implementation)
|
||||
var hash = 5381UL
|
||||
for (byte in seed.toByteArray()) {
|
||||
hash = ((hash shl 5) + hash) + byte.toUByte().toULong()
|
||||
}
|
||||
|
||||
var hue = (hash % 360UL).toDouble() / 360.0
|
||||
|
||||
// Avoid orange (~30°) reserved for self (matches iOS logic)
|
||||
val orange = 30.0 / 360.0
|
||||
if (kotlin.math.abs(hue - orange) < 0.05) {
|
||||
hue = (hue + 0.12) % 1.0
|
||||
}
|
||||
|
||||
val saturation = if (isDark) 0.50 else 0.70
|
||||
val brightness = if (isDark) 0.85 else 0.35
|
||||
|
||||
return Color.hsv(
|
||||
hue = (hue * 360).toFloat(),
|
||||
saturation = saturation.toFloat(),
|
||||
value = brightness.toFloat()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a name into base and a '#abcd' suffix if present (matches iOS splitSuffix exactly)
|
||||
*/
|
||||
@ -430,23 +338,106 @@ fun splitSuffix(name: String): Pair<String, String> {
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS-style content formatting with proper hashtag and mention handling
|
||||
* Build a case-insensitive mention-token lookup from canonical peer identities.
|
||||
*
|
||||
* Suffixed names such as `alice#04af` resolve exactly. Their unsuffixed base is only retained when
|
||||
* it identifies one peer; ambiguous bases are deliberately omitted rather than coloring a mention
|
||||
* as the wrong person.
|
||||
*/
|
||||
internal fun buildMentionPeerIdentityMap(
|
||||
messages: List<BitchatMessage>,
|
||||
knownPeers: List<Pair<String, PeerIdentity>> = emptyList(),
|
||||
): Map<String, PeerIdentity> {
|
||||
val candidates = linkedMapOf<String, MutableSet<PeerIdentity>>()
|
||||
|
||||
fun add(displayName: String, identity: PeerIdentity) {
|
||||
val normalizedName = displayName.trim().removePrefix("@")
|
||||
if (normalizedName.isEmpty()) return
|
||||
|
||||
val (baseName, suffix) = splitSuffix(normalizedName)
|
||||
val exactKey = normalizedName.lowercase(Locale.ROOT)
|
||||
candidates.getOrPut(exactKey) { linkedSetOf() }.add(identity)
|
||||
|
||||
if (suffix.isNotEmpty()) {
|
||||
val baseKey = baseName.lowercase(Locale.ROOT)
|
||||
candidates.getOrPut(baseKey) { linkedSetOf() }.add(identity)
|
||||
}
|
||||
}
|
||||
|
||||
messages
|
||||
.asSequence()
|
||||
.filterNot { it.sender == "system" }
|
||||
.forEach { add(it.sender, peerIdentityForMessage(it)) }
|
||||
knownPeers.forEach { (displayName, identity) -> add(displayName, identity) }
|
||||
|
||||
return candidates.mapNotNull { (token, identities) ->
|
||||
identities.singleOrNull()?.let { token to it }
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
internal fun resolveMentionPeerIdentity(
|
||||
mention: String,
|
||||
mentionPeerIdentities: Map<String, PeerIdentity>,
|
||||
): PeerIdentity? {
|
||||
val mentionWithoutAt = mention.trim().removePrefix("@")
|
||||
val baseName = splitSuffix(mentionWithoutAt).first
|
||||
return mentionPeerIdentities[mentionWithoutAt.lowercase(Locale.ROOT)]
|
||||
?: mentionPeerIdentities[baseName.lowercase(Locale.ROOT)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the deterministic color for a mention on every surface that displays one.
|
||||
*
|
||||
* Exact suffixed tokens win; an unsuffixed nickname is only present in the identity map when it is
|
||||
* unambiguous. The nickname fallback preserves the legacy behavior for peers with no stable ID.
|
||||
*/
|
||||
internal fun colorForMention(
|
||||
mention: String,
|
||||
mentionPeerIdentities: Map<String, PeerIdentity>,
|
||||
palette: BitchatPalette,
|
||||
): Color {
|
||||
val mentionWithoutAt = mention.trim().removePrefix("@")
|
||||
val identity = resolveMentionPeerIdentity(mentionWithoutAt, mentionPeerIdentities)
|
||||
?: PeerIdentity.nickname(mentionWithoutAt)
|
||||
return colorForPeer(identity, palette)
|
||||
}
|
||||
|
||||
/**
|
||||
* A bare `anon` label means the geohash heartbeat has not announced a username yet. The transport
|
||||
* may append a `#abcd` disambiguator, which does not turn it into an announced name. Names such as
|
||||
* `anon1234`, `anonymous`, and `anonracer` are real announced usernames.
|
||||
*/
|
||||
internal fun isUnannouncedNickname(displayName: String): Boolean {
|
||||
val base = splitSuffix(displayName.trim()).first
|
||||
return base.equals("anon", ignoreCase = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS-style content formatting with proper hashtag and mention handling.
|
||||
*
|
||||
* Redesign notes:
|
||||
* - Plain text renders in Material `onSurface`; colour is reserved for `@mentions`,
|
||||
* links and geohashes.
|
||||
* - Mentions get a tinted background chip so they read as a distinct token inside a sentence.
|
||||
* The chip is tinted by *the mentioned peer's* colour, not the sender's, so `@alice` looks
|
||||
* the same everywhere she is referenced.
|
||||
* - Neither "self" nor "you were mentioned" bolds the whole body any more. Bolding entire
|
||||
* paragraphs was the single biggest source of visual noise in the old layout; the mention
|
||||
* chip carries that emphasis instead.
|
||||
*/
|
||||
private fun appendIOSFormattedContent(
|
||||
builder: AnnotatedString.Builder,
|
||||
content: String,
|
||||
mentions: List<String>?,
|
||||
currentUserNickname: String,
|
||||
baseColor: Color,
|
||||
isSelf: Boolean,
|
||||
contentColor: Color = baseColor,
|
||||
palette: BitchatPalette,
|
||||
contentColor: Color,
|
||||
linkColor: Color,
|
||||
mentionPeerIdentities: Map<String, PeerIdentity>,
|
||||
) {
|
||||
// iOS-style patterns: allow optional '#abcd' suffix in mentions
|
||||
val hashtagPattern = "#([a-zA-Z0-9_]+)".toRegex()
|
||||
val mentionPattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)".toRegex()
|
||||
|
||||
val hashtagMatches = hashtagPattern.findAll(content).toList()
|
||||
val mentionMatches = mentionPattern.findAll(content).toList()
|
||||
val mentionMatches = MENTION_TOKEN_REGEX.findAll(content).toList()
|
||||
|
||||
// Combine and sort matches, but exclude hashtags that overlap with mentions
|
||||
val mentionRanges = mentionMatches.map { it.range }
|
||||
@ -507,28 +498,28 @@ private fun appendIOSFormattedContent(
|
||||
}
|
||||
|
||||
allMatches.sortBy { it.first.first }
|
||||
|
||||
|
||||
val plainStyle = SpanStyle(
|
||||
color = contentColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = FontWeight.Normal
|
||||
)
|
||||
val linkStyle = SpanStyle(
|
||||
color = linkColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
textDecoration = TextDecoration.Underline
|
||||
)
|
||||
|
||||
var lastEnd = 0
|
||||
val isMentioned = mentions?.contains(currentUserNickname) == true
|
||||
|
||||
|
||||
for ((range, type) in allMatches) {
|
||||
// Add text before match
|
||||
if (lastEnd < range.first) {
|
||||
val beforeText = content.substring(lastEnd, range.first)
|
||||
if (beforeText.isNotEmpty()) {
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = contentColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
|
||||
))
|
||||
if (isMentioned) {
|
||||
// Make entire message bold if user is mentioned
|
||||
builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
|
||||
builder.append(beforeText)
|
||||
builder.pop()
|
||||
} else {
|
||||
builder.append(beforeText)
|
||||
}
|
||||
builder.pushStyle(plainStyle)
|
||||
builder.append(beforeText)
|
||||
builder.pop()
|
||||
}
|
||||
}
|
||||
@ -540,65 +531,58 @@ private fun appendIOSFormattedContent(
|
||||
// iOS-style mention with hashtag suffix support
|
||||
val mentionWithoutAt = matchText.removePrefix("@")
|
||||
val (mBase, mSuffix) = splitSuffix(mentionWithoutAt)
|
||||
|
||||
// Check if this mention targets current user
|
||||
|
||||
// Mentions targeting you are the one thing worth shouting about.
|
||||
val isMentionToMe = mBase == currentUserNickname
|
||||
val mentionColor = if (isMentionToMe) Color(0xFFFF9500) else baseColor
|
||||
|
||||
// "@" symbol
|
||||
val mentionColor = if (isMentionToMe) {
|
||||
palette.accentOrange
|
||||
} else {
|
||||
colorForMention(
|
||||
mention = mentionWithoutAt,
|
||||
mentionPeerIdentities = mentionPeerIdentities,
|
||||
palette = palette,
|
||||
)
|
||||
}
|
||||
val chipAlpha = if (isMentionToMe) MENTION_CHIP_ALPHA_SELF else MENTION_CHIP_ALPHA
|
||||
val mentionWeight = if (isMentionToMe) FontWeight.Bold else FontWeight.SemiBold
|
||||
|
||||
// A single outer span carrying the background makes the chip render as one
|
||||
// continuous rectangle. Pushing the background per-token would leave hairline
|
||||
// seams between "@", the name and the "#abcd" suffix.
|
||||
builder.pushStyle(SpanStyle(background = mentionColor.copy(alpha = chipAlpha)))
|
||||
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = mentionColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold
|
||||
fontWeight = mentionWeight
|
||||
))
|
||||
builder.append("@")
|
||||
builder.pop()
|
||||
|
||||
// Base name (truncate for rendering)
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = mentionColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold
|
||||
))
|
||||
builder.append(truncateNickname(mBase))
|
||||
builder.pop()
|
||||
|
||||
|
||||
// Hashtag suffix in lighter color
|
||||
if (mSuffix.isNotEmpty()) {
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = mentionColor.copy(alpha = 0.6f),
|
||||
color = mentionColor.copy(alpha = SUFFIX_ALPHA),
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold
|
||||
fontWeight = mentionWeight
|
||||
))
|
||||
builder.append(mSuffix)
|
||||
builder.pop()
|
||||
}
|
||||
|
||||
builder.pop() // background chip
|
||||
}
|
||||
"hashtag" -> {
|
||||
// Render general hashtags like normal content
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = contentColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
|
||||
))
|
||||
if (isMentioned) {
|
||||
builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
|
||||
builder.append(matchText)
|
||||
builder.pop()
|
||||
} else {
|
||||
builder.append(matchText)
|
||||
}
|
||||
builder.pushStyle(plainStyle)
|
||||
builder.append(matchText)
|
||||
builder.pop()
|
||||
}
|
||||
else -> {
|
||||
if (type == "geohash") {
|
||||
// Style geohash in blue, underlined, and add click annotation
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color(0xFF007AFF),
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold,
|
||||
textDecoration = TextDecoration.Underline
|
||||
))
|
||||
// Style geohash as a link and add click annotation
|
||||
builder.pushStyle(linkStyle)
|
||||
val start = builder.length
|
||||
builder.append(matchText)
|
||||
val end = builder.length
|
||||
@ -611,13 +595,8 @@ private fun appendIOSFormattedContent(
|
||||
)
|
||||
builder.pop()
|
||||
} else if (type == "url") {
|
||||
// Style URL in blue, underlined, and add click annotation with the raw text
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color(0xFF007AFF),
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold,
|
||||
textDecoration = TextDecoration.Underline
|
||||
))
|
||||
// Style URL as a link and add click annotation with the raw text
|
||||
builder.pushStyle(linkStyle)
|
||||
val start = builder.length
|
||||
builder.append(matchText)
|
||||
val end = builder.length
|
||||
@ -630,11 +609,7 @@ private fun appendIOSFormattedContent(
|
||||
builder.pop()
|
||||
} else {
|
||||
// Fallback: treat as normal text
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = contentColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
|
||||
))
|
||||
builder.pushStyle(plainStyle)
|
||||
builder.append(matchText)
|
||||
builder.pop()
|
||||
}
|
||||
@ -647,18 +622,8 @@ private fun appendIOSFormattedContent(
|
||||
// Add remaining text
|
||||
if (lastEnd < content.length) {
|
||||
val remainingText = content.substring(lastEnd)
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = contentColor,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
|
||||
))
|
||||
if (isMentioned) {
|
||||
builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
|
||||
builder.append(remainingText)
|
||||
builder.pop()
|
||||
} else {
|
||||
builder.append(remainingText)
|
||||
}
|
||||
builder.pushStyle(plainStyle)
|
||||
builder.append(remainingText)
|
||||
builder.pop()
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,11 +6,12 @@ import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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 com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.bitchat.android.R
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
@ -35,14 +36,13 @@ fun ChatUserSheet(
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
// 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
|
||||
val palette = LocalBitchatPalette.current
|
||||
val standardGreen = colorScheme.primary
|
||||
val standardBlue = colorScheme.secondary
|
||||
val standardPurple = palette.accentPurple
|
||||
val standardRed = colorScheme.error
|
||||
val standardGrey = colorScheme.onSurfaceVariant
|
||||
|
||||
if (isPresented) {
|
||||
BitchatBottomSheet(
|
||||
@ -59,7 +59,7 @@ fun ChatUserSheet(
|
||||
Text(
|
||||
text = stringResource(R.string.at_nickname, targetNickname),
|
||||
fontSize = 18.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
@ -67,7 +67,7 @@ fun ChatUserSheet(
|
||||
Text(
|
||||
text = if (selectedMessage != null) stringResource(R.string.choose_action_message_or_user) else stringResource(R.string.choose_action_user),
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
|
||||
@ -183,7 +183,7 @@ fun ChatUserSheet(
|
||||
Text(
|
||||
text = stringResource(R.string.cancel_lower),
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -214,7 +214,7 @@ private fun UserActionRow(
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = titleColor
|
||||
)
|
||||
@ -222,7 +222,7 @@ private fun UserActionRow(
|
||||
Text(
|
||||
text = subtitle,
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
|
||||
@ -6,9 +6,14 @@ import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitchat.android.favorites.FavoritesPersistenceService
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import com.bitchat.android.mesh.BluetoothMeshDelegate
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
@ -16,10 +21,12 @@ 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.nostr.GeohashConversationRegistry
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
|
||||
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.bitchat.android.util.NotificationIntervalManager
|
||||
import kotlinx.coroutines.delay
|
||||
import java.util.Date
|
||||
@ -99,6 +106,9 @@ class ChatViewModel(
|
||||
// Specialized managers
|
||||
private val dataManager = DataManager(application.applicationContext)
|
||||
private val identityManager by lazy { SecureIdentityStateManager(getApplication()) }
|
||||
private val seenMessageStore by lazy {
|
||||
com.bitchat.android.services.SeenMessageStore.getInstance(getApplication())
|
||||
}
|
||||
private val messageManager = MessageManager(state)
|
||||
private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope)
|
||||
|
||||
@ -109,7 +119,18 @@ class ChatViewModel(
|
||||
override fun getMyPeerID(): String = mesh.myPeerID
|
||||
}
|
||||
|
||||
val privateChatManager = PrivateChatManager(state, messageManager, dataManager, noiseSessionDelegate)
|
||||
val privateChatManager = PrivateChatManager(
|
||||
state,
|
||||
messageManager,
|
||||
dataManager,
|
||||
noiseSessionDelegate,
|
||||
hasReadReceiptBeenSent = { messageID ->
|
||||
seenMessageStore.hasReadReceiptBeenSent(messageID)
|
||||
},
|
||||
markMessageReadLocally = { messageID ->
|
||||
seenMessageStore.markReadLocally(messageID)
|
||||
}
|
||||
)
|
||||
private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager)
|
||||
private val notificationManager = NotificationManager(
|
||||
application.applicationContext,
|
||||
@ -146,7 +167,10 @@ class ChatViewModel(
|
||||
coroutineScope = viewModelScope,
|
||||
onHapticFeedback = { ChatViewModelUtils.triggerHapticFeedback(application.applicationContext) },
|
||||
getMyPeerID = { mesh.myPeerID },
|
||||
getMeshService = { mesh }
|
||||
getMeshService = { mesh },
|
||||
markMessageReadLocally = { messageID ->
|
||||
seenMessageStore.markReadLocally(messageID)
|
||||
}
|
||||
)
|
||||
|
||||
// New Geohash architecture ViewModel (replaces God object service usage in UI path)
|
||||
@ -154,8 +178,6 @@ class ChatViewModel(
|
||||
application = application,
|
||||
state = state,
|
||||
messageManager = messageManager,
|
||||
privateChatManager = privateChatManager,
|
||||
meshDelegateHandler = meshDelegateHandler,
|
||||
dataManager = dataManager,
|
||||
notificationManager = notificationManager
|
||||
)
|
||||
@ -171,6 +193,57 @@ class ChatViewModel(
|
||||
val privateChats: StateFlow<Map<String, List<BitchatMessage>>> = state.privateChats
|
||||
val selectedPrivateChatPeer: StateFlow<String?> = state.selectedPrivateChatPeer
|
||||
val unreadPrivateMessages: StateFlow<Set<String>> = state.unreadPrivateMessages
|
||||
internal val unreadConversations: StateFlow<List<UnreadConversationSummary>> = combine(
|
||||
state.unreadPrivateMessages,
|
||||
state.privateChats,
|
||||
state.nickname,
|
||||
state.connectedPeers
|
||||
) { unreadConversationIDs, chats, currentNickname, connectedPeerIDs ->
|
||||
val seenStore = seenMessageStore
|
||||
val connectedPeerIDSet = connectedPeerIDs.mapTo(mutableSetOf()) { it.lowercase() }
|
||||
buildUnreadConversationSummaries(
|
||||
unreadConversationIDs = unreadConversationIDs,
|
||||
privateChats = chats,
|
||||
currentUserIdentifiers = setOf(currentNickname, mesh.myPeerID),
|
||||
canonicalize = ContactDirectory::canonicalConversationId,
|
||||
isMessageRead = { message -> seenStore.hasBeenReadLocally(message.id) }
|
||||
).map { summary ->
|
||||
val resolution = ContactDirectory.resolve(summary.conversationID)
|
||||
val resolvedNostrPubkey = summary.nostrPubkey
|
||||
?: resolution.nostrPubkey?.let(ContactIdentityResolver::nostrPubkeyHex)
|
||||
val aliases = buildSet {
|
||||
addAll(summary.identityAliases)
|
||||
add(summary.conversationID)
|
||||
add(resolution.conversationID)
|
||||
resolution.meshPeerID?.let(::add)
|
||||
resolution.noiseKeyHex?.let(::add)
|
||||
resolvedNostrPubkey
|
||||
?.let(ContactIdentityResolver::nostrAliasForPubkey)
|
||||
?.let(::add)
|
||||
}.mapTo(mutableSetOf()) { it.lowercase() }
|
||||
|
||||
summary.copy(
|
||||
displayName = resolution.displayName
|
||||
?.takeUnless {
|
||||
it.isBlank() || it.equals("Unknown", ignoreCase = true)
|
||||
}
|
||||
?: summary.displayName,
|
||||
nostrPubkey = resolvedNostrPubkey,
|
||||
identityAliases = aliases,
|
||||
isConnected = aliases.any(connectedPeerIDSet::contains),
|
||||
sourceGeohash = aliases
|
||||
.asSequence()
|
||||
.mapNotNull(GeohashConversationRegistry::get)
|
||||
.firstOrNull()
|
||||
)
|
||||
}
|
||||
}
|
||||
.flowOn(Dispatchers.IO)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = emptyList()
|
||||
)
|
||||
val joinedChannels: StateFlow<Set<String>> = state.joinedChannels
|
||||
val currentChannel: StateFlow<String?> = state.currentChannel
|
||||
val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = state.channelMessages
|
||||
@ -185,6 +258,7 @@ class ChatViewModel(
|
||||
val showMentionSuggestions: StateFlow<Boolean> = state.showMentionSuggestions
|
||||
val mentionSuggestions: StateFlow<List<String>> = state.mentionSuggestions
|
||||
val favoritePeers: StateFlow<Set<String>> = state.favoritePeers
|
||||
val peerFavoritedUs: StateFlow<Set<String>> = state.peerFavoritedUs
|
||||
val peerSessionStates: StateFlow<Map<String, String>> = state.peerSessionStates
|
||||
val peerFingerprints: StateFlow<Map<String, String>> = state.peerFingerprints
|
||||
val peerNicknames: StateFlow<Map<String, String>> = state.peerNicknames
|
||||
@ -220,6 +294,15 @@ class ChatViewModel(
|
||||
loadAndInitialize()
|
||||
ContactDirectory.initialize(getApplication()) { mesh }
|
||||
com.bitchat.android.services.AppStateStore.canonicalizePrivateChats()
|
||||
// Mark queued private messages as failed when the router gives up on them
|
||||
try {
|
||||
com.bitchat.android.services.MessageRouter.getInstance(getApplication(), mesh).onMessageExpired = { messageID ->
|
||||
messageManager.updateMessageDeliveryStatus(
|
||||
messageID,
|
||||
com.bitchat.android.model.DeliveryStatus.Failed("Message expired before delivery")
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
// Hydrate UI state from process-wide AppStateStore to survive Activity recreation
|
||||
viewModelScope.launch {
|
||||
try { com.bitchat.android.services.AppStateStore.peers.collect { peers ->
|
||||
@ -235,18 +318,27 @@ class ChatViewModel(
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try { com.bitchat.android.services.AppStateStore.privateMessages.collect { byPeer ->
|
||||
val canonicalChats = ContactDirectory.canonicalizePrivateChats(byPeer)
|
||||
val (canonicalChats, unreadConversationIDs) = withContext(Dispatchers.IO) {
|
||||
val canonical = ContactDirectory.canonicalizePrivateChats(byPeer)
|
||||
val unread = try {
|
||||
val myNick = state.getNicknameValue().ifBlank { mesh.myPeerID }
|
||||
canonical
|
||||
.filterValues { messages ->
|
||||
messages.any { message ->
|
||||
message.sender != myNick &&
|
||||
message.sender != "system" &&
|
||||
!seenMessageStore.hasBeenReadLocally(message.id)
|
||||
}
|
||||
}
|
||||
.keys
|
||||
} catch (_: Exception) {
|
||||
state.getUnreadPrivateMessagesValue()
|
||||
}
|
||||
canonical to unread
|
||||
}
|
||||
state.setPrivateChats(canonicalChats)
|
||||
// Recompute unread set using SeenMessageStore for robustness across Activity recreation
|
||||
try {
|
||||
val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication())
|
||||
val myNick = state.getNicknameValue() ?: mesh.myPeerID
|
||||
val unread = mutableSetOf<String>()
|
||||
canonicalChats.forEach { (peer, list) ->
|
||||
if (list.any { msg -> msg.sender != myNick && !seen.hasRead(msg.id) }) unread.add(peer)
|
||||
}
|
||||
state.setUnreadPrivateMessages(unread)
|
||||
} catch (_: Exception) { }
|
||||
state.setUnreadPrivateMessages(unreadConversationIDs)
|
||||
} } catch (_: Exception) { }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
@ -324,6 +416,17 @@ class ChatViewModel(
|
||||
// Initialize favorites persistence service
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication())
|
||||
|
||||
// Reflect "they favorited us" changes into reactive UI state (drives star celebrations)
|
||||
refreshPeerFavoritedUs()
|
||||
try {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.addListener(
|
||||
object : com.bitchat.android.favorites.FavoritesChangeListener {
|
||||
override fun onFavoriteChanged(noiseKeyHex: String) = refreshPeerFavoritedUs()
|
||||
override fun onAllCleared() = refreshPeerFavoritedUs()
|
||||
}
|
||||
)
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// Load verified fingerprints from secure storage
|
||||
verificationHandler.loadVerifiedFingerprints()
|
||||
|
||||
@ -340,6 +443,8 @@ class ChatViewModel(
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
geohashViewModel.shutdownUiSubscriptions()
|
||||
com.bitchat.android.services.AppStateStore.setSelectedPrivateChatPeer(null)
|
||||
super.onCleared()
|
||||
// Note: Mesh service lifecycle is now managed by MainActivity
|
||||
}
|
||||
@ -376,30 +481,30 @@ class ChatViewModel(
|
||||
|
||||
// MARK: - Private Chat Management (delegated)
|
||||
|
||||
fun startPrivateChat(peerID: String) {
|
||||
suspend fun startPrivateChat(peerID: String) {
|
||||
// For geohash conversation keys, ensure DM subscription is active
|
||||
if (peerID.startsWith("nostr_")) {
|
||||
ensureGeohashDMSubscriptionIfNeeded(peerID)
|
||||
}
|
||||
|
||||
val success = privateChatManager.startPrivateChat(peerID, mesh)
|
||||
|
||||
val (conversationID, success) = withContext(Dispatchers.IO) {
|
||||
val canonicalID = ContactDirectory.canonicalConversationId(peerID)
|
||||
val unreadAliases = matchingUnreadAliases(
|
||||
unreadConversationIDs = state.getUnreadPrivateMessagesValue(),
|
||||
canonicalConversationID = canonicalID,
|
||||
canonicalize = ContactDirectory::canonicalConversationId
|
||||
)
|
||||
canonicalID to privateChatManager.startPrivateChat(
|
||||
peerID = canonicalID,
|
||||
meshService = mesh,
|
||||
unreadAliases = unreadAliases
|
||||
)
|
||||
}
|
||||
if (success) {
|
||||
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
||||
// Notify notification manager about current private chat
|
||||
setCurrentPrivateChatPeer(conversationID)
|
||||
// Clear notifications for this sender since user is now viewing the chat
|
||||
clearNotificationsForSender(conversationID)
|
||||
|
||||
// Persistently mark all messages in this conversation as read so Nostr fetches
|
||||
// after app restarts won't re-mark them as unread.
|
||||
try {
|
||||
val seen = com.bitchat.android.services.SeenMessageStore.getInstance(getApplication())
|
||||
val chats = state.getPrivateChatsValue()
|
||||
val messages = chats[conversationID] ?: emptyList()
|
||||
messages.forEach { msg ->
|
||||
try { seen.markRead(msg.id) } catch (_: Exception) { }
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
@ -630,8 +735,22 @@ class ChatViewModel(
|
||||
logCurrentFavoriteState()
|
||||
}
|
||||
|
||||
private fun logCurrentFavoriteState() {
|
||||
Log.i("ChatViewModel", "=== CURRENT FAVORITE STATE ===")
|
||||
private fun refreshPeerFavoritedUs() {
|
||||
try {
|
||||
val fingerprints = com.bitchat.android.favorites.FavoritesPersistenceService.shared
|
||||
.getAllRelationships()
|
||||
.filter { it.theyFavoritedUs }
|
||||
.mapNotNull { relationship ->
|
||||
runCatching {
|
||||
ContactIdentityResolver.fingerprintHex(relationship.peerNoisePublicKey)
|
||||
}.getOrNull()
|
||||
}
|
||||
.toSet()
|
||||
state.setPeerFavoritedUs(fingerprints)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
private fun logCurrentFavoriteState() { Log.i("ChatViewModel", "=== CURRENT FAVORITE STATE ===")
|
||||
Log.i("ChatViewModel", "StateFlow favorite peers: ${favoritePeers.value}")
|
||||
Log.i("ChatViewModel", "DataManager favorite peers: ${dataManager.favoritePeers}")
|
||||
Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}")
|
||||
@ -664,8 +783,11 @@ class ChatViewModel(
|
||||
}
|
||||
|
||||
private fun nicknameForPeer(peerID: String): String? {
|
||||
return state.peerNicknames.value[peerID]
|
||||
?: try { mesh.getPeerNicknames()[peerID] } catch (_: Exception) { null }
|
||||
val contact = ContactDirectory.resolve(peerID)
|
||||
val meshPeerID = contact.meshPeerID ?: peerID
|
||||
return contact.displayName
|
||||
?: state.peerNicknames.value[meshPeerID]
|
||||
?: try { mesh.getPeerNicknames()[meshPeerID] } catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
private fun sessionStateForPeer(peerID: String): NoiseSession.NoiseSessionState {
|
||||
@ -1168,22 +1290,17 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iOS-Compatible Color System
|
||||
// MARK: - Canonical peer identities
|
||||
|
||||
/**
|
||||
* Get consistent color for a mesh peer by ID (iOS-compatible)
|
||||
* Return the stable identity used by every UI surface to color a mesh peer.
|
||||
*/
|
||||
fun colorForMeshPeer(peerID: String, isDark: Boolean): androidx.compose.ui.graphics.Color {
|
||||
// Try to get stable Noise key, fallback to peer ID
|
||||
val seed = "noise:${peerID.lowercase()}"
|
||||
return colorForPeerSeed(seed, isDark).copy()
|
||||
}
|
||||
fun peerIdentityForMeshPeer(peerID: String): PeerIdentity = PeerIdentity.mesh(peerID)
|
||||
|
||||
/**
|
||||
* Get consistent color for a Nostr pubkey (iOS-compatible)
|
||||
* Return the stable identity used by every UI surface to color a Nostr peer.
|
||||
*/
|
||||
fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color {
|
||||
return geohashViewModel.colorForNostrPubkey(pubkeyHex, isDark)
|
||||
}
|
||||
fun peerIdentityForNostrPubkey(pubkeyHex: String): PeerIdentity =
|
||||
geohashViewModel.peerIdentityForNostrPubkey(pubkeyHex)
|
||||
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package com.bitchat.android.ui
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Handles processing of IRC-style commands
|
||||
@ -448,18 +449,28 @@ class CommandProcessor(
|
||||
is com.bitchat.android.geohash.ChannelID.Mesh,
|
||||
null -> {
|
||||
// Mesh channel: use Bluetooth mesh peer nicknames
|
||||
meshService.getPeerNicknames().values.filter { it != meshService.getPeerNicknames()[meshService.myPeerID] }
|
||||
val peerNicknames = meshService.getPeerNicknames()
|
||||
peerNicknames.values.filter { it != peerNicknames[meshService.myPeerID] }
|
||||
}
|
||||
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> {
|
||||
// Location channel: use geohash participants with collision-resistant suffixes
|
||||
val geohashPeople = viewModel.geohashPeople.value ?: emptyList()
|
||||
val geohashPeople = viewModel.geohashPeople.value
|
||||
val currentNickname = state.getNicknameValue()
|
||||
val duplicateNames = duplicateGeohashBaseNames(geohashPeople)
|
||||
|
||||
geohashPeople.mapNotNull { person ->
|
||||
val displayName = person.displayName
|
||||
// Exclude self from suggestions
|
||||
if (displayName.startsWith("${currentNickname}#")) {
|
||||
val baseName = splitSuffix(person.displayName).first
|
||||
val hasNicknameCollision =
|
||||
baseName.lowercase(Locale.ROOT) in duplicateNames
|
||||
val displayName = disambiguatedGeohashDisplayName(person, duplicateNames)
|
||||
// A unique local nickname can be excluded directly. If it collides, the
|
||||
// nickname alone cannot identify which row is self, so keep the suffixed
|
||||
// rows rather than accidentally hiding the other user.
|
||||
if (
|
||||
!hasNicknameCollision &&
|
||||
baseName.equals(currentNickname, ignoreCase = true)
|
||||
) {
|
||||
null
|
||||
} else {
|
||||
displayName
|
||||
@ -469,13 +480,11 @@ class CommandProcessor(
|
||||
}
|
||||
} else {
|
||||
// Fallback to mesh peers if no viewModel available
|
||||
meshService.getPeerNicknames().values.filter { it != meshService.getPeerNicknames()[meshService.myPeerID] }
|
||||
val peerNicknames = meshService.getPeerNicknames()
|
||||
peerNicknames.values.filter { it != peerNicknames[meshService.myPeerID] }
|
||||
}
|
||||
|
||||
// Filter nicknames based on the text after @
|
||||
val filteredNicknames = peerCandidates.filter { nickname ->
|
||||
nickname.startsWith(textAfterAt, ignoreCase = true)
|
||||
}.sorted()
|
||||
val filteredNicknames = filterMentionCandidates(peerCandidates, textAfterAt)
|
||||
|
||||
if (filteredNicknames.isNotEmpty()) {
|
||||
state.setMentionSuggestions(filteredNicknames)
|
||||
@ -533,3 +542,22 @@ class CommandProcessor(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep mention autocomplete useful in crowded channels: a bare `anon` identity has not announced
|
||||
* a username and is not actionable. Names such as `anon1234` are announced usernames and remain
|
||||
* valid mention targets.
|
||||
*/
|
||||
internal fun filterMentionCandidates(
|
||||
candidates: List<String>,
|
||||
query: String
|
||||
): List<String> {
|
||||
return candidates.asSequence()
|
||||
.map(String::trim)
|
||||
.filter(String::isNotEmpty)
|
||||
.filterNot(::isUnannouncedNickname)
|
||||
.filter { nickname -> nickname.startsWith(query, ignoreCase = true) }
|
||||
.distinctBy { nickname -> nickname.lowercase(Locale.ROOT) }
|
||||
.sortedWith(String.CASE_INSENSITIVE_ORDER)
|
||||
.toList()
|
||||
}
|
||||
|
||||
@ -198,8 +198,10 @@ class DataManager(private val context: Context) {
|
||||
|
||||
// MARK: - Blocked Users Management
|
||||
|
||||
@Synchronized
|
||||
fun loadBlockedUsers() {
|
||||
val savedBlockedUsers = prefs.getStringSet("blocked_users", emptySet()) ?: emptySet()
|
||||
_blockedUsers.clear()
|
||||
_blockedUsers.addAll(savedBlockedUsers)
|
||||
}
|
||||
|
||||
@ -217,6 +219,7 @@ class DataManager(private val context: Context) {
|
||||
saveBlockedUsers()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun isUserBlocked(fingerprint: String): Boolean {
|
||||
return _blockedUsers.contains(fingerprint)
|
||||
}
|
||||
@ -226,8 +229,10 @@ class DataManager(private val context: Context) {
|
||||
private val _geohashBlockedUsers = mutableSetOf<String>() // Set of nostr pubkey hex
|
||||
val geohashBlockedUsers: Set<String> get() = _geohashBlockedUsers.toSet()
|
||||
|
||||
@Synchronized
|
||||
fun loadGeohashBlockedUsers() {
|
||||
val savedGeohashBlockedUsers = prefs.getStringSet("geohash_blocked_users", emptySet()) ?: emptySet()
|
||||
_geohashBlockedUsers.clear()
|
||||
_geohashBlockedUsers.addAll(savedGeohashBlockedUsers)
|
||||
}
|
||||
|
||||
@ -245,6 +250,7 @@ class DataManager(private val context: Context) {
|
||||
saveGeohashBlockedUsers()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun isGeohashUserBlocked(pubkeyHex: String): Boolean {
|
||||
return _geohashBlockedUsers.contains(pubkeyHex)
|
||||
}
|
||||
|
||||
@ -1,39 +1,34 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.Explore
|
||||
import androidx.compose.material.icons.outlined.LocationOn
|
||||
import androidx.compose.material.icons.filled.Email
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
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
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import java.util.*
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.ui.theme.colorForPeer
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.ui.theme.LocalBitchatPalette
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* GeohashPeopleList - iOS-compatible component for displaying geohash participants
|
||||
* Shows peers discovered through Nostr ephemeral events instead of Bluetooth peers
|
||||
* Geohash people list — card groups matching location / settings sheet rows.
|
||||
*/
|
||||
|
||||
/**
|
||||
* GeoPerson data class - matches iOS GeoPerson structure exactly
|
||||
*/
|
||||
data class GeoPerson(
|
||||
val id: String, // pubkey hex (lowercased) - matches iOS
|
||||
val displayName: String, // nickname with #suffix - matches iOS
|
||||
val displayName: String, // nickname with #suffix - matches iOS
|
||||
val lastSeen: Date // activity timestamp - matches iOS
|
||||
)
|
||||
|
||||
@ -41,118 +36,236 @@ data class GeoPerson(
|
||||
fun GeohashPeopleList(
|
||||
viewModel: ChatViewModel,
|
||||
onTapPerson: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
excludedIdentityAliases: Set<String> = emptySet()
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
// Observe geohash people from ChatViewModel
|
||||
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
val isTeleported by viewModel.isTeleported.collectAsStateWithLifecycle()
|
||||
val teleportedGeo by viewModel.teleportedGeo.collectAsStateWithLifecycle()
|
||||
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||
val unreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
|
||||
|
||||
Column {
|
||||
// Header matching iOS style
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.LocationOn,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.geohash_people_header),
|
||||
style = MaterialTheme.typography.labelSmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
|
||||
if (geohashPeople.isEmpty()) {
|
||||
// Empty state - matches iOS "nobody around..."
|
||||
Text(
|
||||
text = stringResource(R.string.nobody_around),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = BASE_FONT_SIZE.sp
|
||||
),
|
||||
color = colorScheme.onSurface.copy(alpha = 0.5f),
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
|
||||
)
|
||||
} else {
|
||||
// Get current geohash identity for "me" detection
|
||||
val myHex = remember(selectedLocationChannel) {
|
||||
when (val channel = selectedLocationChannel) {
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> {
|
||||
try {
|
||||
val identity = com.bitchat.android.nostr.NostrIdentityBridge.deriveIdentity(
|
||||
forGeohash = channel.channel.geohash,
|
||||
context = viewModel.getApplication()
|
||||
)
|
||||
identity.publicKeyHex.lowercase()
|
||||
} catch (e: Exception) {
|
||||
Log.e("GeohashPeopleList", "Failed to derive identity: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
// Sort people: me first, then by lastSeen (matches iOS exactly)
|
||||
val orderedPeople = remember(geohashPeople, myHex) {
|
||||
geohashPeople.sortedWith { a, b ->
|
||||
when {
|
||||
myHex != null && a.id == myHex && b.id != myHex -> -1
|
||||
myHex != null && b.id == myHex && a.id != myHex -> 1
|
||||
else -> b.lastSeen.compareTo(a.lastSeen) // Most recent first
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute base name collisions to decide whether to show hash suffix
|
||||
val baseNameCounts = remember(geohashPeople) {
|
||||
val counts = mutableMapOf<String, Int>()
|
||||
geohashPeople.forEach { person ->
|
||||
val (b, _) = com.bitchat.android.ui.splitSuffix(person.displayName)
|
||||
counts[b] = (counts[b] ?: 0) + 1
|
||||
val palette = LocalBitchatPalette.current
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val myHex = remember(selectedLocationChannel) {
|
||||
when (val channel = selectedLocationChannel) {
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> {
|
||||
try {
|
||||
val identity = com.bitchat.android.nostr.NostrIdentityBridge.deriveIdentity(
|
||||
forGeohash = channel.channel.geohash,
|
||||
context = viewModel.getApplication()
|
||||
)
|
||||
identity.publicKeyHex.lowercase(Locale.ROOT)
|
||||
} catch (e: Exception) {
|
||||
Log.e("GeohashPeopleList", "Failed to derive identity: ${e.message}")
|
||||
null
|
||||
}
|
||||
counts
|
||||
}
|
||||
|
||||
val firstID = orderedPeople.firstOrNull()?.id
|
||||
|
||||
orderedPeople.forEach { person ->
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
val peopleIncludingSelf = remember(geohashPeople, myHex, nickname) {
|
||||
if (myHex != null && geohashPeople.none { it.id.equals(myHex, ignoreCase = true) }) {
|
||||
listOf(
|
||||
GeoPerson(
|
||||
id = myHex,
|
||||
displayName = nickname.ifBlank { "anon" },
|
||||
lastSeen = Date(0)
|
||||
)
|
||||
) + geohashPeople
|
||||
} else {
|
||||
geohashPeople
|
||||
}
|
||||
}
|
||||
val visiblePeople = remember(peopleIncludingSelf, excludedIdentityAliases) {
|
||||
peopleIncludingSelf.filterNot { person ->
|
||||
val alias = "nostr_${person.id.take(16)}".lowercase()
|
||||
alias in excludedIdentityAliases
|
||||
}
|
||||
}
|
||||
val sections = remember(visiblePeople, myHex, isTeleported, teleportedGeo) {
|
||||
sectionGeohashPeople(
|
||||
people = visiblePeople,
|
||||
myId = myHex,
|
||||
selfIsTeleported = isTeleported,
|
||||
teleportedIds = teleportedGeo
|
||||
)
|
||||
}
|
||||
val displayedPeople = remember(sections) {
|
||||
sections.onLocation + sections.teleportedIn
|
||||
}
|
||||
val teleportedPersonIds = remember(sections.teleportedIn) {
|
||||
sections.teleportedIn.mapTo(mutableSetOf()) { it.id.lowercase(Locale.ROOT) }
|
||||
}
|
||||
val duplicateBaseNames = remember(displayedPeople) {
|
||||
duplicateGeohashBaseNames(displayedPeople)
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
SheetIconSectionHeader(
|
||||
iconRes = R.drawable.ic_spec_people,
|
||||
title = stringResource(R.string.people_count_title, displayedPeople.size)
|
||||
)
|
||||
|
||||
if (displayedPeople.isEmpty()) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding)
|
||||
.padding(top = 10.dp),
|
||||
color = colorScheme.surface,
|
||||
shape = AboutCardShape
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.nobody_around),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = palette.textTertiary,
|
||||
modifier = Modifier.padding(
|
||||
horizontal = SheetRowHorizontal,
|
||||
vertical = SheetRowVertical
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@Composable
|
||||
fun personRow(person: GeoPerson) {
|
||||
val isMe = myHex != null && person.id.equals(myHex, ignoreCase = true)
|
||||
val personIsTeleported = if (isMe) {
|
||||
isTeleported
|
||||
} else {
|
||||
person.id.lowercase(Locale.ROOT) in teleportedPersonIds
|
||||
}
|
||||
GeohashPersonItem(
|
||||
person = person,
|
||||
isFirst = person.id == firstID,
|
||||
isMe = myHex != null && person.id == myHex,
|
||||
isMe = isMe,
|
||||
hasUnreadDM = unreadPrivateMessages.contains("nostr_${person.id.take(16)}"),
|
||||
isTeleported = person.id != myHex && viewModel.isPersonTeleported(person.id),
|
||||
isMyTeleported = person.id == myHex && isTeleported,
|
||||
nickname = nickname,
|
||||
colorScheme = colorScheme,
|
||||
isTeleported = personIsTeleported,
|
||||
viewModel = viewModel,
|
||||
showHashSuffix = (baseNameCounts[com.bitchat.android.ui.splitSuffix(person.displayName).first] ?: 0) > 1,
|
||||
showHashSuffix = splitSuffix(person.displayName)
|
||||
.first
|
||||
.lowercase(Locale.ROOT) in duplicateBaseNames,
|
||||
onTap = {
|
||||
if (person.id != myHex) {
|
||||
// TODO: Re-enable when NIP-17 geohash DM issues are fixed
|
||||
// Start geohash DM (iOS-compatible)
|
||||
if (!isMe) {
|
||||
viewModel.startGeohashDM(person.id)
|
||||
onTapPerson()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (sections.onLocation.isNotEmpty()) {
|
||||
AboutSectionLabel(text = stringResource(R.string.section_on_location))
|
||||
PeopleCard(
|
||||
people = sections.onLocation,
|
||||
row = { personRow(it) }
|
||||
)
|
||||
}
|
||||
|
||||
if (sections.teleportedIn.isNotEmpty()) {
|
||||
AboutSectionLabel(text = stringResource(R.string.section_teleported_in))
|
||||
PeopleCard(
|
||||
people = sections.teleportedIn,
|
||||
row = { personRow(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal data class GeohashPeopleSections(
|
||||
val onLocation: List<GeoPerson>,
|
||||
val teleportedIn: List<GeoPerson>
|
||||
)
|
||||
|
||||
/**
|
||||
* Names that require a short identity suffix, calculated across both people sections.
|
||||
*
|
||||
* Matching is case-insensitive to mirror geohash chat's nickname collision handling.
|
||||
*/
|
||||
internal fun duplicateGeohashBaseNames(people: List<GeoPerson>): Set<String> =
|
||||
people
|
||||
.groupingBy { splitSuffix(it.displayName).first.lowercase(Locale.ROOT) }
|
||||
.eachCount()
|
||||
.filterValues { it > 1 }
|
||||
.keys
|
||||
|
||||
/**
|
||||
* The same `#abcd` disambiguator used by geohash chat.
|
||||
*
|
||||
* Presence rows normally carry only a base nickname, so derive the suffix from the full Nostr
|
||||
* public key when a collision exists. Preserve an already-announced suffix for compatibility.
|
||||
*/
|
||||
internal fun geohashIdentitySuffix(person: GeoPerson, showHashSuffix: Boolean): String {
|
||||
if (!showHashSuffix) return ""
|
||||
val announcedSuffix = splitSuffix(person.displayName).second
|
||||
return announcedSuffix.ifEmpty { "#${person.id.takeLast(4)}" }
|
||||
}
|
||||
|
||||
internal fun disambiguatedGeohashDisplayName(
|
||||
person: GeoPerson,
|
||||
duplicateBaseNames: Set<String>,
|
||||
): String {
|
||||
val baseName = splitSuffix(person.displayName).first
|
||||
val showSuffix = baseName.lowercase(Locale.ROOT) in duplicateBaseNames
|
||||
return baseName + geohashIdentitySuffix(person, showSuffix)
|
||||
}
|
||||
|
||||
/**
|
||||
* Split announced identities by how they entered this geohash. Bare `anon` heartbeat identities
|
||||
* are omitted, while announced names such as `anon1234` remain ordinary participants. Self is
|
||||
* retained even before a nickname announcement and is always first in the matching section.
|
||||
*/
|
||||
internal fun sectionGeohashPeople(
|
||||
people: List<GeoPerson>,
|
||||
myId: String?,
|
||||
selfIsTeleported: Boolean,
|
||||
teleportedIds: Set<String>
|
||||
): GeohashPeopleSections {
|
||||
val normalizedMyId = myId?.lowercase(Locale.ROOT)
|
||||
val normalizedTeleportedIds = teleportedIds
|
||||
.mapTo(mutableSetOf()) { it.lowercase(Locale.ROOT) }
|
||||
fun isSelf(person: GeoPerson): Boolean =
|
||||
normalizedMyId != null && person.id.lowercase(Locale.ROOT) == normalizedMyId
|
||||
fun isTeleported(person: GeoPerson): Boolean =
|
||||
if (isSelf(person)) selfIsTeleported
|
||||
else person.id.lowercase(Locale.ROOT) in normalizedTeleportedIds
|
||||
|
||||
val displayedPeople = people.filter { person ->
|
||||
isSelf(person) || !isUnannouncedNickname(person.displayName)
|
||||
}
|
||||
val ordered = displayedPeople.sortedWith(
|
||||
compareByDescending<GeoPerson>(::isSelf)
|
||||
.thenByDescending { it.lastSeen }
|
||||
)
|
||||
return GeohashPeopleSections(
|
||||
onLocation = ordered.filterNot(::isTeleported),
|
||||
teleportedIn = ordered.filter(::isTeleported)
|
||||
)
|
||||
}
|
||||
|
||||
/** One uncapped card of people. The enclosing sheet owns scrolling. */
|
||||
@Composable
|
||||
private fun PeopleCard(
|
||||
people: List<GeoPerson>,
|
||||
row: @Composable (GeoPerson) -> Unit
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = AboutHorizontalPadding)
|
||||
.padding(top = 10.dp),
|
||||
color = colorScheme.surface,
|
||||
shape = AboutCardShape
|
||||
) {
|
||||
AnimatedRowColumn(items = people, key = { it.id }) { index, person ->
|
||||
Column {
|
||||
if (index > 0) SheetCardDivider()
|
||||
row(person)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -160,113 +273,97 @@ fun GeohashPeopleList(
|
||||
@Composable
|
||||
private fun GeohashPersonItem(
|
||||
person: GeoPerson,
|
||||
isFirst: Boolean,
|
||||
isMe: Boolean,
|
||||
hasUnreadDM: Boolean,
|
||||
isTeleported: Boolean,
|
||||
isMyTeleported: Boolean,
|
||||
nickname: String,
|
||||
colorScheme: ColorScheme,
|
||||
viewModel: ChatViewModel,
|
||||
showHashSuffix: Boolean,
|
||||
onTap: () -> Unit
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
|
||||
val statusIconRes =
|
||||
if (isTeleported) R.drawable.ic_spec_teleport
|
||||
else R.drawable.ic_spec_on_location_person
|
||||
|
||||
val (baseNameRaw, _) = splitSuffix(person.displayName)
|
||||
val baseName = truncateNickname(baseNameRaw)
|
||||
val suffix = geohashIdentitySuffix(person, showHashSuffix)
|
||||
val assignedColor = colorForPeer(
|
||||
viewModel.peerIdentityForNostrPubkey(person.id),
|
||||
palette
|
||||
)
|
||||
val baseColor = if (isMe) palette.accentOrange else assignedColor
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onTap() }
|
||||
.padding(horizontal = 24.dp, vertical = 4.dp)
|
||||
.padding(top = if (isFirst) 10.dp else 0.dp),
|
||||
// Exact height, not padding: a row that sizes to its content makes the card change
|
||||
// height whenever the list reorders.
|
||||
.height(SheetRowHeight)
|
||||
.clickable(onClick = onTap)
|
||||
.padding(horizontal = SheetRowHorizontal),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Icon logic matching iOS exactly
|
||||
if (hasUnreadDM) {
|
||||
// Unread DM indicator (orange envelope)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Email,
|
||||
contentDescription = stringResource(R.string.cd_unread_message),
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = Color(0xFFFF9500) // iOS orange
|
||||
)
|
||||
} else {
|
||||
// Face icon with teleportation state
|
||||
val (iconName, iconColor) = when {
|
||||
isMe && isMyTeleported -> "face.dashed" to Color(0xFFFF9500) // Orange for teleported me
|
||||
isTeleported -> "face.dashed" to colorScheme.onSurface // Regular color for teleported others
|
||||
isMe -> "face.smiling" to Color(0xFFFF9500) // Orange for me
|
||||
else -> "face.smiling" to colorScheme.onSurface // Regular color for others
|
||||
Box(
|
||||
modifier = Modifier.size(SheetRowLeadingSlot),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (hasUnreadDM) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Email,
|
||||
contentDescription = stringResource(R.string.cd_unread_message),
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = palette.accentOrange
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = painterResource(statusIconRes),
|
||||
contentDescription = if (isTeleported) {
|
||||
stringResource(R.string.cd_teleported)
|
||||
} else {
|
||||
stringResource(R.string.section_on_location)
|
||||
},
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = baseColor
|
||||
)
|
||||
}
|
||||
|
||||
// Use appropriate Material icon (closest match to iOS SF Symbols)
|
||||
val icon = when (iconName) {
|
||||
"face.dashed" -> Icons.Outlined.Explore
|
||||
else -> Icons.Outlined.LocationOn
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = if (isTeleported || isMyTeleported) "Teleported user" else "User",
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = iconColor.copy(alpha = if (iconName == "face.dashed") 0.6f else 1.0f) // Make dashed faces slightly transparent
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// Display name with suffix handling
|
||||
val (baseNameRaw, suffixRaw) = com.bitchat.android.ui.splitSuffix(person.displayName)
|
||||
val baseName = truncateNickname(baseNameRaw)
|
||||
val suffix = if (showHashSuffix) suffixRaw else ""
|
||||
|
||||
// Get consistent peer color (matches iOS color assignment exactly)
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
val assignedColor = viewModel.colorForNostrPubkey(person.id, isDark)
|
||||
val baseColor = if (isMe) Color(0xFFFF9500) else assignedColor
|
||||
|
||||
|
||||
Spacer(modifier = Modifier.width(SheetRowLeadingGutter))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Base name with peer-specific color
|
||||
Text(
|
||||
text = baseName,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Normal
|
||||
),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium,
|
||||
color = baseColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
// Suffix (collision-resistant #abcd) in lighter shade
|
||||
|
||||
if (suffix.isNotEmpty()) {
|
||||
Text(
|
||||
text = suffix,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = BASE_FONT_SIZE.sp
|
||||
),
|
||||
color = baseColor.copy(alpha = 0.6f)
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = baseColor.copy(alpha = SUFFIX_ALPHA)
|
||||
)
|
||||
}
|
||||
|
||||
// "You" indicator for current user
|
||||
|
||||
if (isMe) {
|
||||
Text(
|
||||
text = stringResource(R.string.you_suffix),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = BASE_FONT_SIZE.sp
|
||||
),
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 14.sp,
|
||||
color = baseColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Remove
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
@ -15,10 +19,6 @@ import android.webkit.WebViewClient
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Remove
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@ -30,14 +30,14 @@ import androidx.compose.material3.Text
|
||||
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
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitchat.android.ui.theme.BitchatTheme
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.R
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import com.bitchat.android.geohash.Geohash
|
||||
@ -87,15 +87,13 @@ class GeohashPickerActivity : OrientationAwareActivity() {
|
||||
val initialPrecision = geohashToFocus?.length ?: 5
|
||||
|
||||
setContent {
|
||||
MaterialTheme {
|
||||
BitchatTheme {
|
||||
var currentGeohash by remember { mutableStateOf(geohashToFocus ?: "") }
|
||||
var precision by remember { mutableStateOf(initialPrecision.coerceIn(1, 12)) }
|
||||
var webViewRef by remember { mutableStateOf<WebView?>(null) }
|
||||
|
||||
// iOS system-like colors used across app
|
||||
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)
|
||||
val standardGreen = colorScheme.primary
|
||||
|
||||
Scaffold { padding ->
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
@ -186,7 +184,7 @@ class GeohashPickerActivity : OrientationAwareActivity() {
|
||||
text = stringResource(R.string.pan_zoom_instruction),
|
||||
fontSize = 12.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp)
|
||||
@ -211,7 +209,7 @@ class GeohashPickerActivity : OrientationAwareActivity() {
|
||||
Text(
|
||||
text = if (currentGeohash.isNotEmpty()) "#${currentGeohash}" else "select location",
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
@ -277,7 +275,7 @@ class GeohashPickerActivity : OrientationAwareActivity() {
|
||||
Text(
|
||||
text = stringResource(R.string.select),
|
||||
fontSize = (BASE_FONT_SIZE - 2).sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = BitchatFontFamily
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@ import com.bitchat.android.geohash.GeohashNostrPrivacyPolicy
|
||||
import com.bitchat.android.geohash.LiveLocationPrivacyGate
|
||||
import com.bitchat.android.nostr.GeohashMessageHandler
|
||||
import com.bitchat.android.nostr.GeohashRepository
|
||||
import com.bitchat.android.nostr.NostrDirectMessageHandler
|
||||
import com.bitchat.android.nostr.NostrBackgroundRuntime
|
||||
import com.bitchat.android.nostr.NostrIdentityBridge
|
||||
import com.bitchat.android.nostr.NostrProtocol
|
||||
import com.bitchat.android.nostr.NostrRelayManager
|
||||
@ -20,69 +20,48 @@ 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.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
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 java.util.UUID
|
||||
import kotlin.random.asKotlinRandom
|
||||
|
||||
class GeohashViewModel(
|
||||
application: Application,
|
||||
private val state: ChatState,
|
||||
private val messageManager: MessageManager,
|
||||
private val privateChatManager: PrivateChatManager,
|
||||
private val meshDelegateHandler: MeshDelegateHandler,
|
||||
private val dataManager: DataManager,
|
||||
private val notificationManager: NotificationManager
|
||||
) : AndroidViewModel(application), DefaultLifecycleObserver {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "GeohashViewModel"
|
||||
private val secureRandom = SecureRandom().asKotlinRandom()
|
||||
}
|
||||
companion object { private const val TAG = "GeohashViewModel" }
|
||||
|
||||
private val repo = GeohashRepository(application, state, dataManager)
|
||||
private val subscriptionManager = NostrSubscriptionManager(application, viewModelScope)
|
||||
private val uiSubscriptionOwner = "geohash-ui-${UUID.randomUUID()}"
|
||||
private val subscriptionManager = NostrSubscriptionManager(
|
||||
application,
|
||||
owner = uiSubscriptionOwner
|
||||
)
|
||||
private val geohashMessageHandler = GeohashMessageHandler(
|
||||
application = application,
|
||||
state = state,
|
||||
messageManager = messageManager,
|
||||
repo = repo,
|
||||
scope = viewModelScope,
|
||||
dataManager = dataManager
|
||||
)
|
||||
private val dmHandler = NostrDirectMessageHandler(
|
||||
application = application,
|
||||
state = state,
|
||||
privateChatManager = privateChatManager,
|
||||
meshDelegateHandler = meshDelegateHandler,
|
||||
scope = viewModelScope,
|
||||
repo = repo,
|
||||
dataManager = dataManager
|
||||
dataManager = dataManager,
|
||||
addChannelMessage = messageManager::addChannelMessage
|
||||
)
|
||||
|
||||
// Live channel message stream (kind 20000). Low-volume; kept alive in the background.
|
||||
private var currentGeohashMsgSubId: String? = null
|
||||
// Presence heartbeat firehose (kind 20001). High-volume; paused while backgrounded.
|
||||
private var currentGeohashPresenceSubId: String? = null
|
||||
private var currentDmSubId: String? = null
|
||||
private var currentDmGeohash: 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>()
|
||||
private val samplingSubscriptionIds = mutableMapOf<String, String>()
|
||||
private val liveSamplingSubscriptionGeohashes = mutableSetOf<String>()
|
||||
private var requestedLiveSamplingGeohashes: Set<String> = emptySet()
|
||||
private var requestedUserSamplingGeohashes: Set<String> = emptySet()
|
||||
private var uiSubscriptionsShutdown = false
|
||||
private val liveLocationRevocationListener: () -> Unit = {
|
||||
val revokedLiveGeohashes = liveSamplingSubscriptionGeohashes.toSet()
|
||||
revokedLiveGeohashes.forEach { geohash ->
|
||||
@ -105,21 +84,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.
|
||||
subscriptionManager.subscribeGiftWraps(
|
||||
pubkey = identity.publicKeyHex,
|
||||
sinceMs = System.currentTimeMillis() - 172800000L,
|
||||
id = "chat-messages",
|
||||
handler = { event -> dmHandler.onGiftWrap(event, "", identity) } // geohash="" means global account DM (not geohash identity)
|
||||
)
|
||||
}
|
||||
try {
|
||||
locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication())
|
||||
viewModelScope.launch {
|
||||
@ -133,10 +101,6 @@ 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)
|
||||
@ -144,102 +108,17 @@ class GeohashViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun startGlobalPresenceHeartbeat() {
|
||||
globalPresenceJob?.cancel()
|
||||
globalPresenceJob = viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) {
|
||||
val manager = locationChannelManager ?: return@launch
|
||||
combine(
|
||||
manager.availableChannels,
|
||||
LiveLocationPrivacyGate.enabled
|
||||
) { channels, enabled ->
|
||||
GeohashNostrPrivacyPolicy.livePresenceTargets(channels, enabled)
|
||||
}.collectLatest { targetGeohashes ->
|
||||
|
||||
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
|
||||
|
||||
broadcastLiveLocationPresence(geohash)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} 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()
|
||||
currentGeohashMsgSubId = null
|
||||
subscriptionManager.unsubscribeAllOwned()
|
||||
currentGeohashPresenceSubId = null
|
||||
currentDmSubId = null
|
||||
currentDmGeohash = null
|
||||
activeChannelGeohash = null
|
||||
geoTimer?.cancel()
|
||||
geoTimer = null
|
||||
globalPresenceJob?.cancel()
|
||||
globalPresenceJob = null
|
||||
try { NostrIdentityBridge.clearAllAssociations(getApplication()) } catch (_: Exception) {}
|
||||
initialize()
|
||||
}
|
||||
|
||||
private suspend fun broadcastLiveLocationPresence(geohash: String) {
|
||||
val manager = locationChannelManager ?: return
|
||||
val token = LiveLocationPrivacyGate.captureToken() ?: return
|
||||
val isCurrentLiveTarget = GeohashNostrPrivacyPolicy.livePresenceTargets(
|
||||
manager.availableChannels.value,
|
||||
liveLocationEnabled = true
|
||||
).contains(geohash)
|
||||
if (!isCurrentLiveTarget || !LiveLocationPrivacyGate.accepts(token)) return
|
||||
|
||||
try {
|
||||
var identity: com.bitchat.android.nostr.NostrIdentity? = null
|
||||
LiveLocationPrivacyGate.runIfAllowed(token) {
|
||||
identity = NostrIdentityBridge.deriveIdentity(geohash, getApplication())
|
||||
}
|
||||
val preparedIdentity = identity ?: return
|
||||
if (!LiveLocationPrivacyGate.accepts(token)) return
|
||||
val event = NostrProtocol.createGeohashPresenceEvent(geohash, preparedIdentity)
|
||||
LiveLocationPrivacyGate.runIfAllowed(token) {
|
||||
val relayManager = NostrRelayManager.getInstance(getApplication())
|
||||
relayManager.sendEventToGeohash(
|
||||
event,
|
||||
geohash,
|
||||
includeDefaults = false,
|
||||
nRelays = 5,
|
||||
liveLocationToken = token
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to send live-location presence")
|
||||
}
|
||||
NostrBackgroundRuntime.resetSubscriptions()
|
||||
}
|
||||
|
||||
fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) {
|
||||
@ -269,29 +148,27 @@ class GeohashViewModel(
|
||||
powDifficulty = if (pow.enabled) pow.difficulty else null
|
||||
)
|
||||
messageManager.addChannelMessage("geo:${channel.geohash}", localMsg)
|
||||
val startedMining = pow.enabled && pow.difficulty > 0
|
||||
if (startedMining) {
|
||||
com.bitchat.android.ui.PoWMiningTracker.startMiningMessage(tempId)
|
||||
}
|
||||
try {
|
||||
val identity = NostrIdentityBridge.deriveIdentity(forGeohash = channel.geohash, context = getApplication())
|
||||
val teleported = locationChannelManager?.teleported?.value
|
||||
?: state.isTeleported.value
|
||||
val event = NostrProtocol.createEphemeralGeohashEvent(content, channel.geohash, identity, nickname, teleported)
|
||||
val relayManager = NostrRelayManager.getInstance(getApplication())
|
||||
relayManager.sendEventToGeohash(
|
||||
event,
|
||||
channel.geohash,
|
||||
includeDefaults = false,
|
||||
nRelays = 5,
|
||||
liveLocationToken = liveLocationToken
|
||||
)
|
||||
} finally {
|
||||
// Ensure we stop the per-message mining animation regardless of success/failure
|
||||
if (startedMining) {
|
||||
com.bitchat.android.ui.PoWMiningTracker.stopMiningMessage(tempId)
|
||||
}
|
||||
}
|
||||
val identity = NostrIdentityBridge.deriveIdentity(
|
||||
forGeohash = channel.geohash,
|
||||
context = getApplication()
|
||||
)
|
||||
val teleported = locationChannelManager?.teleported?.value
|
||||
?: state.isTeleported.value
|
||||
val event = NostrProtocol.createEphemeralGeohashEvent(
|
||||
content,
|
||||
channel.geohash,
|
||||
identity,
|
||||
nickname,
|
||||
teleported
|
||||
)
|
||||
val relayManager = NostrRelayManager.getInstance(getApplication())
|
||||
relayManager.sendEventToGeohash(
|
||||
event,
|
||||
channel.geohash,
|
||||
includeDefaults = false,
|
||||
nRelays = 5,
|
||||
liveLocationToken = liveLocationToken
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send geohash message: ${e.message}")
|
||||
}
|
||||
@ -426,29 +303,36 @@ class GeohashViewModel(
|
||||
}
|
||||
|
||||
fun ensureGeohashDMSubscriptionForConversation(conversationKey: String) {
|
||||
val geohash = repo.getConversationGeohash(conversationKey) ?: return
|
||||
if (currentDmGeohash == geohash && currentDmSubId != null) return
|
||||
|
||||
currentDmSubId?.let(subscriptionManager::unsubscribe)
|
||||
currentDmSubId = null
|
||||
currentDmGeohash = null
|
||||
subscribeChannelDM(geohash)
|
||||
val geohash = repo.getConversationGeohash(conversationKey)
|
||||
?: NostrBackgroundRuntime.conversationGeohash(conversationKey)
|
||||
?: return
|
||||
NostrBackgroundRuntime.ensureConversationDm(geohash)
|
||||
}
|
||||
|
||||
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()}"
|
||||
return colorForPeerSeed(seed, isDark).copy()
|
||||
fun displayNameForNostrPubkeyUI(pubkeyHex: String): String {
|
||||
val foregroundName = repo.displayNameForNostrPubkeyUI(pubkeyHex)
|
||||
return foregroundName.takeUnless { it == "anon" }
|
||||
?: NostrBackgroundRuntime.displayNameForNostrPubkey(pubkeyHex)
|
||||
?: foregroundName
|
||||
}
|
||||
|
||||
fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String {
|
||||
val foregroundName = repo.displayNameForGeohashConversation(pubkeyHex, sourceGeohash)
|
||||
return foregroundName.takeUnless { it == "anon" }
|
||||
?: NostrBackgroundRuntime.displayNameForGeohashConversation(pubkeyHex, sourceGeohash)
|
||||
?: foregroundName
|
||||
}
|
||||
|
||||
fun conversationGeohash(conversationKey: String): String? =
|
||||
repo.getConversationGeohash(conversationKey)
|
||||
?: NostrBackgroundRuntime.conversationGeohash(conversationKey)
|
||||
|
||||
fun peerIdentityForNostrPubkey(pubkeyHex: String): PeerIdentity =
|
||||
PeerIdentity.nostr(pubkeyHex)
|
||||
|
||||
private fun switchLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) {
|
||||
geoTimer?.cancel(); geoTimer = null
|
||||
currentGeohashMsgSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashMsgSubId = null }
|
||||
currentGeohashPresenceSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashPresenceSubId = null }
|
||||
currentDmSubId?.let { subscriptionManager.unsubscribe(it); currentDmSubId = null }
|
||||
currentDmGeohash = null
|
||||
|
||||
when (channel) {
|
||||
is com.bitchat.android.geohash.ChannelID.Mesh -> {
|
||||
@ -485,17 +369,11 @@ class GeohashViewModel(
|
||||
val liveLocationToken = locationChannelManager
|
||||
?.liveLocationTokenForSelectedChannel(channel.channel)
|
||||
|
||||
// Chat message stream (kind 20000) is low-volume; keep it alive even when
|
||||
// backgrounded so geohash messages still arrive.
|
||||
subscribeChannelMessages(channel.channel.geohash, liveLocationToken)
|
||||
// Presence heartbeat firehose (kind 20001) is the high-volume data hog; only
|
||||
// run it in the foreground. It is restored in onStart() and torn down in onStop().
|
||||
if (isAppInForeground()) {
|
||||
subscribeChannelPresence(channel.channel.geohash, liveLocationToken)
|
||||
}
|
||||
// Gift-wrap DM subscription is lightweight (filtered to our pubkey) and is
|
||||
// kept alive in the background so geohash DMs still arrive.
|
||||
subscribeChannelDM(channel.channel.geohash)
|
||||
}
|
||||
null -> {
|
||||
Log.d(TAG, "📡 No channel selected")
|
||||
@ -505,25 +383,6 @@ class GeohashViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the chat message stream (kind 20000) for a geohash channel.
|
||||
* Low-volume; kept alive in the background so messages keep arriving.
|
||||
*/
|
||||
private fun subscribeChannelMessages(
|
||||
geohash: String,
|
||||
liveLocationToken: Long?
|
||||
) {
|
||||
val subId = "geohash-${UUID.randomUUID()}"; currentGeohashMsgSubId = subId
|
||||
subscriptionManager.subscribeGeohashMessages(
|
||||
geohash = geohash,
|
||||
sinceMs = System.currentTimeMillis() - 3600000L,
|
||||
limit = 200,
|
||||
id = subId,
|
||||
handler = { event -> geohashMessageHandler.onEvent(event, geohash) },
|
||||
liveLocationToken = liveLocationToken
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the presence heartbeat firehose (kind 20001) for a geohash channel.
|
||||
* High-volume; only used to refresh the participant list, so it is torn down in
|
||||
@ -544,25 +403,6 @@ class GeohashViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to gift-wrap DMs for a geohash channel's derived identity.
|
||||
* Lightweight (filtered to our pubkey); kept alive in the background.
|
||||
*/
|
||||
private fun subscribeChannelDM(geohash: String) {
|
||||
val dmIdentity = NostrIdentityBridge.deriveIdentity(geohash, getApplication())
|
||||
val dmSubId = "geo-dm-${UUID.randomUUID()}"
|
||||
currentDmSubId = dmSubId
|
||||
currentDmGeohash = geohash
|
||||
subscriptionManager.subscribeGiftWraps(
|
||||
pubkey = dmIdentity.publicKeyHex,
|
||||
sinceMs = System.currentTimeMillis() - 172800000L,
|
||||
id = dmSubId,
|
||||
handler = { event -> dmHandler.onGiftWrap(event, geohash, dmIdentity) }
|
||||
)
|
||||
// Also register alias in global registry for routing convenience
|
||||
GeohashAliasRegistry.put("nostr_${dmIdentity.publicKeyHex.take(16)}", dmIdentity.publicKeyHex)
|
||||
}
|
||||
|
||||
private fun startGeoParticipantsTimer() {
|
||||
geoTimer = viewModelScope.launch {
|
||||
while (repo.getCurrentGeohash() != null) {
|
||||
@ -573,7 +413,16 @@ class GeohashViewModel(
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
shutdownUiSubscriptions()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
fun shutdownUiSubscriptions() {
|
||||
if (uiSubscriptionsShutdown) return
|
||||
uiSubscriptionsShutdown = true
|
||||
subscriptionManager.unsubscribeAllOwned()
|
||||
geoTimer?.cancel()
|
||||
geoTimer = null
|
||||
kotlin.runCatching {
|
||||
ProcessLifecycleOwner.get().lifecycle.removeObserver(this)
|
||||
}
|
||||
@ -606,10 +455,6 @@ class GeohashViewModel(
|
||||
if (repo.getCurrentGeohash() != null && geoTimer?.isActive != true) {
|
||||
startGeoParticipantsTimer()
|
||||
}
|
||||
// Resume the global presence heartbeat
|
||||
if (globalPresenceJob?.isActive != true) {
|
||||
startGlobalPresenceHeartbeat()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
@ -619,12 +464,9 @@ class GeohashViewModel(
|
||||
currentGeohashPresenceSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashPresenceSubId = null }
|
||||
// Drop geohash sampling subscriptions
|
||||
activeSamplingGeohashes.forEach(::unsubscribeSampling)
|
||||
// Stop broadcasting presence heartbeats
|
||||
globalPresenceJob?.cancel(); globalPresenceJob = null
|
||||
// Stop participant-refresh polling
|
||||
geoTimer?.cancel(); geoTimer = null
|
||||
// NOTE: gift-wrap DM subscriptions (per-geohash + global "chat-messages") are intentionally
|
||||
// left active so direct messages still arrive while backgrounded.
|
||||
// Process-owned message and DM subscriptions remain active.
|
||||
}
|
||||
|
||||
private fun performSubscribeSampling(geohash: String) {
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Link
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.background
|
||||
@ -7,8 +9,6 @@ import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Link
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@ -17,11 +17,11 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import java.net.URL
|
||||
|
||||
@ -43,11 +43,8 @@ fun LinkPreviewPill(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
|
||||
// iOS-style colors
|
||||
val textColor = if (isDark) Color.Green else Color(red = 0f, green = 0.5f, blue = 0f)
|
||||
val backgroundColor = if (isDark) Color.Gray.copy(alpha = 0.15f) else Color.Gray.copy(alpha = 0.08f)
|
||||
val textColor = colorScheme.secondary
|
||||
val backgroundColor = colorScheme.secondaryContainer.copy(alpha = 0.35f)
|
||||
val borderColor = textColor.copy(alpha = 0.3f)
|
||||
|
||||
// Parse URL for host extraction
|
||||
@ -87,7 +84,7 @@ fun LinkPreviewPill(
|
||||
Surface(
|
||||
modifier = Modifier.size(60.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = Color.Blue.copy(alpha = 0.1f)
|
||||
color = colorScheme.secondaryContainer
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@ -97,7 +94,7 @@ fun LinkPreviewPill(
|
||||
imageVector = Icons.Outlined.Link,
|
||||
contentDescription = stringResource(com.bitchat.android.R.string.cd_link),
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = Color.Blue
|
||||
tint = colorScheme.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -110,7 +107,7 @@ fun LinkPreviewPill(
|
||||
// Title - matches iOS styling
|
||||
Text(
|
||||
text = displayTitle,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = textColor,
|
||||
@ -121,7 +118,7 @@ fun LinkPreviewPill(
|
||||
// Host - matches iOS styling
|
||||
Text(
|
||||
text = displayHost,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 11.sp,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
maxLines = 1,
|
||||
|
||||
@ -1,19 +1,21 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Description
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.geohash.ChannelID
|
||||
@ -21,9 +23,9 @@ import com.bitchat.android.geohash.LocationChannelManager
|
||||
import com.bitchat.android.nostr.LocationNotesManager
|
||||
|
||||
/**
|
||||
* Location Notes button component for MainHeader
|
||||
* Shows in mesh mode when location permission granted AND services enabled
|
||||
* Icon turns primary color when notes exist, gray otherwise
|
||||
* Location Notes button for MainHeader.
|
||||
* Mesh-only with location authorized. Tor health tints the glyph with muted colours
|
||||
* and a slow glow while connecting (via [rememberTorConnectionVisual]).
|
||||
*/
|
||||
@Composable
|
||||
fun LocationNotesButton(
|
||||
@ -33,18 +35,15 @@ fun LocationNotesButton(
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val context = LocalContext.current
|
||||
|
||||
// Get channel and permission state
|
||||
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
val locationManager = remember { LocationChannelManager.getInstance(context) }
|
||||
val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
|
||||
val locationServicesEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle(false)
|
||||
|
||||
// Check both permission AND location services enabled
|
||||
val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED
|
||||
val locationEnabled = locationPermissionGranted && locationServicesEnabled
|
||||
|
||||
// Get notes count from LocationNotesManager
|
||||
|
||||
val notesManager = remember { LocationNotesManager.getInstance() }
|
||||
val notes by notesManager.notes.collectAsStateWithLifecycle()
|
||||
val notesCount = notes.size
|
||||
@ -52,15 +51,22 @@ fun LocationNotesButton(
|
||||
// Only show in mesh mode when location is authorized (iOS pattern)
|
||||
if (selectedLocationChannel is ChannelID.Mesh && locationEnabled) {
|
||||
val hasNotes = notesCount > 0
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier.size(24.dp)
|
||||
val contentDescription = stringResource(R.string.cd_location_notes)
|
||||
val normalTint = if (hasNotes) colorScheme.primary else colorScheme.onSurfaceVariant
|
||||
val torVisual = rememberTorConnectionVisual(normal = normalTint)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.pressScaleClickable(onClick = onClick, onClickLabel = contentDescription),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Description, // "long.text.page.and.pencil" equivalent
|
||||
contentDescription = stringResource(R.string.cd_location_notes),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = if (hasNotes) colorScheme.primary else Color.Gray
|
||||
TorAwareHeaderIcon(
|
||||
painter = painterResource(R.drawable.ic_spec_chat_bubbles),
|
||||
tint = torVisual.tint,
|
||||
isProgress = torVisual.isProgress,
|
||||
contentDescription = contentDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,17 +1,16 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowUpward
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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
|
||||
@ -24,7 +23,7 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import com.bitchat.android.ui.theme.BitchatFontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@ -57,11 +56,9 @@ fun LocationNotesSheet(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val accentGreen = colorScheme.primary
|
||||
|
||||
// iOS color scheme
|
||||
val accentGreen =
|
||||
if (isDark) Color.Green else Color(0xFF008000) // dark: green, light: dark green (0, 0.5, 0)
|
||||
|
||||
// Managers
|
||||
val notesManager = remember { LocationNotesManager.getInstance() }
|
||||
@ -274,7 +271,7 @@ private fun LocationNotesHeader(
|
||||
if (name.isNotEmpty()) {
|
||||
Text(
|
||||
text = name,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = accentGreen,
|
||||
)
|
||||
@ -285,7 +282,7 @@ private fun LocationNotesHeader(
|
||||
// Description
|
||||
Text(
|
||||
text = stringResource(R.string.location_notes_description),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
)
|
||||
@ -295,7 +292,7 @@ private fun LocationNotesHeader(
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.location_notes_relays_unavailable),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
)
|
||||
@ -338,7 +335,7 @@ private fun NoteRow(
|
||||
) {
|
||||
Text(
|
||||
text = "@$baseName",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
@ -347,7 +344,7 @@ private fun NoteRow(
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = ts,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
)
|
||||
@ -359,7 +356,7 @@ private fun NoteRow(
|
||||
// Second row: content
|
||||
Text(
|
||||
text = note.content,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
@ -379,7 +376,7 @@ private fun NoRelaysRow(onRetry: () -> Unit) {
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.location_notes_no_relays_title),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
@ -387,14 +384,14 @@ private fun NoRelaysRow(onRetry: () -> Unit) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.location_notes_no_relays_desc),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.retry),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickable(onClick = onRetry),
|
||||
@ -422,7 +419,7 @@ private fun LoadingRow() {
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.loading_location_notes),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
)
|
||||
@ -442,7 +439,7 @@ private fun EmptyRow() {
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.location_notes_empty_title),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
@ -450,7 +447,7 @@ private fun EmptyRow() {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.location_notes_empty_desc),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
)
|
||||
@ -482,7 +479,7 @@ private fun ErrorRow(
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = message,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
@ -490,7 +487,7 @@ private fun ErrorRow(
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.dismiss),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickable(onClick = onDismiss),
|
||||
@ -510,7 +507,6 @@ private fun LocationNotesInputSection(
|
||||
nickname: String?,
|
||||
onSend: () -> Unit
|
||||
) {
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
Column(
|
||||
@ -524,7 +520,7 @@ private fun LocationNotesInputSection(
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "@$baseName",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = colorScheme.onSurface
|
||||
@ -546,33 +542,27 @@ private fun LocationNotesInputSection(
|
||||
androidx.compose.foundation.text.BasicTextField(
|
||||
value = draft,
|
||||
onValueChange = onDraftChange,
|
||||
textStyle =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
color = colorScheme.primary,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
),
|
||||
cursorBrush =
|
||||
androidx.compose.ui.graphics
|
||||
.SolidColor(colorScheme.primary),
|
||||
keyboardOptions =
|
||||
androidx.compose.foundation.text.KeyboardOptions(
|
||||
imeAction = androidx.compose.ui.text.input.ImeAction.Send,
|
||||
),
|
||||
keyboardActions =
|
||||
androidx.compose.foundation.text.KeyboardActions(
|
||||
onSend = { if (sendButtonEnabled) onSend() },
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = colorScheme.primary,
|
||||
fontFamily = BitchatFontFamily
|
||||
),
|
||||
cursorBrush = androidx.compose.ui.graphics.SolidColor(colorScheme.primary),
|
||||
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
|
||||
imeAction = androidx.compose.ui.text.input.ImeAction.Send
|
||||
),
|
||||
keyboardActions = androidx.compose.foundation.text.KeyboardActions(
|
||||
onSend = { if (sendButtonEnabled) onSend() }
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// Placeholder when empty (matches main chat)
|
||||
if (draft.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.location_notes_input_placeholder),
|
||||
style =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = BitchatFontFamily
|
||||
),
|
||||
color = colorScheme.onSurface.copy(alpha = 0.5f),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
@ -604,14 +594,11 @@ private fun LocationNotesInputSection(
|
||||
imageVector = Icons.Filled.ArrowUpward,
|
||||
contentDescription = stringResource(R.string.send_message),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint =
|
||||
if (!sendButtonEnabled) {
|
||||
colorScheme.onSurface.copy(alpha = 0.5f)
|
||||
} else if (isDark) {
|
||||
Color.Black // Black arrow on green in dark theme
|
||||
} else {
|
||||
Color.White // White arrow on green in light theme
|
||||
},
|
||||
tint = if (!sendButtonEnabled) {
|
||||
colorScheme.onSurface.copy(alpha = 0.5f)
|
||||
} else {
|
||||
colorScheme.onPrimary
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -650,7 +637,7 @@ internal fun NoteActionsSheet(
|
||||
Text(
|
||||
text = "@$baseName",
|
||||
fontSize = 18.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
@ -682,7 +669,7 @@ internal fun NoteActionsSheet(
|
||||
Text(
|
||||
text = stringResource(R.string.cancel_lower),
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -717,14 +704,14 @@ private fun NoteActionRow(
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = BASE_FONT_SIZE.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = titleColor,
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = BitchatFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,179 +0,0 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Animation state for individual characters
|
||||
*/
|
||||
private enum class CharacterAnimationState {
|
||||
ENCRYPTED, // Showing random encrypted characters
|
||||
FINAL // Showing final decrypted character
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message should be animated based on its mining state
|
||||
*/
|
||||
@Composable
|
||||
fun shouldAnimateMessage(messageId: String): Boolean {
|
||||
val miningMessages by PoWMiningTracker.miningMessages.collectAsStateWithLifecycle()
|
||||
return miningMessages.contains(messageId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks which messages are currently being mined for PoW
|
||||
* Provides reactive state for UI animations
|
||||
*/
|
||||
object PoWMiningTracker {
|
||||
private val _miningMessages = MutableStateFlow<Set<String>>(emptySet())
|
||||
val miningMessages: StateFlow<Set<String>> = _miningMessages.asStateFlow()
|
||||
|
||||
/**
|
||||
* Start tracking a message as mining
|
||||
*/
|
||||
fun startMiningMessage(messageId: String) {
|
||||
_miningMessages.value = _miningMessages.value + messageId
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop tracking a message as mining
|
||||
*/
|
||||
fun stopMiningMessage(messageId: String) {
|
||||
_miningMessages.value = _miningMessages.value - messageId
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message is currently mining
|
||||
*/
|
||||
fun isMiningMessage(messageId: String): Boolean {
|
||||
return _miningMessages.value.contains(messageId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all mining messages (for cleanup)
|
||||
*/
|
||||
fun clearAllMining() {
|
||||
_miningMessages.value = emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the active PoW animation inside the same two-row layout used by static text messages.
|
||||
*/
|
||||
@Composable
|
||||
fun MessageWithMatrixAnimation(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: MeshService,
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat,
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedMessageDisplay(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Animates only the body content; sender, metadata, gestures, and spacing remain stable.
|
||||
*/
|
||||
@Composable
|
||||
private fun AnimatedMessageDisplay(
|
||||
message: BitchatMessage,
|
||||
currentUserNickname: String,
|
||||
meshService: MeshService,
|
||||
colorScheme: ColorScheme,
|
||||
timeFormatter: SimpleDateFormat,
|
||||
onNicknameClick: ((String) -> Unit)?,
|
||||
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var animatedContent by remember(message.id, message.content) {
|
||||
mutableStateOf(message.content)
|
||||
}
|
||||
|
||||
// Character-by-character animation state like the JavaScript version
|
||||
var characterStates by remember(message.id, message.content) {
|
||||
mutableStateOf(message.content.map { char ->
|
||||
if (char == ' ') CharacterAnimationState.FINAL else CharacterAnimationState.ENCRYPTED
|
||||
})
|
||||
}
|
||||
|
||||
LaunchedEffect(message.id, message.content) {
|
||||
if (message.content.isEmpty()) return@LaunchedEffect
|
||||
|
||||
val encryptedChars = "!@$%^&*()_+-=[]{}|;:,<>?".toCharArray()
|
||||
|
||||
// Start character animations with staggered delays (like JS version).
|
||||
message.content.forEachIndexed { index, targetChar ->
|
||||
if (targetChar != ' ') {
|
||||
launch {
|
||||
delay(index * 50L)
|
||||
|
||||
while (true) {
|
||||
while (characterStates.getOrNull(index) == CharacterAnimationState.ENCRYPTED) {
|
||||
val newContent = animatedContent.toCharArray()
|
||||
if (index < newContent.size) {
|
||||
newContent[index] = encryptedChars[Random.nextInt(encryptedChars.size)]
|
||||
animatedContent = String(newContent)
|
||||
}
|
||||
|
||||
delay(100L)
|
||||
|
||||
if (Random.nextFloat() < 0.1f) {
|
||||
val finalContent = animatedContent.toCharArray()
|
||||
if (index < finalContent.size) {
|
||||
finalContent[index] = targetChar
|
||||
animatedContent = String(finalContent)
|
||||
}
|
||||
|
||||
val finalStates = characterStates.toMutableList()
|
||||
finalStates[index] = CharacterAnimationState.FINAL
|
||||
characterStates = finalStates
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
delay(2000L)
|
||||
|
||||
val resetStates = characterStates.toMutableList()
|
||||
resetStates[index] = CharacterAnimationState.ENCRYPTED
|
||||
characterStates = resetStates
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextMessageLayout(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
meshService = meshService,
|
||||
colorScheme = colorScheme,
|
||||
timeFormatter = timeFormatter,
|
||||
onNicknameClick = onNicknameClick,
|
||||
onMessageLongPress = onMessageLongPress,
|
||||
modifier = modifier,
|
||||
bodyContent = animatedContent,
|
||||
)
|
||||
}
|
||||
@ -43,7 +43,7 @@ class MediaSendingManager(
|
||||
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
|
||||
private const val MAX_FILE_SIZE = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES
|
||||
private const val PENDING_PRIVATE_MEDIA_TIMEOUT_MS = 15_000L
|
||||
}
|
||||
|
||||
@ -81,6 +81,46 @@ class MediaSendingManager(
|
||||
private var automaticRetryRequestedFor: String? = null
|
||||
private var pendingAutomaticTimeoutRequestId: String? = null
|
||||
|
||||
/**
|
||||
* Enforce the send-size cap with a user-visible failure posted to the
|
||||
* conversation the user is sending from. Returns true if the file is
|
||||
* oversized and the send was aborted.
|
||||
*/
|
||||
private fun rejectIfOversized(
|
||||
file: java.io.File,
|
||||
toPeerIDOrNull: String?,
|
||||
channelOrNull: String?
|
||||
): Boolean {
|
||||
val size = file.length()
|
||||
if (size <= MAX_FILE_SIZE) return false
|
||||
Log.e(TAG, "❌ File too large: $size bytes (max: $MAX_FILE_SIZE)")
|
||||
val sizeMb = size / (1024 * 1024)
|
||||
val maxMb = MAX_FILE_SIZE / (1024 * 1024)
|
||||
val text = "cannot send ${file.name}: file is too large (${sizeMb} MB, max $maxMb MB)"
|
||||
when {
|
||||
toPeerIDOrNull != null -> {
|
||||
val sys = BitchatMessage(
|
||||
sender = "system",
|
||||
content = text,
|
||||
timestamp = Date(),
|
||||
isRelay = false
|
||||
)
|
||||
messageManager.addPrivateMessageNoUnread(toPeerIDOrNull, sys)
|
||||
}
|
||||
channelOrNull != null -> {
|
||||
val sys = BitchatMessage(
|
||||
sender = "system",
|
||||
content = text,
|
||||
timestamp = Date(),
|
||||
isRelay = false
|
||||
)
|
||||
messageManager.addChannelMessage(channelOrNull, sys)
|
||||
}
|
||||
else -> messageManager.addSystemMessage(text)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a voice note (audio file)
|
||||
*/
|
||||
@ -103,8 +143,7 @@ class MediaSendingManager(
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
if (file.length() > MAX_FILE_SIZE) {
|
||||
Log.e(TAG, "File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
|
||||
if (rejectIfOversized(file, toPeerIDOrNull, channelOrNull)) {
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
@ -148,8 +187,7 @@ class MediaSendingManager(
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
if (file.length() > MAX_FILE_SIZE) {
|
||||
Log.e(TAG, "File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
|
||||
if (rejectIfOversized(file, toPeerIDOrNull, channelOrNull)) {
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
@ -193,8 +231,7 @@ class MediaSendingManager(
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
if (file.length() > MAX_FILE_SIZE) {
|
||||
Log.e(TAG, "File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
|
||||
if (rejectIfOversized(file, toPeerIDOrNull, channelOrNull)) {
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
|
||||
@ -22,7 +22,8 @@ class MeshDelegateHandler(
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val onHapticFeedback: () -> Unit,
|
||||
private val getMyPeerID: () -> String,
|
||||
private val getMeshService: () -> MeshService
|
||||
private val getMeshService: () -> MeshService,
|
||||
private val markMessageReadLocally: (messageID: String) -> Unit = {}
|
||||
) : BluetoothMeshDelegate {
|
||||
|
||||
override fun didReceiveMessage(message: BitchatMessage) {
|
||||
@ -44,24 +45,29 @@ class MeshDelegateHandler(
|
||||
onHapticFeedback()
|
||||
|
||||
if (message.isPrivate) {
|
||||
// Private message
|
||||
privateChatManager.handleIncomingPrivateMessage(message)
|
||||
if (message.sender == "system") {
|
||||
// System notices (e.g. "x favorited you"): no unread badge, read receipt or push
|
||||
privateChatManager.handleIncomingPrivateMessage(message, suppressUnread = true)
|
||||
} else {
|
||||
// Private message
|
||||
privateChatManager.handleIncomingPrivateMessage(message)
|
||||
|
||||
// Reactive read receipts: if chat is focused, send immediately for this message
|
||||
message.senderPeerID?.let { senderPeerID ->
|
||||
sendReadReceiptIfFocused(message)
|
||||
}
|
||||
|
||||
// Show notification with enhanced information - now includes senderPeerID
|
||||
message.senderPeerID?.let { senderPeerID ->
|
||||
// Use nickname if available, fall back to sender or senderPeerID
|
||||
val senderNickname = message.sender.takeIf { it != senderPeerID } ?: senderPeerID
|
||||
val preview = NotificationTextUtils.buildPrivateMessagePreview(message)
|
||||
notificationManager.showPrivateMessageNotification(
|
||||
senderPeerID = senderPeerID,
|
||||
senderNickname = senderNickname,
|
||||
messageContent = preview
|
||||
)
|
||||
// Reactive read receipts: if chat is focused, send immediately for this message
|
||||
message.senderPeerID?.let { senderPeerID ->
|
||||
sendReadReceiptIfFocused(message)
|
||||
}
|
||||
|
||||
// Show notification with enhanced information - now includes senderPeerID
|
||||
message.senderPeerID?.let { senderPeerID ->
|
||||
// Use nickname if available, fall back to sender or senderPeerID
|
||||
val senderNickname = message.sender.takeIf { it != senderPeerID } ?: senderPeerID
|
||||
val preview = NotificationTextUtils.buildPrivateMessagePreview(message)
|
||||
notificationManager.showPrivateMessageNotification(
|
||||
senderPeerID = senderPeerID,
|
||||
senderNickname = senderNickname,
|
||||
messageContent = preview
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (message.channel != null) {
|
||||
// Channel message: AppStateStore is the source of truth for list; only manage unread
|
||||
@ -242,46 +248,44 @@ class MeshDelegateHandler(
|
||||
val shouldSendReadReceipt = !isAppInBackground &&
|
||||
senderConversationID != null &&
|
||||
focusedConversationID == senderConversationID
|
||||
|
||||
if (shouldSendReadReceipt) {
|
||||
android.util.Log.d(
|
||||
"MeshDelegateHandler",
|
||||
"Sending reactive read receipt for focused chat with $senderConversationID (message=${message.id})"
|
||||
)
|
||||
val nickname = state.getNicknameValue() ?: "unknown"
|
||||
val mesh = getMeshService()
|
||||
val sent = try {
|
||||
val meshPeerID = senderConversationID
|
||||
?.let { ContactDirectory.resolve(it).meshPeerID }
|
||||
?: senderPeerID?.takeIf {
|
||||
com.bitchat.android.services.ContactIdentityResolver.isMeshPeerId(it)
|
||||
}
|
||||
if (meshPeerID != null &&
|
||||
mesh.getPeerInfo(meshPeerID)?.isConnected == true &&
|
||||
mesh.hasEstablishedSession(meshPeerID)
|
||||
) {
|
||||
mesh.sendReadReceipt(message.id, meshPeerID, nickname)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
|
||||
if (shouldSendReadReceipt) {
|
||||
android.util.Log.d(
|
||||
"MeshDelegateHandler",
|
||||
"Sending reactive read receipt for focused chat with $senderConversationID (message=${message.id})"
|
||||
)
|
||||
// UI focus is the source of truth for local read state. Transport acceptance is a
|
||||
// separate fact and may remain retryable when the peer disconnects.
|
||||
try { markMessageReadLocally(message.id) } catch (_: Exception) { }
|
||||
|
||||
val nickname = state.getNicknameValue().ifBlank { "unknown" }
|
||||
val mesh = getMeshService()
|
||||
try {
|
||||
val meshPeerID = ContactDirectory.resolve(senderConversationID).meshPeerID
|
||||
?: senderPeerID.takeIf {
|
||||
com.bitchat.android.services.ContactIdentityResolver.isMeshPeerId(it)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
if (meshPeerID != null &&
|
||||
mesh.getPeerInfo(meshPeerID)?.isConnected == true &&
|
||||
mesh.hasEstablishedSession(meshPeerID)
|
||||
) {
|
||||
mesh.sendReadReceipt(message.id, meshPeerID, nickname)
|
||||
}
|
||||
if (sent) {
|
||||
// Ensure unread badge is cleared for this peer immediately
|
||||
try {
|
||||
val current = state.getUnreadPrivateMessagesValue().toMutableSet()
|
||||
val changed = current.remove(senderPeerID) or current.remove(senderConversationID)
|
||||
if (changed) {
|
||||
state.setUnreadPrivateMessages(current)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// Ensure unread badge is cleared for this peer immediately.
|
||||
try {
|
||||
val current = state.getUnreadPrivateMessagesValue().toMutableSet()
|
||||
val changed = current.remove(senderPeerID) or current.remove(senderConversationID)
|
||||
if (changed) {
|
||||
state.setUnreadPrivateMessages(current)
|
||||
}
|
||||
} else {
|
||||
android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)")
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
} else {
|
||||
android.util.Log.d("MeshDelegateHandler", "Skipping read receipt - chat not focused (background: $isAppInBackground, current peer: $currentPrivateChatPeer, sender: $senderPeerID)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose mesh peer info for components that need to resolve identities (e.g., Nostr mapping)
|
||||
*/
|
||||
|
||||