diff --git a/.gitignore b/.gitignore
index 64ac199e..0986ac74 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
deleted file mode 100644
index 89adc1eb..00000000
--- a/CHANGELOG.md
+++ /dev/null
@@ -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
diff --git a/GOOGLE_PLAY.md b/GOOGLE_PLAY.md
deleted file mode 100644
index bdea715e..00000000
--- a/GOOGLE_PLAY.md
+++ /dev/null
@@ -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.
diff --git a/PRIVACY_POLICY.md b/PRIVACY_POLICY.md
deleted file mode 100644
index a90dd78d..00000000
--- a/PRIVACY_POLICY.md
+++ /dev/null
@@ -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.*
\ No newline at end of file
diff --git a/README.md b/README.md
index c9b0091d..93d740c4 100644
--- a/README.md
+++ b/README.md
@@ -1,308 +1,86 @@
-
-
-
+
-> [!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)
[ ](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.
diff --git a/app/src/main/assets/design-icons/README.md b/app/src/main/assets/design-icons/README.md
new file mode 100644
index 00000000..cd04de8f
--- /dev/null
+++ b/app/src/main/assets/design-icons/README.md
@@ -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.
diff --git a/app/src/main/assets/design-icons/bookmark-filled.svg b/app/src/main/assets/design-icons/bookmark-filled.svg
new file mode 100644
index 00000000..a2f90c78
--- /dev/null
+++ b/app/src/main/assets/design-icons/bookmark-filled.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/app/src/main/assets/design-icons/bookmark-outline.svg b/app/src/main/assets/design-icons/bookmark-outline.svg
new file mode 100644
index 00000000..7ffb105f
--- /dev/null
+++ b/app/src/main/assets/design-icons/bookmark-outline.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/app/src/main/assets/design-icons/chat-bubbles.svg b/app/src/main/assets/design-icons/chat-bubbles.svg
new file mode 100644
index 00000000..32546ff8
--- /dev/null
+++ b/app/src/main/assets/design-icons/chat-bubbles.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/assets/design-icons/command.svg b/app/src/main/assets/design-icons/command.svg
new file mode 100644
index 00000000..3be46195
--- /dev/null
+++ b/app/src/main/assets/design-icons/command.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/assets/design-icons/eye-off.svg b/app/src/main/assets/design-icons/eye-off.svg
new file mode 100644
index 00000000..1e8ad44d
--- /dev/null
+++ b/app/src/main/assets/design-icons/eye-off.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/assets/design-icons/globe.svg b/app/src/main/assets/design-icons/globe.svg
new file mode 100644
index 00000000..e0dc1a45
--- /dev/null
+++ b/app/src/main/assets/design-icons/globe.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/assets/design-icons/lock.svg b/app/src/main/assets/design-icons/lock.svg
new file mode 100644
index 00000000..28a95cb0
--- /dev/null
+++ b/app/src/main/assets/design-icons/lock.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/assets/design-icons/mention.svg b/app/src/main/assets/design-icons/mention.svg
new file mode 100644
index 00000000..59f2fa19
--- /dev/null
+++ b/app/src/main/assets/design-icons/mention.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/assets/design-icons/on-location-person.svg b/app/src/main/assets/design-icons/on-location-person.svg
new file mode 100644
index 00000000..5b448ae9
--- /dev/null
+++ b/app/src/main/assets/design-icons/on-location-person.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/assets/design-icons/panic.svg b/app/src/main/assets/design-icons/panic.svg
new file mode 100644
index 00000000..614b9070
--- /dev/null
+++ b/app/src/main/assets/design-icons/panic.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/app/src/main/assets/design-icons/people.svg b/app/src/main/assets/design-icons/people.svg
new file mode 100644
index 00000000..f0bce1af
--- /dev/null
+++ b/app/src/main/assets/design-icons/people.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/assets/design-icons/person.svg b/app/src/main/assets/design-icons/person.svg
new file mode 100644
index 00000000..c0c93053
--- /dev/null
+++ b/app/src/main/assets/design-icons/person.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/assets/design-icons/range.svg b/app/src/main/assets/design-icons/range.svg
new file mode 100644
index 00000000..88c36115
--- /dev/null
+++ b/app/src/main/assets/design-icons/range.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/main/assets/design-icons/shuffle.svg b/app/src/main/assets/design-icons/shuffle.svg
new file mode 100644
index 00000000..7f054978
--- /dev/null
+++ b/app/src/main/assets/design-icons/shuffle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/app/src/main/assets/design-icons/star-filled.svg b/app/src/main/assets/design-icons/star-filled.svg
new file mode 100644
index 00000000..ebc21c4f
--- /dev/null
+++ b/app/src/main/assets/design-icons/star-filled.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/app/src/main/assets/design-icons/star.svg b/app/src/main/assets/design-icons/star.svg
new file mode 100644
index 00000000..b385fc1f
--- /dev/null
+++ b/app/src/main/assets/design-icons/star.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/app/src/main/assets/design-icons/teleport.svg b/app/src/main/assets/design-icons/teleport.svg
new file mode 100644
index 00000000..4209d337
--- /dev/null
+++ b/app/src/main/assets/design-icons/teleport.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/assets/design-icons/waveform.svg b/app/src/main/assets/design-icons/waveform.svg
new file mode 100644
index 00000000..558ccc9d
--- /dev/null
+++ b/app/src/main/assets/design-icons/waveform.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/app/src/main/assets/design-icons/wifi-off.svg b/app/src/main/assets/design-icons/wifi-off.svg
new file mode 100644
index 00000000..5bb5a4d7
--- /dev/null
+++ b/app/src/main/assets/design-icons/wifi-off.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt
index 282f3295..ecd32327 100644
--- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt
+++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt
@@ -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) { }
diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt b/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt
index 94529d77..731a85fb 100644
--- a/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt
+++ b/app/src/main/java/com/bitchat/android/core/ui/component/button/BitChatBrandButton.kt
@@ -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(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),
)
}
}
diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt b/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt
index 96f408bc..9c60123f 100644
--- a/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt
+++ b/app/src/main/java/com/bitchat/android/core/ui/component/button/CloseButton.kt
@@ -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)
)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt
index 948fd6d9..e53fed46 100644
--- a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt
+++ b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatBottomSheet.kt
@@ -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,
- )
-}
\ No newline at end of file
+ ) {
+ CompositionLocalProvider(LocalSheetDismiss provides animatedDismiss) {
+ content()
+ }
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt
index 036cf01b..b8a027dc 100644
--- a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt
+++ b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt
@@ -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
)
)
}
diff --git a/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt b/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt
index 795cce34..dbd3888b 100644
--- a/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt
+++ b/app/src/main/java/com/bitchat/android/hotspot/HotspotActivity.kt
@@ -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
)
diff --git a/app/src/main/java/com/bitchat/android/mesh/AuthenticatedBleLinkPolicy.kt b/app/src/main/java/com/bitchat/android/mesh/AuthenticatedBleLinkPolicy.kt
deleted file mode 100644
index f2aa1db2..00000000
--- a/app/src/main/java/com/bitchat/android/mesh/AuthenticatedBleLinkPolicy.kt
+++ /dev/null
@@ -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
-}
diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt
index 17e4ce28..f2bb54c6 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt
@@ -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)
}
diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt
index f37d8574..94feb68d 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt
@@ -32,7 +32,7 @@ class BluetoothConnectionTracker(
private val firstAnnounceSeen = ConcurrentHashMap()
// RSSI tracking from scan results (for devices we discover but may connect as servers)
private val scanRSSI = ConcurrentHashMap()
- 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
diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt
index 66971ea4..498339ce 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattClientManager.kt
@@ -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)
}
}
}
diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt
index 53ae88ef..7a15b963 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothGattServerManager.kt
@@ -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)
}
}
}
diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt
index 55cdb6df..f828af9e 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt
@@ -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()
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}")
diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt
index 0e9b0c76..1ec1baea 100644
--- a/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothPacketBroadcaster.kt
@@ -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? = 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()
+ 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
}
/**
diff --git a/app/src/main/java/com/bitchat/android/mesh/DirectLinkAnnouncementPolicy.kt b/app/src/main/java/com/bitchat/android/mesh/DirectLinkAnnouncementPolicy.kt
new file mode 100644
index 00000000..e0be2835
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/mesh/DirectLinkAnnouncementPolicy.kt
@@ -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
+ )
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt
index 375e6531..ae1f535a 100644
--- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt
@@ -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()
diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt b/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt
index b3f2fcc4..354fcd7e 100644
--- a/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/MeshTransport.kt
@@ -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
diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt
index d3356e85..05a5bf00 100644
--- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt
@@ -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()
+
/**
* 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,
diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt
index a5c59901..b00c6a31 100644
--- a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt
@@ -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)
diff --git a/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt b/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt
index 3096828a..9714c63a 100644
--- a/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/PowerManager.kt
@@ -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 = _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
+ }
}
diff --git a/app/src/main/java/com/bitchat/android/mesh/RetryingControlPacketSender.kt b/app/src/main/java/com/bitchat/android/mesh/RetryingControlPacketSender.kt
new file mode 100644
index 00000000..ba81800c
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/mesh/RetryingControlPacketSender.kt
@@ -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()
+
+ 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()
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt
index 5922ff5e..e3c0388a 100644
--- a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt
@@ -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())
private val processedKeyExchanges = Collections.synchronizedSet(mutableSetOf())
private val messageTimestamps = Collections.synchronizedMap(mutableMapOf())
+ private val keyExchangeTimestamps = Collections.synchronizedMap(mutableMapOf())
// 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()
}
/**
diff --git a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt
index ca49def9..5a0cc697 100644
--- a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt
+++ b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt
@@ -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, channel: String?) {
when {
isBleEnabled() -> bluetooth.sendMessage(content, mentions, channel)
diff --git a/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt b/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt
index 8e1731b1..528a269e 100644
--- a/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt
+++ b/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt
@@ -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(
}
}
-
diff --git a/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt b/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt
index d560c648..b1eed65e 100644
--- a/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt
+++ b/app/src/main/java/com/bitchat/android/net/ArtiTorManager.kt
@@ -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)
}
diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt b/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt
index c089255f..dd39eb91 100644
--- a/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt
+++ b/app/src/main/java/com/bitchat/android/noise/NoiseSessionManager.kt
@@ -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()
// 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()
+
+ 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(
diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt
index 1c4896f3..88d41466 100644
--- a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt
+++ b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt
@@ -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}")
}
diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt
index 3a7ced32..50c72cd4 100644
--- a/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt
+++ b/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt
@@ -28,14 +28,17 @@ class GeohashRepository(
// conversation key (e.g., "nostr_") -> source geohash it belongs to
private val conversationGeohash: MutableMap = 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()
@@ -180,12 +193,15 @@ class GeohashRepository(
state.setGeohashParticipantCounts(counts)
}
+ @Synchronized
fun putNostrKeyMapping(tempKeyOrPeer: String, pubkeyHex: String) {
nostrKeyMapping[tempKeyOrPeer] = pubkeyHex
}
+ @Synchronized
fun getNostrKeyMapping(): Map = 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)
diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundEventProcessor.kt b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundEventProcessor.kt
new file mode 100644
index 00000000..442840eb
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundEventProcessor.kt
@@ -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()
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt
new file mode 100644
index 00000000..a895f612
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/nostr/NostrBackgroundRuntime.kt
@@ -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
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt
index 9d8ae8af..2fdc3180 100644
--- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt
+++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt
@@ -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()
@@ -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) {
diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt b/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt
new file mode 100644
index 00000000..dd9e16d1
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/nostr/NostrPendingEventQueue.kt
@@ -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,
+ val liveLocationToken: Long?
+ )
+
+ private val lock = Any()
+ private val entries = ArrayDeque()
+ private var nextQueueId = 1L
+
+ fun enqueue(
+ event: NostrEvent,
+ relayUrls: Collection,
+ 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 = 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 }
+}
diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt
index 5e82612f..3386a014 100644
--- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt
+++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt
@@ -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()
private val connections = ConcurrentHashMap()
+ private val reconnectJobs = ConcurrentHashMap()
+ private val desiredConnected = AtomicBoolean(false)
private val subscriptions = ConcurrentHashMap>() // relay URL -> subscription IDs
private val messageHandlers = ConcurrentHashMap Unit>()
@@ -99,28 +106,25 @@ class NostrRelayManager private constructor() {
val handler: (NostrEvent) -> Unit,
val targetRelayUrls: Set? = 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,
- val liveLocationToken: Long? = null
- )
-
- private val messageQueue = mutableListOf()
- 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? = 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? = 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)
}
}
}
diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt
index 4b8365a1..19ebeb9c 100644
--- a/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt
+++ b/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt
@@ -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)
}
diff --git a/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt
index 1346cb23..14fbb06d 100644
--- a/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt
+++ b/app/src/main/java/com/bitchat/android/onboarding/BackgroundLocationPermissionScreen.kt
@@ -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)
)
}
diff --git a/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt
index 3fe392d6..713fe7f8 100644
--- a/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt
+++ b/app/src/main/java/com/bitchat/android/onboarding/BatteryOptimizationScreen.kt
@@ -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
)
)
diff --git a/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt
index 60c6e5e4..3eb6eea9 100644
--- a/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt
+++ b/app/src/main/java/com/bitchat/android/onboarding/BluetoothCheckScreen.kt
@@ -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)
)
)
diff --git a/app/src/main/java/com/bitchat/android/onboarding/InitializingScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/InitializingScreen.kt
index bd231afd..f7895c65 100644
--- a/app/src/main/java/com/bitchat/android/onboarding/InitializingScreen.kt
+++ b/app/src/main/java/com/bitchat/android/onboarding/InitializingScreen.kt
@@ -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)
)
diff --git a/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt
index c5bb8c9b..3654aba8 100644
--- a/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt
+++ b/app/src/main/java/com/bitchat/android/onboarding/LocationCheckScreen.kt
@@ -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)
)
)
diff --git a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt
index 48138d66..afd91da3 100644
--- a/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt
+++ b/app/src/main/java/com/bitchat/android/onboarding/PermissionExplanationScreen.kt
@@ -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)
diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt
index a952d5fa..47ee8d75 100644
--- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt
+++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt
@@ -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
}
diff --git a/app/src/main/java/com/bitchat/android/protocol/CompressionUtil.kt b/app/src/main/java/com/bitchat/android/protocol/CompressionUtil.kt
index e8b59254..5f8e9d5a 100644
--- a/app/src/main/java/com/bitchat/android/protocol/CompressionUtil.kt
+++ b/app/src/main/java/com/bitchat/android/protocol/CompressionUtil.kt
@@ -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 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()
}
}
diff --git a/app/src/main/java/com/bitchat/android/protocol/DecompressionResourcePool.kt b/app/src/main/java/com/bitchat/android/protocol/DecompressionResourcePool.kt
new file mode 100644
index 00000000..a72c8070
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/protocol/DecompressionResourcePool.kt
@@ -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 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))
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt
index f4ca9f74..30a641d0 100644
--- a/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt
+++ b/app/src/main/java/com/bitchat/android/service/AppShutdownCoordinator.kt
@@ -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()
diff --git a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt
index d9ad2d7f..2f70bf84 100644
--- a/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt
+++ b/app/src/main/java/com/bitchat/android/service/MeshForegroundService.kt
@@ -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
}
}
diff --git a/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt
index f8b10766..67a7a1d8 100644
--- a/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt
+++ b/app/src/main/java/com/bitchat/android/service/TransportBridgeService.kt
@@ -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) {
diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt
index c7971eb8..e484d21a 100644
--- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt
+++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt
@@ -17,6 +17,8 @@ object AppStateStore {
private val peerIdsByTransport = mutableMapOf>()
// Direct (single-hop) peer IDs per transport, used to gossip a unified neighbor set.
private val directPeerIdsByTransport = mutableMapOf>()
+ private val _directPeers = MutableStateFlow>(emptySet())
+ val directPeers: StateFlow> = _directPeers.asStateFlow()
// Connected peer IDs (mesh ephemeral IDs)
private val _peers = MutableStateFlow>(emptyList())
val peers: StateFlow> = _peers.asStateFlow()
@@ -29,6 +31,12 @@ object AppStateStore {
private val _privateMessages = MutableStateFlow>>(emptyMap())
val privateMessages: StateFlow>> = _privateMessages.asStateFlow()
+ private val _nickname = MutableStateFlow("")
+ val nickname: StateFlow = _nickname.asStateFlow()
+
+ private val _selectedPrivateChatPeer = MutableStateFlow(null)
+ val selectedPrivateChatPeer: StateFlow = _selectedPrivateChatPeer.asStateFlow()
+
// Channel messages by channel name
private val _channelMessages = MutableStateFlow>>(emptyMap())
val channelMessages: StateFlow>> = _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) {
synchronized(this) {
peerIdsByTransport[transportId] = ids.toSet()
@@ -69,20 +85,25 @@ object AppStateStore {
fun setTransportDirectPeers(transportId: String, ids: Collection) {
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 {
- synchronized(this) {
- return directPeerIdsByTransport.values.flatten().toSet()
- }
+ fun getDirectPeers(): Set = _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
}
}
diff --git a/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt b/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt
index 5301df6d..28cb695c 100644
--- a/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt
+++ b/app/src/main/java/com/bitchat/android/services/ContactDirectory.kt
@@ -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 { 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)
diff --git a/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt b/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt
index e3c2c61d..96fa15b8 100644
--- a/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt
+++ b/app/src/main/java/com/bitchat/android/services/ConversationAliasResolver.kt
@@ -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
diff --git a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt
index 652e0c93..a09fa31e 100644
--- a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt
+++ b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt
@@ -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>>()
+ // Outbox: conversationID -> queued messages, oldest first
+ private val outbox = ConcurrentHashMap>()
+
+ // Per-conversation handshake retry state for queued messages
+ private val retryState = ConcurrentHashMap()
+
+ 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) {
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)
}
}
diff --git a/app/src/main/java/com/bitchat/android/services/PrivateMessageArrivalOrder.kt b/app/src/main/java/com/bitchat/android/services/PrivateMessageArrivalOrder.kt
new file mode 100644
index 00000000..a5c92453
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/services/PrivateMessageArrivalOrder.kt
@@ -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()
+ private var nextSequence = 0L
+
+ fun record(messageID: String) {
+ synchronized(this) {
+ if (messageID !in sequenceByMessageID) {
+ sequenceByMessageID[messageID] = nextSequence++
+ }
+ }
+ }
+
+ fun order(messages: List): List {
+ 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
+ }
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt b/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt
index 15e1c7ca..3b531846 100644
--- a/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt
+++ b/app/src/main/java/com/bitchat/android/services/SeenMessageStore.kt
@@ -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(MAX_IDS)
- private val read = LinkedHashSet(MAX_IDS)
+ private val locallyRead = LinkedHashSet(MAX_IDS)
+ private val readReceiptsSent = LinkedHashSet(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 = emptyList(),
- val read: List = emptyList()
+ // Keep the existing JSON field name for backward-compatible secure-store migration.
+ @SerializedName("read")
+ val locallyRead: List = emptyList(),
+ @SerializedName("read_receipts_sent")
+ val readReceiptsSent: List? = null
)
}
diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSections.kt b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt
new file mode 100644
index 00000000..e36ee7be
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/ui/AboutSections.kt
@@ -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
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt
index 7a64ff50..67065182 100644
--- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt
+++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt
@@ -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,
diff --git a/app/src/main/java/com/bitchat/android/ui/AnimatedCount.kt b/app/src/main/java/com/bitchat/android/ui/AnimatedCount.kt
new file mode 100644
index 00000000..ecf5eb69
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/ui/AnimatedCount.kt
@@ -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
+ )
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/ui/AnimatedRowColumn.kt b/app/src/main/java/com/bitchat/android/ui/AnimatedRowColumn.kt
new file mode 100644
index 00000000..26c8259a
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/ui/AnimatedRowColumn.kt
@@ -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 =
+ 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 AnimatedRowColumn(
+ items: List,
+ 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)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt
index e91e30be..f8820e91 100644
--- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt
+++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt
@@ -1,108 +1,480 @@
package com.bitchat.android.ui
-
-import android.util.Log
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material.icons.outlined.*
+import androidx.compose.animation.Crossfade
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
-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
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.lerp
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalFocusManager
-import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
-import com.bitchat.android.R
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import androidx.compose.foundation.Canvas
-import androidx.compose.ui.geometry.Offset
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.bitchat.android.ui.theme.BitchatFontFamily
+import androidx.annotation.DrawableRes
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.foundation.layout.RowScope
+import com.bitchat.android.R
import com.bitchat.android.core.ui.component.button.BitChatBrandButton
+import com.bitchat.android.core.ui.component.button.CloseButton
+import com.bitchat.android.net.ArtiTorManager
+import com.bitchat.android.net.TorMode
+import com.bitchat.android.ui.theme.BitchatMotion
+import com.bitchat.android.ui.theme.LocalBitchatPalette
/**
* Header components for ChatScreen
* Extracted from ChatScreen.kt for better organization
*/
+/** Height of the chat top bar. Taller than the old 42.dp so 44.dp tap targets fit properly. */
+val ChatHeaderHeight = 52.dp
+/**
+ * The single visible glyph size used by every icon in the top bar.
+ *
+ * The Figma header pairs 16 px icons with compact labels. At our 17sp header scale, 19dp preserves
+ * that icon-to-cap-height relationship without affecting the surrounding 44dp touch targets.
+ */
+internal val HeaderIconSize = 19.dp
+/**
+ * Text size for the top bar's labels: nickname, channel name, peer count.
+ *
+ * A step up from the 15.sp body scale. The bar is the app's primary status readout and was
+ * noticeably harder to read than the messages below it; the extra point costs nothing because
+ * the bar's height is driven by [HeaderTapTarget], not by the text.
+ */
+private val HeaderTextSize = 17.sp
+/** Minimum tap target for every interactive element in the header. */
+private val HeaderTapTarget = 44.dp
+
+/** Corner radius for the header's tappable label+icon clusters. */
+private val HeaderClusterShape = RoundedCornerShape(8.dp)
+
+/**
+ * Edge insets for the bar.
+ *
+ * Asymmetric because the leading glyph sits in a 44.dp tap target whose padding already supplies
+ * some optical inset, while the trailing action's does the same on the other side.
+ */
+internal val HeaderInsetStart = 12.dp
+internal val HeaderInsetEnd = 8.dp
+
+/**
+ * A minimum-48x40 tap target wrapping a small icon.
+ *
+ * The old header used bare 16.dp icons with `Modifier.clickable`, which produced tap targets far
+ * below the accessibility minimum and made the channel/bookmark controls genuinely hard to hit.
+ */
@Composable
-fun TorStatusDot(
- modifier: Modifier = Modifier
+private fun HeaderIconButton(
+ onClick: () -> Unit,
+ contentDescription: String?,
+ modifier: Modifier = Modifier,
+ content: @Composable () -> Unit
) {
- val torProvider = remember { com.bitchat.android.net.ArtiTorManager.getInstance() }
- val torStatus by torProvider.statusFlow.collectAsState()
-
- if (torStatus.mode != com.bitchat.android.net.TorMode.OFF) {
- val dotColor = when {
- torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500) // Orange - bootstrapping
- torStatus.running && torStatus.bootstrapPercent >= 100 -> Color(0xFF00C851) // Green - connected
- else -> Color.Red // Red - error/disconnected
- }
- Canvas(
- modifier = modifier
- ) {
- val radius = size.minDimension / 2
- drawCircle(
- color = dotColor,
- radius = radius,
- center = Offset(size.width / 2, size.height / 2)
- )
- }
+ Box(
+ modifier = modifier
+ .size(HeaderTapTarget)
+ .clip(CircleShape)
+ .pressScaleClickable(onClick = onClick, onClickLabel = contentDescription),
+ contentAlignment = Alignment.Center
+ ) {
+ content()
}
}
+/**
+ * Tor health for location-channel header glyphs.
+ *
+ * Status colours are heavily muted (blended into [normal]) so they read as a soft signal
+ * rather than an alarm. Connecting / not-yet-running also drives a slow glow pulse.
+ */
+internal data class TorConnectionVisual(
+ val tint: Color,
+ /** True while Tor is enabled but not fully bootstrapped — drives a pulse. */
+ val isProgress: Boolean,
+)
+
+@Composable
+internal fun rememberTorConnectionVisual(normal: Color): TorConnectionVisual {
+ val palette = LocalBitchatPalette.current
+ val colorScheme = MaterialTheme.colorScheme
+ val torStatus by remember { ArtiTorManager.getInstance() }.statusFlow.collectAsState()
+
+ // ~28% of the loud accent mixed into the base tint keeps the hue without intensity.
+ val mutedConnecting = lerp(normal, palette.accentOrange, 0.28f)
+ val mutedFailed = lerp(normal, colorScheme.error, 0.30f)
+
+ val target = when {
+ torStatus.mode == TorMode.OFF -> TorConnectionVisual(normal, isProgress = false)
+ torStatus.running && torStatus.bootstrapPercent >= 100 ->
+ TorConnectionVisual(normal, isProgress = false)
+ torStatus.running -> TorConnectionVisual(mutedConnecting, isProgress = true)
+ else -> TorConnectionVisual(mutedFailed, isProgress = true)
+ }
+
+ val animatedTint by animateColorAsState(
+ targetValue = target.tint,
+ animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
+ label = "torConnectionTint"
+ )
+ return TorConnectionVisual(tint = animatedTint, isProgress = target.isProgress)
+}
+
+/**
+ * Soft, slow brightness pulse used while Tor is connecting. Keeps scale fixed so layout
+ * does not shift; only opacity / a faint halo breathe. Glow strength itself cross-fades so
+ * starting/stopping progress never pops.
+ */
+@Composable
+internal fun TorAwareHeaderIcon(
+ imageVector: ImageVector,
+ tint: Color,
+ isProgress: Boolean,
+ contentDescription: String?,
+ modifier: Modifier = Modifier,
+) {
+ val progressFade by animateFloatAsState(
+ targetValue = if (isProgress) 1f else 0f,
+ animationSpec = tween(BitchatMotion.EMPHASIZED_MS, easing = FastOutSlowInEasing),
+ label = "torGlowFade"
+ )
+ val pulse = if (progressFade > 0.01f) {
+ val transition = rememberInfiniteTransition(label = "torGlow")
+ transition.animateFloat(
+ initialValue = 0.42f,
+ targetValue = 1f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = 1800, easing = FastOutSlowInEasing),
+ repeatMode = RepeatMode.Reverse
+ ),
+ label = "torGlowPulse"
+ ).value
+ } else {
+ 1f
+ }
+
+ // Fixed layout footprint = icon size. Glow is drawn larger via requiredSize so it never
+ // pushes neighbouring text when the pulse starts/stops.
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = modifier.size(HeaderIconSize)
+ ) {
+ if (progressFade > 0.01f) {
+ val glowBrush = remember(tint) {
+ Brush.radialGradient(
+ colorStops = arrayOf(
+ 0.0f to tint.copy(alpha = 0.55f),
+ 0.45f to tint.copy(alpha = 0.22f),
+ 1.0f to Color.Transparent,
+ )
+ )
+ }
+ Box(
+ modifier = Modifier
+ .requiredSize(HeaderIconSize + 14.dp)
+ .graphicsLayer { alpha = pulse * 0.85f * progressFade }
+ .background(glowBrush)
+ )
+ }
+ Icon(
+ imageVector = imageVector,
+ contentDescription = contentDescription,
+ modifier = Modifier
+ .size(HeaderIconSize)
+ .graphicsLayer {
+ // Idle = solid; in-progress = breathing opacity, lerped by [progressFade].
+ val breathing = 0.55f + pulse * 0.45f
+ alpha = 1f - progressFade * (1f - breathing)
+ },
+ tint = tint
+ )
+ }
+}
+
+/** Painter-resource counterpart used by the extracted Figma SVG family. */
+@Composable
+internal fun TorAwareHeaderIcon(
+ painter: Painter,
+ tint: Color,
+ isProgress: Boolean,
+ contentDescription: String?,
+ modifier: Modifier = Modifier,
+) {
+ val progressFade by animateFloatAsState(
+ targetValue = if (isProgress) 1f else 0f,
+ animationSpec = tween(BitchatMotion.EMPHASIZED_MS, easing = FastOutSlowInEasing),
+ label = "torPainterGlowFade"
+ )
+ val pulse = if (progressFade > 0.01f) {
+ val transition = rememberInfiniteTransition(label = "torPainterGlow")
+ transition.animateFloat(
+ initialValue = 0.42f,
+ targetValue = 1f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(durationMillis = 1800, easing = FastOutSlowInEasing),
+ repeatMode = RepeatMode.Reverse
+ ),
+ label = "torPainterGlowPulse"
+ ).value
+ } else {
+ 1f
+ }
+
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = modifier.size(HeaderIconSize)
+ ) {
+ if (progressFade > 0.01f) {
+ val glowBrush = remember(tint) {
+ Brush.radialGradient(
+ colorStops = arrayOf(
+ 0.0f to tint.copy(alpha = 0.55f),
+ 0.45f to tint.copy(alpha = 0.22f),
+ 1.0f to Color.Transparent,
+ )
+ )
+ }
+ Box(
+ modifier = Modifier
+ .requiredSize(HeaderIconSize + 14.dp)
+ .graphicsLayer { alpha = pulse * 0.85f * progressFade }
+ .background(glowBrush)
+ )
+ }
+ Icon(
+ painter = painter,
+ contentDescription = contentDescription,
+ modifier = Modifier
+ .size(HeaderIconSize)
+ .graphicsLayer {
+ val breathing = 0.55f + pulse * 0.45f
+ alpha = 1f - progressFade * (1f - breathing)
+ },
+ tint = tint
+ )
+ }
+}
+
+/**
+ * Noise session status for private-chat headers.
+ *
+ * Same visual language as the main header's Tor-aware globe: tint cross-fades between states,
+ * and a soft radial glow pulse while the handshake is in flight. The glyph itself is the open
+ * lock until a session is established (or fails), then the closed lock — both share the same
+ * baseline so a [Crossfade] reads as the shackle settling shut rather than an icon swap.
+ */
@Composable
fun NoiseSessionIcon(
sessionState: String?,
modifier: Modifier = Modifier
) {
- val (icon, color, contentDescription) = when (sessionState) {
- "uninitialized" -> Triple(
- Icons.Outlined.NoEncryption,
- Color(0x87878700), // Grey - ready to establish
- stringResource(R.string.cd_ready_for_handshake)
- )
- "handshaking" -> Triple(
- Icons.Outlined.Sync,
- Color(0x87878700), // Grey - in progress
+ val palette = LocalBitchatPalette.current
+ val colorScheme = MaterialTheme.colorScheme
+
+ val (targetTint, isProgress, contentDescription) = when {
+ sessionState == "handshaking" -> Triple(
+ palette.accentOrange,
+ true,
stringResource(R.string.cd_handshake_in_progress)
)
- "established" -> Triple(
- Icons.Filled.Lock,
- Color(0xFFFF9500), // Orange - secure
+ sessionState == "established" -> Triple(
+ colorScheme.primary,
+ false,
stringResource(R.string.cd_encrypted)
)
- else -> { // "failed" or any other state
- Triple(
- Icons.Outlined.Warning,
- Color(0xFFFF4444), // Red - error
- stringResource(R.string.cd_handshake_failed)
+ sessionState?.startsWith("failed") == true -> Triple(
+ colorScheme.error,
+ false,
+ stringResource(R.string.cd_handshake_failed)
+ )
+ else -> Triple(
+ // Not yet started — quiet grey open lock.
+ colorScheme.onSurfaceVariant,
+ false,
+ stringResource(R.string.cd_ready_for_handshake)
+ )
+ }
+
+ // Closed once the handshake resolves (success or failure); open while idle or in flight.
+ val lockIconRes = when {
+ sessionState == "established" || sessionState?.startsWith("failed") == true ->
+ R.drawable.ic_spec_lock
+ else -> R.drawable.ic_spec_lock_open
+ }
+
+ // Match the tint wash so open → closed and grey → orange → green land together.
+ val lockTransitionMs = 480
+
+ val animatedTint by animateColorAsState(
+ targetValue = targetTint,
+ animationSpec = tween(durationMillis = lockTransitionMs, easing = FastOutSlowInEasing),
+ label = "noiseSessionTint"
+ )
+
+ Crossfade(
+ targetState = lockIconRes,
+ animationSpec = tween(durationMillis = lockTransitionMs, easing = FastOutSlowInEasing),
+ modifier = modifier,
+ label = "noiseLockGlyph"
+ ) { iconRes ->
+ TorAwareHeaderIcon(
+ painter = painterResource(iconRes),
+ tint = animatedTint,
+ isProgress = isProgress,
+ contentDescription = contentDescription,
+ )
+ }
+}
+
+/**
+ * Reachability glyph for a conversation, drawn from the same spec set the main header uses.
+ *
+ * Mirrors the main header's channel button: a globe for anything reached over the internet, the
+ * range mark for the local mesh, and the more specific transport glyph when we know it.
+ */
+@DrawableRes
+internal fun conversationTransportIcon(
+ isReachedOverInternet: Boolean,
+ isWifiAware: Boolean,
+ isDirect: Boolean
+): Int = when {
+ isReachedOverInternet -> R.drawable.ic_spec_globe
+ isWifiAware -> R.drawable.ic_spec_wifi
+ isDirect -> R.drawable.ic_spec_bluetooth
+ else -> R.drawable.ic_spec_routed
+}
+
+/**
+ * The shared chrome for a conversation header — private chats and channels alike.
+ *
+ * Deliberately built from the same tokens as [MainHeader] rather than from `TopAppBar`: identical
+ * height, identical 12/8.dp edge insets, the leading glyph in a [HeaderTapTarget]-sized slot so it
+ * lands exactly where the brand mark does, the same -6.dp optical nudge pulling the title toward
+ * that glyph, and the same [HeaderTextSize]. Anything less and the header visibly shifts as you
+ * move between the main timeline and a conversation.
+ *
+ * Actions are right-aligned and unweighted, so a long title yields space to them rather than
+ * pushing them off screen.
+ */
+@Composable
+fun ConversationHeader(
+ @DrawableRes leadingIconRes: Int,
+ leadingIconTint: Color,
+ title: String,
+ modifier: Modifier = Modifier,
+ onTitleClick: (() -> Unit)? = null,
+ leadingContentDescription: String? = null,
+ actions: @Composable RowScope.() -> Unit = {}
+) {
+ val colorScheme = MaterialTheme.colorScheme
+
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(ChatHeaderHeight)
+ .padding(start = HeaderInsetStart, end = HeaderInsetEnd),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(
+ modifier = Modifier.weight(1f),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier.size(HeaderTapTarget),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ painter = painterResource(leadingIconRes),
+ contentDescription = leadingContentDescription,
+ modifier = Modifier.size(HeaderIconSize),
+ tint = leadingIconTint
+ )
+ }
+
+ // Same optical correction as the main header: the 44.dp tap target leaves more gap
+ // than the design wants between glyph and label.
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyMedium,
+ fontSize = HeaderTextSize,
+ fontWeight = FontWeight.Medium,
+ color = colorScheme.primary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier
+ .offset(x = (-6).dp)
+ .then(
+ if (onTitleClick != null) {
+ Modifier
+ .clip(HeaderClusterShape)
+ .pressScaleClickable(onClick = onTitleClick)
+ .padding(horizontal = 6.dp, vertical = 4.dp)
+ } else {
+ Modifier
+ }
+ )
)
}
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(2.dp),
+ content = actions
+ )
}
-
- Icon(
- imageVector = icon,
- contentDescription = contentDescription,
- modifier = modifier,
- tint = color
- )
}
+/** An action slot in a [ConversationHeader], matching the main header's icon buttons. */
+@Composable
+fun ConversationHeaderAction(
+ onClick: () -> Unit,
+ contentDescription: String?,
+ modifier: Modifier = Modifier,
+ content: @Composable () -> Unit
+) = HeaderIconButton(
+ onClick = onClick,
+ contentDescription = contentDescription,
+ modifier = modifier,
+ content = content
+)
+
@Composable
fun NicknameEditor(
value: String,
@@ -125,15 +497,17 @@ fun NicknameEditor(
Text(
text = stringResource(R.string.at_symbol),
style = MaterialTheme.typography.bodyMedium,
- color = colorScheme.primary.copy(alpha = 0.8f)
+ fontSize = HeaderTextSize,
+ color = colorScheme.primary.copy(alpha = 0.7f)
)
-
+
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = MaterialTheme.typography.bodyMedium.copy(
color = colorScheme.primary,
- fontFamily = FontFamily.Monospace
+ fontFamily = BitchatFontFamily,
+ fontSize = HeaderTextSize
),
cursorBrush = SolidColor(colorScheme.primary),
singleLine = true,
@@ -144,7 +518,7 @@ fun NicknameEditor(
}
),
modifier = Modifier
- .widthIn(max = 120.dp)
+ .widthIn(max = 150.dp)
.horizontalScroll(scrollState)
)
}
@@ -161,54 +535,68 @@ fun PeerCounter(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
+ val palette = LocalBitchatPalette.current
val colorScheme = MaterialTheme.colorScheme
-
+
// Compute channel-aware people count and color (matches iOS logic exactly)
val (peopleCount, countColor) = when (selectedLocationChannel) {
is com.bitchat.android.geohash.ChannelID.Location -> {
// Geohash channel: show geohash participants
val count = geohashPeople.size
- val green = Color(0xFF00C851) // Standard green
- Pair(count, if (count > 0) green else Color.Gray)
+ Pair(count, if (count > 0) colorScheme.primary else palette.textTertiary)
}
is com.bitchat.android.geohash.ChannelID.Mesh,
null -> {
// Mesh channel: show Bluetooth-connected peers (excluding self)
val count = connectedPeers.size
- val meshBlue = Color(0xFF007AFF) // iOS-style blue for mesh
- Pair(count, if (isConnected && count > 0) meshBlue else Color.Gray)
+ Pair(count, if (isConnected && count > 0) colorScheme.secondary else palette.textTertiary)
}
}
-
+
+ // Peers come and go constantly; fading the tint avoids a flicker every time the count
+ // crosses zero.
+ val animatedCountColor by animateColorAsState(
+ targetValue = countColor,
+ animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
+ label = "peerCountColor"
+ )
+
Row(
verticalAlignment = Alignment.CenterVertically,
- modifier = modifier.clickable { onClick() }.padding(end = 8.dp) // Added right margin to match "bitchat" logo spacing
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ modifier = modifier
+ .clip(HeaderClusterShape)
+ .pressScaleClickable(onClick = onClick)
+ .height(HeaderTapTarget)
+ .padding(horizontal = 6.dp)
) {
Icon(
- imageVector = Icons.Default.Group,
+ // The extracted people glyph stays legible at the compact header scale; the number
+ // beside it carries the precise count.
+ painter = painterResource(R.drawable.ic_spec_people),
contentDescription = when (selectedLocationChannel) {
is com.bitchat.android.geohash.ChannelID.Location -> stringResource(R.string.cd_geohash_participants)
else -> stringResource(R.string.cd_connected_peers)
},
- modifier = Modifier.size(16.dp),
- tint = countColor
+ modifier = Modifier.size(HeaderIconSize),
+ tint = animatedCountColor
)
- Spacer(modifier = Modifier.width(4.dp))
- Text(
- text = "$peopleCount",
+ AnimatedCount(
+ count = peopleCount,
style = MaterialTheme.typography.bodyMedium,
- color = countColor,
- fontSize = 16.sp,
+ fontSize = HeaderTextSize,
+ color = animatedCountColor,
fontWeight = FontWeight.Medium
)
-
+
if (joinedChannels.isNotEmpty()) {
- Text(
- text = stringResource(R.string.channel_count_prefix) + "${joinedChannels.size}",
+ AnimatedCount(
+ count = joinedChannels.size,
+ prefix = stringResource(R.string.channel_count_prefix),
style = MaterialTheme.typography.bodyMedium,
- color = if (isConnected) Color(0xFF00C851) else Color.Red,
- fontSize = 16.sp,
+ fontSize = HeaderTextSize,
+ color = if (isConnected) colorScheme.primary else colorScheme.error,
fontWeight = FontWeight.Medium
)
}
@@ -266,59 +654,18 @@ private fun ChannelHeader(
onSidebarClick: () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
-
- Box(modifier = Modifier.fillMaxWidth()) {
- // Back button - positioned all the way to the left with minimal margin
- Button(
- onClick = onBackClick,
- colors = ButtonDefaults.buttonColors(
- containerColor = Color.Transparent,
- contentColor = colorScheme.primary
- ),
- contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp), // Reduced horizontal padding
- modifier = Modifier
- .align(Alignment.CenterStart)
- .offset(x = (-8).dp) // Move even further left to minimize margin
- ) {
- Row(
- verticalAlignment = Alignment.CenterVertically
- ) {
- Icon(
- imageVector = Icons.Filled.ArrowBack,
- contentDescription = stringResource(R.string.back),
- modifier = Modifier.size(16.dp),
- tint = colorScheme.primary
- )
- Spacer(modifier = Modifier.width(4.dp))
- Text(
- text = stringResource(R.string.chat_back),
- style = MaterialTheme.typography.bodyMedium,
- color = colorScheme.primary
- )
- }
- }
-
- // Title - perfectly centered regardless of other elements
- Text(
- text = stringResource(R.string.chat_channel_prefix, channel),
- style = MaterialTheme.typography.titleMedium,
- color = Color(0xFFFF9500), // Orange to match input field
- modifier = Modifier
- .align(Alignment.Center)
- .clickable { onSidebarClick() }
- )
-
- // Leave button - positioned on the right
- TextButton(
- onClick = onLeaveChannel,
- modifier = Modifier.align(Alignment.CenterEnd)
- ) {
- Text(
- text = stringResource(R.string.chat_leave),
- style = MaterialTheme.typography.bodySmall,
- color = Color.Red
- )
- }
+
+ // No back affordance: the close action on the right is the way out, exactly as in a private
+ // chat. Leaving the channel outright lives on its row in the network sheet, so it does not
+ // need a second, easily-mistaken home next to the exit.
+ ConversationHeader(
+ leadingIconRes = R.drawable.ic_spec_chat_bubbles,
+ leadingIconTint = colorScheme.primary,
+ leadingContentDescription = null,
+ title = "#$channel",
+ onTitleClick = onSidebarClick
+ ) {
+ CloseButton(onClick = onBackClick)
}
}
@@ -334,6 +681,7 @@ private fun MainHeader(
viewModel: ChatViewModel
) {
val colorScheme = MaterialTheme.colorScheme
+ val palette = LocalBitchatPalette.current
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
val hasUnreadChannels by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
@@ -342,109 +690,98 @@ private fun MainHeader(
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
- // Bookmarks store for current geohash toggle (iOS parity)
- val context = androidx.compose.ui.platform.LocalContext.current
- val bookmarksStore = remember { com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(context) }
- val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle()
-
Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(ChatHeaderHeight)
+ .padding(start = HeaderInsetStart, end = HeaderInsetEnd),
verticalAlignment = Alignment.CenterVertically
) {
+ // MARK: - Identity cluster.
+ //
+ // Weighted so it yields space to the status cluster rather than pushing it off screen.
+ // Compose measures unweighted children first, so the icons on the right always get the
+ // width they need and a long nickname simply scrolls within what is left.
Row(
- modifier = Modifier.fillMaxHeight(),
+ modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically
) {
BitChatBrandButton(
onClick = onTitleClick,
onTripleClick = onTripleTitleClick,
contentDescription = stringResource(R.string.cd_open_about),
+ modifier = Modifier.size(HeaderTapTarget),
)
- Text(
- text = "/",
- style = MaterialTheme.typography.bodyMedium,
- color = colorScheme.primary,
- )
+ // Nudge toward the brand glyph: the 44.dp tap target leaves more optical gap than
+ // spacing between the mark and the path label.
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.offset(x = (-6).dp)
+ ) {
+ Text(
+ text = "/",
+ style = MaterialTheme.typography.bodyMedium,
+ fontSize = HeaderTextSize,
+ // Dimmed: the slash is a separator, not content. At full brightness it competed
+ // with the nickname beside it.
+ color = colorScheme.primary.copy(alpha = 0.45f),
+ modifier = Modifier.padding(end = 2.dp)
+ )
- Spacer(modifier = Modifier.width(2.dp))
-
- NicknameEditor(
- value = nickname,
- onValueChange = onNicknameChange
- )
- }
-
- // Right section with location channels button and peer counter
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(5.dp)
- ) {
-
- // Unread private messages badge (click to open most recent DM)
- if (hasUnreadPrivateMessages.isNotEmpty()) {
- // Render icon directly to avoid symbol resolution issues
- Icon(
- imageVector = Icons.Filled.Email,
- contentDescription = stringResource(R.string.cd_unread_private_messages),
- modifier = Modifier
- .size(16.dp)
- .clickable { viewModel.openLatestUnreadPrivateChat() },
- tint = Color(0xFFFF9500)
+ NicknameEditor(
+ value = nickname,
+ onValueChange = onNicknameChange
)
}
+ }
+
+ // MARK: - Status cluster.
+ //
+ // Order, left to right: unread DMs, notes, channel, people.
+ // Tor health is read from the location channel / notes icon colour rather than a
+ // dedicated status dot.
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ // Tight, because every child below is its own >=44.dp tap target.
+ horizontalArrangement = Arrangement.spacedBy(2.dp)
+ ) {
+ // Unread private messages badge (click to open most recent DM)
+ if (hasUnreadPrivateMessages.isNotEmpty()) {
+ HeaderIconButton(
+ onClick = { viewModel.openLatestUnreadPrivateChat() },
+ contentDescription = stringResource(R.string.cd_unread_private_messages)
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_spec_envelope),
+ contentDescription = stringResource(R.string.cd_unread_private_messages),
+ modifier = Modifier.size(HeaderIconSize),
+ tint = palette.accentOrange
+ )
+ }
+ }
+
+ // Location notes + channel badge: one tight unit so the document glyph and the
+ // bluetooth/globe glyph sit at the same visual pitch as other header pairings.
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(0.dp)
+ ) {
+ LocationNotesButton(
+ viewModel = viewModel,
+ onClick = onLocationNotesClick
+ )
+
+ // Bookmarking lives in the Location Channels sheet, one tap away via the channel
+ // button. Duplicating it here bought a shortcut for a rare action at the cost
+ // of a slot in the app's most crowded row.
- // Location channels button (matching iOS implementation) and bookmark grouped tightly
- Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 4.dp)) {
LocationChannelsButton(
viewModel = viewModel,
onClick = onLocationChannelsClick
)
-
- // Bookmark toggle for current geohash (not shown for mesh)
- val currentGeohash: String? = when (val sc = selectedLocationChannel) {
- is com.bitchat.android.geohash.ChannelID.Location -> sc.channel.geohash
- else -> null
- }
- if (currentGeohash != null) {
- val isBookmarked = bookmarks.contains(currentGeohash)
- Box(
- modifier = Modifier
- .padding(start = 2.dp) // minimal gap between geohash and bookmark
- .size(20.dp)
- .clickable { bookmarksStore.toggle(currentGeohash) },
- contentAlignment = Alignment.Center
- ) {
- Icon(
- imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder,
- contentDescription = stringResource(R.string.cd_toggle_bookmark),
- tint = if (isBookmarked) Color(0xFF00C851) else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f),
- modifier = Modifier.size(16.dp)
- )
- }
- }
}
- // Location Notes button (extracted to separate component)
- LocationNotesButton(
- viewModel = viewModel,
- onClick = onLocationNotesClick
- )
-
- // Tor status dot when Tor is enabled
- TorStatusDot(
- modifier = Modifier
- .size(8.dp)
- .padding(start = 0.dp, end = 2.dp)
- )
-
- // PoW status indicator
- PoWStatusIndicator(
- modifier = Modifier,
- style = PoWIndicatorStyle.COMPACT
- )
- Spacer(modifier = Modifier.width(2.dp))
PeerCounter(
connectedPeers = connectedPeers.filter { it != viewModel.myPeerID },
joinedChannels = joinedChannels,
@@ -458,56 +795,72 @@ private fun MainHeader(
}
}
+/**
+ * Current channel indicator: a globe for geohash channels, a mesh glyph for the local mesh.
+ *
+ * The design brief asked for the "addition of globe icon to represent channels". Previously this
+ * was a text-only badge wrapped in an M3 [Button], which imposed a hidden 58.dp minimum width
+ * and 40.dp minimum height that fought the header's explicit sizing.
+ */
@Composable
private fun LocationChannelsButton(
viewModel: ChatViewModel,
onClick: () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
-
+
// Get current channel selection from location manager
val selectedChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
- val teleported by viewModel.isTeleported.collectAsStateWithLifecycle()
-
- val (badgeText, badgeColor) = when (selectedChannel) {
- is com.bitchat.android.geohash.ChannelID.Mesh -> {
- "#mesh" to Color(0xFF007AFF) // iOS blue for mesh
- }
- is com.bitchat.android.geohash.ChannelID.Location -> {
- val geohash = (selectedChannel as com.bitchat.android.geohash.ChannelID.Location).channel.geohash
- "#$geohash" to Color(0xFF00C851) // Green for location
- }
- null -> "#mesh" to Color(0xFF007AFF) // Default to mesh
+
+ val isLocation = selectedChannel is com.bitchat.android.geohash.ChannelID.Location
+ val badgeText = when (val channel = selectedChannel) {
+ // Geohashes keep the '#' because that is how they are written and typed everywhere else.
+ is com.bitchat.android.geohash.ChannelID.Location -> "#${channel.channel.geohash}"
+ // The local mesh is not a hashtag channel, and the mesh glyph already says what it is,
+ // so it is plain "mesh".
+ else -> stringResource(R.string.mesh_label)
}
-
- Button(
- onClick = onClick,
- colors = ButtonDefaults.buttonColors(
- containerColor = Color.Transparent,
- contentColor = badgeColor
- ),
- contentPadding = PaddingValues(start = 4.dp, end = 0.dp, top = 2.dp, bottom = 2.dp)
- ) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Text(
- text = badgeText,
- style = MaterialTheme.typography.bodyMedium.copy(
- fontFamily = FontFamily.Monospace
- ),
- color = badgeColor,
- maxLines = 1
+ val channelColor = if (isLocation) colorScheme.primary else colorScheme.secondary
+ // Tor status only tints the globe (location channels). The local mesh stays blue.
+ val torVisual = if (isLocation) {
+ rememberTorConnectionVisual(normal = channelColor)
+ } else {
+ TorConnectionVisual(tint = channelColor, isProgress = false)
+ }
+ val badgeIconRes = if (isLocation) {
+ R.drawable.ic_spec_globe
+ } else {
+ R.drawable.ic_spec_range
+ }
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ modifier = Modifier
+ .clip(HeaderClusterShape)
+ .pressScaleClickable(
+ onClick = onClick,
+ onClickLabel = stringResource(R.string.location_channels_title)
)
-
- // Teleportation indicator (like iOS)
- if (teleported) {
- Spacer(modifier = Modifier.width(2.dp))
- Icon(
- imageVector = Icons.Default.PinDrop,
- contentDescription = stringResource(R.string.cd_teleported),
- modifier = Modifier.size(12.dp),
- tint = badgeColor
- )
- }
- }
+ .height(HeaderTapTarget)
+ // No start padding: the notes icon is paired directly to the left; keep end
+ // padding so the gap to PeerCounter matches other cluster separations.
+ .padding(start = 0.dp, end = 6.dp)
+ ) {
+ TorAwareHeaderIcon(
+ painter = painterResource(badgeIconRes),
+ tint = torVisual.tint,
+ isProgress = torVisual.isProgress,
+ contentDescription = stringResource(R.string.cd_tor_status)
+ )
+
+ Text(
+ text = badgeText,
+ style = MaterialTheme.typography.bodyMedium,
+ fontSize = HeaderTextSize,
+ fontWeight = FontWeight.Medium,
+ color = channelColor,
+ maxLines = 1
+ )
}
}
diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt
index 0514217d..e9c8e343 100644
--- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt
+++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt
@@ -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,
showMentionSuggestions: Boolean,
mentionSuggestions: List,
+ mentionPeerIdentities: Map = 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()) }
+ 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()
+ }
)
}
}
diff --git a/app/src/main/java/com/bitchat/android/ui/ChatState.kt b/app/src/main/java/com/bitchat/android/ui/ChatState.kt
index 53a298ef..f4504cc2 100644
--- a/app/src/main/java/com/bitchat/android/ui/ChatState.kt
+++ b/app/src/main/java/com/bitchat/android/ui/ChatState.kt
@@ -94,6 +94,10 @@ class ChatState(
// Favorites
private val _favoritePeers = MutableStateFlow>(emptySet())
val favoritePeers: StateFlow> = _favoritePeers.asStateFlow()
+
+ // Fingerprints of peers who favorited us (drives "favorited you" UI celebrations)
+ private val _peerFavoritedUs = MutableStateFlow>(emptySet())
+ val peerFavoritedUs: StateFlow> = _peerFavoritedUs.asStateFlow()
// Noise session states for peers (for reactive UI updates)
private val _peerSessionStates = MutableStateFlow>(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) {
@@ -285,6 +292,10 @@ class ChatState(
Log.d("ChatState", "StateFlow value after set: ${_favoritePeers.value}")
}
+
+ fun setPeerFavoritedUs(fingerprints: Set) {
+ _peerFavoritedUs.value = fingerprints
+ }
fun setPeerSessionStates(states: Map) {
_peerSessionStates.value = states
diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt
index d227eb41..d2ab06d3 100644
--- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt
+++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt
@@ -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 = 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 {
}
/**
- * 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,
+ knownPeers: List> = emptyList(),
+): Map {
+ val candidates = linkedMapOf>()
+
+ 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,
+): 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,
+ 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?,
currentUserNickname: String,
- baseColor: Color,
- isSelf: Boolean,
- contentColor: Color = baseColor,
+ palette: BitchatPalette,
+ contentColor: Color,
+ linkColor: Color,
+ mentionPeerIdentities: Map,
) {
- // 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()
}
}
diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt b/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt
index 0cfa91ba..f0647e6c 100644
--- a/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt
+++ b/app/src/main/java/com/bitchat/android/ui/ChatUserSheet.kt
@@ -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)
)
}
diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt
index c62a39e5..ba5775a0 100644
--- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt
+++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt
@@ -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>> = state.privateChats
val selectedPrivateChatPeer: StateFlow = state.selectedPrivateChatPeer
val unreadPrivateMessages: StateFlow> = state.unreadPrivateMessages
+ internal val unreadConversations: StateFlow> = 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> = state.joinedChannels
val currentChannel: StateFlow = state.currentChannel
val channelMessages: StateFlow>> = state.channelMessages
@@ -185,6 +258,7 @@ class ChatViewModel(
val showMentionSuggestions: StateFlow = state.showMentionSuggestions
val mentionSuggestions: StateFlow> = state.mentionSuggestions
val favoritePeers: StateFlow> = state.favoritePeers
+ val peerFavoritedUs: StateFlow> = state.peerFavoritedUs
val peerSessionStates: StateFlow> = state.peerSessionStates
val peerFingerprints: StateFlow> = state.peerFingerprints
val peerNicknames: StateFlow> = 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()
- 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)
}
diff --git a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt
index a92d2d60..34b205af 100644
--- a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt
+++ b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt
@@ -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,
+ query: String
+): List {
+ 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()
+}
diff --git a/app/src/main/java/com/bitchat/android/ui/DataManager.kt b/app/src/main/java/com/bitchat/android/ui/DataManager.kt
index 4f37d47a..8517ec15 100644
--- a/app/src/main/java/com/bitchat/android/ui/DataManager.kt
+++ b/app/src/main/java/com/bitchat/android/ui/DataManager.kt
@@ -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() // Set of nostr pubkey hex
val geohashBlockedUsers: Set 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)
}
diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt
index 0bdc85ee..d784610a 100644
--- a/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt
+++ b/app/src/main/java/com/bitchat/android/ui/GeohashPeopleList.kt
@@ -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 = 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()
- 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,
+ val teleportedIn: List
+)
+
+/**
+ * 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): Set =
+ 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 {
+ 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,
+ myId: String?,
+ selfIsTeleported: Boolean,
+ teleportedIds: Set
+): 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(::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,
+ 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))
}
}
-
-
diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt
index e15f9930..9565c384 100644
--- a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt
+++ b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt
@@ -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(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
)
}
}
diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt
index 9d949e54..d2b706ec 100644
--- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt
+++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt
@@ -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()
private val samplingSubscriptionIds = mutableMapOf()
private val liveSamplingSubscriptionGeohashes = mutableSetOf()
private var requestedLiveSamplingGeohashes: Set = emptySet()
private var requestedUserSamplingGeohashes: Set = 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) {
diff --git a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt
index 91555b38..7302379b 100644
--- a/app/src/main/java/com/bitchat/android/ui/InputComponents.kt
+++ b/app/src/main/java/com/bitchat/android/ui/InputComponents.kt
@@ -1,27 +1,55 @@
package com.bitchat.android.ui
+
+import com.bitchat.android.ui.theme.BitchatFontFamily
// [Goose] TODO: Replace inline file attachment stub with FilePickerButton abstraction that dispatches via FileShareDispatcher
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.SizeTransform
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.expandHorizontally
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.animation.shrinkHorizontally
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.animation.togetherWith
import androidx.compose.foundation.*
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
-import androidx.compose.ui.text.buildAnnotatedString
-import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
@@ -36,8 +64,10 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
-import androidx.compose.ui.text.withStyle
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
+import com.bitchat.android.ui.theme.BitchatPalette
+import com.bitchat.android.ui.theme.BitchatMotion
+import com.bitchat.android.ui.theme.LocalBitchatPalette
import com.bitchat.android.features.voice.normalizeAmplitudeSample
import com.bitchat.android.features.voice.AudioWaveformExtractor
import com.bitchat.android.ui.media.RealtimeScrollingWaveform
@@ -53,41 +83,28 @@ import com.bitchat.android.ui.media.FilePickerButton
* VisualTransformation that styles slash commands with background and color
* while preserving cursor positioning and click handling
*/
-class SlashCommandVisualTransformation : VisualTransformation {
+class SlashCommandVisualTransformation(
+ private val commandColor: Color,
+ private val commandBackground: Color,
+) : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val slashCommandRegex = Regex("(/\\w+)(?=\\s|$)")
- val annotatedString = buildAnnotatedString {
- var lastIndex = 0
-
- slashCommandRegex.findAll(text.text).forEach { match ->
- // Add text before the match
- if (match.range.first > lastIndex) {
- append(text.text.substring(lastIndex, match.range.first))
- }
-
- // Add the styled slash command
- withStyle(
- style = SpanStyle(
- color = Color(0xFF00FF7F), // Bright green
- fontFamily = FontFamily.Monospace,
- fontWeight = FontWeight.Medium,
- background = Color(0xFF2D2D2D) // Dark gray background
- )
- ) {
- append(match.value)
- }
-
- lastIndex = match.range.last + 1
- }
-
- // Add remaining text
- if (lastIndex < text.text.length) {
- append(text.text.substring(lastIndex))
- }
+ val builder = AnnotatedString.Builder(text)
+ slashCommandRegex.findAll(text.text).forEach { match ->
+ builder.addStyle(
+ style = SpanStyle(
+ color = commandColor,
+ fontFamily = BitchatFontFamily,
+ fontWeight = FontWeight.Medium,
+ background = commandBackground
+ ),
+ start = match.range.first,
+ end = match.range.last + 1,
+ )
}
return TransformedText(
- text = annotatedString,
+ text = builder.toAnnotatedString(),
offsetMapping = OffsetMapping.Identity
)
}
@@ -97,40 +114,56 @@ class SlashCommandVisualTransformation : VisualTransformation {
* VisualTransformation that styles mentions with background and color
* while preserving cursor positioning and click handling
*/
-class MentionVisualTransformation : VisualTransformation {
+class MentionVisualTransformation(
+ private val mentionPeerIdentities: Map,
+ private val palette: BitchatPalette,
+) : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
- val mentionRegex = Regex("@([a-zA-Z0-9_]+)")
- val annotatedString = buildAnnotatedString {
- var lastIndex = 0
-
- mentionRegex.findAll(text.text).forEach { match ->
- // Add text before the match
- if (match.range.first > lastIndex) {
- append(text.text.substring(lastIndex, match.range.first))
- }
-
- // Add the styled mention
- withStyle(
+ val builder = AnnotatedString.Builder(text)
+
+ MENTION_TOKEN_REGEX.findAll(text.text).forEach { match ->
+ val start = match.range.first
+ val end = match.range.last + 1
+ val suffixOffset = match.value.lastIndexOf('#').takeIf { it > 0 }
+ val suffixStart = suffixOffset?.let(start::plus) ?: end
+ val mentionColor = colorForMention(
+ mention = match.value,
+ mentionPeerIdentities = mentionPeerIdentities,
+ palette = palette,
+ )
+
+ // Keep the whole token on one continuous color-derived chip.
+ builder.addStyle(
+ style = SpanStyle(
+ background = mentionColor.copy(alpha = MENTION_CHIP_ALPHA),
+ ),
+ start = start,
+ end = end,
+ )
+ builder.addStyle(
+ style = SpanStyle(
+ color = mentionColor,
+ fontFamily = BitchatFontFamily,
+ fontWeight = FontWeight.SemiBold,
+ ),
+ start = start,
+ end = suffixStart,
+ )
+ if (suffixStart < end) {
+ builder.addStyle(
style = SpanStyle(
- color = Color(0xFFFF9500), // Orange
- fontFamily = FontFamily.Monospace,
- fontWeight = FontWeight.SemiBold
- )
- ) {
- append(match.value)
- }
-
- lastIndex = match.range.last + 1
- }
-
- // Add remaining text
- if (lastIndex < text.text.length) {
- append(text.text.substring(lastIndex))
+ color = mentionColor.copy(alpha = SUFFIX_ALPHA),
+ fontFamily = BitchatFontFamily,
+ fontWeight = FontWeight.SemiBold,
+ ),
+ start = suffixStart,
+ end = end,
+ )
}
}
return TransformedText(
- text = annotatedString,
+ text = builder.toAnnotatedString(),
offsetMapping = OffsetMapping.Identity
)
}
@@ -159,6 +192,107 @@ class CombinedVisualTransformation(private val transformations: List Unit
+) {
+ val palette = LocalBitchatPalette.current
+ val colorScheme = MaterialTheme.colorScheme
+ val accent = if (activeColor == Color.Unspecified) colorScheme.primary else activeColor
+
+ val container by animateColorAsState(
+ // A tint rather than a fill. A solid accent disc next to the text you are typing was the
+ // loudest thing on the screen; at 20% it still reads as "armed" without competing.
+ targetValue = if (isActive) accent.copy(alpha = 0.20f) else palette.inputButton,
+ animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
+ label = "composerButtonContainer"
+ )
+ val tint by animateColorAsState(
+ targetValue = if (isActive) accent else colorScheme.onSurfaceVariant,
+ animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
+ label = "composerButtonTint"
+ )
+ // A small dip on press. Spring rather than tween so the release overshoots very slightly and
+ // the button feels physical instead of merely animated.
+ val scale by animateFloatAsState(
+ targetValue = if (isPressed) 0.88f else 1f,
+ animationSpec = spring(
+ dampingRatio = Spring.DampingRatioMediumBouncy,
+ stiffness = Spring.StiffnessHigh
+ ),
+ label = "composerButtonScale"
+ )
+
+ Box(
+ modifier = modifier.size(ComposerButtonSize),
+ contentAlignment = Alignment.Center
+ ) {
+ Box(
+ modifier = Modifier
+ .size(ComposerButtonDisc)
+ .scale(scale)
+ .background(container, CircleShape)
+ .semantics { contentDescription?.let { this.contentDescription = it } },
+ contentAlignment = Alignment.Center
+ ) {
+ content(tint)
+ }
+ }
+}
+
@Composable
fun MessageInput(
value: TextFieldValue,
@@ -171,192 +305,276 @@ fun MessageInput(
currentChannel: String?,
nickname: String,
showMediaButtons: Boolean,
+ mentionPeerIdentities: Map = emptyMap(),
modifier: Modifier = Modifier
) {
+ val palette = LocalBitchatPalette.current
val colorScheme = MaterialTheme.colorScheme
val isFocused = remember { mutableStateOf(false) }
- val hasText = value.text.isNotBlank() // Check if there's text for send button state
- val keyboard = LocalSoftwareKeyboardController.current
+ val hasText = value.text.isNotBlank()
val focusRequester = remember { FocusRequester() }
var isRecording by remember { mutableStateOf(false) }
var elapsedMs by remember { mutableStateOf(0L) }
var amplitude by remember { mutableStateOf(0) }
+ // Recording is the one state worth shouting about, so it overrides focus.
+ val borderColor by animateColorAsState(
+ targetValue = when {
+ isRecording -> colorScheme.error.copy(alpha = 0.7f)
+ isFocused.value -> palette.inputOutlineFocused
+ else -> palette.inputOutline
+ },
+ animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
+ label = "composerBorder"
+ )
+ // A barely-there lift on focus. Enough to register, not enough to look like a different
+ // component. Slightly translucent so the messages scrolling underneath stay faintly visible.
+ val containerColor by animateColorAsState(
+ targetValue = (if (isFocused.value) palette.inputSurfaceFocused else palette.inputSurface)
+ .copy(alpha = ComposerFillAlpha),
+ animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
+ label = "composerContainer"
+ )
+
Row(
- modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp), // Reduced padding
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(8.dp)
+ modifier = modifier.padding(horizontal = 12.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.Bottom
) {
- // Text input with placeholder OR visualizer when recording
- Box(
- modifier = Modifier.weight(1f)
- ) {
- // Always keep the text field mounted to retain focus and avoid IME collapse
- BasicTextField(
- value = value,
- onValueChange = onValueChange,
- textStyle = MaterialTheme.typography.bodyMedium.copy(
- color = colorScheme.primary,
- fontFamily = FontFamily.Monospace
- ),
- cursorBrush = SolidColor(if (isRecording) Color.Transparent else colorScheme.primary),
- keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
- keyboardActions = KeyboardActions(onSend = {
- if (hasText) onSend() // Only send if there's text
- }),
- visualTransformation = CombinedVisualTransformation(
- listOf(SlashCommandVisualTransformation(), MentionVisualTransformation())
- ),
- modifier = Modifier
- .fillMaxWidth()
- .focusRequester(focusRequester)
- .onFocusChanged { focusState ->
- isFocused.value = focusState.isFocused
- }
- )
-
- // Show placeholder when there's no text and not recording
- if (value.text.isEmpty() && !isRecording) {
- Text(
- text = stringResource(R.string.type_a_message_placeholder),
- style = MaterialTheme.typography.bodyMedium.copy(
- fontFamily = FontFamily.Monospace
- ),
- color = colorScheme.onSurface.copy(alpha = 0.5f), // Muted grey
- modifier = Modifier.fillMaxWidth()
+ // MARK: - The pill. Field and action buttons are one visual object.
+ Row(
+ modifier = Modifier
+ .weight(1f)
+ .heightIn(min = ComposerMinHeight)
+ // Grow smoothly as the field wraps to more lines rather than jumping a line at
+ // a time.
+ .animateContentSize(
+ animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing)
+ )
+ .background(containerColor, ComposerShape)
+ .border(1.dp, borderColor, ComposerShape),
+ verticalAlignment = Alignment.Bottom
+ ) {
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .padding(start = 18.dp, end = 4.dp, top = 15.dp, bottom = 15.dp)
+ ) {
+ // Always keep the text field mounted to retain focus and avoid IME collapse
+ BasicTextField(
+ value = value,
+ onValueChange = onValueChange,
+ // Near-white, not terminal green: this is the one place in the app where the
+ // user is composing rather than reading, and green-on-black is tiring to
+ // type into.
+ textStyle = MaterialTheme.typography.bodyMedium.copy(
+ color = colorScheme.onSurface,
+ fontFamily = BitchatFontFamily
+ ),
+ cursorBrush = SolidColor(
+ if (isRecording) Color.Transparent else colorScheme.onSurface
+ ),
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
+ keyboardActions = KeyboardActions(onSend = {
+ if (hasText) onSend()
+ }),
+ // Cap the growth so a pasted wall of text cannot swallow the message list.
+ maxLines = 6,
+ visualTransformation = remember(
+ palette,
+ colorScheme.primary,
+ mentionPeerIdentities,
+ ) {
+ CombinedVisualTransformation(
+ listOf(
+ SlashCommandVisualTransformation(
+ commandColor = colorScheme.primary,
+ commandBackground = colorScheme.primary.copy(alpha = 0.14f),
+ ),
+ MentionVisualTransformation(
+ mentionPeerIdentities = mentionPeerIdentities,
+ palette = palette,
+ ),
+ )
+ )
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester)
+ .onFocusChanged { focusState ->
+ isFocused.value = focusState.isFocused
+ }
)
- }
- // Overlay the real-time scrolling waveform while recording
- if (isRecording) {
- Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
- RealtimeScrollingWaveform(
- modifier = Modifier.weight(1f).height(32.dp),
- amplitudeNorm = normalizeAmplitudeSample(amplitude)
- )
- Spacer(Modifier.width(20.dp))
- val secs = (elapsedMs / 1000).toInt()
- val mm = secs / 60
- val ss = secs % 60
- val maxSecs = 10 // 10 second max recording time
- val maxMm = maxSecs / 60
- val maxSs = maxSecs % 60
+ // Placeholder fades rather than blinking, which matters because it reappears
+ // every time a message is sent.
+ val placeholderAlpha by animateFloatAsState(
+ targetValue = if (value.text.isEmpty() && !isRecording) 1f else 0f,
+ animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
+ label = "placeholderAlpha"
+ )
+ if (placeholderAlpha > 0f) {
Text(
- text = String.format("%02d:%02d / %02d:%02d", mm, ss, maxMm, maxSs),
- fontFamily = FontFamily.Monospace,
- color = colorScheme.primary,
- fontSize = (BASE_FONT_SIZE - 4).sp
+ text = stringResource(R.string.type_a_message_placeholder),
+ style = MaterialTheme.typography.bodyMedium.copy(
+ fontFamily = BitchatFontFamily
+ ),
+ color = palette.textTertiary,
+ maxLines = 1,
+ modifier = Modifier
+ .fillMaxWidth()
+ .alpha(placeholderAlpha)
)
}
- }
- }
-
- Spacer(modifier = Modifier.width(8.dp)) // Reduced spacing
-
- // Voice and image buttons when no text (only visible in Mesh chat)
- if (value.text.isEmpty() && showMediaButtons) {
- // Hold-to-record microphone
- val bg = if (colorScheme.background == Color.Black) Color(0xFF00FF00).copy(alpha = 0.75f) else Color(0xFF008000).copy(alpha = 0.75f)
- // Ensure latest values are used when finishing recording
+ // Recording visualiser, layered over the (empty) field.
+ val waveformAlpha by animateFloatAsState(
+ targetValue = if (isRecording) 1f else 0f,
+ animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
+ label = "waveformAlpha"
+ )
+ if (isRecording) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .alpha(waveformAlpha),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ RealtimeScrollingWaveform(
+ modifier = Modifier.weight(1f).height(22.dp),
+ amplitudeNorm = normalizeAmplitudeSample(amplitude)
+ )
+ Spacer(Modifier.width(12.dp))
+ val secs = (elapsedMs / 1000).toInt()
+ val maxSecs = 10 // 10 second max recording time
+ Text(
+ text = String.format(
+ "%02d:%02d / %02d:%02d",
+ secs / 60, secs % 60, maxSecs / 60, maxSecs % 60
+ ),
+ fontFamily = BitchatFontFamily,
+ color = colorScheme.error,
+ fontSize = (BASE_FONT_SIZE - 4).sp
+ )
+ }
+ }
+ }
+
+ // MARK: - Action cluster, inside the pill.
+ //
+ // Swaps between the auxiliary buttons and send. AnimatedContent cross-fades and
+ // scales between the two, and SizeTransform animates the width change, so typing the
+ // first character morphs camera+mic into send instead of snapping.
val latestSelectedPeer = rememberUpdatedState(selectedPrivatePeer)
val latestChannel = rememberUpdatedState(currentChannel)
val latestOnSendVoiceNote = rememberUpdatedState(onSendVoiceNote)
- // Image button (image picker) - hide during recording
- if (!isRecording) {
- // Revert to original separate buttons: round File button (left) and the old Image plus button (right)
- Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
- // DISABLE FILE PICKER
- //FilePickerButton(
- // onFileReady = { path ->
- // onSendFileNote(latestSelectedPeer.value, latestChannel.value, path)
- // }
- //)
- ImagePickerButton(
- onImageReady = { outPath ->
- onSendImageNote(latestSelectedPeer.value, latestChannel.value, outPath)
- }
- )
- }
- }
-
- Spacer(Modifier.width(1.dp))
-
- VoiceRecordButton(
- backgroundColor = bg,
- onStart = {
- isRecording = true
- elapsedMs = 0L
- // Keep existing focus to avoid IME collapse, but do not force-show keyboard
- if (isFocused.value) {
- try { focusRequester.requestFocus() } catch (_: Exception) {}
+ AnimatedContent(
+ targetState = hasText,
+ transitionSpec = {
+ (
+ fadeIn(tween(BitchatMotion.STANDARD_MS)) +
+ scaleIn(
+ initialScale = 0.7f,
+ animationSpec = tween(
+ BitchatMotion.STANDARD_MS,
+ easing = FastOutSlowInEasing
+ )
+ )
+ ).togetherWith(
+ fadeOut(tween(BitchatMotion.QUICK_MS)) +
+ scaleOut(
+ targetScale = 0.7f,
+ animationSpec = tween(
+ BitchatMotion.QUICK_MS,
+ easing = FastOutSlowInEasing
+ )
+ )
+ ) using SizeTransform(clip = false) { _, _ ->
+ tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing)
}
},
- onAmplitude = { amp, ms ->
- amplitude = amp
- elapsedMs = ms
- },
- onFinish = { path ->
- isRecording = false
- // Extract and cache waveform from the actual audio file to match receiver rendering
- AudioWaveformExtractor.extractAsync(path, sampleCount = 120) { arr ->
- if (arr != null) {
- try { com.bitchat.android.features.voice.VoiceWaveformCache.put(path, arr) } catch (_: Exception) {}
- }
- }
- // BLE path (private or public) — use latest values to avoid stale captures
- latestOnSendVoiceNote.value(
- latestSelectedPeer.value,
- latestChannel.value,
- path
+ modifier = Modifier.padding(end = 6.dp, bottom = 6.dp),
+ label = "composerActions"
+ ) { showSend ->
+ if (showSend) {
+ SendButton(
+ isAccented = latestSelectedPeer.value != null || latestChannel.value != null,
+ onSend = onSend
)
- }
- )
-
- } else {
- // Send button with enabled/disabled state
- IconButton(
- onClick = { if (hasText) onSend() }, // Only execute if there's text
- enabled = hasText, // Enable only when there's text
- modifier = Modifier.size(32.dp)
- ) {
- // Update send button to match input field colors
- Box(
- modifier = Modifier
- .size(30.dp)
- .background(
- color = if (!hasText) {
- // Disabled state - muted grey
- colorScheme.onSurface.copy(alpha = 0.3f)
- } else if (selectedPrivatePeer != null || currentChannel != null) {
- // Orange for both private messages and channels when enabled
- Color(0xFFFF9500).copy(alpha = 0.75f)
- } else if (colorScheme.background == Color.Black) {
- Color(0xFF00FF00).copy(alpha = 0.75f) // Bright green for dark theme
- } else {
- Color(0xFF008000).copy(alpha = 0.75f) // Dark green for light theme
- },
- shape = CircleShape
- ),
- contentAlignment = Alignment.Center
- ) {
- Icon(
- imageVector = Icons.Filled.ArrowUpward,
- contentDescription = stringResource(id = R.string.send_message),
- modifier = Modifier.size(20.dp),
- tint = if (!hasText) {
- // Disabled state - muted grey icon
- colorScheme.onSurface.copy(alpha = 0.5f)
- } else if (selectedPrivatePeer != null || currentChannel != null) {
- // Black arrow on orange for both private and channel modes
- Color.Black
- } else if (colorScheme.background == Color.Black) {
- Color.Black // Black arrow on bright green in dark theme
+ } else {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (showMediaButtons) {
+ // The camera steps aside while recording so the microphone is the
+ // only thing that can be released.
+ AnimatedVisibility(
+ visible = !isRecording,
+ enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) +
+ expandHorizontally(
+ tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing)
+ ),
+ exit = fadeOut(tween(BitchatMotion.QUICK_MS)) +
+ shrinkHorizontally(
+ tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing)
+ )
+ ) {
+ ImagePickerButton(
+ onImageReady = { outPath ->
+ onSendImageNote(
+ latestSelectedPeer.value,
+ latestChannel.value,
+ outPath
+ )
+ }
+ )
+ }
+
+ VoiceRecordButton(
+ isRecording = isRecording,
+ onStart = {
+ isRecording = true
+ elapsedMs = 0L
+ // Keep existing focus to avoid IME collapse, but do not
+ // force-show the keyboard.
+ if (isFocused.value) {
+ try { focusRequester.requestFocus() } catch (_: Exception) {}
+ }
+ },
+ onAmplitude = { amp, ms ->
+ amplitude = amp
+ elapsedMs = ms
+ },
+ onFinish = { path ->
+ isRecording = false
+ // Extract and cache the waveform from the actual audio file
+ // so it matches the receiver's rendering.
+ AudioWaveformExtractor.extractAsync(path, sampleCount = 120) { arr ->
+ if (arr != null) {
+ try {
+ com.bitchat.android.features.voice.VoiceWaveformCache.put(path, arr)
+ } catch (_: Exception) {}
+ }
+ }
+ latestOnSendVoiceNote.value(
+ latestSelectedPeer.value,
+ latestChannel.value,
+ path
+ )
+ },
+ // Any capture that ends without a file must clear the recording
+ // state here too, otherwise the pill stays red with a live
+ // waveform over an empty field.
+ onCancel = {
+ isRecording = false
+ amplitude = 0
+ elapsedMs = 0L
+ }
+ )
} else {
- Color.White // White arrow on dark green in light theme
+ // No media in this context, so keep an inert send button rather than
+ // leaving a hole where the action cluster should be.
+ SendButton(isAccented = false, onSend = {}, enabled = false)
}
- )
+ }
}
}
}
@@ -365,6 +583,42 @@ fun MessageInput(
// Auto-stop handled inside VoiceRecordButton
}
+/**
+ * Send affordance. Only rendered when there is something to send, so its mere presence is the
+ * signal; it does not need to shout in the terminal's full-brightness green as well.
+ */
+@Composable
+private fun SendButton(
+ isAccented: Boolean,
+ onSend: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true
+) {
+ val palette = LocalBitchatPalette.current
+ val colorScheme = MaterialTheme.colorScheme
+ val interactionSource = remember { MutableInteractionSource() }
+ val isPressed by interactionSource.collectIsPressedAsState()
+
+ ComposerActionSurface(
+ isActive = enabled,
+ isPressed = isPressed,
+ // Private chats and channels keep their orange identity, disc and glyph together.
+ activeColor = if (isAccented) palette.accentOrange else colorScheme.primary,
+ modifier = modifier.clickable(
+ interactionSource = interactionSource,
+ indication = null,
+ enabled = enabled
+ ) { onSend() }
+ ) { tint ->
+ Icon(
+ imageVector = Icons.Filled.ArrowUpward,
+ contentDescription = stringResource(id = R.string.send_message),
+ modifier = Modifier.size(ComposerIconSize),
+ tint = tint
+ )
+ }
+}
+
@Composable
fun CommandSuggestionsBox(
suggestions: List,
@@ -377,8 +631,8 @@ fun CommandSuggestionsBox(
modifier = modifier
.verticalScroll(rememberScrollState())
.background(colorScheme.surface)
- .border(1.dp, colorScheme.outline.copy(alpha = 0.3f), RoundedCornerShape(4.dp))
- .padding(vertical = 8.dp)
+ .border(1.dp, colorScheme.outlineVariant, RoundedCornerShape(8.dp))
+ .padding(vertical = 6.dp)
) {
suggestions.forEach { suggestion: CommandSuggestion ->
CommandSuggestionItem(
@@ -395,13 +649,14 @@ fun CommandSuggestionItem(
onClick: () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
+ val palette = LocalBitchatPalette.current
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() }
- .padding(horizontal = 12.dp, vertical = 3.dp)
- .background(Color.Gray.copy(alpha = 0.1f)),
+ // Roomier rows: at 3.dp vertical these were below a comfortable tap height.
+ .padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
@@ -415,11 +670,11 @@ fun CommandSuggestionItem(
Text(
text = allCommands.joinToString(", "),
style = MaterialTheme.typography.bodySmall.copy(
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Medium
),
color = colorScheme.primary,
- fontSize = (BASE_FONT_SIZE - 4).sp
+ fontSize = (BASE_FONT_SIZE - 2).sp
)
// Show syntax if any
@@ -427,10 +682,10 @@ fun CommandSuggestionItem(
Text(
text = syntax,
style = MaterialTheme.typography.bodySmall.copy(
- fontFamily = FontFamily.Monospace
+ fontFamily = BitchatFontFamily
),
- color = colorScheme.onSurface.copy(alpha = 0.8f),
- fontSize = (BASE_FONT_SIZE - 5).sp
+ color = colorScheme.onSurfaceVariant,
+ fontSize = (BASE_FONT_SIZE - 4).sp
)
}
@@ -438,34 +693,64 @@ fun CommandSuggestionItem(
Text(
text = suggestion.description,
style = MaterialTheme.typography.bodySmall.copy(
- fontFamily = FontFamily.Monospace
+ fontFamily = BitchatFontFamily
),
- color = colorScheme.onSurface.copy(alpha = 0.7f),
- fontSize = (BASE_FONT_SIZE - 5).sp,
+ color = palette.textTertiary,
+ fontSize = (BASE_FONT_SIZE - 4).sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
+@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MentionSuggestionsBox(
suggestions: List,
+ mentionPeerIdentities: Map,
onSuggestionClick: (String) -> Unit,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
-
- Column(
+ val palette = LocalBitchatPalette.current
+
+ LazyColumn(
modifier = modifier
+ .heightIn(max = MentionSuggestionsMaxHeight)
+ .animateContentSize(
+ animationSpec = tween(
+ BitchatMotion.STANDARD_MS,
+ easing = FastOutSlowInEasing
+ )
+ )
+ .clip(MentionSuggestionsShape)
.background(colorScheme.surface)
- .border(1.dp, colorScheme.outline.copy(alpha = 0.3f), RoundedCornerShape(4.dp))
- .padding(vertical = 8.dp)
+ .border(1.dp, colorScheme.outlineVariant, MentionSuggestionsShape),
+ contentPadding = PaddingValues(vertical = MentionSuggestionsVerticalPadding)
) {
- suggestions.forEach { suggestion: String ->
+ items(
+ items = suggestions,
+ key = { suggestion -> suggestion.lowercase() }
+ ) { suggestion ->
MentionSuggestionItem(
suggestion = suggestion,
- onClick = { onSuggestionClick(suggestion) }
+ userColor = colorForMention(
+ mention = suggestion,
+ mentionPeerIdentities = mentionPeerIdentities,
+ palette = palette,
+ ),
+ onClick = { onSuggestionClick(suggestion) },
+ modifier = Modifier.animateItem(
+ fadeInSpec = tween(
+ BitchatMotion.STANDARD_MS,
+ easing = FastOutSlowInEasing
+ ),
+ placementSpec = tween(
+ BitchatMotion.STANDARD_MS,
+ easing = FastOutSlowInEasing
+ ),
+ fadeOutSpec = tween(BitchatMotion.QUICK_MS)
+ )
)
}
}
@@ -474,37 +759,76 @@ fun MentionSuggestionsBox(
@Composable
fun MentionSuggestionItem(
suggestion: String,
- onClick: () -> Unit
+ userColor: Color,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier
) {
- val colorScheme = MaterialTheme.colorScheme
-
+ val palette = LocalBitchatPalette.current
+ val interactionSource = remember { MutableInteractionSource() }
+ val isPressed by interactionSource.collectIsPressedAsState()
+ val pressedBackground by animateColorAsState(
+ targetValue = if (isPressed) {
+ userColor.copy(alpha = 0.10f)
+ } else {
+ Color.Transparent
+ },
+ animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing),
+ label = "mentionSuggestionPressedBackground"
+ )
+ val pressedScale by animateFloatAsState(
+ targetValue = if (isPressed) 0.985f else 1f,
+ animationSpec = spring(
+ dampingRatio = Spring.DampingRatioMediumBouncy,
+ stiffness = Spring.StiffnessHigh
+ ),
+ label = "mentionSuggestionPressedScale"
+ )
+
Row(
- modifier = Modifier
+ modifier = modifier
.fillMaxWidth()
- .clickable { onClick() }
- .padding(horizontal = 12.dp, vertical = 3.dp)
- .background(Color.Gray.copy(alpha = 0.1f)),
+ .height(MentionSuggestionRowHeight)
+ .scale(pressedScale)
+ .background(pressedBackground)
+ .clickable(
+ interactionSource = interactionSource,
+ indication = null,
+ onClick = onClick
+ )
+ .padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.mention_suggestion_at, suggestion),
style = MaterialTheme.typography.bodySmall.copy(
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontWeight = FontWeight.SemiBold
),
- color = Color(0xFFFF9500), // Orange like mentions
- fontSize = (BASE_FONT_SIZE - 4).sp
+ color = userColor,
+ fontSize = (BASE_FONT_SIZE - 2).sp,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f)
)
-
- Spacer(modifier = Modifier.weight(1f))
-
+
+ Spacer(modifier = Modifier.width(12.dp))
+
Text(
text = stringResource(R.string.mention),
style = MaterialTheme.typography.bodySmall.copy(
- fontFamily = FontFamily.Monospace
+ fontFamily = BitchatFontFamily
),
- color = colorScheme.onSurface.copy(alpha = 0.7f),
- fontSize = (BASE_FONT_SIZE - 5).sp
+ color = palette.textTertiary,
+ fontSize = (BASE_FONT_SIZE - 4).sp,
+ maxLines = 1
)
}
}
+
+/** Mention autocomplete stays compact even in crowded channels. */
+internal const val MaxVisibleMentionSuggestions = 5
+private val MentionSuggestionRowHeight = 48.dp
+private val MentionSuggestionsVerticalPadding = 6.dp
+private val MentionSuggestionsMaxHeight =
+ (48 * MaxVisibleMentionSuggestions + 12).dp
+private val MentionSuggestionsShape = RoundedCornerShape(8.dp)
diff --git a/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt b/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt
index 4896e345..57710171 100644
--- a/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt
+++ b/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt
@@ -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,
diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt
index 2480ead0..505ea5b0 100644
--- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt
+++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt
@@ -1,53 +1,105 @@
package com.bitchat.android.ui
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.scale
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Bookmark
+import androidx.compose.material.icons.filled.Hub
+import androidx.compose.material.icons.filled.Map
+import androidx.compose.material.icons.filled.PinDrop
+import androidx.compose.material.icons.filled.Security
+import androidx.compose.material.icons.outlined.BookmarkBorder
+import androidx.compose.material.icons.outlined.Public
import android.content.Intent
import android.net.Uri
import android.provider.Settings
+import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
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.relocation.BringIntoViewRequester
+import androidx.compose.foundation.relocation.bringIntoViewRequester
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Bookmark
-import androidx.compose.material.icons.filled.Check
-import androidx.compose.material.icons.filled.Map
-import androidx.compose.material.icons.filled.PinDrop
-import androidx.compose.material.icons.outlined.BookmarkBorder
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
-import com.bitchat.android.geohash.ChannelID
-import kotlinx.coroutines.launch
-import com.bitchat.android.geohash.GeohashChannel
-import com.bitchat.android.geohash.GeohashChannelLevel
-import com.bitchat.android.geohash.LocationChannelManager
-import com.bitchat.android.geohash.GeohashBookmarksStore
+import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.nostr.NearbyNotesController
import com.bitchat.android.nostr.geohashesForSampling
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
-import androidx.compose.ui.res.stringResource
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.compose.LifecycleResumeEffect
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
-import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
+import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss
+import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
+import com.bitchat.android.geohash.ChannelID
+import com.bitchat.android.geohash.GeohashBookmarksStore
+import com.bitchat.android.geohash.GeohashChannel
+import com.bitchat.android.geohash.GeohashChannelLevel
+import com.bitchat.android.geohash.LocationChannelManager
+import com.bitchat.android.net.ArtiTorManager
+import com.bitchat.android.net.TorMode
+import com.bitchat.android.net.TorPreferenceManager
+import com.bitchat.android.ui.theme.BitchatMotion
+import com.bitchat.android.ui.theme.LocalBitchatPalette
+import com.bitchat.android.wifiaware.WifiAwareController
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeoutOrNull
/**
- * Location Channels Sheet for selecting geohash-based location channels
- * Direct port from iOS LocationChannelsSheet for 100% compatibility
+ * Leading column width matching settings rows: 22.dp glyph + 16.dp gutter before title text.
+ * Selection dots and row icons sit in this column so every option lines up with About settings.
+ */
+private val ChannelLeadingSlot = SheetRowLeadingSlot
+private val ChannelLeadingGutter = SheetRowLeadingGutter
+private val ChannelRowHorizontal = SheetRowHorizontal
+private val ChannelRowVertical = SheetRowVertical
+private val ChannelDividerInset = SheetRowDividerInset
+/** 2× the previous 6.dp selected indicator; sits centered in [ChannelLeadingSlot]. */
+private val ChannelSelectedDot = SheetRowSelectedDot
+
+/**
+ * Pause between applying a channel selection and dismissing the sheet.
+ *
+ * Just enough for the active dot to land on the chosen row, so the tap is acknowledged rather than
+ * answered by the sheet simply disappearing.
+ */
+private const val SelectionConfirmDelayMs = 180L
+
+/**
+ * Location Channels sheet: grouped card rows matching About → Settings.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -61,7 +113,6 @@ fun LocationChannelsSheet(
val locationManager = LocationChannelManager.getInstance(context)
val bookmarksStore = remember { GeohashBookmarksStore.getInstance(context) }
- // Observe location manager state
val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
val notesRevealed by NearbyNotesController.shared.revealed.collectAsStateWithLifecycle()
@@ -71,25 +122,17 @@ fun LocationChannelsSheet(
val systemLocationEnabled by locationManager.systemLocationEnabled.collectAsStateWithLifecycle()
val locationServicesEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle()
- // Observe bookmarks state
val bookmarks by bookmarksStore.bookmarks.collectAsStateWithLifecycle()
val bookmarkNames by bookmarksStore.bookmarkNames.collectAsStateWithLifecycle()
-
- // Observe reactive participant counts
val geohashParticipantCounts by viewModel.geohashParticipantCounts.collectAsStateWithLifecycle()
+ val wifiAwareEnabled by WifiAwareController.enabled.collectAsStateWithLifecycle()
- // UI state
var customGeohash by remember { mutableStateOf("") }
var customError by remember { mutableStateOf(null) }
- var isInputFocused by remember { mutableStateOf(false) }
- // Bottom sheet state
- val sheetState = rememberModalBottomSheetState(
- skipPartiallyExpanded = true
- )
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val coroutineScope = rememberCoroutineScope()
- // Scroll state for LazyColumn with animated top bar
val listState = rememberLazyListState()
val isScrolled by remember {
derivedStateOf {
@@ -97,7 +140,8 @@ fun LocationChannelsSheet(
}
}
val topBarAlpha by animateFloatAsState(
- targetValue = if (isScrolled) 0.95f else 0f,
+ targetValue = if (isScrolled) 0.98f else 0f,
+ animationSpec = tween(BitchatMotion.EMPHASIZED_MS, easing = FastOutSlowInEasing),
label = "topBarAlpha"
)
@@ -113,11 +157,20 @@ fun LocationChannelsSheet(
}
}
- // iOS system colors (matches iOS 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 palette = LocalBitchatPalette.current
+ val standardGreen = colorScheme.primary
+ val standardBlue = colorScheme.secondary
+
+ val nearbyChannels = remember(availableChannels) {
+ availableChannels.filter { it.level != GeohashChannelLevel.BUILDING }
+ }
+ val selectedChannelOutsideNearby = remember(selectedChannel, nearbyChannels) {
+ selectedLocationChannelOutsideNearby(selectedChannel, nearbyChannels)
+ }
+ val showNearbyLoading = nearbyChannels.isEmpty() &&
+ permissionState == LocationChannelManager.PermissionState.AUTHORIZED &&
+ locationServicesEnabled
if (isPresented) {
BitchatBottomSheet(
@@ -125,389 +178,417 @@ fun LocationChannelsSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
+ // Selection is applied immediately so the active dot snaps to the new row, then the
+ // sheet slides away after a beat. Long enough to register the change, short enough
+ // that it never feels like waiting.
+ val animatedDismiss = LocalSheetDismiss.current
+ val confirmSelectionThenDismiss: () -> Unit = {
+ coroutineScope.launch {
+ delay(SelectionConfirmDelayMs)
+ animatedDismiss?.invoke() ?: onDismiss()
+ }
+ }
+
Box(modifier = Modifier.fillMaxWidth()) {
LazyColumn(
state = listState,
- modifier = Modifier.fillMaxSize(),
- contentPadding = PaddingValues(top = 64.dp, bottom = 16.dp)
+ // Edge-to-edge + adjustResize reports the keyboard as WindowInsets.ime —
+ // without consuming it here the list stays full-height and the teleport
+ // field can't scroll above the keyboard.
+ modifier = Modifier
+ .fillMaxSize()
+ .imePadding(),
+ contentPadding = PaddingValues(top = 72.dp, bottom = 32.dp),
+ verticalArrangement = Arrangement.spacedBy(0.dp)
) {
- // Header Section
- item(key = "header") {
- Text(
- text = stringResource(R.string.location_channels_desc),
- fontSize = 12.sp,
- fontFamily = FontFamily.Monospace,
- color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
- modifier = Modifier
- .padding(horizontal = 24.dp)
+ // Mesh section: icon + title header, offline subtitle, then selection card
+ item(key = "mesh_card") {
+ Column {
+ SheetIconSectionHeader(
+ iconRes = R.drawable.ic_spec_range,
+ title = stringResource(R.string.mesh_title),
+ subtitle = stringResource(R.string.mesh_section_subtitle),
+ modifier = Modifier.padding(top = 8.dp)
+ )
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = AboutHorizontalPadding)
+ .padding(top = 10.dp),
+ color = colorScheme.surface,
+ shape = AboutCardShape
+ ) {
+ ChannelOptionRow(
+ title = meshTitleWithCount(viewModel),
+ subtitle = stringResource(
+ if (wifiAwareEnabled) {
+ R.string.location_bluetooth_wifi_subtitle
+ } else {
+ R.string.location_bluetooth_subtitle
+ },
+ meshRangeString()
+ ),
+ isSelected = selectedChannel is ChannelID.Mesh,
+ participantCount = meshCount(viewModel),
+ titleColor = standardBlue,
+ titleBold = meshCount(viewModel) > 0,
+ onClick = {
+ locationManager.select(ChannelID.Mesh)
+ onDismiss()
+ }
+ )
+ }
+ }
+ }
+
+ item(key = "location_channels_header") {
+ SheetIconSectionHeader(
+ iconRes = R.drawable.ic_spec_globe,
+ title = stringResource(R.string.location_channels_heading),
+ subtitle = stringResource(R.string.location_channels_desc),
+ modifier = Modifier.padding(top = 20.dp)
)
}
- // Permission controls if services enabled
- if (locationServicesEnabled) {
+ selectedChannelOutsideNearby?.let { channel ->
+ item(key = "teleported_card") {
+ val coverage = coverageString(channel.geohash.length)
+ val name = bookmarkNames[channel.geohash]
+ val subtitle = "#${channel.geohash} • $coverage" +
+ (name?.let { " • ${formattedNamePrefix(channel.level)}$it" } ?: "")
+ val participantCount = geohashParticipantCounts[channel.geohash] ?: 0
+ val isBookmarked = bookmarksStore.isBookmarked(channel.geohash)
+
+ Column {
+ AboutSectionLabel(text = stringResource(R.string.cd_teleported))
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = AboutHorizontalPadding),
+ color = colorScheme.surface,
+ shape = AboutCardShape
+ ) {
+ ChannelOptionRow(
+ title = geohashHashTitleWithCount(
+ channel.geohash,
+ participantCount
+ ),
+ subtitle = subtitle,
+ isSelected = true,
+ participantCount = participantCount,
+ titleColor = standardGreen,
+ titleBold = participantCount > 0,
+ trailingContent = {
+ ChannelBookmarkButton(
+ bookmarked = isBookmarked,
+ onClick = {
+ bookmarksStore.toggle(channel.geohash)
+ }
+ )
+ },
+ onClick = {
+ locationManager.select(ChannelID.Location(channel))
+ onDismiss()
+ }
+ )
+ }
+ }
+ }
+ }
+
+ if (bookmarks.isNotEmpty()) {
+ item(key = "bookmarks_card") {
+ Column {
+ AboutSectionLabel(text = stringResource(R.string.bookmarked))
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = AboutHorizontalPadding),
+ color = colorScheme.surface,
+ shape = AboutCardShape
+ ) {
+ Column {
+ bookmarks.forEachIndexed { index, gh ->
+ if (index > 0) SheetCardDivider()
+ val level = levelForLength(gh.length)
+ val channel = GeohashChannel(level = level, geohash = gh)
+ val coverage = coverageString(gh.length)
+ val name = bookmarkNames[gh]
+ val subtitle = "#$gh • $coverage" +
+ (name?.let { " • ${formattedNamePrefix(level)}$it" } ?: "")
+ val participantCount = geohashParticipantCounts[gh] ?: 0
+
+ ChannelOptionRow(
+ title = geohashHashTitleWithCount(gh, participantCount),
+ subtitle = subtitle,
+ isSelected = isChannelSelected(channel, selectedChannel),
+ participantCount = participantCount,
+ titleBold = participantCount > 0,
+ trailingContent = {
+ ChannelBookmarkButton(
+ bookmarked = true,
+ onClick = { bookmarksStore.toggle(gh) }
+ )
+ },
+ onClick = {
+ val inRegional =
+ availableChannels.any { it.geohash == gh }
+ locationManager.selectManual(
+ channel = channel,
+ teleported = !appLocationEnabled ||
+ availableChannels.isEmpty() ||
+ !inRegional
+ )
+ onDismiss()
+ }
+ )
+ LaunchedEffect(gh) {
+ bookmarksStore.resolveNameIfNeeded(gh)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if (locationServicesEnabled &&
+ permissionState == LocationChannelManager.PermissionState.DENIED
+ ) {
item(key = "permissions") {
Column(
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 24.dp)
- .padding(bottom = 8.dp),
+ .padding(horizontal = AboutHorizontalPadding)
+ .padding(top = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
- when (permissionState) {
- LocationChannelManager.PermissionState.DENIED -> {
- Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
- Text(
- text = stringResource(R.string.location_permission_denied),
- fontSize = 11.sp,
- fontFamily = FontFamily.Monospace,
- color = Color.Red.copy(alpha = 0.8f)
- )
- TextButton(
- onClick = {
- val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
- data = Uri.fromParts("package", context.packageName, null)
- }
- context.startActivity(intent)
- }
- ) {
- Text(
- text = stringResource(R.string.open_settings),
- fontSize = 11.sp,
- fontFamily = FontFamily.Monospace
+ Text(
+ text = stringResource(R.string.location_permission_denied),
+ fontSize = 12.sp,
+ fontFamily = BitchatFontFamily,
+ color = colorScheme.error
+ )
+ TextButton(
+ onClick = {
+ val intent =
+ Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
+ data = Uri.fromParts(
+ "package",
+ context.packageName,
+ null
)
}
- }
- }
- LocationChannelManager.PermissionState.AUTHORIZED -> {
- Text(
- text = stringResource(R.string.location_permission_granted),
- fontSize = 11.sp,
- fontFamily = FontFamily.Monospace,
- color = standardGreen
- )
- }
- }
- }
- }
- }
-
- // Mesh option first
- item(key = "mesh") {
- ChannelRow(
- title = meshTitleWithCount(viewModel),
- subtitle = stringResource(R.string.location_bluetooth_subtitle, bluetoothRangeString()),
- isSelected = selectedChannel is ChannelID.Mesh,
- titleColor = standardBlue,
- titleBold = meshCount(viewModel) > 0,
- trailingContent = null,
- onClick = {
- locationManager.select(ChannelID.Mesh)
- onDismiss()
- }
- )
- }
-
- // Nearby options (only show if location services are enabled)
- // CRITICAL: Filter out .building level (precision 8) - iOS pattern
- // iOS: let nearby = manager.availableChannels.filter { $0.level != .building }
- if (availableChannels.isNotEmpty() && locationServicesEnabled) {
- val nearbyChannels = availableChannels.filter { it.level != GeohashChannelLevel.BUILDING }
- items(nearbyChannels) { channel ->
- val coverage = coverageString(channel.geohash.length)
- val nameBase = locationNames[channel.level]
- val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it }
- val subtitlePrefix = "#${channel.geohash} • $coverage"
- val participantCount = geohashParticipantCounts[channel.geohash] ?: 0
- val highlight = participantCount > 0
- val isBookmarked = bookmarksStore.isBookmarked(channel.geohash)
-
- ChannelRow(
- title = geohashTitleWithCount(channel, participantCount),
- subtitle = subtitlePrefix + (namePart?.let { " • $it" } ?: ""),
- isSelected = isChannelSelected(channel, selectedChannel),
- titleColor = standardGreen,
- titleBold = highlight,
- trailingContent = {
- IconButton(onClick = { bookmarksStore.toggle(channel.geohash) }) {
- Icon(
- imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder,
- contentDescription = if (isBookmarked) stringResource(R.string.cd_remove_bookmark) else stringResource(R.string.cd_add_bookmark),
- tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
- )
- }
- },
- onClick = {
- if (locationManager.selectNearby(channel)) {
- onDismiss()
- }
- }
- )
- }
- } else if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) {
- item {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 32.dp, vertical = 12.dp),
- horizontalArrangement = Arrangement.spacedBy(12.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- CircularProgressIndicator(
- modifier = Modifier.size(16.dp),
- strokeWidth = 2.dp,
- color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
- )
- Text(
- text = stringResource(R.string.finding_nearby_channels),
- fontSize = 12.sp,
- fontFamily = FontFamily.Monospace
- )
- }
- }
- }
-
- // Bookmarked geohashes
- if (bookmarks.isNotEmpty()) {
- item(key = "bookmarked_header") {
- Text(
- text = stringResource(R.string.bookmarked),
- style = MaterialTheme.typography.labelLarge,
- fontFamily = FontFamily.Monospace,
- color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f),
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 24.dp)
- .padding(top = 8.dp, bottom = 4.dp)
- )
- }
- items(bookmarks) { gh ->
- val level = levelForLength(gh.length)
- val channel = GeohashChannel(level = level, geohash = gh)
- val coverage = coverageString(gh.length)
- val subtitlePrefix = "#${gh} • $coverage"
- val name = bookmarkNames[gh]
- val subtitle = subtitlePrefix + (name?.let { " • ${formattedNamePrefix(level)}$it" } ?: "")
- val participantCount = geohashParticipantCounts[gh] ?: 0
- val title = geohashHashTitleWithCount(gh, participantCount)
-
- ChannelRow(
- title = title,
- subtitle = subtitle,
- isSelected = isChannelSelected(channel, selectedChannel),
- titleColor = null,
- titleBold = participantCount > 0,
- trailingContent = {
- IconButton(onClick = { bookmarksStore.toggle(gh) }) {
- Icon(
- imageVector = Icons.Filled.Bookmark,
- contentDescription = stringResource(R.string.cd_remove_bookmark),
- tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
- )
- }
- },
- onClick = {
- val inRegional = availableChannels.any { it.geohash == gh }
- locationManager.selectManual(
- channel = channel,
- teleported = !appLocationEnabled ||
- availableChannels.isEmpty() ||
- !inRegional
- )
- onDismiss()
- }
- )
- LaunchedEffect(gh) { bookmarksStore.resolveNameIfNeeded(gh) }
- }
- }
-
- // Custom geohash teleport (iOS-style inline form)
- item(key = "custom_geohash") {
- Surface(
- color = Color.Transparent,
- shape = MaterialTheme.shapes.medium,
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 24.dp, vertical = 2.dp)
- ) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 6.dp),
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- text = stringResource(R.string.hash_symbol),
- fontSize = BASE_FONT_SIZE.sp,
- fontFamily = FontFamily.Monospace,
- color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
- )
-
- BasicTextField(
- value = customGeohash,
- onValueChange = { newValue ->
- // iOS-style geohash validation (base32 characters only)
- val allowed = "0123456789bcdefghjkmnpqrstuvwxyz".toSet()
- val filtered = newValue
- .lowercase()
- .replace("#", "")
- .filter { it in allowed }
- .take(12)
-
- customGeohash = filtered
- customError = null
+ context.startActivity(intent)
},
- textStyle = androidx.compose.ui.text.TextStyle(
- fontSize = BASE_FONT_SIZE.sp,
- fontFamily = FontFamily.Monospace,
- color = MaterialTheme.colorScheme.onSurface
- ),
- modifier = Modifier
- .weight(1f)
- .onFocusChanged { focusState ->
- isInputFocused = focusState.isFocused
- if (focusState.isFocused) {
- coroutineScope.launch {
- sheetState.expand()
- // Scroll to bottom to show input and remove button
- listState.animateScrollToItem(
- index = listState.layoutInfo.totalItemsCount - 1
- )
- }
- }
- },
- singleLine = true,
- decorationBox = { innerTextField ->
- if (customGeohash.isEmpty()) {
- Text(
- text = stringResource(R.string.geohash_placeholder),
- fontSize = BASE_FONT_SIZE.sp,
- fontFamily = FontFamily.Monospace,
- color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
- )
- }
- innerTextField()
- }
- )
-
- val normalized = customGeohash.trim().lowercase().replace("#", "")
-
- // Map picker button
- IconButton(onClick = {
- val initial = when {
- normalized.isNotBlank() -> normalized
- selectedChannel is ChannelID.Location -> (selectedChannel as ChannelID.Location).channel.geohash
- else -> ""
- }
- val intent = Intent(context, GeohashPickerActivity::class.java).apply {
- putExtra(GeohashPickerActivity.EXTRA_INITIAL_GEOHASH, initial)
- }
- mapPickerLauncher.launch(intent)
- }) {
- Icon(
- imageVector = Icons.Filled.Map,
- contentDescription = stringResource(R.string.cd_open_map),
- tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
- )
- }
-
- val isValid = validateGeohash(normalized)
-
- // iOS-style teleport button
- Button(
- onClick = {
- if (isValid) {
- val level = levelForLength(normalized.length)
- val channel = GeohashChannel(level = level, geohash = normalized)
- locationManager.selectManual(channel)
- onDismiss()
- } else {
- customError = context.getString(R.string.invalid_geohash)
- }
- },
- enabled = isValid,
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
- contentColor = MaterialTheme.colorScheme.onSurface
- )
+ contentPadding = PaddingValues(0.dp)
) {
- Row(
- horizontalArrangement = Arrangement.spacedBy(4.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- text = stringResource(R.string.teleport),
- fontSize = BASE_FONT_SIZE.sp,
- fontFamily = FontFamily.Monospace
- )
- Icon(
- imageVector = Icons.Filled.PinDrop,
- contentDescription = stringResource(R.string.cd_teleport),
- modifier = Modifier.size(14.dp),
- tint = MaterialTheme.colorScheme.onSurface
- )
- }
+ Text(
+ text = stringResource(R.string.open_settings),
+ fontSize = 12.sp,
+ fontFamily = BitchatFontFamily
+ )
}
}
}
}
- // Error message for custom geohash
- if (customError != null) {
- item(key = "geohash_error") {
- Text(
- text = customError!!,
- fontSize = 12.sp,
- fontFamily = FontFamily.Monospace,
- color = Color.Red,
+ // Nearby location channels and the control for teleporting somewhere new.
+ item(key = "channels_card") {
+ Column {
+ AboutSectionLabel(
+ text = stringResource(R.string.location_channels_nearby)
+ )
+ if (!appLocationEnabled) {
+ SheetDestructiveButton(
+ text = stringResource(R.string.enable_location_services),
+ isDestructive = false,
+ onClick = { locationManager.enableLocationServices() },
+ modifier = Modifier.padding(
+ start = AboutHorizontalPadding,
+ end = AboutHorizontalPadding,
+ bottom = 10.dp
+ )
+ )
+ }
+ Surface(
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 24.dp)
- )
+ .padding(horizontal = AboutHorizontalPadding),
+ color = colorScheme.surface,
+ shape = AboutCardShape
+ ) {
+ Column {
+ if (locationServicesEnabled) {
+ if (nearbyChannels.isNotEmpty()) {
+ nearbyChannels.forEachIndexed { index, channel ->
+ if (index > 0) SheetCardDivider()
+ val coverage = coverageString(channel.geohash.length)
+ val nameBase = locationNames[channel.level]
+ val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it }
+ val subtitlePrefix = "#${channel.geohash} • $coverage"
+ val participantCount = geohashParticipantCounts[channel.geohash] ?: 0
+ val isBookmarked = bookmarksStore.isBookmarked(channel.geohash)
+
+ ChannelOptionRow(
+ title = geohashTitleWithCount(channel, participantCount),
+ subtitle = subtitlePrefix + (namePart?.let { " • $it" } ?: ""),
+ isSelected = isChannelSelected(channel, selectedChannel),
+ participantCount = participantCount,
+ titleColor = standardGreen,
+ titleBold = participantCount > 0,
+ trailingContent = {
+ ChannelBookmarkButton(
+ bookmarked = isBookmarked,
+ onClick = { bookmarksStore.toggle(channel.geohash) }
+ )
+ },
+ onClick = {
+ if (locationManager.selectNearby(channel)) {
+ onDismiss()
+ }
+ }
+ )
+ }
+ SheetCardDivider()
+ } else if (showNearbyLoading) {
+ ChannelLoadingRow()
+ SheetCardDivider()
+ }
+ }
+
+ CustomGeohashRow(
+ customGeohash = customGeohash,
+ onGeohashChange = { value ->
+ val allowed = "0123456789bcdefghjkmnpqrstuvwxyz".toSet()
+ customGeohash = value
+ .lowercase()
+ .replace("#", "")
+ .filter { it in allowed }
+ .take(12)
+ customError = null
+ },
+ onFocusGained = {
+ coroutineScope.launch { sheetState.expand() }
+ },
+ onOpenMap = {
+ val normalized = customGeohash.trim().lowercase().replace("#", "")
+ val initial = when {
+ normalized.isNotBlank() -> normalized
+ selectedChannel is ChannelID.Location ->
+ (selectedChannel as ChannelID.Location).channel.geohash
+ else -> ""
+ }
+ val intent = Intent(context, GeohashPickerActivity::class.java).apply {
+ putExtra(GeohashPickerActivity.EXTRA_INITIAL_GEOHASH, initial)
+ }
+ mapPickerLauncher.launch(intent)
+ },
+ onTeleport = {
+ val normalized = customGeohash.trim().lowercase().replace("#", "")
+ if (validateGeohash(normalized)) {
+ val level = levelForLength(normalized.length)
+ val channel = GeohashChannel(level = level, geohash = normalized)
+ locationManager.selectManual(channel)
+ onDismiss()
+ } else {
+ customError = context.getString(R.string.invalid_geohash)
+ }
+ }
+ )
+ }
+ }
+
+ AnimatedVisibility(
+ visible = customError != null,
+ enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) +
+ expandVertically(
+ tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing)
+ ),
+ exit = fadeOut(tween(BitchatMotion.QUICK_MS)) +
+ shrinkVertically(
+ tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing)
+ )
+ ) {
+ // Held across the exit animation: by the time it plays, the error
+ // itself has already been cleared.
+ val shownError = remember(customError) { customError ?: "" }
+ Text(
+ text = shownError,
+ fontSize = 12.sp,
+ fontFamily = BitchatFontFamily,
+ color = colorScheme.error,
+ modifier = Modifier.padding(
+ start = AboutHorizontalPadding + ChannelRowHorizontal,
+ top = 8.dp
+ )
+ )
+ }
}
}
- // Location services toggle button
- item(key = "location_toggle") {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 24.dp)
- .padding(top = 8.dp)
- ) {
- Button(
- onClick = {
- if (appLocationEnabled) {
- locationManager.disableLocationServices()
- } else {
- locationManager.enableLocationServices()
- }
- },
- colors = ButtonDefaults.buttonColors(
- containerColor = if (appLocationEnabled) {
- Color.Red.copy(alpha = 0.08f)
- } else {
- standardGreen.copy(alpha = 0.12f)
- },
- contentColor = if (appLocationEnabled) {
- Color(0xFFBF1A1A)
- } else {
- standardGreen
- }
- ),
- modifier = Modifier.fillMaxWidth()
+ item(key = "tor_routing") {
+ val torProvider = remember { ArtiTorManager.getInstance() }
+ val torAvailable = remember { torProvider.isTorAvailable() }
+ var torMode by remember { mutableStateOf(TorPreferenceManager.get(context)) }
+
+ Column {
+ AboutSectionLabel(text = stringResource(R.string.about_network))
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = AboutHorizontalPadding),
+ color = colorScheme.surface,
+ shape = AboutCardShape
) {
- Text(
- text = if (appLocationEnabled) stringResource(R.string.disable_location_services) else stringResource(R.string.enable_location_services),
- fontSize = 12.sp,
- fontFamily = FontFamily.Monospace
+ ChannelSettingsToggleRow(
+ icon = Icons.Filled.Security,
+ title = stringResource(R.string.location_tor_routing_title),
+ subtitle = stringResource(R.string.location_tor_routing_desc),
+ checked = torMode == TorMode.ON,
+ enabled = torAvailable,
+ statusIndicator = {
+ BitchatBadge(text = stringResource(R.string.badge_recommended))
+ },
+ onCheckedChange = { enabled ->
+ if (torAvailable) {
+ torMode = if (enabled) TorMode.ON else TorMode.OFF
+ TorPreferenceManager.set(context, torMode)
+ }
+ }
)
}
+ if (!torAvailable) {
+ Text(
+ text = stringResource(R.string.tor_not_available_in_this_build),
+ fontSize = 12.sp,
+ fontFamily = BitchatFontFamily,
+ color = palette.textTertiary,
+ modifier = Modifier.padding(
+ start = AboutHorizontalPadding + ChannelRowHorizontal,
+ top = 8.dp
+ )
+ )
+ }
+ }
+ }
+
+ if (appLocationEnabled) {
+ item(key = "location_toggle") {
+ SheetDestructiveButton(
+ text = stringResource(R.string.disable_location_services),
+ isDestructive = true,
+ onClick = { locationManager.disableLocationServices() },
+ modifier = Modifier.padding(
+ start = AboutHorizontalPadding,
+ end = AboutHorizontalPadding,
+ top = 24.dp
+ )
+ )
}
}
}
- // TopBar (animated)
BitchatSheetTopBar(
onClose = onDismiss,
modifier = modifier.align(Alignment.TopCenter),
@@ -521,7 +602,6 @@ fun LocationChannelsSheet(
}
}
- // Lifecycle management: when presented, manage location updates
LifecycleResumeEffect(isPresented, appLocationEnabled, systemLocationEnabled) {
if (isPresented) {
val currentPermission = locationManager.syncPermissionState()
@@ -529,6 +609,9 @@ fun LocationChannelsSheet(
systemLocationEnabled &&
currentPermission == LocationChannelManager.PermissionState.AUTHORIZED
) {
+ // Retain the redesign branch's immediate refresh when the sheet resumes while
+ // honoring main's independent app/system privacy gates.
+ locationManager.enableLocationChannels()
locationManager.beginLiveRefresh()
}
}
@@ -561,104 +644,388 @@ fun LocationChannelsSheet(
}
}
- // Ensure cleanup when the composable is destroyed (e.g. removed from parent composition)
DisposableEffect(Unit) {
- onDispose {
- viewModel.endGeohashSampling()
+ onDispose { viewModel.endGeohashSampling() }
+ }
+}
+
+/**
+ * Single channel option — settings-row geometry: 22.dp leading slot, title + subtitle, trailing.
+ * Selected state is a 12.dp green dot centered in the leading slot (icon-sized footprint).
+ */
+@Composable
+private fun ChannelOptionRow(
+ title: String,
+ subtitle: String,
+ isSelected: Boolean,
+ participantCount: Int,
+ titleColor: Color? = null,
+ titleBold: Boolean = false,
+ leadingIcon: ImageVector? = null,
+ trailingContent: (@Composable (() -> Unit))? = null,
+ onClick: () -> Unit
+) {
+ val colorScheme = MaterialTheme.colorScheme
+ val (baseTitle, countSuffix) = splitTitleAndCount(title)
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(horizontal = ChannelRowHorizontal, vertical = ChannelRowVertical),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier.size(ChannelLeadingSlot),
+ contentAlignment = Alignment.Center
+ ) {
+ when {
+ isSelected -> {
+ Box(
+ modifier = Modifier
+ .size(ChannelSelectedDot)
+ .background(colorScheme.primary, CircleShape)
+ )
+ }
+ leadingIcon != null -> {
+ Icon(
+ imageVector = leadingIcon,
+ contentDescription = null,
+ tint = colorScheme.primary,
+ modifier = Modifier.size(22.dp)
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.width(ChannelLeadingGutter))
+
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(2.dp)
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = baseTitle,
+ fontSize = 14.sp,
+ fontFamily = BitchatFontFamily,
+ fontWeight = if (titleBold) FontWeight.SemiBold else FontWeight.Medium,
+ color = titleColor ?: colorScheme.onSurface
+ )
+ countSuffix?.let { count ->
+ AnimatedCountLabel(
+ count = participantCount,
+ text = count,
+ fontSize = 11.sp,
+ fontFamily = BitchatFontFamily,
+ color = colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ Text(
+ text = subtitle,
+ fontSize = 12.sp,
+ fontFamily = BitchatFontFamily,
+ lineHeight = 17.sp,
+ color = colorScheme.onSurfaceVariant
+ )
+ }
+
+ if (trailingContent != null) {
+ Spacer(modifier = Modifier.width(8.dp))
+ trailingContent()
}
}
}
@Composable
-private fun ChannelRow(
- title: String,
- subtitle: String,
- isSelected: Boolean,
- titleColor: Color? = null,
- titleBold: Boolean = false,
- trailingContent: (@Composable (() -> Unit))? = null,
+private fun ChannelBookmarkButton(
+ bookmarked: Boolean,
onClick: () -> Unit
) {
- // iOS-style list row (plain button, no card background)
- Surface(
- onClick = onClick,
- color = if (isSelected) {
- MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
- } else {
- Color.Transparent
- },
- shape = MaterialTheme.shapes.medium,
+ val colorScheme = MaterialTheme.colorScheme
+ Box(
+ modifier = Modifier
+ .size(36.dp)
+ .clickable(onClick = onClick),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ painter = painterResource(
+ if (bookmarked) {
+ R.drawable.ic_spec_bookmark_filled
+ } else {
+ R.drawable.ic_spec_bookmark_outline
+ }
+ ),
+ contentDescription = stringResource(
+ if (bookmarked) R.string.cd_remove_bookmark else R.string.cd_add_bookmark
+ ),
+ tint = if (bookmarked) colorScheme.primary else colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(22.dp)
+ )
+ }
+}
+
+@Composable
+private fun ChannelLoadingRow() {
+ val colorScheme = MaterialTheme.colorScheme
+ Row(
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 24.dp, vertical = 2.dp)
+ .padding(horizontal = ChannelRowHorizontal, vertical = ChannelRowVertical),
+ horizontalArrangement = Arrangement.spacedBy(ChannelLeadingGutter),
+ verticalAlignment = Alignment.CenterVertically
) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 6.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
+ Box(
+ modifier = Modifier.size(ChannelLeadingSlot),
+ contentAlignment = Alignment.Center
) {
- Column(
- modifier = Modifier.weight(1f),
- verticalArrangement = Arrangement.spacedBy(2.dp)
- ) {
- // Split title to handle count part with smaller font (iOS style)
- val (baseTitle, countSuffix) = splitTitleAndCount(title)
+ CircularProgressIndicator(
+ modifier = Modifier.size(16.dp),
+ strokeWidth = 2.dp,
+ color = colorScheme.onSurfaceVariant
+ )
+ }
+ Text(
+ text = stringResource(R.string.finding_nearby_channels),
+ fontSize = 12.sp,
+ fontFamily = BitchatFontFamily,
+ color = colorScheme.onSurfaceVariant
+ )
+ }
+}
- Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
- Text(
- text = baseTitle,
- fontSize = BASE_FONT_SIZE.sp,
- fontFamily = FontFamily.Monospace,
- fontWeight = if (titleBold) FontWeight.Bold else FontWeight.Normal,
- color = titleColor ?: MaterialTheme.colorScheme.onSurface
- )
+/**
+ * Teleport / custom geohash control unified into the same card as channel options.
+ */
+@Composable
+private fun CustomGeohashRow(
+ customGeohash: String,
+ onGeohashChange: (String) -> Unit,
+ onFocusGained: () -> Unit,
+ onOpenMap: () -> Unit,
+ onTeleport: () -> Unit
+) {
+ val colorScheme = MaterialTheme.colorScheme
+ val palette = LocalBitchatPalette.current
+ val density = LocalDensity.current
+ val imeInsets = WindowInsets.ime
+ val coroutineScope = rememberCoroutineScope()
+ val bringIntoViewRequester = remember { BringIntoViewRequester() }
+ val normalized = customGeohash.trim().lowercase().replace("#", "")
+ val isValid = validateGeohash(normalized)
+ // Typing the last character of a valid geohash arms the button; cross-fading both the label
+ // and its container makes that the moment the row confirms the input is usable.
+ val teleportColor by animateColorAsState(
+ targetValue = if (isValid) colorScheme.primary else palette.textTertiary,
+ animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
+ label = "teleportLabel"
+ )
+ val teleportContainer by animateColorAsState(
+ targetValue = if (isValid) {
+ colorScheme.primary.copy(alpha = 0.16f)
+ } else {
+ colorScheme.surfaceVariant
+ },
+ animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
+ label = "teleportContainer"
+ )
+ val teleportInteraction = remember { MutableInteractionSource() }
+ val teleportScale = rememberPressScale(teleportInteraction, pressedScale = 0.92f)
- countSuffix?.let { count ->
- Text(
- text = count,
- fontSize = 11.sp,
- fontFamily = FontFamily.Monospace,
- color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
- )
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .bringIntoViewRequester(bringIntoViewRequester)
+ .padding(horizontal = ChannelRowHorizontal, vertical = ChannelRowVertical),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier.size(ChannelLeadingSlot),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = Icons.Filled.PinDrop,
+ contentDescription = null,
+ tint = colorScheme.primary,
+ modifier = Modifier.size(22.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.width(ChannelLeadingGutter))
+
+ Text(
+ text = stringResource(R.string.hash_symbol),
+ fontSize = 14.sp,
+ fontFamily = BitchatFontFamily,
+ color = palette.textTertiary
+ )
+
+ Spacer(modifier = Modifier.width(4.dp))
+
+ BasicTextField(
+ value = customGeohash,
+ onValueChange = onGeohashChange,
+ textStyle = TextStyle(
+ fontSize = 14.sp,
+ fontFamily = BitchatFontFamily,
+ color = colorScheme.primary
+ ),
+ cursorBrush = SolidColor(colorScheme.primary),
+ singleLine = true,
+ modifier = Modifier
+ .weight(1f)
+ .onFocusChanged { focusState ->
+ if (!focusState.isFocused) return@onFocusChanged
+ onFocusGained()
+ // Wait until IME insets have landed so the LazyColumn has shrunk; bringIntoView
+ // against the full-height viewport leaves the field tucked under the keyboard.
+ // Re-request once the IME animation settles — early insets are still growing.
+ coroutineScope.launch {
+ withTimeoutOrNull(750) {
+ snapshotFlow { imeInsets.getBottom(density) }
+ .first { it > 0 }
+ }
+ bringIntoViewRequester.bringIntoView()
+ delay(BitchatMotion.STANDARD_MS.toLong())
+ bringIntoViewRequester.bringIntoView()
}
- }
-
- Text(
- text = subtitle,
- fontSize = 12.sp,
- fontFamily = FontFamily.Monospace,
- color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
- )
- }
-
- Row(verticalAlignment = Alignment.CenterVertically) {
- if (isSelected) {
- Icon(
- imageVector = Icons.Filled.Check,
- contentDescription = stringResource(R.string.cd_selected),
- tint = Color(0xFF32D74B), // iOS green for checkmark
- modifier = Modifier.size(20.dp)
+ },
+ decorationBox = { inner ->
+ if (customGeohash.isEmpty()) {
+ Text(
+ text = stringResource(R.string.geohash_placeholder),
+ fontSize = 14.sp,
+ fontFamily = BitchatFontFamily,
+ color = palette.textTertiary
)
}
-
- if (trailingContent != null) {
- trailingContent()
- }
+ inner()
}
+ )
+
+ Box(
+ modifier = Modifier
+ .size(36.dp)
+ .clip(CircleShape)
+ .pressScaleClickable(onClick = onOpenMap),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Map,
+ contentDescription = stringResource(R.string.cd_open_map),
+ tint = colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(22.dp)
+ )
+ }
+
+ Surface(
+ onClick = onTeleport,
+ enabled = isValid,
+ shape = RoundedCornerShape(8.dp),
+ color = teleportContainer,
+ interactionSource = teleportInteraction,
+ modifier = Modifier.scale(teleportScale)
+ ) {
+ Text(
+ text = stringResource(R.string.teleport).uppercase(),
+ fontSize = 11.sp,
+ letterSpacing = 0.8.sp,
+ fontWeight = FontWeight.Medium,
+ fontFamily = BitchatFontFamily,
+ color = teleportColor,
+ modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp)
+ )
}
}
}
-// MARK: - Helper Functions (matching iOS implementation)
+/** Settings-toggle row geometry (icon + title/subtitle + switch), local copy for this sheet. */
+@Composable
+private fun ChannelSettingsToggleRow(
+ icon: ImageVector,
+ title: String,
+ subtitle: String,
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+ enabled: Boolean = true,
+ statusIndicator: (@Composable () -> Unit)? = null
+) {
+ val colorScheme = MaterialTheme.colorScheme
+ val palette = LocalBitchatPalette.current
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = ChannelRowHorizontal, vertical = ChannelRowVertical),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = if (enabled) colorScheme.primary else palette.textTertiary,
+ modifier = Modifier.size(ChannelLeadingSlot)
+ )
+ Spacer(modifier = Modifier.width(ChannelLeadingGutter))
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(2.dp)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = title,
+ fontFamily = BitchatFontFamily,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium,
+ color = if (enabled) colorScheme.onSurface else palette.textTertiary
+ )
+ statusIndicator?.invoke()
+ }
+ Text(
+ text = subtitle,
+ fontFamily = BitchatFontFamily,
+ fontSize = 12.sp,
+ lineHeight = 17.sp,
+ color = if (enabled) colorScheme.onSurfaceVariant else palette.textTertiary
+ )
+ }
+ Spacer(modifier = Modifier.width(16.dp))
+ Switch(
+ checked = checked,
+ onCheckedChange = { if (enabled) onCheckedChange(it) },
+ enabled = enabled,
+ colors = SwitchDefaults.colors(
+ checkedThumbColor = Color.White,
+ checkedTrackColor = colorScheme.primary,
+ uncheckedThumbColor = Color.White,
+ uncheckedTrackColor = colorScheme.surfaceVariant
+ )
+ )
+ }
+}
+
+// MARK: - Helper Functions
+
+/** Separates the channel name from the animated people-count label in composed titles. */
+private const val TITLE_COUNT_SEP = '\u001F'
private fun splitTitleAndCount(title: String): Pair {
+ val sep = title.indexOf(TITLE_COUNT_SEP)
+ if (sep != -1) {
+ return Pair(title.substring(0, sep), title.substring(sep + 1).ifEmpty { null })
+ }
+ // Legacy "[n people]" form
val lastBracketIndex = title.lastIndexOf('[')
return if (lastBracketIndex != -1) {
- val prefix = title.substring(0, lastBracketIndex).trim()
- val suffix = title.substring(lastBracketIndex)
- Pair(prefix, suffix)
+ val count = title.substring(lastBracketIndex + 1).removeSuffix("]")
+ Pair(title.substring(0, lastBracketIndex).trim(), count.ifEmpty { null })
} else {
Pair(title, null)
}
@@ -667,56 +1034,48 @@ private fun splitTitleAndCount(title: String): Pair {
@Composable
private fun meshTitleWithCount(viewModel: ChatViewModel): String {
val meshCount = meshCount(viewModel)
- val ctx = androidx.compose.ui.platform.LocalContext.current
- val peopleText = ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, meshCount, meshCount)
- val meshLabel = stringResource(com.bitchat.android.R.string.mesh_label)
- return "$meshLabel [$peopleText]"
+ val ctx = LocalContext.current
+ val peopleText = ctx.resources.getQuantityString(R.plurals.people_count, meshCount, meshCount)
+ val meshLabel = stringResource(R.string.mesh_title)
+ return "$meshLabel$TITLE_COUNT_SEP$peopleText"
}
private fun meshCount(viewModel: ChatViewModel): Int {
val myID = viewModel.myPeerID
- return viewModel.connectedPeers.value?.count { peerID ->
- peerID != myID
- } ?: 0
+ return viewModel.connectedPeers.value?.count { it != myID } ?: 0
}
@Composable
private fun geohashTitleWithCount(channel: GeohashChannel, participantCount: Int): String {
- val ctx = androidx.compose.ui.platform.LocalContext.current
-
- // For high precision channels (Neighborhood, Block) where we don't broadcast presence,
- // show "? people" instead of "0 people" to avoid misleading "nobody is here" indication.
+ val ctx = LocalContext.current
val isHighPrecision = channel.level.precision > 5
val peopleText = if (isHighPrecision && participantCount == 0) {
- ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, 0, 0).replace("0", "?")
+ ctx.resources.getQuantityString(R.plurals.people_count, 0, 0).replace("0", "?")
} else {
- ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount)
+ ctx.resources.getQuantityString(R.plurals.people_count, participantCount, participantCount)
}
-
val levelName = when (channel.level) {
- com.bitchat.android.geohash.GeohashChannelLevel.BUILDING -> "Building" // iOS: precision 8 for location notes
- com.bitchat.android.geohash.GeohashChannelLevel.BLOCK -> stringResource(com.bitchat.android.R.string.location_level_block)
- com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD -> stringResource(com.bitchat.android.R.string.location_level_neighborhood)
- com.bitchat.android.geohash.GeohashChannelLevel.CITY -> stringResource(com.bitchat.android.R.string.location_level_city)
- com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE -> stringResource(com.bitchat.android.R.string.location_level_province)
- com.bitchat.android.geohash.GeohashChannelLevel.REGION -> stringResource(com.bitchat.android.R.string.location_level_region)
+ GeohashChannelLevel.BUILDING -> "Building"
+ GeohashChannelLevel.BLOCK -> stringResource(R.string.location_level_block)
+ GeohashChannelLevel.NEIGHBORHOOD -> stringResource(R.string.location_level_neighborhood)
+ GeohashChannelLevel.CITY -> stringResource(R.string.location_level_city)
+ GeohashChannelLevel.PROVINCE -> stringResource(R.string.location_level_province)
+ GeohashChannelLevel.REGION -> stringResource(R.string.location_level_region)
}
- return "$levelName [$peopleText]"
+ return "$levelName$TITLE_COUNT_SEP$peopleText"
}
@Composable
private fun geohashHashTitleWithCount(geohash: String, participantCount: Int): String {
- val ctx = androidx.compose.ui.platform.LocalContext.current
+ val ctx = LocalContext.current
val level = levelForLength(geohash.length)
val isHighPrecision = level.precision > 5
-
val peopleText = if (isHighPrecision && participantCount == 0) {
- ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, 0, 0).replace("0", "?")
+ ctx.resources.getQuantityString(R.plurals.people_count, 0, 0).replace("0", "?")
} else {
- ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount)
+ ctx.resources.getQuantityString(R.plurals.people_count, participantCount, participantCount)
}
-
- return "#$geohash [$peopleText]"
+ return "#$geohash$TITLE_COUNT_SEP$peopleText"
}
private fun isChannelSelected(channel: GeohashChannel, selectedChannel: ChannelID?): Boolean {
@@ -726,6 +1085,24 @@ private fun isChannelSelected(channel: GeohashChannel, selectedChannel: ChannelI
}
}
+/**
+ * Returns the active location channel when it has no row in the nearby-channel list.
+ *
+ * This commonly happens after teleporting to a remote geohash. Keeping the selected channel in the
+ * main channel card makes its selection and bookmark action available even before it is bookmarked.
+ */
+internal fun selectedLocationChannelOutsideNearby(
+ selectedChannel: ChannelID?,
+ nearbyChannels: List
+): GeohashChannel? {
+ val selected = (selectedChannel as? ChannelID.Location)?.channel ?: return null
+ return selected.takeUnless { active ->
+ nearbyChannels.any { nearby ->
+ nearby.geohash.equals(active.geohash, ignoreCase = true)
+ }
+ }
+}
+
private fun validateGeohash(geohash: String): Boolean {
if (geohash.isEmpty() || geohash.length > 12) return false
val allowed = "0123456789bcdefghjkmnpqrstuvwxyz".toSet()
@@ -739,13 +1116,12 @@ private fun levelForLength(length: Int): GeohashChannelLevel {
5 -> GeohashChannelLevel.CITY
6 -> GeohashChannelLevel.NEIGHBORHOOD
7 -> GeohashChannelLevel.BLOCK
- 8 -> GeohashChannelLevel.BUILDING // iOS: precision 8 for building-level
+ 8 -> GeohashChannelLevel.BUILDING
else -> if (length > 8) GeohashChannelLevel.BUILDING else GeohashChannelLevel.BLOCK
}
}
private fun coverageString(precision: Int): String {
- // Approximate max cell dimension at equator for a given geohash length
val maxMeters = when (precision) {
2 -> 1_250_000.0
3 -> 156_000.0
@@ -758,10 +1134,7 @@ private fun coverageString(precision: Int): String {
10 -> 1.19
else -> if (precision <= 1) 5_000_000.0 else 1.19 * Math.pow(0.25, (precision - 10).toDouble())
}
-
- // Use metric system for simplicity (could be made locale-aware)
- val km = maxMeters / 1000.0
- return "~${formatDistance(km)} km"
+ return "~${formatDistance(maxMeters / 1000.0)} km"
}
private fun formatDistance(value: Double): String {
@@ -772,11 +1145,6 @@ private fun formatDistance(value: Double): String {
}
}
-private fun bluetoothRangeString(): String {
- // Approximate Bluetooth LE range for typical mobile devices
- return "~10–50 m"
-}
+private fun meshRangeString(): String = "~10–50m"
-private fun formattedNamePrefix(level: GeohashChannelLevel): String {
- return "~"
-}
+private fun formattedNamePrefix(level: GeohashChannelLevel): String = "~"
diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt
index 6ad9bdd8..489ddaf4 100644
--- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt
+++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt
@@ -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
)
}
}
diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt
index 777317a8..2d0dacfe 100644
--- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt
+++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt
@@ -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),
)
}
diff --git a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt
deleted file mode 100644
index 4ca8674f..00000000
--- a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt
+++ /dev/null
@@ -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>(emptySet())
- val miningMessages: StateFlow> = _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,
- )
-}
diff --git a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt
index ec8c064a..68797e08 100644
--- a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt
+++ b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt
@@ -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
}
diff --git a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt
index 8d18563a..de2f12c5 100644
--- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt
+++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt
@@ -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)
*/
diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt
index ec137437..27bb7448 100644
--- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt
+++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt
@@ -1,26 +1,40 @@
package com.bitchat.android.ui
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material.icons.outlined.*
+import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.R
import android.util.Log
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
import androidx.compose.foundation.*
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.foundation.shape.RoundedCornerShape
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.ArrowBack
-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
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
-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
@@ -29,6 +43,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.core.ui.component.button.CloseButton
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
import com.bitchat.android.core.ui.component.sheet.BitchatSheetCenterTopBar
+import com.bitchat.android.core.ui.component.sheet.LocalSheetDismiss
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
import com.bitchat.android.favorites.FavoriteRelationship
@@ -36,11 +51,15 @@ import com.bitchat.android.favorites.FavoritesPersistenceService
import com.bitchat.android.geohash.ChannelID
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
+import com.bitchat.android.ui.theme.BitchatMotion
+import com.bitchat.android.ui.theme.LocalBitchatPalette
+import com.bitchat.android.ui.theme.colorForPeer
import com.bitchat.android.nostr.GeohashAliasRegistry
import com.bitchat.android.nostr.GeohashConversationRegistry
import com.bitchat.android.services.ContactDirectory
import com.bitchat.android.services.ContactIdentityResolver
import com.bitchat.android.util.hexEncodedString
+import kotlinx.coroutines.launch
/**
@@ -68,8 +87,18 @@ fun MeshPeerListSheet(
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
+ val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
+ val unreadConversations by viewModel.unreadConversations.collectAsStateWithLifecycle()
+ val geohashPeopleCount = geohashPeople.size
val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle()
val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() }
+ val unreadIdentityAliases = remember(unreadConversations) {
+ unreadConversations
+ .flatMapTo(mutableSetOf()) { it.identityAliases }
+ }
+ val visibleConnectedPeers = connectedPeers.filterNot { peerID ->
+ peerID.lowercase() in unreadIdentityAliases
+ }
// Bottom sheet state
val sheetState = rememberModalBottomSheetState(
@@ -98,81 +127,115 @@ fun MeshPeerListSheet(
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
- contentPadding = PaddingValues(top = 64.dp, bottom = 20.dp)
+ contentPadding = PaddingValues(top = 72.dp, bottom = 32.dp)
) {
- // Channels section
- if (joinedChannels.isNotEmpty()) {
- item(key = "channels_header") {
- Text(
- text = stringResource(id = R.string.channels).uppercase(),
- style = MaterialTheme.typography.labelLarge,
- color = colorScheme.onSurface.copy(alpha = 0.7f),
- fontWeight = FontWeight.Bold,
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 24.dp)
- .padding(top = 8.dp, bottom = 4.dp)
- )
- }
+ val peopleCount = when (selectedLocationChannel) {
+ is ChannelID.Location -> geohashPeopleCount
+ else -> visibleConnectedPeers.count { it != viewModel.myPeerID }
+ }
- items(
- items = joinedChannels.toList(),
- key = { "channel_$it" }
- ) { channel ->
- val isSelected = channel == currentChannel
- val unreadCount = unreadChannelMessages[channel] ?: 0
-
- ChannelRow(
- channel = channel,
- isSelected = isSelected,
- unreadCount = unreadCount,
- colorScheme = colorScheme,
- onChannelClick = {
- // Check if this is a DM channel (starts with @)
- if (channel.startsWith("@")) {
- // Extract peer name and find the peer ID
- val peerName = channel.removePrefix("@")
- val peerID =
- peerNicknames.entries.firstOrNull { it.value == peerName }?.key
- if (peerID != null) {
- viewModel.showPrivateChatSheet(peerID)
- onDismiss()
- }
- } else {
- // Regular channel switch
- viewModel.switchToChannel(channel)
- onDismiss()
- }
- },
- onLeaveChannel = {
- viewModel.leaveChannel(channel)
+ if (unreadConversations.isNotEmpty()) {
+ item(key = "unread_private_messages_section") {
+ UnreadDirectMessagesSection(
+ conversations = unreadConversations,
+ viewModel = viewModel,
+ onPrivateChatStart = { conversationID ->
+ viewModel.showPrivateChatSheet(conversationID)
+ onDismiss()
},
+ modifier = Modifier.padding(top = 8.dp)
)
}
}
- // People section - switch between mesh and geohash lists (iOS-compatible)
+ // Channels section
+ if (joinedChannels.isNotEmpty()) {
+ item(key = "channels_section") {
+ Column {
+ SheetIconSectionHeader(
+ iconRes = R.drawable.ic_spec_chat_bubbles,
+ title = stringResource(R.string.channels),
+ modifier = Modifier.padding(
+ top = if (unreadConversations.isNotEmpty()) 20.dp else 8.dp
+ )
+ )
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = AboutHorizontalPadding)
+ .padding(top = 10.dp),
+ color = MaterialTheme.colorScheme.surface,
+ shape = AboutCardShape
+ ) {
+ Column {
+ joinedChannels.toList().forEachIndexed { index, channel ->
+ if (index > 0) SheetCardDivider()
+ val isSelected = channel == currentChannel
+ val unreadCount = unreadChannelMessages[channel] ?: 0
+ ChannelRow(
+ channel = channel,
+ isSelected = isSelected,
+ unreadCount = unreadCount,
+ colorScheme = colorScheme,
+ onChannelClick = {
+ if (channel.startsWith("@")) {
+ val peerName = channel.removePrefix("@")
+ val peerID =
+ peerNicknames.entries.firstOrNull { it.value == peerName }?.key
+ if (peerID != null) {
+ viewModel.showPrivateChatSheet(peerID)
+ onDismiss()
+ }
+ } else {
+ viewModel.switchToChannel(channel)
+ onDismiss()
+ }
+ },
+ onLeaveChannel = {
+ viewModel.leaveChannel(channel)
+ },
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // People / geohash participants
item(key = "people_section") {
when (selectedLocationChannel) {
is ChannelID.Location -> {
- // Show geohash people list when in location channel
GeohashPeopleList(
viewModel = viewModel,
- onTapPerson = onDismiss
+ onTapPerson = onDismiss,
+ excludedIdentityAliases = unreadIdentityAliases,
+ modifier = Modifier.padding(
+ top = if (
+ joinedChannels.isNotEmpty() ||
+ unreadConversations.isNotEmpty()
+ ) 20.dp else 8.dp
+ )
)
}
else -> {
- // Show mesh peer list when in mesh channel (default)
PeopleSection(
- modifier = Modifier.padding(top = if (joinedChannels.isNotEmpty()) 16.dp else 0.dp),
- connectedPeers = connectedPeers,
+ modifier = Modifier.padding(
+ top = if (
+ joinedChannels.isNotEmpty() ||
+ unreadConversations.isNotEmpty()
+ ) 20.dp else 8.dp
+ ),
+ connectedPeers = visibleConnectedPeers,
peerNicknames = peerNicknames,
peerRSSI = peerRSSI,
nickname = nickname,
colorScheme = colorScheme,
selectedPrivatePeer = selectedPrivatePeer,
wifiAwarePeerIDs = wifiAwarePeerIDs,
+ peopleCount = peopleCount,
+ excludedIdentityAliases = unreadIdentityAliases,
viewModel = viewModel,
onPrivateChatStart = { peerID ->
viewModel.showPrivateChatSheet(peerID)
@@ -194,13 +257,13 @@ fun MeshPeerListSheet(
if (selectedLocationChannel !is ChannelID.Location) {
IconButton(
onClick = onShowVerification,
- modifier = Modifier.size(24.dp)
+ modifier = Modifier.size(44.dp)
) {
Icon(
imageVector = Icons.Outlined.QrCode,
contentDescription = stringResource(R.string.verify_title),
- tint = colorScheme.onSurface.copy(alpha = 0.8f),
- modifier = Modifier.size(18.dp)
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(22.dp)
)
}
}
@@ -213,6 +276,9 @@ fun MeshPeerListSheet(
}
}
+/** Icon size for trailing actions on peer rows (matches settings glyph scale). */
+private val PeerRowIconSize = 22.dp
+
@Composable
private fun ChannelRow(
channel: String,
@@ -222,59 +288,52 @@ private fun ChannelRow(
onChannelClick: () -> Unit,
onLeaveChannel: () -> Unit,
) {
- Surface(
- onClick = onChannelClick,
- color = if (isSelected) {
- colorScheme.primaryContainer.copy(alpha = 0.15f)
- } else {
- Color.Transparent
- },
- shape = MaterialTheme.shapes.medium,
+ val palette = LocalBitchatPalette.current
+
+ Row(
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 24.dp, vertical = 2.dp)
+ .clickable(onClick = onChannelClick)
+ .padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical),
+ verticalAlignment = Alignment.CenterVertically
) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 12.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
+ Box(
+ modifier = Modifier.size(SheetRowLeadingSlot),
+ contentAlignment = Alignment.Center
) {
- Row(
- modifier = Modifier.weight(1f),
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- // Unread badge
- if (unreadCount > 0) {
- UnreadBadge(
- count = unreadCount,
- colorScheme = colorScheme
- )
- }
-
- Text(
- text = channel,
- style = MaterialTheme.typography.bodyMedium.copy(
- fontFamily = FontFamily.Monospace,
- fontSize = BASE_FONT_SIZE.sp
- ),
- color = if (isSelected) colorScheme.primary else colorScheme.onSurface,
- fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal
+ if (isSelected) {
+ Box(
+ modifier = Modifier
+ .size(SheetRowSelectedDot)
+ .background(colorScheme.primary, CircleShape)
)
- }
-
- Row(
- horizontalArrangement = Arrangement.spacedBy(4.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- // Leave channel button
- CloseButton(
- onClick = onLeaveChannel,
+ } else if (unreadCount > 0) {
+ UnreadBadge(count = unreadCount, colorScheme = colorScheme)
+ } else {
+ Text(
+ text = "#",
+ fontFamily = BitchatFontFamily,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium,
+ color = palette.textTertiary
)
}
}
+
+ Spacer(modifier = Modifier.width(SheetRowLeadingGutter))
+
+ Text(
+ text = channel,
+ fontFamily = BitchatFontFamily,
+ fontSize = 14.sp,
+ color = if (isSelected) colorScheme.primary else colorScheme.onSurface,
+ fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium,
+ modifier = Modifier.weight(1f),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+
+ CloseButton(onClick = onLeaveChannel)
}
}
@@ -290,6 +349,8 @@ fun PeopleSection(
colorScheme: ColorScheme,
selectedPrivatePeer: String?,
wifiAwarePeerIDs: Set = emptySet(),
+ peopleCount: Int = 0,
+ excludedIdentityAliases: Set = emptySet(),
viewModel: ChatViewModel,
onPrivateChatStart: (String) -> Unit
) {
@@ -298,36 +359,40 @@ fun PeopleSection(
SecureIdentityStateManager(context.applicationContext)
}
+ val palette = LocalBitchatPalette.current
+
Column(modifier = modifier) {
- Text(
- text = stringResource(id = R.string.people).uppercase(),
- style = MaterialTheme.typography.labelLarge,
- color = colorScheme.onSurface.copy(alpha = 0.7f),
- fontWeight = FontWeight.Bold,
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 24.dp)
- .padding(top = 8.dp, bottom = 4.dp)
+ SheetIconSectionHeader(
+ iconRes = R.drawable.ic_spec_people,
+ title = stringResource(R.string.people_count_title, peopleCount)
)
- if (connectedPeers.isEmpty()) {
- Text(
- text = stringResource(id = R.string.no_one_connected),
- style = MaterialTheme.typography.bodyMedium.copy(
- fontFamily = FontFamily.Monospace,
- fontSize = 12.sp
- ),
- color = colorScheme.onSurface.copy(alpha = 0.5f),
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 40.dp, vertical = 12.dp)
- )
- }
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = AboutHorizontalPadding)
+ .padding(top = 10.dp),
+ color = colorScheme.surface,
+ shape = AboutCardShape
+ ) {
+ Column {
+ if (connectedPeers.isEmpty()) {
+ Text(
+ text = stringResource(id = R.string.no_one_connected),
+ fontFamily = BitchatFontFamily,
+ fontSize = 12.sp,
+ color = palette.textTertiary,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical)
+ )
+ }
// Observe reactive state for favorites and fingerprints
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
+ val peerFavoritedUs by viewModel.peerFavoritedUs.collectAsStateWithLifecycle()
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
@@ -339,6 +404,22 @@ fun PeopleSection(
}
}
+ // Same "they favorited us" signal the private-chat header uses for orange outline stars.
+ val peerTheyFavoritedUsStates = remember(peerFavoritedUs, peerFingerprints, connectedPeers) {
+ connectedPeers.associateWith { peerID ->
+ val fingerprint = peerFingerprints[peerID]
+ if (fingerprint != null && peerFavoritedUs.contains(fingerprint)) {
+ true
+ } else {
+ try {
+ FavoritesPersistenceService.shared.getFavoriteStatus(peerID)?.theyFavoritedUs == true
+ } catch (_: Exception) {
+ false
+ }
+ }
+ }
+ }
+
val peerVerifiedStates = remember(verifiedFingerprints, peerFingerprints, connectedPeers) {
connectedPeers.associateWith { peerID ->
viewModel.isPeerVerified(peerID, verifiedFingerprints)
@@ -385,8 +466,6 @@ fun PeopleSection(
)
// Build a map of base name counts across all people shown in the list (connected + offline + nostr)
- val hex64Regex = Regex("^[0-9a-fA-F]{64}$")
-
// Helper to compute display name used for a given key
fun computeDisplayNameForPeerId(key: String): String {
return if (key == nickname) "You" else (peerNicknames[key] ?: (privateChats[key]?.lastOrNull()?.sender ?: key.take(12)))
@@ -405,30 +484,40 @@ fun PeopleSection(
val offlineFavorites = FavoritesPersistenceService.shared.getOurFavorites()
offlineFavorites.forEach { fav ->
val favPeerID = ContactIdentityResolver.noiseKeyHex(fav.peerNoisePublicKey)
- if (!isFavoriteMappedToConnected(fav)) {
+ if (
+ favPeerID.lowercase() !in excludedIdentityAliases &&
+ !isFavoriteMappedToConnected(fav)
+ ) {
val dn = peerNicknames[favPeerID] ?: fav.peerNickname
val (b, _) = splitSuffix(dn)
if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1
}
}
- // Nostr-only conversations
- val connectedIds = sortedPeers.toSet()
- privateChats.keys
- .filter { key ->
- (key.startsWith("nostr_") || hex64Regex.matches(key)) &&
- !connectedIds.contains(key) &&
- !connectedNoiseHexes.contains(key.lowercase())
- }
- .forEach { convKey ->
- val dn = peerNicknames[convKey] ?: (privateChats[convKey]?.lastOrNull()?.sender ?: convKey.take(12))
- val (b, _) = splitSuffix(dn)
- if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1
- }
+ // Every row this card will show, in final order, so the animated list can key on identity
+ // and animate reordering. Offline favourites are appended after the connected peers.
+ // Collected once for the whole card rather than once per row.
+ val directMap by viewModel.peerDirect.collectAsStateWithLifecycle()
- sortedPeers.forEach { peerID ->
+ val offlineFavoriteRows = offlineFavorites.filterNot { favorite ->
+ val favoriteNoiseKey = ContactIdentityResolver.noiseKeyHex(
+ favorite.peerNoisePublicKey
+ )
+ favoriteNoiseKey.lowercase() in excludedIdentityAliases ||
+ isFavoriteMappedToConnected(favorite)
+ }
+ val rowKeys: List = sortedPeers +
+ offlineFavoriteRows.map { ContactIdentityResolver.noiseKeyHex(it.peerNoisePublicKey) }
+
+ AnimatedRowColumn(items = rowKeys, key = { it }) { rowIndex, rowKey ->
+ Column {
+ if (rowIndex > 0) SheetCardDivider()
+ val connectedPeerForRow = sortedPeers.firstOrNull { it == rowKey }
+ if (connectedPeerForRow != null) {
+ val peerID = connectedPeerForRow
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val isFavorite = peerFavoriteStates[peerID] ?: false
+ val theyFavoritedUs = peerTheyFavoritedUsStates[peerID] ?: false
val isVerified = peerVerifiedStates[peerID] ?: false
// fingerprint and favorite relationship resolution not needed here; UI will show Nostr globe for appended offline favorites below
@@ -446,7 +535,6 @@ fun PeopleSection(
val (bName, _) = splitSuffix(displayName)
val showHash = (baseNameCounts[bName] ?: 0) > 1
- val directMap by viewModel.peerDirect.collectAsStateWithLifecycle()
val isDirectLive = directMap[peerID] ?: try { viewModel.getMeshPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false }
PeerItem(
peerID = peerID,
@@ -455,6 +543,7 @@ fun PeopleSection(
isWifiAware = peerID in wifiAwarePeerIDs,
isSelected = conversationID == selectedPrivatePeer || peerID == selectedPrivatePeer,
isFavorite = isFavorite,
+ theyFavoritedUs = theyFavoritedUs,
isVerified = isVerified,
hasUnreadDM = combinedHasUnread,
colorScheme = colorScheme,
@@ -468,12 +557,12 @@ fun PeopleSection(
showNostrGlobe = false,
showHashSuffix = showHash
)
- }
-
- // Append offline favorites we actively favorite (and not currently connected)
- offlineFavorites.forEach { fav ->
- val favPeerID = ContactIdentityResolver.noiseKeyHex(fav.peerNoisePublicKey)
- if (isFavoriteMappedToConnected(fav)) return@forEach
+ } else {
+ // Offline favourite: still worth showing, reachable over Nostr.
+ val fav = offlineFavoriteRows.first {
+ ContactIdentityResolver.noiseKeyHex(it.peerNoisePublicKey) == rowKey
+ }
+ val favPeerID = rowKey
val nostrConvKey: String? = try {
FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey)
@@ -504,6 +593,7 @@ fun PeopleSection(
isDirect = false,
isSelected = conversationID == selectedPrivatePeer || (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer,
isFavorite = true,
+ theyFavoritedUs = fav.theyFavoritedUs,
isVerified = isVerified,
hasUnreadDM = hasUnread,
colorScheme = colorScheme,
@@ -518,6 +608,130 @@ fun PeopleSection(
showHashSuffix = showHash
)
}
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun UnreadDirectMessagesSection(
+ conversations: List,
+ viewModel: ChatViewModel,
+ onPrivateChatStart: (String) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val palette = LocalBitchatPalette.current
+ val colorScheme = MaterialTheme.colorScheme
+
+ Column(modifier = modifier) {
+ SheetIconSectionHeader(
+ iconRes = R.drawable.ic_spec_envelope,
+ title = stringResource(R.string.cd_unread_private_messages)
+ )
+
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = AboutHorizontalPadding)
+ .padding(top = 10.dp),
+ color = colorScheme.surface,
+ shape = AboutCardShape
+ ) {
+ AnimatedRowColumn(
+ items = conversations,
+ key = { it.conversationID }
+ ) { index, conversation ->
+ Column {
+ if (index > 0) SheetCardDivider()
+
+ val subtitle = when {
+ conversation.sourceGeohash != null -> "#${conversation.sourceGeohash}"
+ conversation.transport == DirectMessageTransport.NOSTR ->
+ stringResource(R.string.cd_reachable_via_nostr)
+ !conversation.isConnected ->
+ stringResource(R.string.cd_offline_mesh_chat)
+ else -> null
+ }
+ val peerIdentity = conversation.nostrPubkey
+ ?.let(viewModel::peerIdentityForNostrPubkey)
+ ?: viewModel.peerIdentityForMeshPeer(conversation.conversationID)
+ val assignedColor = colorForPeer(peerIdentity, palette)
+ val (baseNameRaw, suffix) = splitSuffix(conversation.displayName)
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ onPrivateChatStart(conversation.conversationID)
+ }
+ .padding(
+ horizontal = SheetRowHorizontal,
+ vertical = SheetRowVertical
+ ),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier.size(SheetRowLeadingSlot),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_spec_envelope),
+ contentDescription = stringResource(R.string.cd_unread_message),
+ modifier = Modifier.size(PeerRowIconSize),
+ tint = palette.accentOrange
+ )
+ }
+
+ Spacer(modifier = Modifier.width(SheetRowLeadingGutter))
+
+ Column(modifier = Modifier.weight(1f)) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Text(
+ text = truncateNickname(baseNameRaw),
+ fontFamily = BitchatFontFamily,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium,
+ color = assignedColor,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+
+ if (suffix.isNotEmpty()) {
+ Text(
+ text = suffix,
+ fontFamily = BitchatFontFamily,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium,
+ color = assignedColor.copy(alpha = SUFFIX_ALPHA)
+ )
+ }
+ }
+
+ if (subtitle != null) {
+ Text(
+ text = subtitle,
+ fontFamily = BitchatFontFamily,
+ fontSize = 11.sp,
+ color = palette.textTertiary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+
+ UnreadBadge(
+ count = conversation.unreadCount,
+ colorScheme = colorScheme
+ )
+ }
+ }
+ }
+ }
}
}
@@ -529,6 +743,7 @@ private fun PeerItem(
isWifiAware: Boolean = false,
isSelected: Boolean,
isFavorite: Boolean,
+ theyFavoritedUs: Boolean = false,
isVerified: Boolean,
hasUnreadDM: Boolean,
colorScheme: ColorScheme,
@@ -547,143 +762,125 @@ private fun PeerItem(
val isMe = displayName == "You" || peerID == currentNickname
// Get consistent peer color (iOS-compatible)
- val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
- val assignedColor = viewModel.colorForMeshPeer(peerID, isDark)
- val baseColor = if (isMe) Color(0xFFFF9500) else assignedColor
+ val palette = LocalBitchatPalette.current
+ val assignedColor = colorForPeer(
+ viewModel.peerIdentityForMeshPeer(peerID),
+ palette
+ )
+ val baseColor = if (isMe) palette.accentOrange else assignedColor
- Surface(
- onClick = onItemClick,
- color = if (isSelected) {
- colorScheme.primaryContainer.copy(alpha = 0.15f)
- } else {
- Color.Transparent
- },
- shape = MaterialTheme.shapes.medium,
+ Row(
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 24.dp, vertical = 2.dp)
+ .clickable(onClick = onItemClick)
+ .padding(horizontal = SheetRowHorizontal, vertical = SheetRowVertical),
+ verticalAlignment = Alignment.CenterVertically
) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 12.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
+ Box(
+ modifier = Modifier.size(SheetRowLeadingSlot),
+ contentAlignment = Alignment.Center
) {
- Row(
- modifier = Modifier.weight(1f),
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- // Connection/status indicator
- if (hasUnreadDM) {
- // Show mail icon for unread DMs (iOS orange)
- Icon(
- imageVector = Icons.Filled.Email,
- contentDescription = stringResource(R.string.cd_unread_message),
- modifier = Modifier.size(16.dp),
- tint = Color(0xFFFF9500) // iOS orange
- )
- } else if (showNostrGlobe) {
- // Purple globe to indicate Nostr availability
- Icon(
- imageVector = Icons.Filled.Public,
- contentDescription = stringResource(R.string.cd_reachable_via_nostr),
- modifier = Modifier.size(16.dp),
- tint = Color(0xFF9C27B0) // Purple
- )
- } else if (!isDirect && isFavorite) {
- // Offline favorited user: show outlined circle icon
- Icon(
- imageVector = Icons.Outlined.Circle,
- contentDescription = stringResource(R.string.cd_offline_favorite),
- modifier = Modifier.size(16.dp),
- tint = Color.Gray
- )
- } else {
- Icon(
- imageVector = when {
- isWifiAware -> Icons.Filled.Wifi
- isDirect -> Icons.Outlined.Bluetooth
- else -> Icons.Filled.Route
- },
- contentDescription = when {
- isWifiAware -> "Direct Wi-Fi Aware"
- isDirect -> "Direct Bluetooth"
- else -> "Routed"
- },
- modifier = Modifier.size(16.dp),
- tint = colorScheme.onSurface.copy(alpha = 0.6f)
- )
- }
-
- // Display name with iOS-style color and hashtag suffix support
- Row(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
- ),
- color = baseColor,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
-
- // Hashtag suffix in lighter shade (iOS-style)
- 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)
- )
- }
-
- if (isWifiAware && hasUnreadDM) {
- Spacer(modifier = Modifier.width(4.dp))
- Icon(
- imageVector = Icons.Filled.Wifi,
- contentDescription = "Direct Wi-Fi Aware",
- modifier = Modifier.size(13.dp),
- tint = colorScheme.onSurface.copy(alpha = 0.8f)
- )
- }
- }
- }
-
- if (isVerified) {
- Spacer(modifier = Modifier.width(4.dp))
+ if (isSelected) {
+ Box(
+ modifier = Modifier
+ .size(SheetRowSelectedDot)
+ .background(colorScheme.primary, CircleShape)
+ )
+ } else if (hasUnreadDM) {
Icon(
- imageVector = Icons.Filled.Verified,
- contentDescription = null,
- modifier = Modifier.size(14.dp),
- tint = Color(0xFF32D74B) // iOS Green
+ painter = painterResource(R.drawable.ic_spec_envelope),
+ contentDescription = stringResource(R.string.cd_unread_message),
+ modifier = Modifier.size(PeerRowIconSize),
+ tint = palette.accentOrange
+ )
+ } else if (showNostrGlobe) {
+ Icon(
+ painter = painterResource(R.drawable.ic_spec_globe),
+ contentDescription = stringResource(R.string.cd_reachable_via_nostr),
+ modifier = Modifier.size(PeerRowIconSize),
+ tint = palette.accentPurple
+ )
+ } else if (!isDirect && isFavorite) {
+ Icon(
+ imageVector = Icons.Outlined.Circle,
+ contentDescription = stringResource(R.string.cd_offline_favorite),
+ modifier = Modifier.size(PeerRowIconSize),
+ tint = palette.textTertiary
+ )
+ } else {
+ Icon(
+ painter = painterResource(
+ conversationTransportIcon(
+ isReachedOverInternet = false,
+ isWifiAware = isWifiAware,
+ isDirect = isDirect
+ )
+ ),
+ contentDescription = when {
+ isWifiAware -> "Direct Wi-Fi Aware"
+ isDirect -> "Direct Bluetooth"
+ else -> "Routed"
+ },
+ modifier = Modifier.size(PeerRowIconSize),
+ tint = colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.width(SheetRowLeadingGutter))
+
+ Row(
+ modifier = Modifier.weight(1f),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Text(
+ text = baseName,
+ fontFamily = BitchatFontFamily,
+ fontSize = 14.sp,
+ fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium,
+ color = baseColor,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+
+ if (suffix.isNotEmpty()) {
+ Text(
+ text = suffix,
+ fontFamily = BitchatFontFamily,
+ fontSize = 14.sp,
+ fontWeight = if (isMe) FontWeight.Bold else FontWeight.Medium,
+ color = baseColor.copy(alpha = SUFFIX_ALPHA)
)
}
- Row(
- horizontalArrangement = Arrangement.spacedBy(4.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- // Favorite star with proper filled/outlined states
- IconButton(
- onClick = onToggleFavorite,
- modifier = Modifier.size(32.dp)
- ) {
- Icon(
- imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star,
- contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites",
- modifier = Modifier.size(16.dp),
- tint = if (isFavorite) Color(0xFFFFD700) else Color(0xFF4CAF50)
- )
- }
+ if (isVerified) {
+ Icon(
+ painter = painterResource(R.drawable.ic_spec_check),
+ contentDescription = stringResource(R.string.verify_title),
+ modifier = Modifier.size(16.dp),
+ tint = colorScheme.primary
+ )
}
}
+
+ Box(
+ modifier = Modifier
+ .size(36.dp)
+ .clickable(onClick = onToggleFavorite),
+ contentAlignment = Alignment.Center
+ ) {
+ // Three-state star (matches private-chat header): grey outline (no relation),
+ // orange outline (they favorited us), filled orange (we favorited them).
+ Icon(
+ painter = painterResource(
+ if (isFavorite) R.drawable.ic_spec_star_filled else R.drawable.ic_spec_star
+ ),
+ contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites",
+ modifier = Modifier.size(PeerRowIconSize),
+ tint = if (isFavorite || theyFavoritedUs) palette.accentOrange else palette.textTertiary
+ )
+ }
}
}
@@ -696,25 +893,41 @@ private fun UnreadBadge(
colorScheme: ColorScheme,
modifier: Modifier = Modifier
) {
- if (count > 0) {
+ val palette = LocalBitchatPalette.current
+ // Scale/fade in and out so a badge appearing while the sheet is open is noticed, and one
+ // clearing does not just blink away.
+ AnimatedVisibility(
+ visible = count > 0,
+ enter = fadeIn(tween(BitchatMotion.STANDARD_MS)) +
+ scaleIn(
+ initialScale = 0.5f,
+ animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing)
+ ),
+ exit = fadeOut(tween(BitchatMotion.QUICK_MS)) +
+ scaleOut(
+ targetScale = 0.5f,
+ animationSpec = tween(BitchatMotion.QUICK_MS, easing = FastOutSlowInEasing)
+ ),
+ modifier = modifier
+ ) {
Box(
- modifier = modifier
+ modifier = Modifier
.background(
- color = Color(0xFFFFD700), // Yellow color
+ color = palette.accentOrange,
shape = RoundedCornerShape(10.dp)
)
.padding(horizontal = 6.dp, vertical = 2.dp)
.defaultMinSize(minWidth = 18.dp, minHeight = 18.dp),
contentAlignment = Alignment.Center
) {
- Text(
+ AnimatedCountLabel(
+ count = count,
text = if (count > 99) "99+" else count.toString(),
- style = MaterialTheme.typography.labelSmall.copy(
- fontSize = 10.sp,
- fontWeight = FontWeight.Bold,
- fontFamily = FontFamily.Monospace
- ),
- color = Color.Black // Black text on yellow background
+ style = MaterialTheme.typography.labelSmall,
+ fontSize = 10.sp,
+ fontWeight = FontWeight.Bold,
+ fontFamily = BitchatFontFamily,
+ color = Color.Black
)
}
}
@@ -761,6 +974,7 @@ fun PrivateChatSheet(
val peerDirectMap by viewModel.peerDirect.collectAsStateWithLifecycle()
val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle()
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
+ val peerFavoritedUs by viewModel.peerFavoritedUs.collectAsStateWithLifecycle()
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
@@ -777,7 +991,7 @@ fun PrivateChatSheet(
}
val isNostrPeer = peerID.startsWith("nostr_") || peerID.startsWith("nostr:")
- val favoriteRelationship = remember(peerID, favoritePeers) {
+ val favoriteRelationship = remember(peerID, favoritePeers, peerFavoritedUs) {
try {
FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
} catch (_: Exception) {
@@ -804,7 +1018,7 @@ fun PrivateChatSheet(
val name = if (fullPubkey.isNotEmpty()) {
viewModel.geohashViewModel.displayNameForGeohashConversation(fullPubkey, gh)
} else {
- peerNicknames[peerID] ?: "unknown"
+ peerNicknames[peerID] ?: "Unknown"
}
"#$gh/@$name"
} else {
@@ -821,17 +1035,55 @@ fun PrivateChatSheet(
val isFavorite = remember(favoritePeers, fingerprint, peerID, favoriteRelationship) {
if (fingerprint != null) favoritePeers.contains(fingerprint) else viewModel.isFavorite(peerID)
}
+ val theyFavoritedUs = remember(peerFavoritedUs, fingerprint, favoriteRelationship) {
+ (fingerprint != null && peerFavoritedUs.contains(fingerprint)) ||
+ favoriteRelationship?.theyFavoritedUs == true
+ }
+
+ // Celebrate being favorited: a springy wobble of the header star. Springs rather than
+ // keyframed tweens, matching the app's press feedback, so the settle overshoots slightly.
+ val starWobbleRotation = remember { Animatable(0f) }
+ val starWobbleScale = remember { Animatable(1f) }
+ var previousTheyFavoritedUs by remember { mutableStateOf(null) }
+ LaunchedEffect(theyFavoritedUs) {
+ val wasFavoritedUs = previousTheyFavoritedUs
+ previousTheyFavoritedUs = theyFavoritedUs
+ if (theyFavoritedUs && wasFavoritedUs == false) {
+ starWobbleRotation.snapTo(-16f)
+ starWobbleScale.snapTo(1.35f)
+ launch {
+ starWobbleRotation.animateTo(
+ targetValue = 0f,
+ animationSpec = spring(dampingRatio = 0.3f, stiffness = Spring.StiffnessMedium)
+ )
+ }
+ launch {
+ starWobbleScale.animateTo(
+ targetValue = 1f,
+ animationSpec = spring(
+ dampingRatio = Spring.DampingRatioMediumBouncy,
+ stiffness = Spring.StiffnessHigh
+ )
+ )
+ }
+ }
+ }
val isVerified = remember(peerID, verifiedFingerprints) {
viewModel.isPeerVerified(peerID, verifiedFingerprints)
}
- val securityModifier = if (!isNostrPeer && !isNostrReachableFavorite) {
- Modifier.clickable { viewModel.showSecurityVerificationSheet() }
- } else {
- Modifier
- }
-
+ val palette = LocalBitchatPalette.current
+ // Three-state star: grey outline (no relation), orange outline (they favorited us),
+ // filled orange (we favorited them, mutual or not).
+ val favoriteStarTint by animateColorAsState(
+ targetValue = when {
+ isFavorite || theyFavoritedUs -> palette.accentOrange
+ else -> colorScheme.onSurfaceVariant
+ },
+ animationSpec = tween(BitchatMotion.STANDARD_MS, easing = FastOutSlowInEasing),
+ label = "favoriteStarTint"
+ )
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
@@ -845,9 +1097,9 @@ fun PrivateChatSheet(
Column(
modifier = Modifier.fillMaxSize()
) {
- Spacer(modifier = Modifier.height(64.dp))
+ Spacer(modifier = Modifier.height(ChatHeaderHeight))
- HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f))
+ HorizontalDivider(thickness = 1.dp, color = colorScheme.outlineVariant)
// Messages list
var forceScrollToBottom by remember { mutableStateOf(false) }
@@ -858,6 +1110,7 @@ fun PrivateChatSheet(
currentUserNickname = nickname,
meshService = viewModel.meshServiceFacade,
modifier = Modifier.weight(1f),
+ conversationKey = "dm:$peerID",
forceScrollToBottom = forceScrollToBottom,
onScrolledUpChanged = { isUp -> isScrolledUp = isUp },
onNicknameClick = { /* handle mention */ },
@@ -866,9 +1119,8 @@ fun PrivateChatSheet(
onImageClick = { _, _, _ -> /* handle image click */ }
)
- HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f))
-
- // Input section
+ // Input section. No divider here: ChatInputSection draws its own fade and
+ // hairline.
var messageText by remember {
mutableStateOf(
androidx.compose.ui.text.input.TextFieldValue(
@@ -913,112 +1165,89 @@ fun PrivateChatSheet(
)
}
- // TopBar (fixed at top, iOS-style)
- BitchatSheetCenterTopBar(
- onClose = onDismiss,
+ // Header. Built from the same tokens as the main chat header rather than a
+ // TopAppBar, so moving between the timeline and a conversation does not shift the
+ // bar's height, insets or type.
+ Surface(
modifier = Modifier.align(Alignment.TopCenter),
- navigationIcon = {
- IconButton(
- onClick = onDismiss,
- modifier = Modifier
- .align(Alignment.CenterStart)
- .padding(start = 16.dp)
- .size(32.dp)
+ color = colorScheme.background
+ ) {
+ ConversationHeader(
+ leadingIconRes = conversationTransportIcon(
+ isReachedOverInternet = isNostrPeer || isNostrReachableFavorite,
+ isWifiAware = isWifiAware,
+ isDirect = isDirect
+ ),
+ leadingIconTint = colorScheme.primary,
+ leadingContentDescription = when {
+ isNostrPeer || isNostrReachableFavorite ->
+ stringResource(R.string.cd_nostr_reachable)
+ else -> null
+ },
+ title = titleText
+ ) {
+ ConversationHeaderAction(
+ onClick = { viewModel.toggleFavorite(peerID) },
+ contentDescription = if (isFavorite) {
+ stringResource(R.string.cd_remove_favorite)
+ } else {
+ stringResource(R.string.cd_add_favorite)
+ }
) {
Icon(
- imageVector = Icons.AutoMirrored.Filled.ArrowBack,
- contentDescription = stringResource(R.string.chat_back),
- tint = colorScheme.onSurface
+ painter = painterResource(
+ if (isFavorite) {
+ R.drawable.ic_spec_star_filled
+ } else {
+ R.drawable.ic_spec_star
+ }
+ ),
+ contentDescription = null,
+ modifier = Modifier
+ .size(HeaderIconSize)
+ .graphicsLayer {
+ rotationZ = starWobbleRotation.value
+ scaleX = starWobbleScale.value
+ scaleY = starWobbleScale.value
+ },
+ tint = favoriteStarTint
)
}
- },
- title = {
- // Center content: connection status + name + encryption
- Row(
- modifier = Modifier.align(Alignment.Center),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(6.dp)
- ) {
- when {
- isNostrPeer || isNostrReachableFavorite -> {
- Icon(
- imageVector = Icons.Filled.Public,
- contentDescription = stringResource(R.string.cd_nostr_reachable),
- modifier = Modifier.size(14.dp),
- tint = Color(0xFF9C27B0)
- )
- }
- isWifiAware -> {
- Icon(
- imageVector = Icons.Filled.Wifi,
- contentDescription = "Direct Wi-Fi Aware",
- modifier = Modifier.size(14.dp),
- tint = colorScheme.onSurface.copy(alpha = 0.6f)
- )
- }
- isDirect -> {
- Icon(
- imageVector = Icons.Outlined.Bluetooth,
- contentDescription = "Direct Bluetooth",
- modifier = Modifier.size(14.dp),
- tint = colorScheme.onSurface.copy(alpha = 0.6f)
- )
- }
- isConnected -> {
- Icon(
- imageVector = Icons.Filled.Route,
- contentDescription = "Routed",
- modifier = Modifier.size(14.dp),
- tint = colorScheme.onSurface.copy(alpha = 0.6f)
- )
- }
- }
- Text(
- text = titleText,
- style = MaterialTheme.typography.titleMedium.copy(
- fontWeight = FontWeight.Bold,
- fontFamily = FontFamily.Monospace
- ),
- color = if (isNostrPeer || isNostrReachableFavorite) Color(0xFFFF9500) else colorScheme.onSurface
- )
-
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier.then(securityModifier)
+ // Encryption state, and the verification badge that qualifies it. Both are
+ // read-only for Nostr peers, which have no Noise session at all.
+ if (!isNostrPeer && !isNostrReachableFavorite) {
+ ConversationHeaderAction(
+ onClick = { viewModel.showSecurityVerificationSheet() },
+ contentDescription = stringResource(R.string.verify_title)
) {
- if (!isNostrPeer && !isNostrReachableFavorite) {
+ Box(contentAlignment = Alignment.Center) {
NoiseSessionIcon(
sessionState = sessionState,
- modifier = Modifier.size(14.dp)
- )
- }
-
- if (isVerified) {
- Spacer(modifier = Modifier.width(4.dp))
- Icon(
- imageVector = Icons.Filled.Verified,
- contentDescription = stringResource(R.string.verify_title),
- modifier = Modifier.size(14.dp),
- tint = Color(0xFF32D74B) // iOS Green
+ modifier = Modifier.size(HeaderIconSize)
)
}
}
+ }
- IconButton(
- onClick = { viewModel.toggleFavorite(peerID) },
- modifier = Modifier.size(28.dp)
+ if (isVerified) {
+ Box(
+ modifier = Modifier.size(HeaderIconSize),
+ contentAlignment = Alignment.Center
) {
Icon(
- imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star,
- contentDescription = if (isFavorite) stringResource(R.string.cd_remove_favorite) else stringResource(R.string.cd_add_favorite),
- modifier = Modifier.size(16.dp),
- tint = if (isFavorite) Color(0xFFFFD700) else colorScheme.onSurface.copy(alpha = 0.6f)
+ painter = painterResource(R.drawable.ic_spec_check),
+ contentDescription = stringResource(R.string.verify_title),
+ modifier = Modifier.size(HeaderIconSize),
+ tint = colorScheme.primary
)
}
}
+
+ val dismiss = LocalSheetDismiss.current
+ CloseButton(onClick = { dismiss?.invoke() ?: onDismiss() })
}
- )
+ }
}
}
}
diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt
index 73fac04d..16c7f63d 100644
--- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt
+++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt
@@ -1,6 +1,18 @@
package com.bitchat.android.ui
-
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.core.AnimationSpec
+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.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -9,16 +21,16 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.calculateEndPadding
+import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
-import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -29,20 +41,25 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.platform.LocalLayoutDirection
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.IntOffset
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.component.text.AnnotatedClickableText
import com.bitchat.android.mesh.MeshService
@@ -50,6 +67,13 @@ import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.ui.media.FileMessageItem
+import com.bitchat.android.ui.theme.BASE_FONT_SIZE
+import com.bitchat.android.ui.theme.BitchatMotion
+import com.bitchat.android.ui.theme.ChatVisualTokens
+import com.bitchat.android.ui.theme.LocalBitchatPalette
+import com.bitchat.android.ui.theme.MessageBodyTextStyle
+import com.bitchat.android.ui.theme.MessageSenderTextStyle
+import kotlinx.coroutines.delay
import java.text.SimpleDateFormat
import java.util.Locale
@@ -61,12 +85,119 @@ import java.util.Locale
* Extracted from ChatScreen.kt for better organization
*/
+/** How far a newly arrived message travels up into place. */
+private val MessageEntrySlide = 14.dp
+
+/**
+ * Entry motion for a new message: quick, with just enough damping to settle rather than snap.
+ * Runs entirely on a graphics layer, so it costs a transform and nothing else.
+ */
+private val MessageEntrySpec: AnimationSpec =
+ spring(dampingRatio = 0.85f, stiffness = 1200f)
+
+/**
+ * Motion for messages being pushed out of the way by an arrival. Softer than the entry so the
+ * conversation glides up while the new message itself lands crisply.
+ */
+private val MessagePlacementSpec: FiniteAnimationSpec = spring(
+ dampingRatio = Spring.DampingRatioNoBouncy,
+ stiffness = Spring.StiffnessMediumLow,
+ visibilityThreshold = IntOffset.VisibilityThreshold
+)
+
+/** Removals are not worth dwelling on. */
+private val MessageFadeOutSpec: FiniteAnimationSpec = tween(BitchatMotion.QUICK_MS)
+
+/**
+ * How long placement animation stays armed after the list gains or loses a message.
+ *
+ * Comfortably longer than [MessagePlacementSpec] takes to settle, so an arrival's push is never cut
+ * short.
+ */
+private const val PlacementArmWindowMs = 600L
+
+/**
+ * Above this many simultaneous arrivals, entry animations are skipped.
+ *
+ * A history sync or a channel switch can append hundreds of messages in one frame. Animating each
+ * would spend the entire frame budget on motion nobody asked to see, so a burst is adopted
+ * silently and only conversational-pace arrivals animate.
+ */
+internal const val MaxAnimatedArrivals = 6
+
+/**
+ * Remembers which message ids have already been seen, so genuine arrivals can be told apart from
+ * items merely scrolling back into view.
+ *
+ * This distinction is the whole reason the entry animation is usable: `LazyColumn` composes items
+ * on demand, so animating on first composition would replay the animation for every old message
+ * the user scrolled back to.
+ */
+internal class MessageArrivalTracker {
+ val known = HashSet()
+ var seeded = false
+}
+
+/**
+ * Ids that should animate in on this composition pass.
+ *
+ * Deliberately computed during composition rather than in a `LaunchedEffect`: effects run *after*
+ * the frame's composition, by which point a new message's item has already composed and would
+ * have missed its cue.
+ */
+internal fun MessageArrivalTracker.arrivals(messages: List): Set {
+ if (!seeded) {
+ // First load adopts everything silently. A whole screenful animating on open reads as a
+ // glitch, not a flourish.
+ messages.forEach { known.add(it.id) }
+ seeded = true
+ return emptySet()
+ }
+
+ // A list with nothing in common with the last one is a different conversation, not a burst of
+ // arrivals — /clear, or a switch the caller did not give us a distinct key for. Adopt it
+ // silently rather than sliding in every message at once.
+ val isWholesaleReplacement =
+ messages.isNotEmpty() && known.isNotEmpty() && messages.none { it.id in known }
+
+ // `HashSet.add` reports whether the id was new, so this both diffs and updates in one pass.
+ val added = messages.filter { known.add(it.id) }
+
+ if (known.size > messages.size) {
+ // Messages disappeared (/clear, channel switch). Drop the stale ids so the set cannot
+ // grow without bound and so re-added messages animate again.
+ known.retainAll(messages.mapTo(HashSet(messages.size)) { it.id })
+ }
+
+ return when {
+ isWholesaleReplacement -> emptySet()
+ added.isEmpty() || added.size > MaxAnimatedArrivals -> emptySet()
+ else -> added.mapTo(HashSet(added.size)) { it.id }
+ }
+}
+
@Composable
fun MessagesList(
messages: List,
currentUserNickname: String,
meshService: MeshService,
modifier: Modifier = Modifier,
+ mentionPeerIdentities: Map? = null,
+ /**
+ * Extra inset on top of the list's own gutters.
+ *
+ * The chat screen's bars are translucent and the list scrolls underneath them, so the caller
+ * has to reserve room for their heights here rather than by shrinking the viewport.
+ */
+ contentPadding: PaddingValues = PaddingValues(0.dp),
+ /**
+ * Identity of the conversation being shown — a channel, a geohash, a peer.
+ *
+ * Everything below that is per-conversation state is keyed on this. Without it, switching
+ * channels reused the previous conversation's scroll offset, follow flag and seen-message set,
+ * so the new channel opened at a stale position and then animated itself into place.
+ */
+ conversationKey: Any? = null,
forceScrollToBottom: Boolean = false,
onScrolledUpChanged: ((Boolean) -> Unit)? = null,
onNicknameClick: ((String) -> Unit)? = null,
@@ -74,11 +205,23 @@ fun MessagesList(
onCancelTransfer: ((BitchatMessage) -> Unit)? = null,
onImageClick: ((String, List, Int) -> Unit)? = null
) {
- val listState = rememberLazyListState()
-
+ val resolvedMentionPeerIdentities = remember(messages, mentionPeerIdentities) {
+ mentionPeerIdentities ?: buildMentionPeerIdentityMap(messages)
+ }
+
+ // A fresh scroll position per conversation. Sharing one state meant a switch inherited the
+ // previous channel's offset and then had to correct itself, which is what the jump was.
+ //
+ // Passing the key as an *input* rather than as `key =` is deliberate: it discards the saved
+ // offset on every switch, so a conversation always opens on its newest message instead of
+ // wherever the reader happened to be some time ago, with unseen messages below them.
+ val listState = rememberSaveable(conversationKey, saver = LazyListState.Saver) {
+ LazyListState()
+ }
+
// Track if this is the first time messages are being loaded
- var hasScrolledToInitialPosition by remember { mutableStateOf(false) }
- var followIncomingMessages by remember { mutableStateOf(true) }
+ var hasScrolledToInitialPosition by remember(conversationKey) { mutableStateOf(false) }
+ var followIncomingMessages by remember(conversationKey) { mutableStateOf(true) }
// Smart scroll: auto-scroll to bottom for initial load, then follow unless user scrolls away
LaunchedEffect(messages.size) {
@@ -94,7 +237,7 @@ fun MessagesList(
}
// Track whether user has scrolled away from the latest messages
- val isAtLatest by remember {
+ val isAtLatest by remember(listState) {
derivedStateOf {
val firstVisibleIndex = listState.layoutInfo.visibleItemsInfo.firstOrNull()?.index ?: -1
firstVisibleIndex <= 2
@@ -114,27 +257,107 @@ fun MessagesList(
}
}
+ // Recomputed only when the list actually gains or loses a message, and synchronously, so the
+ // arriving item can read its cue during the same composition pass in which it first appears.
+ // Reset per conversation, so a switch adopts the incoming messages silently instead of
+ // treating a whole channel's backlog as brand-new arrivals and sliding each one in.
+ val arrivalTracker = remember(conversationKey) { MessageArrivalTracker() }
+ val enteringIds = remember(conversationKey, messages.size, messages.lastOrNull()?.id) {
+ arrivalTracker.arrivals(messages)
+ }
+
+ // Placement animation exists to soften insertions and removals. But *any* relayout moves every
+ // item — the keyboard opening behind a bottom sheet, that sheet closing again, the composer
+ // growing a line — and animating those made the whole conversation lurch. So it is armed only
+ // briefly around a genuine change to the list, and is otherwise off, letting items track the
+ // viewport exactly.
+ var placementArmed by remember(conversationKey) { mutableStateOf(false) }
+ var previousMessageCount by remember(conversationKey) { mutableStateOf(null) }
+ LaunchedEffect(conversationKey, messages.size) {
+ val previous = previousMessageCount
+ previousMessageCount = messages.size
+ // Skip the first composition: the list settling into its initial padding is not a change
+ // worth animating.
+ if (previous == null || previous == messages.size) return@LaunchedEffect
+ placementArmed = true
+ delay(PlacementArmWindowMs)
+ placementArmed = false
+ }
+
+ val layoutDirection = LocalLayoutDirection.current
LazyColumn(
state = listState,
- contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp),
- verticalArrangement = Arrangement.spacedBy(4.dp),
+ // Wider side gutters than the old 12.dp: the redesign trades a little line length for
+ // a much calmer edge, and long monospace lines were running into the screen bezel.
+ contentPadding = PaddingValues(
+ start = 16.dp + contentPadding.calculateStartPadding(layoutDirection),
+ end = 16.dp + contentPadding.calculateEndPadding(layoutDirection),
+ top = 8.dp + contentPadding.calculateTopPadding(),
+ bottom = 12.dp + contentPadding.calculateBottomPadding()
+ ),
+ // Spacing is owned by each item. The exported transcript uses a consistent 8.dp rhythm;
+ // a new speaker gets additional separation from the visible sender row's top inset.
+ verticalArrangement = Arrangement.spacedBy(0.dp),
modifier = modifier,
reverseLayout = true
) {
- items(
- items = messages.asReversed(),
- key = { it.id }
- ) { message ->
- MessageItem(
- message = message,
- messages = messages,
- currentUserNickname = currentUserNickname,
- meshService = meshService,
- onNicknameClick = onNicknameClick,
- onMessageLongPress = onMessageLongPress,
- onCancelTransfer = onCancelTransfer,
- onImageClick = onImageClick
- )
+ val reversed = messages.asReversed()
+ itemsIndexed(
+ items = reversed,
+ key = { _, message -> message.id }
+ ) { reversedIndex, message ->
+ // reverseLayout renders index 0 at the bottom, so the chronological predecessor of
+ // this row lives at a *higher* original index offset. Resolve against the original
+ // list rather than the reversed view to keep the grouping logic readable.
+ val originalIndex = messages.lastIndex - reversedIndex
+ val previous = messages.getOrNull(originalIndex - 1)
+ val isGrouped = MessageGrouping.shouldGroup(previous, message)
+
+ // Decided once per item instance, so an item recycling back into view during a scroll
+ // never re-animates. Items that are not arriving skip the animation machinery
+ // entirely: no Animatable, no coroutine, and no extra render layer per row.
+ val isArriving = remember(message.id) { message.id in enteringIds }
+ val entryModifier = if (isArriving) {
+ val entry = remember(message.id) { Animatable(0f) }
+ LaunchedEffect(message.id) { entry.animateTo(1f, MessageEntrySpec) }
+ // A draw-time transform only: no measure, no layout, and no recomposition of the
+ // message content on any frame of the animation.
+ Modifier.graphicsLayer {
+ val progress = entry.value
+ alpha = progress
+ translationY = (1f - progress) * MessageEntrySlide.toPx()
+ }
+ } else {
+ Modifier
+ }
+
+ MessageItem(
+ message = message,
+ messages = messages,
+ currentUserNickname = currentUserNickname,
+ meshService = meshService,
+ mentionPeerIdentities = resolvedMentionPeerIdentities,
+ showSender = !isGrouped,
+ topSpacing = MessageGrouping.topSpacingFor(
+ isGrouped = isGrouped,
+ isFirstInList = originalIndex == 0
+ ),
+ onNicknameClick = onNicknameClick,
+ onMessageLongPress = onMessageLongPress,
+ onCancelTransfer = onCancelTransfer,
+ onImageClick = onImageClick,
+ modifier = Modifier
+ // Animates the shift when a neighbour is inserted or removed: this is what
+ // makes the conversation glide up instead of jumping.
+ .animateItem(
+ // Entry fade is handled by entryModifier, together with the slide, so the
+ // two cannot drift out of step.
+ fadeInSpec = null,
+ placementSpec = if (placementArmed) MessagePlacementSpec else null,
+ fadeOutSpec = MessageFadeOutSpec
+ )
+ .then(entryModifier)
+ )
}
}
}
@@ -146,16 +369,22 @@ fun MessageItem(
currentUserNickname: String,
meshService: MeshService,
messages: List = emptyList(),
+ mentionPeerIdentities: Map = emptyMap(),
+ showSender: Boolean = true,
+ topSpacing: Dp = 0.dp,
onNicknameClick: ((String) -> Unit)? = null,
onMessageLongPress: ((BitchatMessage) -> Unit)? = null,
onCancelTransfer: ((BitchatMessage) -> Unit)? = null,
- onImageClick: ((String, List, Int) -> Unit)? = null
+ onImageClick: ((String, List, Int) -> Unit)? = null,
+ modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
- val timeFormatter = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) }
-
+ val timeFormatter = remember { SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()) }
+
Column(
- modifier = Modifier.fillMaxWidth(),
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(top = topSpacing),
verticalArrangement = Arrangement.spacedBy(0.dp)
) {
Box(modifier = Modifier.fillMaxWidth()) {
@@ -172,8 +401,10 @@ fun MessageItem(
messages = messages,
currentUserNickname = currentUserNickname,
meshService = meshService,
+ mentionPeerIdentities = mentionPeerIdentities,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
+ showSender = showSender,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
onCancelTransfer = onCancelTransfer,
@@ -209,14 +440,18 @@ fun MessageItem(
messages: List,
currentUserNickname: String,
meshService: MeshService,
+ mentionPeerIdentities: Map,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat,
+ showSender: Boolean,
onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((BitchatMessage) -> Unit)?,
onCancelTransfer: ((BitchatMessage) -> Unit)?,
onImageClick: ((String, List, Int) -> Unit)?,
modifier: Modifier = Modifier
) {
+ val palette = LocalBitchatPalette.current
+
// Image special rendering
if (message.type == BitchatMessageType.Image) {
com.bitchat.android.ui.media.ImageMessageItem(
@@ -226,6 +461,7 @@ fun MessageItem(
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
+ showSender = showSender,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
onCancelTransfer = onCancelTransfer,
@@ -243,6 +479,7 @@ fun MessageItem(
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
+ showSender = showSender,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
onCancelTransfer = onCancelTransfer,
@@ -268,9 +505,11 @@ fun MessageItem(
val headerText = formatMessageHeaderAnnotatedString(
message = message,
currentUserNickname = currentUserNickname,
- meshService = meshService,
- colorScheme = colorScheme,
- timeFormatter = timeFormatter
+ myPeerID = meshService.myPeerID,
+ palette = palette,
+ contentColor = colorScheme.onSurface,
+ timeFormatter = timeFormatter,
+ includeSender = showSender
)
val haptic = LocalHapticFeedback.current
AnnotatedClickableText(
@@ -286,7 +525,7 @@ fun MessageItem(
}
},
onLongPress = { onMessageLongPress?.invoke(message) },
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
color = colorScheme.onSurface,
)
@@ -335,15 +574,24 @@ fun MessageItem(
.align(Alignment.TopEnd)
.padding(4.dp)
.size(22.dp)
- .background(Color.Gray.copy(alpha = 0.6f), CircleShape)
+ .background(colorScheme.surfaceVariant.copy(alpha = 0.85f), CircleShape)
.clickable { onCancelTransfer?.invoke(message) },
contentAlignment = Alignment.Center
) {
- Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(R.string.cd_cancel), tint = Color.White, modifier = Modifier.size(14.dp))
+ Icon(
+ imageVector = Icons.Filled.Close,
+ contentDescription = stringResource(R.string.cd_cancel),
+ tint = colorScheme.onSurface,
+ modifier = Modifier.size(14.dp)
+ )
}
}
} else {
- Text(text = stringResource(R.string.file_unavailable), fontFamily = FontFamily.Monospace, color = Color.Gray)
+ Text(
+ text = stringResource(R.string.file_unavailable),
+ fontFamily = BitchatFontFamily,
+ color = palette.textTertiary
+ )
}
}
}
@@ -351,31 +599,15 @@ fun MessageItem(
return
}
- // Check if this message should be animated during PoW mining
- val shouldAnimate = shouldAnimateMessage(message.id)
-
- // If animation is needed, use the matrix animation component for content only
- if (shouldAnimate) {
- // Display message with matrix animation for content
- MessageWithMatrixAnimation(
- message = message,
- currentUserNickname = currentUserNickname,
- meshService = meshService,
- colorScheme = colorScheme,
- timeFormatter = timeFormatter,
- onNicknameClick = onNicknameClick,
- onMessageLongPress = onMessageLongPress,
- modifier = modifier
- )
- } else if (message.sender == "system") {
- // Keep system messages on the compact legacy line.
- val annotatedText = formatMessageAsAnnotatedString(
- message = message,
- currentUserNickname = currentUserNickname,
- meshService = meshService,
- colorScheme = colorScheme,
- timeFormatter = timeFormatter
- )
+ if (message.sender == "system") {
+ // Background narration: `// Tor started. Routing all chats…`
+ val annotatedText = remember(message, colorScheme.onSurface) {
+ formatSystemMessage(
+ message = message,
+ contentColor = colorScheme.onSurface,
+ timeFormatter = timeFormatter
+ )
+ }
val haptic = LocalHapticFeedback.current
Text(
@@ -388,11 +620,11 @@ fun MessageItem(
}
)
},
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
softWrap = true,
overflow = TextOverflow.Visible,
- style = androidx.compose.ui.text.TextStyle(
- color = colorScheme.onSurface
+ style = ChatVisualTokens.SystemActionStyle.copy(
+ color = colorScheme.onSurface.copy(alpha = ChatVisualTokens.MutedTextAlpha),
)
)
} else {
@@ -400,8 +632,10 @@ fun MessageItem(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
+ mentionPeerIdentities = mentionPeerIdentities,
colorScheme = colorScheme,
timeFormatter = timeFormatter,
+ showSender = showSender,
onNicknameClick = onNicknameClick,
onMessageLongPress = onMessageLongPress,
modifier = modifier,
@@ -414,37 +648,47 @@ internal fun TextMessageLayout(
message: BitchatMessage,
currentUserNickname: String,
meshService: MeshService,
+ mentionPeerIdentities: Map = emptyMap(),
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat,
onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((BitchatMessage) -> Unit)?,
modifier: Modifier = Modifier,
+ showSender: Boolean = true,
bodyContent: String = message.content,
) {
+ val palette = LocalBitchatPalette.current
val myPeerId = meshService.myPeerID
val displayMessage = remember(message, bodyContent) {
if (bodyContent == message.content) message else message.copy(content = bodyContent)
}
- val senderText = remember(message, currentUserNickname, myPeerId, colorScheme) {
+ val senderText = remember(message, currentUserNickname, myPeerId, palette) {
formatTextMessageSender(
message = message,
currentUserNickname = currentUserNickname,
- meshService = meshService,
- colorScheme = colorScheme,
+ myPeerID = myPeerId,
+ palette = palette,
)
}
- val metadataText = remember(message.timestamp, message.powDifficulty, timeFormatter) {
- formatTextMessageMetadata(
- message = message,
- timeFormatter = timeFormatter,
- )
- }
- val bodyText = remember(displayMessage, currentUserNickname, myPeerId, colorScheme) {
+ // The timestamp trails the body rather than occupying its own column, so a short message
+ // no longer reserves a full-width row for eight grey characters.
+ val bodyText = remember(
+ displayMessage,
+ currentUserNickname,
+ palette,
+ colorScheme.onSurface,
+ colorScheme.secondary,
+ mentionPeerIdentities,
+ timeFormatter
+ ) {
formatTextMessageBody(
message = displayMessage,
currentUserNickname = currentUserNickname,
- meshService = meshService,
- colorScheme = colorScheme,
+ palette = palette,
+ contentColor = colorScheme.onSurface,
+ linkColor = colorScheme.secondary,
+ mentionPeerIdentities = mentionPeerIdentities,
+ timeFormatter = timeFormatter,
)
}
val isSelf = message.isFromSelf(currentUserNickname, myPeerId)
@@ -457,12 +701,9 @@ internal fun TextMessageLayout(
Column(
modifier = modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(2.dp),
+ verticalArrangement = Arrangement.spacedBy(MessageGrouping.SENDER_TO_BODY_SPACING),
) {
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- ) {
+ if (showSender) {
AnnotatedClickableText(
text = senderText,
annotationTags = listOf("nickname_click"),
@@ -476,18 +717,13 @@ internal fun TextMessageLayout(
}
},
onLongPress = handleLongPress,
- modifier = Modifier.weight(1f),
- fontFamily = FontFamily.Monospace,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = MessageGrouping.SENDER_TOP_PADDING),
+ fontFamily = BitchatFontFamily,
softWrap = false,
overflow = TextOverflow.Ellipsis,
- )
- AnnotatedClickableText(
- text = metadataText,
- annotationTags = emptyList(),
- onAnnotationClick = { _, _ -> false },
- onLongPress = handleLongPress,
- fontFamily = FontFamily.Monospace,
- softWrap = false,
+ style = MessageSenderTextStyle,
)
}
@@ -512,10 +748,10 @@ internal fun TextMessageLayout(
}
},
onLongPress = handleLongPress,
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
softWrap = true,
overflow = TextOverflow.Visible,
- style = androidx.compose.ui.text.TextStyle(color = colorScheme.onSurface),
+ style = MessageBodyTextStyle.copy(color = colorScheme.onSurface),
)
}
}
@@ -523,53 +759,40 @@ internal fun TextMessageLayout(
@Composable
fun DeliveryStatusIcon(status: DeliveryStatus) {
val colorScheme = MaterialTheme.colorScheme
-
- when (status) {
- is DeliveryStatus.Sending -> {
- Text(
- text = stringResource(R.string.status_sending),
- fontSize = 10.sp,
- color = colorScheme.primary.copy(alpha = 0.6f)
- )
- }
- is DeliveryStatus.Sent -> {
- // Use a subtle hollow marker for Sent; single check is reserved for Delivered (iOS parity)
- Text(
- text = stringResource(R.string.status_pending),
- fontSize = 10.sp,
- color = colorScheme.primary.copy(alpha = 0.6f)
- )
- }
- is DeliveryStatus.Delivered -> {
- // Single check for Delivered (matches iOS expectations)
- Text(
- text = stringResource(R.string.status_sent),
- fontSize = 10.sp,
- color = colorScheme.primary.copy(alpha = 0.8f)
- )
- }
- is DeliveryStatus.Read -> {
- Text(
- text = stringResource(R.string.status_delivered),
- fontSize = 10.sp,
- color = Color(0xFF007AFF), // Blue
- fontWeight = FontWeight.Bold
- )
- }
- is DeliveryStatus.Failed -> {
- Text(
- text = stringResource(R.string.status_failed),
- fontSize = 10.sp,
- color = Color.Red.copy(alpha = 0.8f)
- )
- }
- is DeliveryStatus.PartiallyDelivered -> {
- // Show a single subdued check without numeric label
- Text(
- text = stringResource(R.string.status_sent),
- fontSize = 10.sp,
- color = colorScheme.primary.copy(alpha = 0.6f)
- )
+
+ // Status advances on its own as acks come back, so a hard glyph swap reads as a flicker.
+ // Keyed on the status *type* rather than the instance, because Delivered/Read carry a
+ // timestamp that would otherwise retrigger the transition on every identical update.
+ AnimatedContent(
+ targetState = status::class,
+ transitionSpec = {
+ fadeIn(tween(BitchatMotion.STANDARD_MS)) togetherWith
+ fadeOut(tween(BitchatMotion.QUICK_MS))
+ },
+ label = "deliveryStatus"
+ ) { statusClass ->
+ val (text, color, weight) = when (statusClass) {
+ DeliveryStatus.Sending::class ->
+ Triple(R.string.status_sending, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal)
+ // Subtle hollow marker for Sent; a single check is reserved for Delivered (iOS parity).
+ DeliveryStatus.Sent::class ->
+ Triple(R.string.status_pending, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal)
+ DeliveryStatus.Delivered::class ->
+ Triple(R.string.status_sent, colorScheme.primary.copy(alpha = 0.8f), FontWeight.Normal)
+ DeliveryStatus.Read::class ->
+ Triple(R.string.status_delivered, colorScheme.secondary, FontWeight.Bold)
+ DeliveryStatus.Failed::class ->
+ Triple(R.string.status_failed, colorScheme.error, FontWeight.Normal)
+ // A single subdued check, without the numeric label.
+ else ->
+ Triple(R.string.status_sent, colorScheme.primary.copy(alpha = 0.6f), FontWeight.Normal)
}
+
+ Text(
+ text = stringResource(text),
+ fontSize = 10.sp,
+ color = color,
+ fontWeight = weight
+ )
}
}
diff --git a/app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt b/app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt
new file mode 100644
index 00000000..9b3278c4
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/ui/MessageGrouping.kt
@@ -0,0 +1,84 @@
+package com.bitchat.android.ui
+
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.bitchat.android.model.BitchatMessage
+import com.bitchat.android.ui.theme.ChatVisualTokens
+
+/**
+ * Consecutive-message grouping for the chat surface.
+ *
+ * The redesign suppresses the `@sender` label on runs of messages from the same author so the
+ * eye only has to register a name when the speaker actually changes. The transcript uses one
+ * consistent item rhythm; new groups get their additional separation from the visible sender
+ * row's own top padding.
+ */
+object MessageGrouping {
+
+ /** Space above the first message of a group (i.e. one that renders its sender label). */
+ val NEW_GROUP_SPACING: Dp = ChatVisualTokens.MessageItemSpacing
+
+ /** Space above a continuation message inside an existing group. */
+ val GROUPED_SPACING: Dp = ChatVisualTokens.MessageItemSpacing
+
+ /** Gap between the sender label and the first line of the body. */
+ val SENDER_TO_BODY_SPACING: Dp = ChatVisualTokens.SenderToBodySpacing
+
+ /** Top inset inside a visible sender row. */
+ val SENDER_TOP_PADDING: Dp = ChatVisualTokens.SenderTopPadding
+
+ /**
+ * Messages further apart than this always start a new group, even from the same sender:
+ * a reply an hour later is a new thought, not a continuation.
+ */
+ const val GROUPING_WINDOW_MS: Long = 5 * 60 * 1000L
+
+ private const val SYSTEM_SENDER = "system"
+
+ /**
+ * Whether [current] should be rendered as a continuation of [previous], hiding its sender
+ * label.
+ *
+ * [previous] is the message immediately *before* [current] in chronological order. Note the
+ * message list renders with `reverseLayout = true`, so callers must be careful to resolve
+ * the chronological predecessor rather than the visually preceding item.
+ */
+ fun shouldGroup(previous: BitchatMessage?, current: BitchatMessage): Boolean {
+ if (previous == null) return false
+
+ // System/action lines never participate in grouping in either direction: they are
+ // narration, and folding a real message into them would attribute it to "system".
+ if (previous.sender == SYSTEM_SENDER || current.sender == SYSTEM_SENDER) return false
+
+ // Never group across the public/private boundary or between different channels.
+ if (previous.isPrivate != current.isPrivate) return false
+ if (previous.channel != current.channel) return false
+
+ if (!isSameSender(previous, current)) return false
+
+ val elapsed = current.timestamp.time - previous.timestamp.time
+ return elapsed in 0..GROUPING_WINDOW_MS
+ }
+
+ /**
+ * Identity comparison. Peer IDs are authoritative when both messages carry one, because two
+ * different peers can share a nickname. Falls back to the display name (which includes the
+ * `#abcd` suffix) when peer IDs are unavailable, e.g. for locally injected messages.
+ */
+ private fun isSameSender(previous: BitchatMessage, current: BitchatMessage): Boolean {
+ val previousPeerID = previous.senderPeerID
+ val currentPeerID = current.senderPeerID
+ return if (previousPeerID != null && currentPeerID != null) {
+ previousPeerID.equals(currentPeerID, ignoreCase = true)
+ } else {
+ previous.sender == current.sender
+ }
+ }
+
+ /** Top padding for a message given whether it continues the previous author's run. */
+ fun topSpacingFor(isGrouped: Boolean, isFirstInList: Boolean): Dp = when {
+ isFirstInList -> 0.dp
+ isGrouped -> GROUPED_SPACING
+ else -> NEW_GROUP_SPACING
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt
index e93ef39a..41a610f4 100644
--- a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt
+++ b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt
@@ -3,6 +3,7 @@ package com.bitchat.android.ui
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.services.ContactDirectory
+import com.bitchat.android.services.PrivateMessageArrivalOrder
import java.util.*
import java.util.Collections
@@ -110,6 +111,8 @@ class MessageManager(private val state: ChatState) {
val chatMessages = currentPrivateChats[conversationID]?.toMutableList() ?: mutableListOf()
chatMessages.add(message)
currentPrivateChats[conversationID] = chatMessages
+ // Record the local arrival sequence before canonicalizing UI aliases.
+ PrivateMessageArrivalOrder.record(message.id)
state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentPrivateChats))
// Reflect into process-wide store
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(conversationID, message) } catch (_: Exception) { }
@@ -132,6 +135,8 @@ class MessageManager(private val state: ChatState) {
val chatMessages = currentPrivateChats[conversationID]?.toMutableList() ?: mutableListOf()
chatMessages.add(message)
currentPrivateChats[conversationID] = chatMessages
+ // Record the local arrival sequence before canonicalizing UI aliases.
+ PrivateMessageArrivalOrder.record(message.id)
state.setPrivateChats(ContactDirectory.canonicalizePrivateChats(currentPrivateChats))
// Reflect into process-wide store
try { com.bitchat.android.services.AppStateStore.addPrivateMessage(conversationID, message) } catch (_: Exception) { }
@@ -153,11 +158,17 @@ class MessageManager(private val state: ChatState) {
state.setPrivateChats(updatedChats)
}
- fun clearPrivateUnreadMessages(peerID: String) {
+ fun clearPrivateUnreadMessages(
+ peerID: String,
+ aliases: Set = emptySet()
+ ) {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val updatedUnread = state.getUnreadPrivateMessagesValue().toMutableSet()
- updatedUnread.remove(peerID)
- updatedUnread.remove(conversationID)
+ val normalizedAliases = (aliases + peerID + conversationID)
+ .mapTo(mutableSetOf()) { it.lowercase() }
+ updatedUnread.removeAll { unreadID ->
+ unreadID.lowercase() in normalizedAliases
+ }
state.setUnreadPrivateMessages(updatedUnread)
}
diff --git a/app/src/main/java/com/bitchat/android/ui/PeerColorSeed.kt b/app/src/main/java/com/bitchat/android/ui/PeerColorSeed.kt
new file mode 100644
index 00000000..b6eac7cd
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/ui/PeerColorSeed.kt
@@ -0,0 +1,33 @@
+package com.bitchat.android.ui
+
+import com.bitchat.android.model.BitchatMessage
+import java.util.Locale
+
+/**
+ * Stable, presentation-neutral identity used to derive a peer hue.
+ *
+ * ViewModels may expose this value, but only the UI theme resolves it to a rendered color.
+ */
+@JvmInline
+value class PeerColorSeed(val value: String)
+
+fun meshPeerColorSeed(peerID: String): PeerColorSeed =
+ PeerColorSeed("noise:${peerID.lowercase(Locale.ROOT)}")
+
+fun nostrPeerColorSeed(pubkeyHex: String): PeerColorSeed =
+ PeerColorSeed("nostr:${pubkeyHex.lowercase(Locale.ROOT)}")
+
+fun peerColorSeedForMessage(message: BitchatMessage): PeerColorSeed {
+ val value = when {
+ message.senderPeerID?.startsWith("nostr:") == true ||
+ message.senderPeerID?.startsWith("nostr_") == true -> {
+ "nostr:${message.senderPeerID.lowercase(Locale.ROOT)}"
+ }
+ message.senderPeerID?.length == 16 || message.senderPeerID?.length == 64 -> {
+ "noise:${message.senderPeerID.lowercase(Locale.ROOT)}"
+ }
+ else -> message.sender.lowercase(Locale.ROOT)
+ }
+
+ return PeerColorSeed(value)
+}
diff --git a/app/src/main/java/com/bitchat/android/ui/PeerIdentity.kt b/app/src/main/java/com/bitchat/android/ui/PeerIdentity.kt
new file mode 100644
index 00000000..47dd5629
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/ui/PeerIdentity.kt
@@ -0,0 +1,71 @@
+package com.bitchat.android.ui
+
+import com.bitchat.android.model.BitchatMessage
+import java.util.Locale
+
+/**
+ * Canonical, presentation-neutral identity used to derive a peer hue.
+ *
+ * Callers cannot construct arbitrary color seeds. Every identity is normalized and namespaced
+ * here so the same mesh or Nostr user resolves to the same color on every surface.
+ */
+@JvmInline
+value class PeerIdentity private constructor(internal val stableKey: String) {
+ companion object {
+ fun mesh(peerID: String): PeerIdentity =
+ PeerIdentity("noise:${normalize(peerID)}")
+
+ /**
+ * Preserve the established geohash-chat color mapping while accepting a full public key.
+ *
+ * Older chat rendering hashed `nostr:nostr:`. Keeping that visual
+ * key here avoids recoloring existing conversations; the important change is that every
+ * surface now derives it from the same canonical Nostr identity.
+ */
+ fun nostr(pubkeyHex: String): PeerIdentity {
+ val normalized = normalizeNostrIdentifier(pubkeyHex)
+ return PeerIdentity("nostr:nostr:${normalized.take(8)}")
+ }
+
+ /**
+ * Last-resort identity for legacy messages and mentions that carry no stable peer ID.
+ */
+ fun nickname(nickname: String): PeerIdentity =
+ PeerIdentity(normalize(nickname))
+
+ private fun normalize(value: String): String =
+ value.trim().lowercase(Locale.ROOT)
+
+ private fun normalizeNostrIdentifier(value: String): String {
+ var normalized = normalize(value)
+ while (normalized.startsWith("nostr:") || normalized.startsWith("nostr_")) {
+ normalized = normalized.removePrefix("nostr:").removePrefix("nostr_")
+ }
+ return normalized
+ }
+ }
+}
+
+/**
+ * Resolve the canonical identity attached to a rendered message.
+ *
+ * Full Nostr keys take precedence over routing aliases such as `nostr_abcd…`; aliases remain a
+ * compatibility fallback for messages saved by older app versions.
+ */
+fun peerIdentityForMessage(message: BitchatMessage): PeerIdentity {
+ message.senderNostrPubkey?.takeIf { it.isNotBlank() }?.let {
+ return PeerIdentity.nostr(it)
+ }
+
+ val senderPeerID = message.senderPeerID
+ return when {
+ senderPeerID?.startsWith("nostr:", ignoreCase = true) == true ||
+ senderPeerID?.startsWith("nostr_", ignoreCase = true) == true -> {
+ PeerIdentity.nostr(senderPeerID)
+ }
+ senderPeerID?.length == 16 || senderPeerID?.length == 64 -> {
+ PeerIdentity.mesh(senderPeerID)
+ }
+ else -> PeerIdentity.nickname(message.sender)
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt b/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt
deleted file mode 100644
index 883b2b45..00000000
--- a/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt
+++ /dev/null
@@ -1,133 +0,0 @@
-package com.bitchat.android.ui
-
-import androidx.compose.animation.core.*
-import androidx.compose.foundation.layout.*
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Security
-import androidx.compose.material3.*
-import androidx.compose.runtime.*
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.graphicsLayer
-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.nostr.NostrProofOfWork
-import androidx.compose.ui.res.stringResource
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import com.bitchat.android.R
-import com.bitchat.android.nostr.PoWPreferenceManager
-
-/**
- * Shows the current Proof of Work status and settings
- */
-@Composable
-fun PoWStatusIndicator(
- modifier: Modifier = Modifier,
- style: PoWIndicatorStyle = PoWIndicatorStyle.COMPACT
-) {
- val powEnabled by PoWPreferenceManager.powEnabled.collectAsStateWithLifecycle()
- val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsStateWithLifecycle()
- val isMining by PoWPreferenceManager.isMining.collectAsStateWithLifecycle()
- val colorScheme = MaterialTheme.colorScheme
- val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
-
- if (!powEnabled) return
-
- when (style) {
- PoWIndicatorStyle.COMPACT -> {
- Row(
- modifier = modifier,
- horizontalArrangement = Arrangement.spacedBy(4.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- // PoW icon with animation if mining
- if (isMining) {
- val rotation by rememberInfiniteTransition(label = "pow-rotation").animateFloat(
- initialValue = 0f,
- targetValue = 360f,
- animationSpec = infiniteRepeatable(
- animation = tween(1000, easing = LinearEasing),
- repeatMode = RepeatMode.Restart
- ),
- label = "pow-icon-rotation"
- )
-
- Icon(
- imageVector = Icons.Filled.Security,
- contentDescription = stringResource(R.string.cd_mining_pow),
- tint = Color(0xFFFF9500), // Orange for mining
- modifier = Modifier
- .size(12.dp)
- .graphicsLayer { rotationZ = rotation }
- )
- } else {
- Icon(
- imageVector = Icons.Filled.Security,
- contentDescription = stringResource(R.string.cd_pow_enabled),
- tint = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), // Green when ready
- modifier = Modifier.size(12.dp)
- )
- }
- }
- }
-
- PoWIndicatorStyle.DETAILED -> {
- Surface(
- modifier = modifier,
- color = colorScheme.surfaceVariant.copy(alpha = 0.3f),
- shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp)
- ) {
- Row(
- modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
- horizontalArrangement = Arrangement.spacedBy(6.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- // PoW icon
- Icon(
- imageVector = Icons.Filled.Security,
- contentDescription = stringResource(R.string.cd_proof_of_work),
- tint = if (isMining) Color(0xFFFF9500) else {
- if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
- },
- modifier = Modifier.size(14.dp)
- )
-
- // Status text
- Text(
- text = if (isMining) {
- stringResource(R.string.pow_mining_ellipsis)
- } else {
- stringResource(R.string.pow_label_format, powDifficulty)
- },
- fontSize = 11.sp,
- fontFamily = FontFamily.Monospace,
- color = if (isMining) Color(0xFFFF9500) else {
- colorScheme.onSurface.copy(alpha = 0.7f)
- }
- )
-
- // Time estimate
- if (!isMining && powDifficulty > 0) {
- Text(
- text = stringResource(R.string.pow_time_estimate, NostrProofOfWork.estimateMiningTime(powDifficulty)),
- fontSize = 9.sp,
- fontFamily = FontFamily.Monospace,
- color = colorScheme.onSurface.copy(alpha = 0.5f)
- )
- }
- }
- }
- }
- }
-}
-
-/**
- * Style options for the PoW status indicator
- */
-enum class PoWIndicatorStyle {
- COMPACT, // Small icon + difficulty number
- DETAILED // Icon + status text + time estimate
-}
diff --git a/app/src/main/java/com/bitchat/android/ui/PressFeedback.kt b/app/src/main/java/com/bitchat/android/ui/PressFeedback.kt
new file mode 100644
index 00000000..01c9c6ca
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/ui/PressFeedback.kt
@@ -0,0 +1,64 @@
+package com.bitchat.android.ui
+
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.InteractionSource
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsPressedAsState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.scale
+
+/**
+ * Press feedback for the app's icon buttons.
+ *
+ * Terminal-style chrome has no elevation and no fills to lean on, so a Material ripple has almost
+ * nothing to show. A brief scale dip is legible on any background and reads as physical. Springs
+ * rather than tweens, so releasing overshoots very slightly instead of stopping dead.
+ */
+@Composable
+fun rememberPressScale(
+ interactionSource: InteractionSource,
+ pressedScale: Float = 0.86f
+): Float {
+ val isPressed by interactionSource.collectIsPressedAsState()
+ val scale by animateFloatAsState(
+ targetValue = if (isPressed) pressedScale else 1f,
+ animationSpec = spring(
+ dampingRatio = Spring.DampingRatioMediumBouncy,
+ stiffness = Spring.StiffnessHigh
+ ),
+ label = "pressScale"
+ )
+ return scale
+}
+
+/**
+ * Convenience wrapper for the common case: a clickable that scales while held.
+ *
+ * Returns the modifier chain to apply, having disabled the default indication — the scale *is* the
+ * indication, and a ripple underneath it just muddies the edges of these small glyphs.
+ */
+@Composable
+fun Modifier.pressScaleClickable(
+ onClick: () -> Unit,
+ enabled: Boolean = true,
+ onClickLabel: String? = null,
+ pressedScale: Float = 0.86f
+): Modifier {
+ val interactionSource = remember { MutableInteractionSource() }
+ val scale = rememberPressScale(interactionSource, pressedScale)
+ return this
+ .clickable(
+ interactionSource = interactionSource,
+ indication = null,
+ enabled = enabled,
+ onClickLabel = onClickLabel,
+ onClick = onClick
+ )
+ .scale(scale)
+}
diff --git a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt
index 1af48a04..ee28a534 100644
--- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt
+++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt
@@ -33,7 +33,10 @@ class PrivateChatManager(
private val state: ChatState,
private val messageManager: MessageManager,
private val dataManager: DataManager,
- private val noiseSessionDelegate: NoiseSessionDelegate
+ private val noiseSessionDelegate: NoiseSessionDelegate,
+ private val trackUnreadMessages: Boolean = true,
+ private val hasReadReceiptBeenSent: (messageID: String) -> Boolean = { false },
+ private val markMessageReadLocally: (messageID: String) -> Unit = {}
) {
companion object {
@@ -47,7 +50,11 @@ class PrivateChatManager(
// MARK: - Private Chat Lifecycle
- fun startPrivateChat(peerID: String, meshService: MeshService): Boolean {
+ fun startPrivateChat(
+ peerID: String,
+ meshService: MeshService,
+ unreadAliases: Set = emptySet()
+ ): Boolean {
val conversationID = ContactDirectory.canonicalConversationId(peerID)
val route = ContactDirectory.resolve(conversationID)
val meshPeerID = route.meshPeerID ?: peerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) }
@@ -75,7 +82,7 @@ class PrivateChatManager(
state.setSelectedPrivateChatPeer(conversationID)
// Clear unread
- messageManager.clearPrivateUnreadMessages(conversationID)
+ messageManager.clearPrivateUnreadMessages(conversationID, unreadAliases)
// Initialize chat if needed
messageManager.initializePrivateChat(conversationID)
@@ -337,7 +344,7 @@ class PrivateChatManager(
// Nostr messages originate here and must be added explicitly, even after their
// sender alias has canonicalized to a contact_* conversation ID.
if (origin == PrivateMessageOrigin.NOSTR) {
- if (suppressUnread) {
+ if (suppressUnread || !trackUnreadMessages) {
messageManager.addPrivateMessageNoUnread(conversationID, message)
} else {
messageManager.addPrivateMessage(conversationID, message)
@@ -345,7 +352,10 @@ class PrivateChatManager(
}
// Track as unread for read receipt purposes if not focused
- if (!suppressUnread && state.getSelectedPrivateChatPeerValue() != conversationID) {
+ if (trackUnreadMessages &&
+ !suppressUnread &&
+ state.getSelectedPrivateChatPeerValue() != conversationID
+ ) {
val unreadList = unreadReceivedMessages.getOrPut(conversationID) { mutableListOf() }
unreadList.add(message)
Log.d(TAG, "Queued unread from $conversationID (count=${unreadList.size})")
@@ -398,7 +408,14 @@ class PrivateChatManager(
senderPeerID == meshPeerID ||
ContactDirectory.canonicalConversationId(senderPeerID) == canonicalConversationID
)
- if (isFromTarget && meshPeerID != null) {
+ if (isFromTarget) {
+ try {
+ markMessageReadLocally(msg.id)
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to persist local read for message ${msg.id}: ${e.message}")
+ }
+ }
+ if (isFromTarget && meshPeerID != null && !hasReadReceiptBeenSent(msg.id)) {
try {
if (hasMesh) {
meshService.sendReadReceipt(msg.id, meshPeerID, myNickname)
diff --git a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt
index 2633f888..40da30be 100644
--- a/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt
+++ b/app/src/main/java/com/bitchat/android/ui/SecurityVerificationSheet.kt
@@ -1,9 +1,15 @@
package com.bitchat.android.ui
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Lock
+import androidx.compose.material.icons.filled.Verified
+import androidx.compose.material.icons.filled.Warning
+import androidx.compose.material.icons.outlined.NoEncryption
+import androidx.compose.material.icons.outlined.Sync
+import androidx.compose.material.icons.outlined.Warning as OutlinedWarning
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -12,13 +18,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Lock
-import androidx.compose.material.icons.filled.Verified
-import androidx.compose.material.icons.filled.Warning
-import androidx.compose.material.icons.outlined.NoEncryption
-import androidx.compose.material.icons.outlined.Sync
-import androidx.compose.material.icons.outlined.Warning as OutlinedWarning
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenu
@@ -39,14 +38,15 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource
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.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
private data class SecurityStatusInfo(
@@ -69,9 +69,9 @@ fun SecurityVerificationSheet(
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle()
- val isDark = isSystemInDarkTheme()
- val accent = if (isDark) Color.Green else Color(0xFF008000)
- val boxColor = if (isDark) Color.White.copy(alpha = 0.06f) else Color.Black.copy(alpha = 0.06f)
+ val colorScheme = MaterialTheme.colorScheme
+ val accent = colorScheme.primary
+ val boxColor = colorScheme.surfaceVariant
val peerHexRegex = remember { Regex("^[0-9a-fA-F]{16}$") }
BitchatBottomSheet(
@@ -92,7 +92,7 @@ fun SecurityVerificationSheet(
if (peerID == null) {
Text(
text = stringResource(R.string.fingerprint_no_peer),
- style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
+ style = MaterialTheme.typography.bodyMedium.copy(fontFamily = BitchatFontFamily),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
} else {
@@ -155,13 +155,14 @@ private fun SecurityVerificationHeader(
Text(
text = stringResource(R.string.security_verification_title),
style = MaterialTheme.typography.titleSmall.copy(
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Bold
),
color = accent
)
Spacer(modifier = Modifier.weight(1f))
- CloseButton(onClick = onClose)
+ val dismiss = LocalSheetDismiss.current
+ CloseButton(onClick = { dismiss?.invoke() ?: onClose() })
}
}
@@ -219,7 +220,7 @@ private fun SecurityStatusCard(
Text(
text = displayName,
style = MaterialTheme.typography.titleMedium.copy(
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Bold
),
color = accent
@@ -227,7 +228,7 @@ private fun SecurityStatusCard(
Text(
text = statusInfo.text,
style = MaterialTheme.typography.bodySmall.copy(
- fontFamily = FontFamily.Monospace
+ fontFamily = BitchatFontFamily
),
color = accent.copy(alpha = 0.8f)
)
@@ -257,7 +258,7 @@ private fun SecurityVerificationActions(
) {
Text(
text = stringResource(R.string.fingerprint_start_handshake),
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontSize = 12.sp
)
}
@@ -273,7 +274,7 @@ private fun SecurityVerificationActions(
Text(
text = stringResource(R.string.fingerprint_verified_message),
style = MaterialTheme.typography.bodySmall.copy(
- fontFamily = FontFamily.Monospace
+ fontFamily = BitchatFontFamily
),
color = accent.copy(alpha = 0.7f),
modifier = Modifier.fillMaxWidth(),
@@ -289,7 +290,7 @@ private fun SecurityVerificationActions(
) {
Text(
text = stringResource(R.string.verify_remove),
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontSize = 12.sp
)
}
@@ -303,7 +304,7 @@ private fun SecurityVerificationActions(
Text(
text = stringResource(R.string.fingerprint_not_verified_message_fmt, displayName),
style = MaterialTheme.typography.bodySmall.copy(
- fontFamily = FontFamily.Monospace
+ fontFamily = BitchatFontFamily
),
color = accent.copy(alpha = 0.7f),
modifier = Modifier.fillMaxWidth(),
@@ -320,7 +321,7 @@ private fun SecurityVerificationActions(
) {
Text(
text = stringResource(R.string.fingerprint_mark_verified),
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontSize = 12.sp
)
}
@@ -349,7 +350,7 @@ private fun VerificationStatusRow(
Text(
text = text,
style = MaterialTheme.typography.bodyMedium.copy(
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Bold
),
color = textTint
@@ -375,7 +376,7 @@ private fun FingerprintBlock(
Text(
text = title,
style = MaterialTheme.typography.labelSmall.copy(
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Bold
),
color = accent.copy(alpha = 0.8f)
@@ -385,7 +386,7 @@ private fun FingerprintBlock(
Text(
text = formatFingerprint(fingerprint),
style = MaterialTheme.typography.bodyMedium.copy(
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontSize = 14.sp
),
color = accent,
@@ -417,7 +418,7 @@ private fun FingerprintBlock(
} else {
Text(
text = stringResource(R.string.fingerprint_pending),
- style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
+ style = MaterialTheme.typography.bodyMedium.copy(fontFamily = BitchatFontFamily),
color = Color(0xFFFF9500),
modifier = Modifier.padding(16.dp)
)
diff --git a/app/src/main/java/com/bitchat/android/ui/UnreadConversationSummary.kt b/app/src/main/java/com/bitchat/android/ui/UnreadConversationSummary.kt
new file mode 100644
index 00000000..5d0ad81a
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/ui/UnreadConversationSummary.kt
@@ -0,0 +1,105 @@
+package com.bitchat.android.ui
+
+import com.bitchat.android.model.BitchatMessage
+
+internal enum class DirectMessageTransport {
+ MESH,
+ NOSTR
+}
+
+/**
+ * Presence-independent presentation state for an unread private conversation.
+ *
+ * A conversation remains in this model until it is read, even when none of its identities are in
+ * the current mesh or geohash participant lists.
+ */
+internal data class UnreadConversationSummary(
+ val conversationID: String,
+ val displayName: String,
+ val unreadCount: Int,
+ val latestMessageAt: Long,
+ val transport: DirectMessageTransport,
+ val nostrPubkey: String?,
+ val identityAliases: Set,
+ val isConnected: Boolean = false,
+ val sourceGeohash: String? = null
+)
+
+internal fun buildUnreadConversationSummaries(
+ unreadConversationIDs: Set,
+ privateChats: Map>,
+ currentUserIdentifiers: Set,
+ canonicalize: (String) -> String,
+ isMessageRead: (BitchatMessage) -> Boolean
+): List {
+ if (unreadConversationIDs.isEmpty()) return emptyList()
+
+ val normalizedCurrentUserIdentifiers = currentUserIdentifiers.filterTo(mutableSetOf()) {
+ it.isNotBlank()
+ }
+ val canonicalUnreadIDs = unreadConversationIDs
+ .mapTo(linkedSetOf()) { canonicalize(it) }
+ val unreadAliasesByCanonicalID = unreadConversationIDs.groupBy(canonicalize)
+ val messagesByCanonicalID = linkedMapOf>()
+
+ privateChats.forEach { (conversationID, messages) ->
+ val canonicalID = canonicalize(conversationID)
+ messagesByCanonicalID.getOrPut(canonicalID) { mutableListOf() }.addAll(messages)
+ }
+
+ return canonicalUnreadIDs.map { conversationID ->
+ val messages = messagesByCanonicalID[conversationID]
+ .orEmpty()
+ .distinctBy { it.id }
+ val incomingMessages = messages.filterNot {
+ it.sender in normalizedCurrentUserIdentifiers
+ }
+ val unreadIncomingMessages = incomingMessages.filterNot(isMessageRead)
+ val latestMessage = (unreadIncomingMessages.ifEmpty { incomingMessages })
+ .maxWithOrNull(compareBy { it.timestamp.time }.thenBy { it.id })
+ val aliases = unreadAliasesByCanonicalID[conversationID].orEmpty()
+ val nostrPubkey = latestMessage?.senderNostrPubkey
+ val isNostrConversation = nostrPubkey != null ||
+ aliases.any(::isNostrConversationID) ||
+ isNostrConversationID(conversationID)
+
+ UnreadConversationSummary(
+ conversationID = conversationID,
+ displayName = latestMessage
+ ?.sender
+ ?.takeIf { it.isNotBlank() }
+ ?: conversationID.take(12),
+ unreadCount = unreadIncomingMessages.size.coerceAtLeast(1),
+ latestMessageAt = latestMessage?.timestamp?.time ?: Long.MIN_VALUE,
+ transport = if (isNostrConversation) {
+ DirectMessageTransport.NOSTR
+ } else {
+ DirectMessageTransport.MESH
+ },
+ nostrPubkey = nostrPubkey,
+ identityAliases = (aliases + conversationID)
+ .mapTo(mutableSetOf()) { it.lowercase() }
+ )
+ }.sortedWith(
+ compareByDescending { it.latestMessageAt }
+ .thenBy { it.displayName.lowercase() }
+ .thenBy { it.conversationID }
+ )
+}
+
+private fun isNostrConversationID(value: String): Boolean =
+ value.startsWith("nostr_") || value.startsWith("nostr:")
+
+internal fun matchingUnreadAliases(
+ unreadConversationIDs: Set,
+ canonicalConversationID: String,
+ canonicalize: (String) -> String
+): Set {
+ val normalizedCanonicalID = canonicalConversationID.lowercase()
+ return unreadConversationIDs
+ .filterTo(mutableSetOf()) { unreadID ->
+ canonicalize(unreadID).equals(normalizedCanonicalID, ignoreCase = true)
+ }
+ .plus(canonicalConversationID)
+ .mapTo(mutableSetOf()) { it.lowercase() }
+}
diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt b/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt
index 28c051bc..8003508e 100644
--- a/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt
+++ b/app/src/main/java/com/bitchat/android/ui/VerificationHandler.kt
@@ -8,6 +8,7 @@ import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.noise.NoiseSession
import com.bitchat.android.nostr.GeohashAliasRegistry
+import com.bitchat.android.services.ContactIdentityResolver
import com.bitchat.android.services.VerificationService
import com.bitchat.android.util.dataFromHexString
import com.bitchat.android.util.hexEncodedString
@@ -185,6 +186,9 @@ class VerificationHandler(
val hexRegex = Regex("^[0-9a-fA-F]+$")
return try {
when {
+ ContactIdentityResolver.isContactConversationId(peerID) -> {
+ ContactIdentityResolver.fingerprintFromContactConversationId(peerID)
+ }
peerID.length == 64 && peerID.matches(hexRegex) -> {
identityManager.getCachedNoiseFingerprint(peerID)?.let { return it }
fingerprintFromNoiseHex(peerID)?.also { identityManager.cacheNoiseFingerprint(peerID, it) }
diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt
index 97392eae..a84c3c94 100644
--- a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt
+++ b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt
@@ -1,5 +1,7 @@
package com.bitchat.android.ui
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.QrCodeScanner
import android.graphics.Bitmap
import android.os.Handler
import android.os.Looper
@@ -17,7 +19,6 @@ import androidx.compose.animation.Crossfade
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
-import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -31,8 +32,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.outlined.QrCodeScanner
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -60,7 +59,6 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -70,8 +68,10 @@ import androidx.core.graphics.createBitmap
import androidx.core.graphics.set
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+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.services.VerificationService
import com.google.accompanist.permissions.ExperimentalPermissionsApi
@@ -98,8 +98,7 @@ fun VerificationSheet(
) {
if (!isPresented) return
- val isDark = isSystemInDarkTheme()
- val accent = if (isDark) Color.Green else Color(0xFF008000)
+ val accent = MaterialTheme.colorScheme.primary
var selectedTab by remember { mutableStateOf(0) } // 0 = My Code, 1 = Scan
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
@@ -144,7 +143,7 @@ fun VerificationSheet(
text = {
Text(
text = "My QR",
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontSize = 14.sp
)
}
@@ -155,7 +154,7 @@ fun VerificationSheet(
text = {
Text(
text = "Scan",
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontSize = 14.sp
)
}
@@ -208,7 +207,7 @@ fun VerificationSheet(
) {
Text(
text = stringResource(R.string.verify_remove),
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontSize = 12.sp
)
}
@@ -232,10 +231,11 @@ private fun VerificationHeader(
Text(
text = stringResource(R.string.verify_title).uppercase(),
fontSize = 14.sp,
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
color = accent
)
- CloseButton(onClick = onClose)
+ val dismiss = LocalSheetDismiss.current
+ CloseButton(onClick = { dismiss?.invoke() ?: onClose() })
}
}
@@ -258,7 +258,7 @@ private fun MyQrTabContent(
Text(
text = stringResource(R.string.verify_my_qr_title),
style = MaterialTheme.typography.titleMedium,
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
color = accent
)
@@ -283,7 +283,7 @@ private fun MyQrTabContent(
) {
Text(
text = stringResource(R.string.verify_qr_unavailable),
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontSize = 12.sp,
color = Color.Black.copy(alpha = 0.6f)
)
@@ -296,7 +296,7 @@ private fun MyQrTabContent(
Text(
text = nickname,
style = MaterialTheme.typography.headlineSmall,
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
@@ -307,7 +307,7 @@ private fun MyQrTabContent(
Text(
text = stringResource(R.string.app_name).lowercase(),
style = MaterialTheme.typography.bodyMedium,
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
textAlign = TextAlign.Center
)
@@ -355,7 +355,7 @@ private fun ScanTabContent(
Text(
text = stringResource(R.string.verify_scan_prompt_friend),
color = Color.White,
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontSize = 12.sp,
modifier = Modifier
.align(Alignment.BottomCenter)
@@ -386,7 +386,7 @@ private fun ScanTabContent(
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.verify_camera_permission),
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
)
@@ -397,7 +397,7 @@ private fun ScanTabContent(
) {
Text(
text = stringResource(R.string.verify_request_camera),
- fontFamily = FontFamily.Monospace
+ fontFamily = BitchatFontFamily
)
}
}
diff --git a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt
index c61f2f9a..c9922bf1 100644
--- a/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt
+++ b/app/src/main/java/com/bitchat/android/ui/VoiceInputComponents.kt
@@ -1,24 +1,18 @@
package com.bitchat.android.ui
-import android.Manifest
-import androidx.compose.foundation.background
-import androidx.compose.foundation.gestures.detectTapGestures
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
+import android.Manifest
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.runtime.*
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
-import androidx.compose.ui.unit.dp
import com.bitchat.android.features.voice.VoiceRecorder
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionStatus
@@ -27,21 +21,59 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withTimeoutOrNull
+
+/**
+ * How long the button must be held before a recording starts.
+ *
+ * Push-to-talk should require intent. Firing on raw pointer-down meant a stray touch started a
+ * recording, and it also made the control impossible to defend against pointer events it should
+ * never have seen (see [ArmDelayMs]).
+ */
+private const val HoldToRecordMs = 220L
+
+/**
+ * How long the button ignores presses after entering composition.
+ *
+ * The action cluster swaps send out for camera+microphone the instant the field is cleared, which
+ * puts the microphone exactly where the send button was a frame earlier. Tapping send quickly
+ * could hand the microphone a pointer-down whose matching pointer-up had already been delivered
+ * to the send button that no longer exists — leaving the gesture waiting for a release that will
+ * never come, stuck in "recording" until the composable is disposed. Refusing presses until the
+ * swap animation has settled removes that whole class of failure.
+ */
+private const val ArmDelayMs = 350L
+
+/** Hard cap on a single recording. */
+private const val MaxRecordingMs = 10_000L
+
+/** Tail kept after release so the last syllable is not clipped. */
+private const val ReleaseTailMs = 500L
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun VoiceRecordButton(
modifier: Modifier = Modifier,
- backgroundColor: Color,
+ /**
+ * Recording state as the composer sees it. Drives the active tint so the button and the
+ * pill's border change together instead of one lagging the other.
+ */
+ isRecording: Boolean = false,
onStart: () -> Unit,
onAmplitude: (amplitude: Int, elapsedMs: Long) -> Unit,
- onFinish: (filePath: String) -> Unit
+ onFinish: (filePath: String) -> Unit,
+ /**
+ * Invoked whenever a recording ends without producing a file — permission denied, recorder
+ * failure, or the button being torn down mid-capture. The caller needs this to clear its own
+ * recording state; without it a failed capture left the composer stuck in recording mode.
+ */
+ onCancel: () -> Unit = {}
) {
val context = LocalContext.current
val haptic = LocalHapticFeedback.current
val micPermission = rememberPermissionState(Manifest.permission.RECORD_AUDIO)
- var isRecording by remember { mutableStateOf(false) }
+ var isCapturing by remember { mutableStateOf(false) }
var recorder by remember { mutableStateOf(null) }
var recordedFilePath by remember { mutableStateOf(null) }
var recordingStart by remember { mutableStateOf(0L) }
@@ -53,71 +85,127 @@ fun VoiceRecordButton(
val latestOnStart = rememberUpdatedState(onStart)
val latestOnAmplitude = rememberUpdatedState(onAmplitude)
val latestOnFinish = rememberUpdatedState(onFinish)
+ val latestOnCancel = rememberUpdatedState(onCancel)
- Box(
+ // Set when this instance was composed, so presses inherited from whatever occupied this spot
+ // beforehand can be rejected.
+ val composedAt = remember { System.currentTimeMillis() }
+
+ fun buzz() {
+ try {
+ haptic.performHapticFeedback(HapticFeedbackType.LongPress)
+ } catch (_: Exception) {
+ }
+ }
+
+ // Last line of defence: if the button is removed while capturing — the cluster swapping to
+ // send, the sheet closing, the screen going away — release the recorder and tell the caller,
+ // so nothing is left holding the microphone or showing a recording UI.
+ DisposableEffect(Unit) {
+ onDispose {
+ ampJob?.cancel()
+ ampJob = null
+ if (isCapturing) {
+ isCapturing = false
+ runCatching { recorder?.stop() }
+ recorder = null
+ recordedFilePath = null
+ latestOnCancel.value()
+ }
+ }
+ }
+
+ // Same disc, same sizing and the same press feedback as the camera and send buttons.
+ ComposerActionSurface(
+ isActive = isRecording || isCapturing,
+ isPressed = isCapturing,
modifier = modifier
- .size(32.dp)
- .background(backgroundColor, CircleShape)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
- if (!isRecording) {
- if (micPermission.status !is PermissionStatus.Granted) {
- micPermission.launchPermissionRequest()
- return@detectTapGestures
- }
- val rec = VoiceRecorder(context)
- val f = rec.start()
- recorder = rec
- isRecording = f != null
- recordedFilePath = f?.absolutePath
- recordingStart = System.currentTimeMillis()
- if (isRecording) {
- latestOnStart.value()
- // Haptic "knock" when recording starts
- try { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } catch (_: Exception) {}
- // Start amplitude polling loop
- ampJob?.cancel()
- ampJob = scope.launch {
- while (isActive && isRecording) {
- val amp = recorder?.pollAmplitude() ?: 0
- val elapsedMs = (System.currentTimeMillis() - recordingStart).coerceAtLeast(0L)
- latestOnAmplitude.value(amp, elapsedMs)
- // Auto-stop after 10 seconds
- if (elapsedMs >= 10_000 && isRecording) {
- val file = recorder?.stop()
- isRecording = false
- recorder = null
- val path = file?.absolutePath
- if (!path.isNullOrBlank()) {
- // Haptic "knock" on auto stop
- try { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } catch (_: Exception) {}
- latestOnFinish.value(path)
- }
- break
- }
- delay(80)
+ // Guard 1: ignore anything arriving before the swap animation settled.
+ if (System.currentTimeMillis() - composedAt < ArmDelayMs) {
+ return@detectTapGestures
+ }
+ // Guard 2: never start a second capture on top of a live one.
+ if (isCapturing) return@detectTapGestures
+
+ if (micPermission.status !is PermissionStatus.Granted) {
+ micPermission.launchPermissionRequest()
+ return@detectTapGestures
+ }
+
+ // Guard 3: require a deliberate hold. `tryAwaitRelease` returns true on
+ // release and false on cancellation; either way the press was not a hold,
+ // so nothing should happen. Only a timeout means the finger is still down.
+ val stillHeld = withTimeoutOrNull(HoldToRecordMs) {
+ tryAwaitRelease()
+ } == null
+ if (!stillHeld) return@detectTapGestures
+
+ val rec = VoiceRecorder(context)
+ val startedFile = rec.start()
+ if (startedFile == null) {
+ // Recorder refused to start; make sure the caller does not sit in a
+ // recording state that never began.
+ runCatching { rec.stop() }
+ latestOnCancel.value()
+ return@detectTapGestures
+ }
+
+ recorder = rec
+ recordedFilePath = startedFile.absolutePath
+ recordingStart = System.currentTimeMillis()
+ isCapturing = true
+ latestOnStart.value()
+ buzz()
+
+ ampJob?.cancel()
+ ampJob = scope.launch {
+ while (isActive && isCapturing) {
+ val amp = recorder?.pollAmplitude() ?: 0
+ val elapsed =
+ (System.currentTimeMillis() - recordingStart).coerceAtLeast(0L)
+ latestOnAmplitude.value(amp, elapsed)
+
+ if (elapsed >= MaxRecordingMs && isCapturing) {
+ val file = recorder?.stop()
+ isCapturing = false
+ recorder = null
+ val path = file?.absolutePath ?: recordedFilePath
+ recordedFilePath = null
+ buzz()
+ // Always report the outcome, even when the file is unusable,
+ // or the caller stays stuck showing the waveform.
+ if (!path.isNullOrBlank()) {
+ latestOnFinish.value(path)
+ } else {
+ latestOnCancel.value()
}
+ break
}
+ delay(80)
}
}
+
try {
- awaitRelease()
+ tryAwaitRelease()
} finally {
- if (isRecording) {
- // Extend recording for 500ms after release to avoid clipping
- delay(500)
+ if (isCapturing) {
+ // Keep going briefly past the release so the tail is not clipped.
+ delay(ReleaseTailMs)
}
- if (isRecording) {
+ if (isCapturing) {
val file = recorder?.stop()
- isRecording = false
+ isCapturing = false
recorder = null
- val path = (file?.absolutePath ?: recordedFilePath)
+ val path = file?.absolutePath ?: recordedFilePath
recordedFilePath = null
+ buzz()
if (!path.isNullOrBlank()) {
- // Haptic "knock" when recording stops
- try { haptic.performHapticFeedback(HapticFeedbackType.LongPress) } catch (_: Exception) {}
latestOnFinish.value(path)
+ } else {
+ latestOnCancel.value()
}
}
ampJob?.cancel()
@@ -125,14 +213,13 @@ fun VoiceRecordButton(
}
}
)
- },
- contentAlignment = Alignment.Center
- ) {
+ }
+ ) { tint ->
Icon(
imageVector = Icons.Filled.Mic,
contentDescription = stringResource(com.bitchat.android.R.string.cd_record_voice),
- tint = Color.Black,
- modifier = Modifier.size(20.dp)
+ tint = tint,
+ modifier = Modifier.size(ComposerIconSize)
)
}
}
diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt
index 32427c44..9c8c48b5 100644
--- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt
+++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt
@@ -3,6 +3,14 @@ package com.bitchat.android.ui.debug
import android.content.ClipData
import android.content.ClipboardManager
import android.widget.Toast
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Bluetooth
+import androidx.compose.material.icons.filled.Wifi
+import androidx.compose.material.icons.filled.WifiTethering
+import androidx.compose.material.icons.filled.BugReport
+import androidx.compose.material.icons.filled.Devices
+import androidx.compose.material.icons.filled.PowerSettingsNew
+import androidx.compose.material.icons.filled.SettingsEthernet
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@@ -12,25 +20,17 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.animation.core.animateFloatAsState
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Bluetooth
-import androidx.compose.material.icons.filled.Wifi
-import androidx.compose.material.icons.filled.WifiTethering
-import androidx.compose.material.icons.filled.BugReport
-import androidx.compose.material.icons.filled.Devices
-import androidx.compose.material.icons.filled.PowerSettingsNew
-import androidx.compose.material.icons.filled.SettingsEthernet
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
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 androidx.compose.ui.draw.rotate
+import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.services.meshgraph.MeshGraphService
import kotlinx.coroutines.launch
@@ -67,13 +67,13 @@ fun MeshTopologySection(
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF8E8E93))
- Text("mesh topology", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
+ Text("Mesh topology", fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
val nodes = snapshot.nodes
val edges = snapshot.edges
val empty = nodes.isEmpty()
if (empty) {
- Text("no gossip yet", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
+ Text("No gossip yet", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
} else {
ForceDirectedMeshGraph(
nodes = nodes,
@@ -94,10 +94,10 @@ fun MeshTopologySection(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
nodes.forEach { node ->
- val label = "${node.peerID.take(8)} • ${node.nickname ?: "unknown"}"
+ val label = "${node.peerID.take(8)} • ${node.nickname ?: "Unknown"}"
Text(
text = label,
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontSize = 11.sp,
color = colorScheme.onSurface.copy(alpha = 0.85f)
)
@@ -128,7 +128,7 @@ private fun DistributionInfoSection(info: DistributionInfoProvider.DistributionI
Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF5856D6))
Text(
"Distribution info",
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontSize = 14.sp,
fontWeight = FontWeight.Medium
)
@@ -137,7 +137,7 @@ private fun DistributionInfoSection(info: DistributionInfoProvider.DistributionI
if (info == null) {
Text(
"Inspecting installed package…",
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontSize = 11.sp,
color = colorScheme.onSurface.copy(alpha = 0.6f)
)
@@ -170,7 +170,7 @@ private fun DistributionInfoSection(info: DistributionInfoProvider.DistributionI
},
contentPadding = PaddingValues(horizontal = 0.dp)
) {
- Text("Copy certificate fingerprint", fontFamily = FontFamily.Monospace)
+ Text("Copy certificate fingerprint", fontFamily = BitchatFontFamily)
}
}
}
@@ -184,13 +184,13 @@ private fun DistributionInfoRow(label: String, value: String) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
label,
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontSize = 10.sp,
color = colorScheme.onSurface.copy(alpha = 0.55f)
)
Text(
value,
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontSize = 11.sp,
color = colorScheme.onSurface.copy(alpha = 0.9f)
)
@@ -347,7 +347,7 @@ fun DebugSettingsSheet(
item {
Text(
text = stringResource(R.string.debug_tools_desc),
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontSize = 12.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f)
)
@@ -361,13 +361,13 @@ fun DebugSettingsSheet(
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF00C851))
- Text(stringResource(R.string.debug_verbose_logging), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
+ Text(stringResource(R.string.debug_verbose_logging), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.weight(1f))
Switch(checked = verboseLogging, onCheckedChange = { manager.setVerboseLoggingEnabled(it) })
}
Text(
stringResource(R.string.debug_verbose_hint),
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontSize = 11.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f)
)
@@ -392,10 +392,10 @@ fun DebugSettingsSheet(
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF))
- Text(stringResource(R.string.debug_bluetooth_roles), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
+ Text(stringResource(R.string.debug_bluetooth_roles), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
Row(verticalAlignment = Alignment.CenterVertically) {
- Text(stringResource(R.string.debug_gatt_server), fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
+ Text(stringResource(R.string.debug_gatt_server), fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f))
Switch(checked = gattServerEnabled, onCheckedChange = {
manager.setGattServerEnabled(it)
scope.launch {
@@ -404,9 +404,9 @@ fun DebugSettingsSheet(
})
}
val serverCount = connectedDevices.count { it.connectionType == ConnectionType.GATT_SERVER }
- Text(stringResource(R.string.debug_connections_fmt, serverCount, maxServer), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
+ Text(stringResource(R.string.debug_connections_fmt, serverCount, maxServer), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Row(verticalAlignment = Alignment.CenterVertically) {
- Text(stringResource(R.string.debug_max_server), fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
+ Text(stringResource(R.string.debug_max_server), fontFamily = BitchatFontFamily, modifier = Modifier.width(90.dp))
Slider(
value = maxServer.toFloat(),
onValueChange = { manager.setMaxServerConnections(it.toInt().coerceAtLeast(1)) },
@@ -415,7 +415,7 @@ fun DebugSettingsSheet(
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
- Text(stringResource(R.string.debug_gatt_client), fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
+ Text(stringResource(R.string.debug_gatt_client), fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f))
Switch(checked = gattClientEnabled, onCheckedChange = {
manager.setGattClientEnabled(it)
scope.launch {
@@ -424,9 +424,9 @@ fun DebugSettingsSheet(
})
}
val clientCount = connectedDevices.count { it.connectionType == ConnectionType.GATT_CLIENT }
- Text(stringResource(R.string.debug_connections_fmt, clientCount, maxClient), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
+ Text(stringResource(R.string.debug_connections_fmt, clientCount, maxClient), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Row(verticalAlignment = Alignment.CenterVertically) {
- Text(stringResource(R.string.debug_max_client), fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
+ Text(stringResource(R.string.debug_max_client), fontFamily = BitchatFontFamily, modifier = Modifier.width(90.dp))
Slider(
value = maxClient.toFloat(),
onValueChange = { manager.setMaxClientConnections(it.toInt().coerceAtLeast(1)) },
@@ -435,9 +435,9 @@ fun DebugSettingsSheet(
)
}
val overallCount = connectedDevices.size
- Text(stringResource(R.string.debug_overall_connections_fmt, overallCount, maxOverall), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
+ Text(stringResource(R.string.debug_overall_connections_fmt, overallCount, maxOverall), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Row(verticalAlignment = Alignment.CenterVertically) {
- Text(stringResource(R.string.debug_max_overall), fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
+ Text(stringResource(R.string.debug_max_overall), fontFamily = BitchatFontFamily, modifier = Modifier.width(90.dp))
Slider(
value = maxOverall.toFloat(),
onValueChange = { manager.setMaxConnectionsOverall(it.toInt().coerceAtLeast(1)) },
@@ -447,7 +447,7 @@ fun DebugSettingsSheet(
}
Text(
stringResource(R.string.debug_roles_hint),
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontSize = 11.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f)
)
@@ -461,12 +461,12 @@ fun DebugSettingsSheet(
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF4CAF50))
- Text("Transports", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
+ Text("Transports", fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF))
Spacer(Modifier.width(8.dp))
- Text("BLE", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
+ Text("BLE", fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f))
Switch(checked = bleEnabled, onCheckedChange = {
manager.setBleEnabled(it)
})
@@ -474,7 +474,7 @@ fun DebugSettingsSheet(
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Wifi, contentDescription = null, tint = Color(0xFF9C27B0))
Spacer(Modifier.width(8.dp))
- Text("Wi‑Fi Aware", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
+ Text("Wi‑Fi Aware", fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f))
val wifiSwitchEnabled = wifiAwareSupported
Text(
when {
@@ -482,7 +482,7 @@ fun DebugSettingsSheet(
wifiAwareAvailable -> "available"
else -> "unavailable"
},
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontSize = 11.sp,
color = colorScheme.onSurface.copy(alpha = 0.6f)
)
@@ -497,7 +497,7 @@ fun DebugSettingsSheet(
}
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(Modifier.width(24.dp))
- Text("Wi‑Fi Aware verbose", fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
+ Text("Wi‑Fi Aware verbose logging", fontFamily = BitchatFontFamily, modifier = Modifier.weight(1f))
Switch(checked = wifiAwareVerbose, onCheckedChange = { manager.setWifiAwareVerbose(it) })
}
}
@@ -512,7 +512,7 @@ fun DebugSettingsSheet(
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.PowerSettingsNew, contentDescription = null, tint = Color(0xFFFF9500))
- Text(stringResource(R.string.debug_packet_relay), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
+ Text(stringResource(R.string.debug_packet_relay), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.weight(1f))
Switch(checked = packetRelayed, onCheckedChange = { manager.setPacketRelayEnabled(it) })
}
@@ -648,10 +648,10 @@ fun DebugSettingsSheet(
// Helper functions moved to top-level composable below to avoid scope issues
// Render two blocks: Incoming and Outgoing
- Text("Incoming", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
+ Text("Incoming", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(
"${relayStats.lastSecondIncoming}/s • ${relayStats.lastMinuteIncoming}/m • ${relayStats.last15MinuteIncoming}/15m • total ${relayStats.totalIncomingCount}",
- fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)
+ fontFamily = BitchatFontFamily, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)
)
DrawGraphBlock(
title = "Incoming",
@@ -704,10 +704,10 @@ fun DebugSettingsSheet(
if (graphMode != GraphMode.OVERALL && stackedKeysIncoming.isNotEmpty()) { /* legend printed inside DrawGraphBlock */ }
Spacer(Modifier.height(8.dp))
- Text("Outgoing", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
+ Text("Outgoing", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Text(
"${relayStats.lastSecondOutgoing}/s • ${relayStats.lastMinuteOutgoing}/m • ${relayStats.last15MinuteOutgoing}/15m • total ${relayStats.totalOutgoingCount}",
- fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)
+ fontFamily = BitchatFontFamily, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.6f)
)
DrawGraphBlock(
title = "Outgoing",
@@ -770,7 +770,7 @@ fun DebugSettingsSheet(
val running by com.bitchat.android.wifiaware.WifiAwareController.running.collectAsState()
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.WifiTethering, contentDescription = null, tint = Color(0xFF9C27B0))
- Text("Wi‑Fi Aware", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
+ Text("Wi‑Fi Aware", fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.weight(1f))
val wifiStatusText = when {
!wifiAwareSupported -> "unsupported"
@@ -778,12 +778,12 @@ fun DebugSettingsSheet(
!wifiAwareAvailable -> "unavailable"
else -> "stopped"
}
- Text(wifiStatusText, fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
+ Text(wifiStatusText, fontFamily = BitchatFontFamily, fontSize = 12.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
}
if (!wifiAwareSupported) {
Text(
wifiAwareSupportStatus?.reason ?: "Wi-Fi Aware is not supported on this device",
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontSize = 11.sp,
color = colorScheme.onSurface.copy(alpha = 0.6f)
)
@@ -801,21 +801,21 @@ fun DebugSettingsSheet(
label = { Text("Announce") }
)
}
- Text("Discovered: ${wifiAwareDiscovered.size}", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
+ Text("Discovered: ${wifiAwareDiscovered.size}", fontFamily = BitchatFontFamily, fontSize = 12.sp)
if (wifiAwareDiscovered.isEmpty()) {
- Text("No discoveries yet", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
+ Text("No discoveries yet", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
} else {
wifiAwareDiscovered.entries.take(50).forEach { (peer, nick) ->
- Text("• ${if (nick.isBlank()) peer.take(8) + "…" else nick} (${peer.take(8)}…) ", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
+ Text("• ${if (nick.isBlank()) peer.take(8) + "…" else nick} (${peer.take(8)}…) ", fontFamily = BitchatFontFamily, fontSize = 12.sp)
}
}
Divider()
- Text("Connected: ${wifiAwareConnected.size}", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
+ Text("Connected: ${wifiAwareConnected.size}", fontFamily = BitchatFontFamily, fontSize = 12.sp)
if (wifiAwareConnected.isEmpty()) {
- Text("No active sockets", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
+ Text("No active sockets", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
} else {
wifiAwareConnected.entries.take(50).forEach { (peer, ip) ->
- Text("• ${peer.take(8)}… @ $ip", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
+ Text("• ${peer.take(8)}… @ $ip", fontFamily = BitchatFontFamily, fontSize = 12.sp)
}
}
}
@@ -828,17 +828,17 @@ fun DebugSettingsSheet(
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.SettingsEthernet, contentDescription = null, tint = Color(0xFF9C27B0))
- Text("sync settings", fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
+ Text("Sync settings", fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
- Text(stringResource(R.string.debug_max_packets_per_sync_fmt, seenCapacity), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
+ Text(stringResource(R.string.debug_max_packets_per_sync_fmt, seenCapacity), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Slider(value = seenCapacity.toFloat(), onValueChange = { manager.setSeenPacketCapacity(it.toInt()) }, valueRange = 10f..1000f, steps = 99)
- Text(stringResource(R.string.debug_max_gcs_filter_size_fmt, gcsMaxBytes), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
+ Text(stringResource(R.string.debug_max_gcs_filter_size_fmt, gcsMaxBytes), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Slider(value = gcsMaxBytes.toFloat(), onValueChange = { manager.setGcsMaxBytes(it.toInt()) }, valueRange = 128f..1024f, steps = 0)
- Text(stringResource(R.string.debug_target_fpr_fmt, gcsFpr), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
+ Text(stringResource(R.string.debug_target_fpr_fmt, gcsFpr), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
Slider(value = gcsFpr.toFloat(), onValueChange = { manager.setGcsFprPercent(it.toDouble()) }, valueRange = 0.1f..5.0f, steps = 49)
val p = remember(gcsFpr) { com.bitchat.android.sync.GCSFilter.deriveP(gcsFpr / 100.0) }
val nmax = remember(gcsFpr, gcsMaxBytes) { com.bitchat.android.sync.GCSFilter.estimateMaxElementsForSize(gcsMaxBytes, p) }
- Text(stringResource(R.string.debug_derived_p_fmt, p.toString(), nmax.toString()), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
+ Text(stringResource(R.string.debug_derived_p_fmt, p.toString(), nmax.toString()), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
}
}
}
@@ -849,22 +849,22 @@ fun DebugSettingsSheet(
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.Devices, contentDescription = null, tint = Color(0xFF4CAF50))
- Text(stringResource(R.string.debug_connected_devices), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
+ Text(stringResource(R.string.debug_connected_devices), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
val localAddr = remember { meshService.connectionManager.getLocalAdapterAddress() }
- Text(stringResource(R.string.debug_our_device_id_fmt, localAddr ?: stringResource(R.string.unknown)), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
+ Text(stringResource(R.string.debug_our_device_id_fmt, localAddr ?: stringResource(R.string.unknown)), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
if (connectedDevices.isEmpty()) {
- Text("none", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
+ Text("None", fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
} else {
connectedDevices.forEach { dev ->
Surface(shape = RoundedCornerShape(8.dp), color = colorScheme.surface.copy(alpha = 0.6f)) {
Row(Modifier.fillMaxWidth().padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
- Text((dev.peerID ?: stringResource(R.string.unknown)) + " • ${dev.deviceAddress}", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
+ Text((dev.peerID ?: stringResource(R.string.unknown)) + " • ${dev.deviceAddress}", fontFamily = BitchatFontFamily, fontSize = 12.sp)
val roleLabel = if (dev.connectionType == ConnectionType.GATT_SERVER) stringResource(R.string.debug_role_server) else stringResource(R.string.debug_role_client)
- Text("${dev.nickname ?: ""} • " + stringResource(R.string.debug_rssi_fmt, dev.rssi ?: stringResource(R.string.debug_question_mark)) + " • $roleLabel" + (if (dev.isDirectConnection) stringResource(R.string.debug_direct_suffix) else ""), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
+ Text("${dev.nickname ?: ""} • " + stringResource(R.string.debug_rssi_fmt, dev.rssi ?: stringResource(R.string.debug_question_mark)) + " • $roleLabel" + (if (dev.isDirectConnection) stringResource(R.string.debug_direct_suffix) else ""), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
}
- Text(stringResource(R.string.debug_disconnect), color = Color(0xFFBF1A1A), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable {
+ Text(stringResource(R.string.debug_disconnect), color = Color(0xFFBF1A1A), fontFamily = BitchatFontFamily, modifier = Modifier.clickable {
meshService.connectionManager.disconnectAddress(dev.deviceAddress)
})
}
@@ -881,19 +881,19 @@ fun DebugSettingsSheet(
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.Bluetooth, contentDescription = null, tint = Color(0xFF007AFF))
- Text(stringResource(R.string.debug_recent_scan_results), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
+ Text(stringResource(R.string.debug_recent_scan_results), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
if (scanResults.isEmpty()) {
- Text(stringResource(R.string.debug_none), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
+ Text(stringResource(R.string.debug_none), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.6f))
} else {
scanResults.forEach { res ->
Surface(shape = RoundedCornerShape(8.dp), color = colorScheme.surface.copy(alpha = 0.6f)) {
Row(Modifier.fillMaxWidth().padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
- Text((res.peerID ?: stringResource(R.string.unknown)) + " • ${res.deviceAddress}", fontFamily = FontFamily.Monospace, fontSize = 12.sp)
- Text(stringResource(R.string.debug_rssi_fmt, res.rssi.toString()), fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
+ Text((res.peerID ?: stringResource(R.string.unknown)) + " • ${res.deviceAddress}", fontFamily = BitchatFontFamily, fontSize = 12.sp)
+ Text(stringResource(R.string.debug_rssi_fmt, res.rssi.toString()), fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
}
- Text(stringResource(R.string.debug_connect), color = Color(0xFF00C851), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable {
+ Text(stringResource(R.string.debug_connect), color = Color(0xFF00C851), fontFamily = BitchatFontFamily, modifier = Modifier.clickable {
meshService.connectionManager.connectToAddress(res.deviceAddress)
})
}
@@ -910,15 +910,15 @@ fun DebugSettingsSheet(
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Filled.BugReport, contentDescription = null, tint = Color(0xFFFF9500))
- Text(stringResource(R.string.debug_debug_console), fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Medium)
+ Text(stringResource(R.string.debug_debug_console), fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.weight(1f))
- Text(stringResource(R.string.debug_clear), color = Color(0xFFBF1A1A), fontFamily = FontFamily.Monospace, modifier = Modifier.clickable {
+ Text(stringResource(R.string.debug_clear), color = Color(0xFFBF1A1A), fontFamily = BitchatFontFamily, modifier = Modifier.clickable {
manager.clearDebugMessages()
})
}
Column(Modifier.heightIn(max = 260.dp).background(colorScheme.surface.copy(alpha = 0.5f)).padding(8.dp)) {
debugMessages.takeLast(100).reversed().forEach { msg ->
- Text("${msg.content}", fontFamily = FontFamily.Monospace, fontSize = 11.sp)
+ Text("${msg.content}", fontFamily = BitchatFontFamily, fontSize = 11.sp)
}
}
}
@@ -1039,7 +1039,7 @@ private fun DrawGraphBlock(
Box(Modifier.width(leftGutter).fillMaxHeight()) {
Text(
"p/s",
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontSize = 10.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f),
modifier = Modifier.align(Alignment.CenterStart).padding(start = 2.dp).rotate(-90f)
@@ -1053,14 +1053,14 @@ private fun DrawGraphBlock(
}
Text(
topLabel,
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontSize = 10.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f),
modifier = Modifier.align(Alignment.TopEnd).padding(end = 4.dp)
)
Text(
"0",
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontSize = 10.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f),
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 4.dp)
@@ -1085,8 +1085,8 @@ private fun DrawGraphBlock(
) {
Box(Modifier.size(10.dp).background(swatchColor, RoundedCornerShape(2.dp)))
Column {
- Text(legendTitleFor(key), fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.6f else 0.95f))
- Text(legendMetricsFor(key), fontFamily = FontFamily.Monospace, fontSize = 9.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.45f else 0.75f))
+ Text(legendTitleFor(key), fontFamily = BitchatFontFamily, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.6f else 0.95f))
+ Text(legendMetricsFor(key), fontFamily = BitchatFontFamily, fontSize = 9.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (dimmed) 0.45f else 0.75f))
}
}
}
diff --git a/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt b/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt
index 44c7ef55..3254c565 100644
--- a/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt
+++ b/app/src/main/java/com/bitchat/android/ui/debug/MeshGraph.kt
@@ -1,14 +1,14 @@
package com.bitchat.android.ui.debug
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Wifi
+import androidx.compose.material.icons.outlined.Bluetooth
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Wifi
-import androidx.compose.material.icons.outlined.Bluetooth
import androidx.compose.material3.Icon
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
diff --git a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt
index 7c0a1ac6..c646d570 100644
--- a/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt
+++ b/app/src/main/java/com/bitchat/android/ui/media/AudioMessageItem.kt
@@ -1,11 +1,11 @@
package com.bitchat.android.ui.media
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@@ -13,14 +13,15 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
-import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
+import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.R
import com.bitchat.android.core.ui.component.text.AnnotatedClickableText
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import androidx.compose.material3.ColorScheme
+import com.bitchat.android.ui.theme.LocalBitchatPalette
import java.text.SimpleDateFormat
@Composable
@@ -33,8 +34,10 @@ fun AudioMessageItem(
onNicknameClick: ((String) -> Unit)?,
onMessageLongPress: ((BitchatMessage) -> Unit)?,
onCancelTransfer: ((BitchatMessage) -> Unit)?,
- modifier: Modifier = Modifier
+ modifier: Modifier = Modifier,
+ showSender: Boolean = true
) {
+ val palette = LocalBitchatPalette.current
val path = message.content.trim()
// Derive sending progress if applicable
val (overrideProgress, overrideColor) = when (val st = message.deliveryStatus) {
@@ -50,9 +53,11 @@ fun AudioMessageItem(
val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString(
message = message,
currentUserNickname = currentUserNickname,
- meshService = meshService,
- colorScheme = colorScheme,
- timeFormatter = timeFormatter
+ myPeerID = meshService.myPeerID,
+ palette = palette,
+ contentColor = colorScheme.onSurface,
+ timeFormatter = timeFormatter,
+ includeSender = showSender
)
val haptic = LocalHapticFeedback.current
AnnotatedClickableText(
@@ -68,7 +73,7 @@ fun AudioMessageItem(
}
},
onLongPress = { onMessageLongPress?.invoke(message) },
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
color = colorScheme.onSurface,
)
diff --git a/app/src/main/java/com/bitchat/android/ui/media/FileMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/FileMessageItem.kt
index ca57931b..7a289686 100644
--- a/app/src/main/java/com/bitchat/android/ui/media/FileMessageItem.kt
+++ b/app/src/main/java/com/bitchat/android/ui/media/FileMessageItem.kt
@@ -1,5 +1,7 @@
package com.bitchat.android.ui.media
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Description
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -12,8 +14,6 @@ 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.material.icons.Icons
-import androidx.compose.material.icons.filled.Description
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
diff --git a/app/src/main/java/com/bitchat/android/ui/media/FilePickerButton.kt b/app/src/main/java/com/bitchat/android/ui/media/FilePickerButton.kt
index 16d92d7a..bd5f30e0 100644
--- a/app/src/main/java/com/bitchat/android/ui/media/FilePickerButton.kt
+++ b/app/src/main/java/com/bitchat/android/ui/media/FilePickerButton.kt
@@ -1,11 +1,11 @@
package com.bitchat.android.ui.media
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Attachment
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.size
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Attachment
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
diff --git a/app/src/main/java/com/bitchat/android/ui/media/FileSendingAnimation.kt b/app/src/main/java/com/bitchat/android/ui/media/FileSendingAnimation.kt
index 2f40dec0..51e65108 100644
--- a/app/src/main/java/com/bitchat/android/ui/media/FileSendingAnimation.kt
+++ b/app/src/main/java/com/bitchat/android/ui/media/FileSendingAnimation.kt
@@ -1,5 +1,7 @@
package com.bitchat.android.ui.media
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Description
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
@@ -11,8 +13,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Description
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@@ -30,6 +30,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
+import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.R
import kotlinx.coroutines.delay
@@ -96,7 +97,7 @@ fun FileSendingAnimation(
androidx.compose.material3.Text(
text = revealedText,
style = MaterialTheme.typography.bodyMedium.copy(
- fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
color = Color.White
),
modifier = Modifier.padding(end = 2.dp)
@@ -107,7 +108,7 @@ fun FileSendingAnimation(
androidx.compose.material3.Text(
text = stringResource(R.string.underscore),
style = MaterialTheme.typography.bodyMedium.copy(
- fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
color = Color.White
)
)
@@ -149,7 +150,7 @@ private fun FileProgressBars(
androidx.compose.material3.Text(
text = progressString,
style = MaterialTheme.typography.bodySmall.copy(
- fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
color = Color(0xFF00FF7F) // Matrix green
),
modifier = modifier
diff --git a/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt b/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt
index 2964f9c0..3544b637 100644
--- a/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt
+++ b/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt
@@ -1,5 +1,8 @@
package com.bitchat.android.ui.media
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Download
import android.content.ContentValues
import android.os.Build
import android.provider.MediaStore
@@ -10,9 +13,6 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Close
-import androidx.compose.material.icons.filled.Download
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -33,6 +33,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.res.stringResource
+import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.R
import java.io.File
@@ -101,7 +102,7 @@ fun FullScreenImageViewer(imagePaths: List, initialIndex: Int = 0, onClo
text = stringResource(R.string.image_counter, (pagerState.currentPage ?: 0) + 1, imagePaths.size),
color = Color.White,
fontSize = 14.sp,
- fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
+ fontFamily = BitchatFontFamily
)
}
}
diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt
index 35b0bbea..5d9908fc 100644
--- a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt
+++ b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt
@@ -1,12 +1,12 @@
package com.bitchat.android.ui.media
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
@@ -21,12 +21,13 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.text.font.FontFamily
+import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import androidx.compose.material3.ColorScheme
import com.bitchat.android.core.ui.component.text.AnnotatedClickableText
+import com.bitchat.android.ui.theme.LocalBitchatPalette
import java.text.SimpleDateFormat
@Composable
@@ -41,16 +42,20 @@ fun ImageMessageItem(
onMessageLongPress: ((BitchatMessage) -> Unit)?,
onCancelTransfer: ((BitchatMessage) -> Unit)?,
onImageClick: ((String, List, Int) -> Unit)?,
- modifier: Modifier = Modifier
+ modifier: Modifier = Modifier,
+ showSender: Boolean = true
) {
+ val palette = LocalBitchatPalette.current
val path = message.content.trim()
Column(modifier = modifier.fillMaxWidth()) {
val headerText = com.bitchat.android.ui.formatMessageHeaderAnnotatedString(
message = message,
currentUserNickname = currentUserNickname,
- meshService = meshService,
- colorScheme = colorScheme,
- timeFormatter = timeFormatter
+ myPeerID = meshService.myPeerID,
+ palette = palette,
+ contentColor = colorScheme.onSurface,
+ timeFormatter = timeFormatter,
+ includeSender = showSender
)
val haptic = LocalHapticFeedback.current
AnnotatedClickableText(
@@ -66,7 +71,7 @@ fun ImageMessageItem(
}
},
onLongPress = { onMessageLongPress?.invoke(message) },
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
color = colorScheme.onSurface,
)
@@ -138,7 +143,7 @@ fun ImageMessageItem(
}
}
} else {
- Text(text = stringResource(com.bitchat.android.R.string.image_unavailable), fontFamily = FontFamily.Monospace, color = Color.Gray)
+ Text(text = stringResource(com.bitchat.android.R.string.image_unavailable), fontFamily = BitchatFontFamily, color = Color.Gray)
}
}
}
diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt b/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt
index eb1f5d86..97ee01a4 100644
--- a/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt
+++ b/app/src/main/java/com/bitchat/android/ui/media/ImagePickerButton.kt
@@ -1,5 +1,9 @@
package com.bitchat.android.ui.media
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Camera
+import androidx.compose.material.icons.filled.Photo
+import androidx.compose.material.icons.filled.PhotoCamera
import android.Manifest
import android.content.pm.PackageManager
import android.net.Uri
@@ -7,23 +11,20 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
-import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.size
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Camera
-import androidx.compose.material.icons.filled.Photo
-import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material3.Icon
import androidx.compose.runtime.*
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import com.bitchat.android.features.media.ImageUtils
+import com.bitchat.android.ui.ComposerActionSurface
+import com.bitchat.android.ui.ComposerIconSize
import java.io.File
@OptIn(ExperimentalFoundationApi::class)
@@ -86,26 +87,31 @@ fun ImagePickerButton(
}
}
- Box(
- modifier = modifier
- .size(32.dp)
- .combinedClickable(
- onClick = { imagePicker.launch("image/*") },
- onLongClick = {
- if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
- startCameraCapture()
- } else {
- permissionLauncher.launch(Manifest.permission.CAMERA)
- }
+ val interactionSource = remember { MutableInteractionSource() }
+ val isPressed by interactionSource.collectIsPressedAsState()
+
+ // Shares the composer's button treatment so camera, microphone and send read as one set.
+ ComposerActionSurface(
+ isActive = false,
+ isPressed = isPressed,
+ modifier = modifier.combinedClickable(
+ interactionSource = interactionSource,
+ indication = null,
+ onClick = { imagePicker.launch("image/*") },
+ onLongClick = {
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
+ startCameraCapture()
+ } else {
+ permissionLauncher.launch(Manifest.permission.CAMERA)
}
- ),
- contentAlignment = Alignment.Center
- ) {
+ }
+ )
+ ) { tint ->
Icon(
imageVector = Icons.Filled.PhotoCamera,
contentDescription = stringResource(com.bitchat.android.R.string.pick_image),
- tint = Color.Gray,
- modifier = Modifier.size(20.dp)
+ tint = tint,
+ modifier = Modifier.size(ComposerIconSize)
)
}
diff --git a/app/src/main/java/com/bitchat/android/ui/media/MediaPickerOptions.kt b/app/src/main/java/com/bitchat/android/ui/media/MediaPickerOptions.kt
index 47883499..6909a52d 100644
--- a/app/src/main/java/com/bitchat/android/ui/media/MediaPickerOptions.kt
+++ b/app/src/main/java/com/bitchat/android/ui/media/MediaPickerOptions.kt
@@ -1,5 +1,8 @@
package com.bitchat.android.ui.media
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Description
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -9,9 +12,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.Description
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
diff --git a/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt b/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt
index 67719d87..10109194 100644
--- a/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt
+++ b/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt
@@ -1,13 +1,14 @@
package com.bitchat.android.ui.media
+import com.bitchat.android.ui.theme.BitchatFontFamily
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
import android.media.MediaPlayer
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Pause
-import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -18,7 +19,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import androidx.compose.ui.text.font.FontFamily
@Composable
fun VoiceNotePlayer(
@@ -110,7 +110,7 @@ fun VoiceNotePlayer(
onSeek = seekTo
)
val durText = if (durationMs > 0) String.format("%02d:%02d", (durationMs / 1000) / 60, (durationMs / 1000) % 60) else "--:--"
- Text(text = durText, fontFamily = FontFamily.Monospace, fontSize = 12.sp)
+ Text(text = durText, fontFamily = BitchatFontFamily, fontSize = 12.sp)
}
}
diff --git a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt
new file mode 100644
index 00000000..8dfb87b0
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt
@@ -0,0 +1,90 @@
+package com.bitchat.android.ui.theme
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.graphics.Color
+
+/**
+ * Bitchat-specific color tokens that do not have a faithful Material 3 semantic role.
+ *
+ * Standard backgrounds, surfaces, text, outlines, primary/secondary accents, and errors belong
+ * to [androidx.compose.material3.MaterialTheme.colorScheme]. Keeping only the extra app semantics
+ * here lets Material components inherit correct defaults without losing Bitchat's identity.
+ */
+@Immutable
+data class BitchatPalette(
+ // MARK: - Form controls
+ /**
+ * Resting border for text inputs. Deliberately a neutral grey rather than the green-tinted
+ * Material outline: the composer is the one surface the user stares at while typing.
+ */
+ val inputOutline: Color,
+ /** Border for a focused text input. A step brighter, still neutral. */
+ val inputOutlineFocused: Color,
+ /**
+ * Fill for text inputs. Near-black / near-white and completely untinted, for the same reason
+ * as [inputOutline] — and because the composer sits on top of a green-tinted scrim, so any
+ * tint of its own compounds into something muddy.
+ */
+ val inputSurface: Color,
+ /** Fill for a focused text input. A barely perceptible lift. */
+ val inputSurfaceFocused: Color,
+ /** Resting disc behind the composer's action glyphs. Neutral grey. */
+ val inputButton: Color,
+
+ // MARK: - Extra semantics
+ /** Timestamps, placeholders, section labels, disabled states. */
+ val textTertiary: Color,
+ /** Self, mentions targeting you, unread DMs. */
+ val accentOrange: Color,
+ /** Nostr reachability. */
+ val accentPurple: Color,
+
+ // MARK: - Deterministic peer colors
+ /**
+ * Saturation/value applied after deriving a peer's stable hue. Swap this when adding a
+ * new theme — see [PeerColorStyle] for contrast guidelines.
+ */
+ val peerColors: PeerColorStyle,
+)
+
+val DarkBitchatPalette = BitchatPalette(
+ inputOutline = Color(0xFF333635),
+ inputOutlineFocused = Color(0xFF5A605D),
+ inputSurface = Color(0xFF0B0B0B),
+ inputSurfaceFocused = Color(0xFF151515),
+ inputButton = Color(0xFF1E1E1E),
+ textTertiary = Color(0xFF6B776B),
+ accentOrange = Color(0xFFFF9F0A),
+ accentPurple = Color(0xFFBF5AF2),
+ peerColors = PeerColorStyle.Dark,
+)
+
+val LightBitchatPalette = BitchatPalette(
+ inputOutline = Color(0xFFCFD3D1),
+ inputOutlineFocused = Color(0xFF8E9490),
+ inputSurface = Color(0xFFFAFAFA),
+ inputSurfaceFocused = Color(0xFFF2F2F2),
+ inputButton = Color(0xFFE8E8E8),
+ textTertiary = Color(0xFF757F75),
+ accentOrange = Color(0xFFFF9500),
+ accentPurple = Color(0xFFAF52DE),
+ peerColors = PeerColorStyle.Light,
+)
+
+val LocalBitchatPalette = staticCompositionLocalOf { DarkBitchatPalette }
+
+/**
+ * Motion tokens. The redesign leans on short, snappy transitions: long durations read as
+ * sluggish on a chat surface where the user is scanning quickly.
+ */
+object BitchatMotion {
+ /** Icon tints, text colors, small fills. */
+ const val QUICK_MS = 120
+
+ /** Tab indicators, pill growth, chip reveals. */
+ const val STANDARD_MS = 180
+
+ /** Sheet-level fades and scroll-driven top bars. */
+ const val EMPHASIZED_MS = 240
+}
diff --git a/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt b/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt
new file mode 100644
index 00000000..cdd4d418
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/ui/theme/ChatVisualTokens.kt
@@ -0,0 +1,64 @@
+package com.bitchat.android.ui.theme
+
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.Font
+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.TextUnit
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.bitchat.android.R
+
+/**
+ * The bundled Geist Mono family used throughout the app.
+ *
+ * Keeping the fonts in the APK preserves offline behavior and guarantees that the design-spec
+ * metrics do not depend on which monospace family a device happens to provide.
+ */
+internal val BitchatFontFamily = FontFamily(
+ Font(R.font.geist_mono_regular, FontWeight.Normal),
+ Font(R.font.geist_mono_medium, FontWeight.Medium),
+ Font(R.font.geist_mono_semibold, FontWeight.SemiBold),
+ Font(R.font.geist_mono_bold, FontWeight.Bold),
+)
+
+/** Exact typography, spacing, and opacity values exported for the chat transcript. */
+internal object ChatVisualTokens {
+ val MessageBodyFontSize: TextUnit = 14.sp
+ val MessageBodyLineHeight: TextUnit = 20.sp
+ val SenderFontSize: TextUnit = 14.sp
+ val SenderLineHeight: TextUnit = 16.sp
+ val SystemActionFontSize: TextUnit = 12.sp
+ val SystemActionLineHeight: TextUnit = 16.sp
+ val SystemTimeFontSize: TextUnit = 10.sp
+
+ val MessageItemSpacing: Dp = 8.dp
+ val SenderTopPadding: Dp = 8.dp
+ val SenderToBodySpacing: Dp = 4.dp
+
+ const val SenderSuffixAlpha: Float = 0.60f
+ const val HighlightAlpha: Float = 0.20f
+ const val MutedTextAlpha: Float = 0.50f
+
+ val MessageBodyStyle = TextStyle(
+ fontFamily = BitchatFontFamily,
+ fontWeight = FontWeight.Normal,
+ fontSize = MessageBodyFontSize,
+ lineHeight = MessageBodyLineHeight,
+ )
+
+ val SenderStyle = TextStyle(
+ fontFamily = BitchatFontFamily,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = SenderFontSize,
+ lineHeight = SenderLineHeight,
+ )
+
+ val SystemActionStyle = TextStyle(
+ fontFamily = BitchatFontFamily,
+ fontWeight = FontWeight.Medium,
+ fontSize = SystemActionFontSize,
+ lineHeight = SystemActionLineHeight,
+ )
+}
diff --git a/app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt b/app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt
new file mode 100644
index 00000000..168c9863
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/ui/theme/PeerColors.kt
@@ -0,0 +1,58 @@
+package com.bitchat.android.ui.theme
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.graphics.Color
+import com.bitchat.android.ui.PeerIdentity
+import kotlin.math.abs
+
+/**
+ * Theme-specific chroma applied after a peer's stable hue is derived.
+ *
+ * Hue stays identity-stable across themes (and byte-identical to iOS). Only saturation
+ * and value change so peer labels remain readable on each background.
+ *
+ * Guidelines when adding a future theme:
+ * - Dim / dark backgrounds: keep [value] high so colors are not lost against the surface;
+ * prefer muted [saturation] over neon.
+ * - Light backgrounds: keep [value] moderate-low so colors are not blinding; avoid
+ * near-full saturation.
+ */
+@Immutable
+data class PeerColorStyle(
+ val saturation: Float,
+ val value: Float,
+) {
+ companion object {
+ /** Soft pastels that stay bright enough on near-black chat surfaces. */
+ val Dark = PeerColorStyle(saturation = 0.55f, value = 0.82f)
+
+ /** Deeper, less saturated tones that stay readable on near-white surfaces. */
+ val Light = PeerColorStyle(saturation = 0.70f, value = 0.42f)
+ }
+}
+
+/**
+ * The single identity-to-color boundary used by chat, people sheets, and mentions.
+ *
+ * The djb2 hash and hue adjustment are byte-identical to the iOS implementation. Orange is
+ * avoided because it is reserved for the current user.
+ */
+fun colorForPeer(identity: PeerIdentity, palette: BitchatPalette): Color {
+ var hash = 5381UL
+ for (byte in identity.stableKey.toByteArray()) {
+ hash = ((hash shl 5) + hash) + byte.toUByte().toULong()
+ }
+
+ var hue = (hash % 360UL).toDouble() / 360.0
+ val orange = 30.0 / 360.0
+ if (abs(hue - orange) < 0.05) {
+ hue = (hue + 0.12) % 1.0
+ }
+
+ val style = palette.peerColors
+ return Color.hsv(
+ hue = (hue * 360).toFloat(),
+ saturation = style.saturation,
+ value = style.value
+ )
+}
diff --git a/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt b/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt
index 76ef3130..f2c4b0b7 100644
--- a/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt
+++ b/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt
@@ -9,6 +9,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -16,30 +17,51 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
-// Colors that match the iOS bitchat theme
-private val DarkColorScheme = darkColorScheme(
- primary = Color(0xFF39FF14), // Bright green (terminal-like)
+// Standard UI semantics live in Material so stock components and custom Bitchat composables
+// share one source of truth. LocalBitchatPalette below only supplies app-specific extra colors.
+internal val DarkBitchatColorScheme = darkColorScheme(
+ primary = Color(0xFF32D74B),
onPrimary = Color.Black,
- secondary = Color(0xFF2ECB10), // Darker green
+ primaryContainer = Color(0xFF163D1D),
+ onPrimaryContainer = Color(0xFFB8F5C1),
+ secondary = Color(0xFF0A84FF),
onSecondary = Color.Black,
- background = Color.Black,
- onBackground = Color(0xFF39FF14), // Green on black
- surface = Color(0xFF111111), // Very dark gray
- onSurface = Color(0xFF39FF14), // Green text
- error = Color(0xFFFF5555), // Red for errors
+ secondaryContainer = Color(0xFF082E54),
+ onSecondaryContainer = Color(0xFFC2E0FF),
+ tertiary = DarkBitchatPalette.accentOrange,
+ onTertiary = Color.Black,
+ background = Color(0xFF000000),
+ onBackground = Color(0xFFF5F5F5),
+ surface = Color(0xFF0E150E),
+ onSurface = Color(0xFFF5F5F5),
+ surfaceVariant = Color(0xFF182118),
+ onSurfaceVariant = Color(0xFF9AA69A),
+ outline = Color(0xFF2A3A2A),
+ outlineVariant = Color(0xFF1C271C),
+ error = Color(0xFFFF453A),
onError = Color.Black
)
-private val LightColorScheme = lightColorScheme(
- primary = Color(0xFF008000), // Dark green
+internal val LightBitchatColorScheme = lightColorScheme(
+ primary = Color(0xFF248A3D),
onPrimary = Color.White,
- secondary = Color(0xFF006600), // Even darker green
+ primaryContainer = Color(0xFFD5F1D8),
+ onPrimaryContainer = Color(0xFF0A3212),
+ secondary = Color(0xFF007AFF),
onSecondary = Color.White,
- background = Color.White,
- onBackground = Color(0xFF008000), // Dark green on white
- surface = Color(0xFFF8F8F8), // Very light gray
- onSurface = Color(0xFF008000), // Dark green text
- error = Color(0xFFCC0000), // Dark red for errors
+ secondaryContainer = Color(0xFFD6E9FF),
+ onSecondaryContainer = Color(0xFF002C5C),
+ tertiary = LightBitchatPalette.accentOrange,
+ onTertiary = Color.Black,
+ background = Color(0xFFFFFFFF),
+ onBackground = Color(0xFF131A13),
+ surface = Color(0xFFF2F6F2),
+ onSurface = Color(0xFF131A13),
+ surfaceVariant = Color(0xFFE7EDE7),
+ onSurfaceVariant = Color(0xFF4C574C),
+ outline = Color(0xFFCBD6CB),
+ outlineVariant = Color(0xFFDEE6DE),
+ error = Color(0xFFD70015),
onError = Color.White
)
@@ -60,7 +82,8 @@ fun BitchatTheme(
}
}
- val colorScheme = if (shouldUseDark) DarkColorScheme else LightColorScheme
+ val colorScheme = if (shouldUseDark) DarkBitchatColorScheme else LightBitchatColorScheme
+ val palette = if (shouldUseDark) DarkBitchatPalette else LightBitchatPalette
val view = LocalView.current
SideEffect {
@@ -83,9 +106,11 @@ fun BitchatTheme(
}
}
- MaterialTheme(
- colorScheme = colorScheme,
- typography = Typography,
- content = content
- )
+ CompositionLocalProvider(LocalBitchatPalette provides palette) {
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ content = content
+ )
+ }
}
diff --git a/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt b/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt
index a8c68a4b..2002a44a 100644
--- a/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt
+++ b/app/src/main/java/com/bitchat/android/ui/theme/Typography.kt
@@ -2,53 +2,81 @@ package com.bitchat.android.ui.theme
import androidx.compose.material3.Typography
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.sp
// Base font size for consistent scaling across the app
-internal const val BASE_FONT_SIZE = com.bitchat.android.util.AppConstants.UI.BASE_FONT_SIZE_SP // sp - increased from 14sp for better readability
+internal const val BASE_FONT_SIZE = com.bitchat.android.util.AppConstants.UI.BASE_FONT_SIZE_SP
+
+/**
+ * Message body style. The generous leading (1.4x) is what gives the redesigned chat surface
+ * its readable rhythm; without an explicit lineHeight, Compose falls back to the font's own
+ * metrics and lines sit too tightly for long paragraphs.
+ */
+val MessageBodyTextStyle = ChatVisualTokens.MessageBodyStyle
+
+/** Sender label above a message group. Single line, never wraps. */
+val MessageSenderTextStyle = ChatVisualTokens.SenderStyle
// Typography matching the iOS monospace design - using BASE_FONT_SIZE for consistency
val Typography = Typography(
bodyLarge = TextStyle(
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Normal,
fontSize = (BASE_FONT_SIZE + 1).sp,
lineHeight = (BASE_FONT_SIZE + 7).sp
),
bodyMedium = TextStyle(
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Normal,
fontSize = BASE_FONT_SIZE.sp,
- lineHeight = (BASE_FONT_SIZE + 3).sp
+ lineHeight = (BASE_FONT_SIZE + 6).sp
),
bodySmall = TextStyle(
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Normal,
fontSize = (BASE_FONT_SIZE - 3).sp,
lineHeight = (BASE_FONT_SIZE + 1).sp
),
headlineSmall = TextStyle(
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Medium,
fontSize = (BASE_FONT_SIZE + 3).sp,
lineHeight = (BASE_FONT_SIZE + 9).sp
),
+ // Previously unset, which leaked the Roboto default into onboarding + sheet titles.
+ headlineLarge = TextStyle(
+ fontFamily = BitchatFontFamily,
+ fontWeight = FontWeight.Bold,
+ fontSize = (BASE_FONT_SIZE + 13).sp,
+ lineHeight = (BASE_FONT_SIZE + 21).sp
+ ),
+ titleLarge = TextStyle(
+ fontFamily = BitchatFontFamily,
+ fontWeight = FontWeight.Medium,
+ fontSize = (BASE_FONT_SIZE + 5).sp,
+ lineHeight = (BASE_FONT_SIZE + 13).sp
+ ),
titleMedium = TextStyle(
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Medium,
fontSize = (BASE_FONT_SIZE + 1).sp,
lineHeight = (BASE_FONT_SIZE + 7).sp
),
+ labelLarge = TextStyle(
+ fontFamily = BitchatFontFamily,
+ fontWeight = FontWeight.Medium,
+ fontSize = (BASE_FONT_SIZE - 1).sp,
+ lineHeight = (BASE_FONT_SIZE + 5).sp
+ ),
labelMedium = TextStyle(
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Medium,
fontSize = (BASE_FONT_SIZE - 2).sp,
lineHeight = (BASE_FONT_SIZE + 3).sp
),
labelSmall = TextStyle(
- fontFamily = FontFamily.Monospace,
+ fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Normal,
fontSize = (BASE_FONT_SIZE - 4).sp,
lineHeight = (BASE_FONT_SIZE + 1).sp
diff --git a/app/src/main/java/com/bitchat/android/util/AppConstants.kt b/app/src/main/java/com/bitchat/android/util/AppConstants.kt
index 11df5b08..0210f58c 100644
--- a/app/src/main/java/com/bitchat/android/util/AppConstants.kt
+++ b/app/src/main/java/com/bitchat/android/util/AppConstants.kt
@@ -14,6 +14,7 @@ object AppConstants {
// Peer lifecycle
const val STALE_PEER_TIMEOUT_MS: Long = 180_000L // 3 minutes
const val PEER_CLEANUP_INTERVAL_MS: Long = 60_000L
+ const val PEER_DISCONNECT_GRACE_MS: Long = 10_000L
// BLE connection tracking
const val CONNECTION_RETRY_DELAY_MS: Long = 5_000L
@@ -52,6 +53,7 @@ object AppConstants {
const val CLEANUP_INTERVAL_MS: Long = 300_000L
const val MAX_PROCESSED_MESSAGES: Int = 10_000
const val MAX_PROCESSED_KEY_EXCHANGES: Int = 1_000
+ const val KEY_EXCHANGE_DEDUP_TIMEOUT_MS: Long = 60_000L
}
object Noise {
@@ -90,7 +92,7 @@ object AppConstants {
const val SCAN_OFF_DURATION_ULTRA_LOW_MS: Long = 29_000L
const val MAX_CONNECTIONS_NORMAL: Int = 8
const val MAX_CONNECTIONS_POWER_SAVE: Int = 8
- const val MAX_CONNECTIONS_ULTRA_LOW: Int = 4
+ const val MAX_CONNECTIONS_ULTRA_LOW: Int = 8
}
object Nostr {
@@ -120,7 +122,7 @@ object AppConstants {
object UI {
const val MAX_NICKNAME_LENGTH: Int = 15
- const val BASE_FONT_SIZE_SP: Int = 15
+ const val BASE_FONT_SIZE_SP: Int = 14
const val MESSAGE_DEDUP_TIMEOUT_MS: Long = 30_000L
const val SYSTEM_EVENT_DEDUP_TIMEOUT_MS: Long = 5_000L
const val ACTIVE_PEERS_NOTIFICATION_INTERVAL_MS: Long = 300_000L
@@ -129,7 +131,16 @@ object AppConstants {
}
object Media {
- const val MAX_FILE_SIZE_BYTES: Long = 50L * 1024 * 1024
+ // A file is currently encoded into one protocol payload before BLE fragmentation.
+ // Reserve room for maximum filename/MIME TLVs and encryption envelope overhead.
+ const val MAX_FILE_SIZE_BYTES: Long = (10L * 1024 * 1024) - (132L * 1024)
+ }
+
+ object Router {
+ const val OUTBOX_TICK_MS: Long = 2_000L
+ const val OUTBOX_MESSAGE_TTL_MS: Long = 86_400_000L // 24 hours
+ const val OUTBOX_MAX_PER_PEER: Int = 100
+ val HANDSHAKE_RETRY_BACKOFF_MS: LongArray = longArrayOf(5_000L, 15_000L, 30_000L, 60_000L)
}
object Services {
diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicy.kt b/app/src/main/java/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicy.kt
deleted file mode 100644
index d522c812..00000000
--- a/app/src/main/java/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicy.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-package com.bitchat.android.wifiaware
-
-/**
- * Resolves an authenticated callback to the exact still-active ingress link that completed Noise.
- * A relay/discovery ID alone is not sufficient because a replacement socket may reuse it.
- */
-internal object AuthenticatedIngressLinkPolicy {
- data class Claim(
- val relayAddress: String,
- val linkID: String
- )
-
- data class Link(
- val relayAddress: String,
- val transport: T
- )
-
- fun matches(
- expected: Claim?,
- authenticatedRelayAddress: String?,
- authenticatedLinkID: String?
- ): Boolean =
- expected != null &&
- expected.relayAddress == authenticatedRelayAddress &&
- expected.linkID == authenticatedLinkID
-
- fun resolve(
- authenticatedLinkID: String?,
- authenticatedRelayAddress: String?,
- links: Map>,
- currentTransportForRelay: (String) -> T?
- ): Link? {
- val linkID = authenticatedLinkID ?: return null
- val relayAddress = authenticatedRelayAddress ?: return null
- val link = links[linkID] ?: return null
- if (link.relayAddress != relayAddress) return null
- return link.takeIf { currentTransportForRelay(relayAddress) === it.transport }
- }
-}
diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/IngressLinkPolicy.kt b/app/src/main/java/com/bitchat/android/wifi-aware/IngressLinkPolicy.kt
new file mode 100644
index 00000000..f5572e17
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/wifi-aware/IngressLinkPolicy.kt
@@ -0,0 +1,22 @@
+package com.bitchat.android.wifiaware
+
+/** Resolves a packet to the exact still-active ingress link that delivered it. */
+internal object IngressLinkPolicy {
+ data class Link(
+ val relayAddress: String,
+ val transport: T
+ )
+
+ fun resolve(
+ ingressLinkID: String?,
+ relayAddress: String?,
+ links: Map>,
+ currentTransportForRelay: (String) -> T?
+ ): Link? {
+ val linkID = ingressLinkID ?: return null
+ val relayAddress = relayAddress ?: return null
+ val link = links[linkID] ?: return null
+ if (link.relayAddress != relayAddress) return null
+ return link.takeIf { currentTransportForRelay(relayAddress) === it.transport }
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/SyncedSocket.kt b/app/src/main/java/com/bitchat/android/wifi-aware/SyncedSocket.kt
index c3039327..9688f04f 100644
--- a/app/src/main/java/com/bitchat/android/wifi-aware/SyncedSocket.kt
+++ b/app/src/main/java/com/bitchat/android/wifi-aware/SyncedSocket.kt
@@ -24,10 +24,9 @@ class SyncedSocket(
private val outputStream: DataOutputStream
companion object {
- // Both peers exchange keep-alive frames every ~2s while connected, so a read that
- // stalls well beyond that means the link is dead (half-open). Time out so the read
- // loop can detect it and trigger disconnection instead of blocking forever.
- const val DEFAULT_READ_TIMEOUT_MS = 15_000
+ // Critical-background peers send at most every 30s. Tolerate three missed frames so a
+ // faster local profile cannot drop a healthy, slower remote peer merely for being idle.
+ const val DEFAULT_READ_TIMEOUT_MS = 90_000
}
init {
diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt
index 9376b078..2b5ab61f 100644
--- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt
+++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareConnectionTracker.kt
@@ -100,9 +100,9 @@ class WifiAwareConnectionTracker(
}
/**
- * Atomically require that [expectedSocket] is still the active provisional transport and, only
- * then, promote it. This closes the gap where a replacement socket could land after validation
- * but before mutation and the stale authenticated socket would become canonical.
+ * Atomically require that [expectedSocket] is still the active provisional transport before
+ * rebinding it. This closes the gap where a replacement socket could land after ANNOUNCE
+ * validation but before mutation and the stale socket would become canonical.
*/
fun rebindPeerIdIfCurrent(
previousPeerId: String,
diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt
index f2ec4cfa..1c2b7e5f 100644
--- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt
+++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareController.kt
@@ -6,12 +6,10 @@ import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
@@ -68,24 +66,20 @@ object WifiAwareController {
Log.i(TAG, "Wi-Fi Aware unsupported: ${status.reason}")
}
setEnabled(enabledByDefault)
- // Start background poller for debug surfacing
- scope.launch {
- while (isActive) {
- try {
- val s = service
- if (s != null) {
- _connectedPeers.value = s.getDeviceAddressToPeerMapping() // peerID -> ip
- _knownPeers.value = s.getPeerNicknames()
- _discoveredPeers.value = s.getDiscoveredPeerIds()
- } else {
- _connectedPeers.value = emptyMap()
- _knownPeers.value = emptyMap()
- _discoveredPeers.value = emptySet()
- }
- } catch (_: Exception) { }
- delay(1000)
- }
- }
+ }
+
+ internal fun publishDebugSnapshot(
+ connected: Map,
+ known: Map,
+ discovered: Set
+ ) {
+ _connectedPeers.value = connected
+ _knownPeers.value = known
+ _discoveredPeers.value = discovered
+ }
+
+ private fun clearDebugSnapshot() {
+ publishDebugSnapshot(emptyMap(), emptyMap(), emptySet())
}
fun setEnabled(value: Boolean) {
@@ -202,9 +196,7 @@ object WifiAwareController {
}
try { stopped?.stopServices() } catch (_: Exception) { }
try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { }
- _connectedPeers.value = emptyMap()
- _knownPeers.value = emptyMap()
- _discoveredPeers.value = emptySet()
+ clearDebugSnapshot()
try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi‑Fi Aware stopped")) } catch (_: Exception) {}
}
@@ -214,9 +206,7 @@ object WifiAwareController {
service = null
_running.value = false
try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { }
- _connectedPeers.value = emptyMap()
- _knownPeers.value = emptyMap()
- _discoveredPeers.value = emptySet()
+ clearDebugSnapshot()
}
}
diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt
index d6cbf981..85669aba 100644
--- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt
+++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt
@@ -13,6 +13,7 @@ import android.util.Log
import androidx.annotation.RequiresApi
import androidx.annotation.RequiresPermission
import com.bitchat.android.crypto.EncryptionService
+import com.bitchat.android.mesh.DirectLinkAnnouncementPolicy
import com.bitchat.android.mesh.FragmentingPacketSender
import com.bitchat.android.mesh.MeshCore
import com.bitchat.android.mesh.MeshService
@@ -75,11 +76,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
private const val CLIENT_SOCKET_RETRY_DELAY_MS = 750L
private const val CLIENT_SOCKET_ATTEMPTS = 3
private const val CLIENT_ROLE_REVERSAL_FAILURES = 3
- private const val WIFI_AUTHENTICATION_TIMEOUT_MS = 30_000L
- // Discovery freshness window for reconnection maintenance
- private const val DISCOVERY_STALE_MS = 5L * 60 * 1000
- private const val DISCOVERY_IDLE_REFRESH_MS = 2L * 60 * 1000
- private const val DISCOVERY_SESSION_REFRESH_MIN_INTERVAL_MS = 90L * 1000
private const val ROLE_REVERSAL_PREFIX = "ROLE_SERVER:"
}
@@ -89,6 +85,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
// Peer ID must match BluetoothMeshService: first 16 hex chars of identity fingerprint (8 bytes)
override val myPeerID: String = encryptionService.getIdentityFingerprint().take(16)
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private val powerManager = com.bitchat.android.mesh.PowerManager.getInstance(context.applicationContext)
private val wifiTransport = WifiAwareTransport()
private lateinit var meshCore: MeshCore
private lateinit var fragmentingSender: FragmentingPacketSender
@@ -125,12 +122,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
private val connectionTracker = WifiAwareConnectionTracker(serviceScope, cm)
private val ingressLinks = ConcurrentHashMap<
String,
- AuthenticatedIngressLinkPolicy.Link
+ IngressLinkPolicy.Link
>()
- private val provisionalWifiClaims =
- ConcurrentHashMap()
- private val authenticatedWifiLinks =
- ConcurrentHashMap()
private val handleToPeerId = ConcurrentHashMap() // discovery mapping
private val discoveredTimestamps = ConcurrentHashMap() // peerID -> last seen time
// Subscribe-session-scoped handles only. PeerHandles are session-scoped, so a handle obtained
@@ -175,39 +168,15 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
hooks = MeshCore.Hooks(
onMessageReceived = { message -> handleMessageReceived(message) },
onAnnounceProcessed = { routed, _ ->
+ publishControllerDebugSnapshot()
routed.peerID?.let { pid ->
- try { meshCore.gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { }
-
- // Discovery IDs from older clients can be provisional. A verified direct
- // announce is enough to start a handshake for the canonical ID, but not to
- // rebind the socket. A fresh challenge is sent through the exact transport
- // generation, and only its same-link completion may promote that alias.
- val relay = routed.relayAddress
- val linkID = routed.ingressLinkID
- if (
- routed.packet.ttl == MAX_TTL &&
- relay != null &&
- linkID != null
- ) {
- val claim = AuthenticatedIngressLinkPolicy.Claim(relay, linkID)
- if (!AuthenticatedIngressLinkPolicy.matches(
- authenticatedWifiLinks[pid],
- relay,
- linkID
- )
- ) {
- registerProvisionalWifiClaim(pid, claim)
- if (!meshCore.initiateNoiseHandshakeOnLink(pid, relay, linkID)) {
- provisionalWifiClaims.remove(pid, claim)
- Log.w(TAG, "Could not send Noise challenge on Wi-Fi link for ${pid.take(8)}")
- }
- }
- }
+ DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL)
+ ?.let(::observeDirectIngressLink)
+ try {
+ meshCore.gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000)
+ } catch (_: Exception) { }
}
},
- onDirectNoiseAuthenticated = { peerID, relayAddress, ingressLinkID, _ ->
- promoteAuthenticatedIngressLink(peerID, relayAddress, ingressLinkID)
- },
announcementNicknameProvider = {
try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { null }
},
@@ -251,14 +220,17 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
/**
* Broadcasts raw bytes to currently connected peer.
*/
- private fun broadcastRaw(bytes: ByteArray) {
+ private fun broadcastRaw(bytes: ByteArray): Boolean {
+ var accepted = false
connectionTracker.peerSockets.forEach { (pid, sock) ->
try {
sock.write(bytes)
+ accepted = true
} catch (e: IOException) {
Log.e(TAG, "TX: write failed to ${pid.take(8)}: ${e.message}")
}
}
+ return accepted
}
// TransportLayer implementation
@@ -268,6 +240,10 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
meshCore.sendFromBridge(packet)
}
+ override suspend fun sendAndReport(packet: RoutedPacket): Boolean {
+ return meshCore.sendFromBridgeAndReport(packet)
+ }
+
override fun sendToPeer(peerID: String, packet: BitchatPacket) {
sendPacketToPeer(peerID, packet)
}
@@ -275,23 +251,23 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
/**
* Broadcasts routed packet to currently connected peers.
*/
- private fun broadcastPacket(routed: RoutedPacket) {
+ private fun broadcastPacket(routed: RoutedPacket): Boolean {
val packet = routed.packet
if (packet.senderID.toHexString() == myPeerID && !packet.route.isNullOrEmpty()) {
val firstHop = packet.route!![0].toHexString()
if (sendRoutedPacketToPeer(firstHop, routed)) {
- return
+ return true
}
}
val recipientId = packet.recipientID?.toHexString()
if (recipientId != null && !packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)) {
if (sendRoutedPacketToPeer(recipientId, routed)) {
- return
+ return true
}
}
- fragmentingSender.send(routed, "Wi-Fi Aware broadcast") { single ->
+ return fragmentingSender.send(routed, "Wi-Fi Aware broadcast") { single ->
broadcastSinglePacket(single)
}
}
@@ -319,8 +295,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
private fun broadcastSinglePacket(routed: RoutedPacket): Boolean {
val data = routed.packet.toBinaryData() ?: return false
- broadcastRaw(data)
- return true
+ return broadcastRaw(data)
}
private fun sendSinglePacketToPeer(peerID: String, packet: BitchatPacket): Boolean {
@@ -587,8 +562,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
publishHandles.clear()
discoveredTimestamps.clear()
ingressLinks.clear()
- provisionalWifiClaims.clear()
- authenticatedWifiLinks.clear()
meshCore.shutdown()
@@ -634,8 +607,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
publishHandles.clear()
discoveredTimestamps.clear()
ingressLinks.clear()
- provisionalWifiClaims.clear()
- authenticatedWifiLinks.clear()
}
} finally {
recoveryInProgress = false
@@ -653,6 +624,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
val now = System.currentTimeMillis()
discoveredTimestamps[peerId] = now
lastDiscoveryActivityAt.set(now)
+ publishControllerDebugSnapshot()
}
@RequiresApi(Build.VERSION_CODES.Q)
@@ -669,7 +641,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
if (!com.bitchat.android.wifiaware.WifiAwareController.enabled.value) return false
val lastRefresh = lastDiscoveryRefreshAt.get()
- if ((now - lastRefresh) < DISCOVERY_SESSION_REFRESH_MIN_INTERVAL_MS) return false
+ val minRefreshMs = powerManager.profile.value.wifiAware.discoverySessionRefreshMinMs
+ if ((now - lastRefresh) < minRefreshMs) return false
if (!lastDiscoveryRefreshAt.compareAndSet(lastRefresh, now)) return false
Log.i(TAG, "Refreshing Wi-Fi Aware discovery sessions ($reason)")
@@ -684,7 +657,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
serviceScope.launch {
while (isActive) {
try {
- delay(15_000) // Check every 15 seconds
+ val schedule = powerManager.profile.value.wifiAware
+ delay(schedule.connectionMaintenanceMs)
if (!isActive) break
val now = System.currentTimeMillis()
@@ -692,18 +666,19 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
// 0. Prune stale discovery entries. PeerHandles become invalid when the
// discovery sessions restart, so we must not keep pinging old handles forever.
val staleIds = discoveredTimestamps.filter { (id, ts) ->
- (now - ts) >= DISCOVERY_STALE_MS && !connectionTracker.isConnected(id)
+ (now - ts) >= schedule.discoveryStaleMs && !connectionTracker.isConnected(id)
}.keys.toSet()
if (staleIds.isNotEmpty()) {
staleIds.forEach { discoveredTimestamps.remove(it) }
handleToPeerId.entries.removeIf { it.value in staleIds }
staleIds.forEach { subscribeHandles.remove(it) }
staleIds.forEach { publishHandles.remove(it) }
+ publishControllerDebugSnapshot()
}
// 1. Identify peers that are discovered (recently seen) but not currently connected
val recentDiscovered = discoveredTimestamps.filter { (id, ts) ->
- (now - ts) < DISCOVERY_STALE_MS // Seen in last 5 minutes
+ (now - ts) < schedule.discoveryStaleMs
}.keys
// 2. Filter out those who are already connected
@@ -753,7 +728,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
disconnectedPeers.isNotEmpty() && missingUsableHandle && !attemptedReconnect -> {
refreshDiscoverySessions("missing peer handle", now)
}
- recentDiscovered.isEmpty() && idleFor >= DISCOVERY_IDLE_REFRESH_MS -> {
+ recentDiscovered.isEmpty() && idleFor >= schedule.discoveryIdleRefreshMs -> {
refreshDiscoverySessions("idle discovery", now)
}
}
@@ -896,12 +871,12 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
val synced = SyncedSocket(client)
activeSocket = synced
connectionTracker.onClientConnected(peerId, synced)
+ publishControllerDebugSnapshot()
// We only ever accept a single data socket per server request. Close the
// listening ServerSocket now so it can't block a future re-serve (its
// presence makes hasOpenServerSocket() true for the life of the process)
// and so we free the fd/port promptly.
connectionTracker.closeServerSocket(peerId)
- try { meshCore.setDirectConnection(peerId, true) } catch (_: Exception) {}
try { meshCore.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {}
listenerExec.execute { listenToPeer(synced, peerId) }
handleSubscriberKeepAlive(synced, peerId, pubSession, peerHandle)
@@ -964,30 +939,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
pubSession: PublishDiscoverySession,
peerHandle: PeerHandle
) {
- // TCP keep-alive pings
- serviceScope.launch {
- try {
- while (connectionTracker.isConnected(peerId)) {
- // write empty byte array effectively sends [4 bytes length=0] which is our ping
- try {
- client.write(ByteArray(0))
- } catch (_: IOException) {
- // The write side is dead. Don't just stop pinging: actively tear down so the
- // half-open socket stops counting as "connected" and maintenance can retry.
- handlePeerDisconnection(peerId, client)
- break
- }
- delay(2_000)
- }
- } catch (_: Exception) {}
- }
- // Discovery keep-alive
- serviceScope.launch {
- var msgId = 0
- while (connectionTracker.isConnected(peerId)) {
- try { pubSession.sendMessage(peerHandle, msgId++, ByteArray(0)) } catch (_: Exception) { break }
- delay(20_000)
- }
+ startProfiledKeepAlive(client, peerId) { msgId ->
+ pubSession.sendMessage(peerHandle, msgId, ByteArray(0))
}
}
@@ -1155,8 +1108,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
val synced = SyncedSocket(sock)
activeSocket = synced
connectionTracker.onClientConnected(peerId, synced)
+ publishControllerDebugSnapshot()
clientSocketFailures.remove(peerId)
- try { meshCore.setDirectConnection(peerId, true) } catch (_: Exception) {}
try { meshCore.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {}
listenerExec.execute { listenToPeer(synced, peerId) }
handleServerKeepAlive(synced, peerId, peerHandle)
@@ -1199,28 +1152,50 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
peerId: String,
peerHandle: PeerHandle
) {
- // TCP keep-alive
+ startProfiledKeepAlive(sock, peerId) { msgId ->
+ subscribeSession?.sendMessage(peerHandle, msgId, ByteArray(0))
+ }
+ }
+
+ /**
+ * One coroutine per peer schedules both TCP and discovery deadlines. This avoids two
+ * independently waking jobs while still adapting after every send to the latest profile.
+ */
+ private fun startProfiledKeepAlive(
+ socket: SyncedSocket,
+ peerId: String,
+ sendDiscovery: (Int) -> Unit
+ ) {
serviceScope.launch {
- try {
- while (connectionTracker.isConnected(peerId)) {
+ var discoveryMessageId = 0
+ var nextTcpAt = 0L
+ var nextDiscoveryAt = 0L
+ while (connectionTracker.isConnected(peerId)) {
+ val now = android.os.SystemClock.elapsedRealtime()
+ val schedule = powerManager.profile.value.wifiAware
+
+ if (now >= nextTcpAt) {
try {
- sock.write(ByteArray(0))
+ socket.write(ByteArray(0))
} catch (_: IOException) {
- // The write side is dead. Tear down so the half-open socket stops counting
- // as "connected" and maintenance can retry instead of silently stalling.
- handlePeerDisconnection(peerId, sock)
+ handlePeerDisconnection(peerId, socket)
break
}
- delay(2_000)
+ nextTcpAt = now + schedule.tcpKeepAliveMs
}
- } catch (_: Exception) {}
- }
- // Discovery keep-alive
- serviceScope.launch {
- var msgId = 0
- while (connectionTracker.isConnected(peerId)) {
- try { subscribeSession?.sendMessage(peerHandle, msgId++, ByteArray(0)) } catch (_: Exception) { break }
- delay(20_000)
+
+ if (now >= nextDiscoveryAt) {
+ try {
+ sendDiscovery(discoveryMessageId++)
+ } catch (_: Exception) {
+ // The TCP data path remains usable; discovery maintenance will recover its
+ // session independently without dropping this idle connection.
+ }
+ nextDiscoveryAt = now + schedule.discoveryKeepAliveMs
+ }
+
+ val nextWakeAt = minOf(nextTcpAt, nextDiscoveryAt)
+ delay((nextWakeAt - android.os.SystemClock.elapsedRealtime()).coerceAtLeast(250L))
}
}
}
@@ -1235,70 +1210,78 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
}
/**
- * Promote a provisional discovery alias only when the exact, still-active socket delivered the
- * Noise frame that completed authentication for the canonical peer ID.
+ * Records a validated, non-relayed ANNOUNCE as a direct route. The exact-link check keeps stale
+ * socket readers from rebinding a replacement connection, but Noise remains peer-scoped and is
+ * not restarted or coupled to this routing observation.
*/
- private fun promoteAuthenticatedIngressLink(
- canonicalPeerId: String,
- relayAddress: String,
- ingressLinkID: String
+ private fun observeDirectIngressLink(
+ observation: DirectLinkAnnouncementPolicy.Observation
) {
- val expectedClaim = provisionalWifiClaims[canonicalPeerId]
- if (!AuthenticatedIngressLinkPolicy.matches(
- expectedClaim,
- relayAddress,
- ingressLinkID
- )
- ) {
- Log.w(TAG, "Ignoring unsolicited or cross-link Noise promotion for ${canonicalPeerId.take(8)}")
- return
- }
- provisionalWifiClaims.remove(canonicalPeerId, expectedClaim)
-
- val link = AuthenticatedIngressLinkPolicy.resolve(
- authenticatedLinkID = ingressLinkID,
- authenticatedRelayAddress = relayAddress,
+ val link = IngressLinkPolicy.resolve(
+ ingressLinkID = observation.ingressLinkID,
+ relayAddress = observation.relayAddress,
links = ingressLinks,
currentTransportForRelay = connectionTracker::getSocketForPeer
) ?: run {
- Log.w(TAG, "Ignoring Noise link promotion for ${canonicalPeerId.take(8)}: ingress link is stale or mismatched")
+ Log.d(
+ TAG,
+ "Ignoring direct ANNOUNCE for ${observation.peerID.take(8)}: ingress link is stale"
+ )
return
}
val provisionalPeerId = link.relayAddress
val existingCanonical = connectionTracker.canonicalPeerId(provisionalPeerId)
- if (existingCanonical == canonicalPeerId) {
- authenticatedWifiLinks[canonicalPeerId] =
- AuthenticatedIngressLinkPolicy.Claim(relayAddress, ingressLinkID)
- try { meshCore.setDirectConnection(canonicalPeerId, true) } catch (_: Exception) { }
+ if (existingCanonical == observation.peerID) {
+ try { meshCore.setDirectConnection(observation.peerID, true) } catch (_: Exception) { }
return
}
if (existingCanonical != provisionalPeerId) {
- Log.w(TAG, "Refusing authenticated Wi-Fi rebind ${existingCanonical.take(8)} -> ${canonicalPeerId.take(8)} on existing alias")
+ Log.w(
+ TAG,
+ "Refusing Wi-Fi route change ${existingCanonical.take(8)} -> ${observation.peerID.take(8)} on existing alias"
+ )
return
}
- if (!connectionTracker.rebindPeerIdIfCurrent(provisionalPeerId, canonicalPeerId, link.transport)) {
- Log.w(TAG, "Ignoring Noise link promotion for ${canonicalPeerId.take(8)}: provisional socket changed")
+ if (!connectionTracker.rebindPeerIdIfCurrent(
+ provisionalPeerId,
+ observation.peerID,
+ link.transport
+ )
+ ) {
+ Log.d(
+ TAG,
+ "Ignoring direct ANNOUNCE for ${observation.peerID.take(8)}: provisional socket changed"
+ )
return
}
- authenticatedWifiLinks[canonicalPeerId] =
- AuthenticatedIngressLinkPolicy.Claim(relayAddress, ingressLinkID)
handleToPeerId.forEach { (handle, peerId) ->
- if (peerId == provisionalPeerId) handleToPeerId[handle] = canonicalPeerId
+ if (peerId == provisionalPeerId) handleToPeerId[handle] = observation.peerID
}
- subscribeHandles.remove(provisionalPeerId)?.let { subscribeHandles[canonicalPeerId] = it }
- publishHandles.remove(provisionalPeerId)?.let { publishHandles[canonicalPeerId] = it }
+ subscribeHandles.remove(provisionalPeerId)?.let { subscribeHandles[observation.peerID] = it }
+ publishHandles.remove(provisionalPeerId)?.let { publishHandles[observation.peerID] = it }
val discoveredAt = discoveredTimestamps.remove(provisionalPeerId) ?: System.currentTimeMillis()
- discoveredTimestamps[canonicalPeerId] = discoveredAt
+ discoveredTimestamps[observation.peerID] = discoveredAt
try { meshCore.setDirectConnection(provisionalPeerId, false) } catch (_: Exception) { }
try { meshCore.removePeer(provisionalPeerId) } catch (_: Exception) { }
- try { meshCore.addOrUpdatePeer(canonicalPeerId, meshCore.getPeerNickname(canonicalPeerId) ?: canonicalPeerId) } catch (_: Exception) { }
- try { meshCore.setDirectConnection(canonicalPeerId, true) } catch (_: Exception) { }
- try { meshCore.gossipSyncManager.scheduleInitialSyncToPeer(canonicalPeerId, 1_000) } catch (_: Exception) { }
+ try {
+ meshCore.addOrUpdatePeer(
+ observation.peerID,
+ meshCore.getPeerNickname(observation.peerID) ?: observation.peerID
+ )
+ } catch (_: Exception) { }
+ try { meshCore.setDirectConnection(observation.peerID, true) } catch (_: Exception) { }
+ try {
+ meshCore.gossipSyncManager.scheduleInitialSyncToPeer(observation.peerID, 1_000)
+ } catch (_: Exception) { }
+ publishControllerDebugSnapshot()
- Log.i(TAG, "Noise-authenticated Wi-Fi peer ${provisionalPeerId.take(8)} -> ${canonicalPeerId.take(8)}")
+ Log.i(
+ TAG,
+ "Observed direct Wi-Fi route ${provisionalPeerId.take(8)} -> ${observation.peerID.take(8)}"
+ )
}
/**
@@ -1311,7 +1294,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
private fun listenToPeer(socket: SyncedSocket, initialLogicalPeerId: String) {
val logicalPeerId = initialLogicalPeerId
val ingressLinkID = UUID.randomUUID().toString()
- val ingressLink = AuthenticatedIngressLinkPolicy.Link(logicalPeerId, socket)
+ val ingressLink = IngressLinkPolicy.Link(logicalPeerId, socket)
ingressLinks[ingressLinkID] = ingressLink
while (isActive) {
val raw = socket.read() ?: break
@@ -1326,10 +1309,11 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
val senderPeerHex = pkt.senderID?.toHexString()?.take(16) ?: continue
if (pkt.type == MessageType.ANNOUNCE.value && pkt.ttl >= MAX_TTL && senderPeerHex != logicalPeerId) {
- // The socket's discovery identity remains provisional until Noise proves possession
- // of the claimed static key on this link. A canonical self-signed announcement is
- // only TOFU and cannot safely rebind/remove transport state on its own.
- Log.d(TAG, "RX: deferred Wi-Fi peer rebind ${logicalPeerId.take(8)} -> ${senderPeerHex.take(8)} pending Noise proof")
+ // Rebinding happens only after MeshCore validates and accepts this ANNOUNCE.
+ Log.d(
+ TAG,
+ "RX: Wi-Fi peer observation ${logicalPeerId.take(8)} -> ${senderPeerHex.take(8)} pending ANNOUNCE validation"
+ )
}
// Route the packet:
@@ -1339,7 +1323,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
}
ingressLinks.remove(ingressLinkID, ingressLink)
- clearProvisionalWifiClaimsForLink(logicalPeerId, ingressLinkID)
// Breaking out of the loop means the socket is dead or service is stopping.
Log.i(TAG, "Disconnected from ${logicalPeerId.take(8)} (socket closed)")
@@ -1347,27 +1330,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
socket.close()
}
- private fun registerProvisionalWifiClaim(
- peerID: String,
- claim: AuthenticatedIngressLinkPolicy.Claim
- ) {
- provisionalWifiClaims[peerID] = claim
- serviceScope.launch {
- delay(WIFI_AUTHENTICATION_TIMEOUT_MS)
- if (provisionalWifiClaims.remove(peerID, claim)) {
- Log.d(TAG, "Expired provisional Wi-Fi authentication claim for ${peerID.take(8)}")
- } }
- }
-
- private fun clearProvisionalWifiClaimsForLink(relayAddress: String, linkID: String) {
- provisionalWifiClaims.entries.removeIf { (_, claim) ->
- claim.relayAddress == relayAddress && claim.linkID == linkID
- }
- authenticatedWifiLinks.entries.removeIf { (_, claim) ->
- claim.relayAddress == relayAddress && claim.linkID == linkID
- }
- }
-
private fun handleNetworkFailure(peerId: String) {
serviceScope.launch {
if (!connectionTracker.isConnected(peerId)) {
@@ -1401,6 +1363,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
}
}
// Else: socket replaced or inactive; do not remove peer/session, as a new socket has likely taken over
+ publishControllerDebugSnapshot()
}
}
@@ -1611,6 +1574,16 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
fun getDiscoveredPeerIds(): Set =
(handleToPeerId.values + discoveredTimestamps.keys).filter { it.isNotBlank() }.toSet()
+ private fun publishControllerDebugSnapshot() {
+ try {
+ com.bitchat.android.wifiaware.WifiAwareController.publishDebugSnapshot(
+ connected = getDeviceAddressToPeerMapping(),
+ known = getPeerNicknames(),
+ discovered = getDiscoveredPeerIds()
+ )
+ } catch (_: Exception) { }
+ }
+
/**
* Utility for logs/UI: pretty-prints one peer-to-address mapping per line.
*/
@@ -1644,9 +1617,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
private inner class WifiAwareTransport : MeshTransport {
override val id: String = "WIFI"
- override fun broadcastPacket(routed: RoutedPacket) {
+ override fun broadcastPacket(routed: RoutedPacket): Boolean =
this@WifiAwareMeshService.broadcastPacket(routed)
- }
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean {
return this@WifiAwareMeshService.sendPacketToPeer(peerID, packet)
}
@@ -1655,9 +1627,9 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
ingressLinkID: String,
packet: BitchatPacket
): Boolean {
- val link = AuthenticatedIngressLinkPolicy.resolve(
- authenticatedLinkID = ingressLinkID,
- authenticatedRelayAddress = relayAddress,
+ val link = IngressLinkPolicy.resolve(
+ ingressLinkID = ingressLinkID,
+ relayAddress = relayAddress,
links = ingressLinks,
currentTransportForRelay = connectionTracker::getSocketForPeer
) ?: return false
diff --git a/app/src/main/res/drawable/ic_spec_bluetooth.xml b/app/src/main/res/drawable/ic_spec_bluetooth.xml
new file mode 100644
index 00000000..8c1ed7ae
--- /dev/null
+++ b/app/src/main/res/drawable/ic_spec_bluetooth.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_spec_bookmark_filled.xml b/app/src/main/res/drawable/ic_spec_bookmark_filled.xml
new file mode 100644
index 00000000..2e73b458
--- /dev/null
+++ b/app/src/main/res/drawable/ic_spec_bookmark_filled.xml
@@ -0,0 +1,6 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_spec_bookmark_outline.xml b/app/src/main/res/drawable/ic_spec_bookmark_outline.xml
new file mode 100644
index 00000000..988fe44c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_spec_bookmark_outline.xml
@@ -0,0 +1,6 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_spec_chat_bubbles.xml b/app/src/main/res/drawable/ic_spec_chat_bubbles.xml
new file mode 100644
index 00000000..76109058
--- /dev/null
+++ b/app/src/main/res/drawable/ic_spec_chat_bubbles.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_spec_check.xml b/app/src/main/res/drawable/ic_spec_check.xml
new file mode 100644
index 00000000..16eb9e1c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_spec_check.xml
@@ -0,0 +1,7 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_spec_close.xml b/app/src/main/res/drawable/ic_spec_close.xml
new file mode 100644
index 00000000..e0fbc4ea
--- /dev/null
+++ b/app/src/main/res/drawable/ic_spec_close.xml
@@ -0,0 +1,8 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_spec_command.xml b/app/src/main/res/drawable/ic_spec_command.xml
new file mode 100644
index 00000000..366173cd
--- /dev/null
+++ b/app/src/main/res/drawable/ic_spec_command.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_spec_envelope.xml b/app/src/main/res/drawable/ic_spec_envelope.xml
new file mode 100644
index 00000000..15552d37
--- /dev/null
+++ b/app/src/main/res/drawable/ic_spec_envelope.xml
@@ -0,0 +1,8 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_spec_eye_off.xml b/app/src/main/res/drawable/ic_spec_eye_off.xml
new file mode 100644
index 00000000..2e852cf4
--- /dev/null
+++ b/app/src/main/res/drawable/ic_spec_eye_off.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_spec_globe.xml b/app/src/main/res/drawable/ic_spec_globe.xml
new file mode 100644
index 00000000..b56bb3a8
--- /dev/null
+++ b/app/src/main/res/drawable/ic_spec_globe.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_spec_lock.xml b/app/src/main/res/drawable/ic_spec_lock.xml
new file mode 100644
index 00000000..42713a11
--- /dev/null
+++ b/app/src/main/res/drawable/ic_spec_lock.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_spec_lock_open.xml b/app/src/main/res/drawable/ic_spec_lock_open.xml
new file mode 100644
index 00000000..11d69821
--- /dev/null
+++ b/app/src/main/res/drawable/ic_spec_lock_open.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_spec_mention.xml b/app/src/main/res/drawable/ic_spec_mention.xml
new file mode 100644
index 00000000..a57bf9af
--- /dev/null
+++ b/app/src/main/res/drawable/ic_spec_mention.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_spec_on_location_person.xml b/app/src/main/res/drawable/ic_spec_on_location_person.xml
new file mode 100644
index 00000000..507d47d8
--- /dev/null
+++ b/app/src/main/res/drawable/ic_spec_on_location_person.xml
@@ -0,0 +1,13 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_spec_panic.xml b/app/src/main/res/drawable/ic_spec_panic.xml
new file mode 100644
index 00000000..7c876da3
--- /dev/null
+++ b/app/src/main/res/drawable/ic_spec_panic.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_spec_people.xml b/app/src/main/res/drawable/ic_spec_people.xml
new file mode 100644
index 00000000..3b2d46f5
--- /dev/null
+++ b/app/src/main/res/drawable/ic_spec_people.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_spec_person.xml b/app/src/main/res/drawable/ic_spec_person.xml
new file mode 100644
index 00000000..33c42dcb
--- /dev/null
+++ b/app/src/main/res/drawable/ic_spec_person.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_spec_range.xml b/app/src/main/res/drawable/ic_spec_range.xml
new file mode 100644
index 00000000..5ba47d6c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_spec_range.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_spec_routed.xml b/app/src/main/res/drawable/ic_spec_routed.xml
new file mode 100644
index 00000000..36e271d7
--- /dev/null
+++ b/app/src/main/res/drawable/ic_spec_routed.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_spec_shuffle.xml b/app/src/main/res/drawable/ic_spec_shuffle.xml
new file mode 100644
index 00000000..9f431732
--- /dev/null
+++ b/app/src/main/res/drawable/ic_spec_shuffle.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_spec_star.xml b/app/src/main/res/drawable/ic_spec_star.xml
new file mode 100644
index 00000000..c48025ab
--- /dev/null
+++ b/app/src/main/res/drawable/ic_spec_star.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_spec_star_filled.xml b/app/src/main/res/drawable/ic_spec_star_filled.xml
new file mode 100644
index 00000000..d29d62d7
--- /dev/null
+++ b/app/src/main/res/drawable/ic_spec_star_filled.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_spec_sync.xml b/app/src/main/res/drawable/ic_spec_sync.xml
new file mode 100644
index 00000000..1ab434e5
--- /dev/null
+++ b/app/src/main/res/drawable/ic_spec_sync.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_spec_teleport.xml b/app/src/main/res/drawable/ic_spec_teleport.xml
new file mode 100644
index 00000000..6d215dea
--- /dev/null
+++ b/app/src/main/res/drawable/ic_spec_teleport.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_spec_warning.xml b/app/src/main/res/drawable/ic_spec_warning.xml
new file mode 100644
index 00000000..ffb64e3c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_spec_warning.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_spec_waveform.xml b/app/src/main/res/drawable/ic_spec_waveform.xml
new file mode 100644
index 00000000..2e0070b6
--- /dev/null
+++ b/app/src/main/res/drawable/ic_spec_waveform.xml
@@ -0,0 +1,5 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_spec_wifi.xml b/app/src/main/res/drawable/ic_spec_wifi.xml
new file mode 100644
index 00000000..4757653f
--- /dev/null
+++ b/app/src/main/res/drawable/ic_spec_wifi.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_spec_wifi_off.xml b/app/src/main/res/drawable/ic_spec_wifi_off.xml
new file mode 100644
index 00000000..4a716e57
--- /dev/null
+++ b/app/src/main/res/drawable/ic_spec_wifi_off.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/font/geist_mono_bold.ttf b/app/src/main/res/font/geist_mono_bold.ttf
new file mode 100644
index 00000000..90eb8a86
Binary files /dev/null and b/app/src/main/res/font/geist_mono_bold.ttf differ
diff --git a/app/src/main/res/font/geist_mono_medium.ttf b/app/src/main/res/font/geist_mono_medium.ttf
new file mode 100644
index 00000000..ff49ece5
Binary files /dev/null and b/app/src/main/res/font/geist_mono_medium.ttf differ
diff --git a/app/src/main/res/font/geist_mono_regular.ttf b/app/src/main/res/font/geist_mono_regular.ttf
new file mode 100644
index 00000000..50c9d5a6
Binary files /dev/null and b/app/src/main/res/font/geist_mono_regular.ttf differ
diff --git a/app/src/main/res/font/geist_mono_semibold.ttf b/app/src/main/res/font/geist_mono_semibold.ttf
new file mode 100644
index 00000000..1b21724d
Binary files /dev/null and b/app/src/main/res/font/geist_mono_semibold.ttf differ
diff --git a/app/src/main/res/raw/geist_mono_ofl.txt b/app/src/main/res/raw/geist_mono_ofl.txt
new file mode 100644
index 00000000..34ffeb6f
--- /dev/null
+++ b/app/src/main/res/raw/geist_mono_ofl.txt
@@ -0,0 +1,93 @@
+Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://openfontlicense.org
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index 71acd613..6ca21b08 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -71,12 +71,8 @@
إضافة إشارة مرجعية
- رجوع
- القناة: %1$s
- مغادرة
يمكن الوصول عبر Nostr
رسائل خاصة غير مقروءة
- تبديل الإشارة المرجعية
تم الانتقال
حالة Tor
الأقران المتصلون
@@ -96,14 +92,12 @@
اختيار صورة
- دردشة شبكية لامركزية مع تشفير طرف إلى طرف
دردشة شبكية دون اتصال
تواصل مباشرة عبر Bluetooth LE بدون إنترنت أو خوادم. تُعاد توجيه الرسائل عبر الأجهزة القريبة لتوسيع النطاق.
قنوات geohash عبر الإنترنت
تواصل مع أشخاص بالقرب منك عبر قنوات تعتمد على geohash. وسّع الشبكة عبر مرحلات الإنترنت العامة.
تشفير طرف إلى طرف
الرسائل الخاصة مشفّرة. رسائل القنوات عامة.
- المظهر
النظام
فاتح
داكن
@@ -127,7 +121,6 @@
حالة Tor: %1$s، التمهيد %2$d%%
آخر: %1$s
مسح البيانات الطارئ
- تلميح: انقر ثلاث مرات على عنوان التطبيق لمسح كل البيانات المحفوظة – بما في ذلك الرسائل والمفاتيح والإعدادات.
إعدادات التصحيح
مفتوح المصدر • الخصوصية أولاً • لامركزي
إغلاق
@@ -147,7 +140,6 @@
فتح الخريطة
إزالة الإشارة المرجعية
انتقال
- محدد
مغادرة القناة
يمكن الوصول عبر Nostr
مفضل (غير متصل)
@@ -288,14 +280,12 @@
الأذونات
منح الأذونات
bitchat لا يتتبع موقعك
- الأشخاص
لا أحد هنا…
(أنت)
حرّك وقرّب لاختيار geohash
اختيار
اكتب رسالة…
إشارة
- (~%1$s)
v%1$s
image/*
صورة
@@ -309,11 +299,6 @@
تشغيل
إيقاف مؤقت
- تعدين PoW
- PoW مفعّل
- برهان العمل
- يتم التعدين…
- pow: %1$dbit
مطلوب لاكتشاف مستخدمي bitchat عبر البلوتوث
diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml
index 20760841..f75fec27 100644
--- a/app/src/main/res/values-bn/strings.xml
+++ b/app/src/main/res/values-bn/strings.xml
@@ -71,12 +71,8 @@
বুকমার্ক যোগ করুন
- ফিরে যান
- চ্যানেল: %1$s
- ছেড়ে দিন
Nostr এর মাধ্যমে পৌঁছানো যায়
অপঠিত ব্যক্তিগত বার্তা
- বুকমার্ক টগল করুন
টেলিপোর্ট করা হয়েছে
Tor অবস্থা
সংযুক্ত পিয়ার
@@ -96,14 +92,12 @@
ছবি নির্বাচন করুন
- এন্ড-টু-এন্ড এনক্রিপশন সহ বিকেন্দ্রীভূত মেশ চ্যাট
অফলাইন মেশ চ্যাট
ইন্টারনেট বা সার্ভার ছাড়াই Bluetooth LE এর মাধ্যমে সরাসরি যোগাযোগ করুন। পরিসর বাড়ানোর জন্য কাছাকাছি ডিভাইসের মাধ্যমে বার্তা রিলে করা হয়।
অনলাইন জিওহ্যাশ চ্যানেল
জিওহ্যাশ-ভিত্তিক চ্যানেলের মাধ্যমে আপনার এলাকার মানুষদের সাথে সংযুক্ত হন। পাবলিক ইন্টারনেট রিলেগুলির মাধ্যমে মেশ বিস্তৃত করুন।
এন্ড-টু-এন্ড এনক্রিপশন
ব্যক্তিগত বার্তাগুলি এনক্রিপ্ট করা। চ্যানেল বার্তাগুলি পাবলিক।
- চেহারা
সিস্টেম
হালকা
অন্ধকার
@@ -127,7 +121,6 @@
Tor অবস্থা: %1$s, বুটস্ট্র্যাপ %2$d%%
শেষ: %1$s
জরুরী ডেটা ক্লিয়ার
- টিপ: সংরক্ষিত সমস্ত ডেটা – বার্তা, কী এবং সেটিংস সহ – সাফ করতে অ্যাপ শিরোনামে তিনবার ট্যাপ করুন।
ডিবাগ সেটিংস
ওপেন সোর্স • গোপনীয়তা প্রথম • বিকেন্দ্রীভূত
বন্ধ করুন
@@ -147,7 +140,6 @@
মানচিত্র খুলুন
বুকমার্ক সরান
টেলিপোর্ট
- নির্বাচিত
চ্যানেল ছেড়ে দিন
Nostr এর মাধ্যমে পৌঁছানো যায়
প্রিয় (অফলাইন)
@@ -288,14 +280,12 @@
অনুমতি
অনুমতি দিন
bitchat আপনার অবস্থান ট্র্যাক করে না
- মানুষ
কেউ আশেপাশে নেই …
(আপনি)
জিওহ্যাশ নির্বাচন করতে প্যান এবং জুম করুন
নির্বাচন করুন
বার্তা টাইপ করুন …
উল্লেখ
- (~%1$s)
v%1$s
image/*
ছবি
@@ -309,11 +299,6 @@
প্লে করুন
বিরাম
- মাইনিং PoW
- PoW সক্রিয়
- প্রুফ অফ ওয়ার্ক
- মাইনিং …
- pow: %1$dbit
ব্লুটুথের মাধ্যমে bitchat ব্যবহারকারী আবিষ্কার করতে প্রয়োজন
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index aba0d3c5..9f0409cc 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -71,12 +71,8 @@
Lesezeichen hinzufügen
- zurück
- Kanal: %1$s
- verlassen
Über Nostr erreichbar
Ungelesene private Nachrichten
- Lesezeichen umschalten
Teleportiert
Tor‑Status
Verbundenen Peers
@@ -96,14 +92,12 @@
Bild auswählen
- dezentraler Mesh‑Chat mit Ende‑zu‑Ende‑Verschlüsselung
Offline‑Mesh‑Chat
Kommuniziere direkt über Bluetooth LE ohne Internet oder Server. Nachrichten werden über nahe Geräte weitergeleitet, um die Reichweite zu erhöhen.
Online‑Geohash‑Kanäle
Vernetze dich mit Menschen in deiner Umgebung über geohash‑basierte Kanäle. Erweitere das Mesh über öffentliche Internet‑Relays.
Ende‑zu‑Ende‑Verschlüsselung
Private Nachrichten sind verschlüsselt. Kanal‑Nachrichten sind öffentlich.
- Darstellung
System
Hell
Dunkel
@@ -127,7 +121,6 @@
Tor‑Status: %1$s, Bootstrap %2$d%%
Zuletzt: %1$s
Notfall‑Datenlöschung
- Tipp: Dreifach auf den App‑Titel tippen, um alle gespeicherten Daten – einschließlich Nachrichten, Schlüssel und Einstellungen – zu löschen.
Debug‑Einstellungen
Open Source • Privacy First • Dezentral
Schließen
@@ -147,7 +140,6 @@
Karte öffnen
Lesezeichen entfernen
Teleportieren
- Ausgewählt
Kanal verlassen
Über Nostr erreichbar
Favorit (offline)
@@ -288,14 +280,12 @@
Berechtigungen
Berechtigungen erteilen
bitchat verfolgt NICHT deinen Standort
- PERSONEN
niemand da …
(du)
schwenken und zoomen, um einen Geohash auszuwählen
auswählen
Nachricht eingeben …
erwähnen
- (~%1$s)
v%1$s
image/*
Bild
@@ -309,11 +299,6 @@
Abspielen
Pause
- Mining‑PoW
- PoW aktiviert
- Proof of Work
- Mining …
- pow: %1$dbit
Erforderlich, um bitchat‑Benutzer über Bluetooth zu entdecken
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index e798952f..f26a1560 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -71,12 +71,8 @@
Agregar marcador
- atrás
- Canal: %1$s
- salir
Accesible vía Nostr
Mensajes privados sin leer
- Alternar marcador
Teletransportado
Estado de Tor
Pares conectados
@@ -96,14 +92,12 @@
Elegir imagen
- chat mesh descentralizado con cifrado de extremo a extremo
Chat mesh sin conexión
Comunícate directamente a través de Bluetooth LE sin internet o servidores. Los mensajes se retransmiten a través de dispositivos cercanos para extender el alcance.
Canales geohash en línea
Conéctate con personas cerca de ti a través de canales basados en geohash. Extiende la red mesh a través de repetidores de internet público.
Cifrado de extremo a extremo
Los mensajes privados están cifrados. Los mensajes de canal son públicos.
- Apariencia
Sistema
Claro
Oscuro
@@ -127,7 +121,6 @@
Estado de Tor: %1$s, bootstrap %2$d%%
Último: %1$s
Borrado de datos de emergencia
- Consejo: Toca tres veces en el título de la app para borrar todos los datos almacenados, incluyendo mensajes, claves y configuraciones.
Configuraciones de depuración
Código abierto • Privacidad primero • Descentralizado
Cerrar
@@ -147,7 +140,6 @@
Abrir mapa
Quitar marcador
Teletransportar
- Seleccionado
Salir del canal
Accesible vía Nostr
Favorito (sin conexión)
@@ -288,14 +280,12 @@
Permisos
Otorgar permisos
bitchat NO rastrea tu ubicación
- PERSONAS
nadie por aquí…
(tú)
desplaza y hace zoom para seleccionar un geohash
seleccionar
Escribe un mensaje…
mencionar
- (~%1$s)
v%1$s
image/*
Imagen
@@ -309,11 +299,6 @@
Reproducir
Pausar
- Minando PoW
- PoW habilitado
- Prueba de trabajo
- Minando…
- pow: %1$dbit
Requerido para descubrir usuarios bitchat a través de Bluetooth
diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml
index 1dda1e13..42aa8223 100644
--- a/app/src/main/res/values-fa/strings.xml
+++ b/app/src/main/res/values-fa/strings.xml
@@ -71,12 +71,8 @@
افزودن نشانک
- بازگشت
- کانال: %1$s
- خروج
قابل دسترس از طریق Nostr
پیامهای خصوصی خواندهنشده
- تغییر وضعیت نشانک
انتقال مکانی
وضعیت Tor
همتایان متصل
@@ -96,14 +92,12 @@
انتخاب تصویر
- گفتوگوی مِش غیرمتمرکز با رمزگذاری سرتاسری
گفتوگوی مِش آفلاین
بدون اینترنت یا سرور، مستقیماً از طریق Bluetooth LE ارتباط برقرار کنید. پیامها از طریق دستگاههای نزدیک رله میشوند تا بُرد افزایش یابد.
کانالهای geohash آنلاین
از طریق کانالهای مبتنی بر geohash با افراد منطقهٔ خود ارتباط برقرار کنید. مِش را از طریق رلههای عمومی اینترنت گسترش دهید.
رمزگذاری سرتاسری
پیامهای خصوصی رمزگذاری میشوند. پیامهای کانال عمومی هستند.
- نما
سامانه
روشن
تیره
@@ -127,7 +121,6 @@
وضعیت Tor: %1$s، بوتاسترپ %2$d%%
آخرین: %1$s
پاکسازی اضطراری داده
- نکته: برای پاک کردن همهٔ دادههای ذخیرهشده — شامل پیامها، کلیدها و تنظیمات — سه بار روی عنوان برنامه ضربه بزنید.
تنظیمات اشکالزدایی
متنباز • حریم خصوصی در اولویت • غیرمتمرکز
بستن
@@ -147,7 +140,6 @@
باز کردن نقشه
حذف نشانک
انتقال
- انتخاب شد
خروج از کانال
قابل دسترس از طریق Nostr
دلخواه (آفلاین)
@@ -288,14 +280,12 @@
مجوزها
اعطای مجوزها
bitchat موقعیت شما را پیگیری نمیکند
- افراد
کسی در اطراف نیست …
(شما)
برای انتخاب geohash، جابهجا و زوم کنید
انتخاب
نوشتن پیام …
ذکر
- (~%1$s)
v%1$s
image/*
تصویر
@@ -309,11 +299,6 @@
پخش
توقف
- استخراج PoW
- PoW فعال است
- گواه کار
- در حال استخراج …
- pow: %1$dbit
برای یافتن کاربران bitchat از طریق بلوتوث لازم است
diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml
index a061add0..b226b67e 100644
--- a/app/src/main/res/values-fil/strings.xml
+++ b/app/src/main/res/values-fil/strings.xml
@@ -71,12 +71,8 @@
Mag‑bookmark
- bumalik
- channel: %1$s
- umalis
Maabot sa Nostr
Hindi nabasang pribadong mensahe
- I‑toggle ang bookmark
Naiteleport
Katayuan ng Tor
Nakakonektang peers
@@ -96,14 +92,12 @@
Pumili ng larawan
- desentralisadong mesh messaging na may end‑to‑end encryption
Offline Mesh Chat
Makipag‑usap direkta sa Bluetooth LE nang walang internet o server. Ang mga mensahe ay nire‑relay ng mga kalapit na device.
Online na Geohash na mga Channel
Makipag‑ugnayan sa mga tao sa paligid gamit ang geohash‑based na mga channel. Palawakin ang mesh sa pampublikong internet relays.
End‑to‑End Encryption
Ang mga pribadong mensahe ay naka‑encrypt. Ang mga mensahe sa channel ay publiko.
- hitsura
system
light
dark
@@ -127,7 +121,6 @@
katayuan ng Tor: %1$s, bootstrap %2$d%%
Huli: %1$s
Emergency Data Deletion
- Tip: i‑tap ang pamagat ng app nang 3 beses para burahin ang lahat ng data (mga mensahe, susi, setting).
Debug Settings
Open Source • Privacy First • Desentralisado
Isara
@@ -147,7 +140,6 @@
Buksan ang mapa
Alisin ang bookmark
Teleport
- Napili
Umalis sa channel
Maabot sa Nostr
Paborito (offline)
@@ -283,7 +275,6 @@
HINDI sinusubaybayan ng bitchat ang lokasyon mo
@
· ⧉
- TAO
walang tao sa paligid…
(ikaw)
i‑pan at i‑zoom para pumili ng geohash
@@ -292,7 +283,6 @@
@%1$s
banggit
%1$d / %2$d
- (~%1$s)
@%1$s
v%1$s
#
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index a3406d24..3cffaee4 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -71,12 +71,8 @@
Ajouter un signet
- retour
- canal : %1$s
- quitter
Joignable via Nostr
Messages privés non lus
- Basculer le signet
Téléporté
Statut Tor
Pairs connectés
@@ -96,14 +92,12 @@
Choisir une image
- messagerie mesh décentralisée avec chiffrement de bout en bout
Chat Mesh hors‑ligne
Communique directement via Bluetooth LE sans internet ni serveurs. Les messages sont relayés par les appareils proches pour étendre la portée.
Canaux Geohash en ligne
Discute avec des personnes proches via des canaux basés sur geohash. Étends le mesh grâce à des relais internet publics.
Chiffrement de bout en bout
Les messages privés sont chiffrés. Les messages de canal sont publics.
- apparence
système
clair
sombre
@@ -127,7 +121,6 @@
statut Tor : %1$s, bootstrap %2$d%%
Dernier : %1$s
Suppression d’urgence des données
- Astuce : tapote 3 fois le titre de l’app pour supprimer toutes les données (messages, clés, paramètres).
Paramètres de debug
Open Source • Privacy First • Décentralisé
Fermer
@@ -147,7 +140,6 @@
Ouvrir la carte
Retirer le signet
Téléporter
- Sélectionné
Quitter le canal
Joignable via Nostr
Favori (hors‑ligne)
@@ -284,7 +276,6 @@
bitchat ne suit PAS ta position
@
· ⧉
- PERSONNES
personne aux alentours…
(toi)
déplacer et zoomer pour choisir un geohash
@@ -293,7 +284,6 @@
@%1$s
mention
%1$d / %2$d
- (~%1$s)
@%1$s
v%1$s
#
diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml
index bb21f5b3..fe22f4e8 100644
--- a/app/src/main/res/values-hi/strings.xml
+++ b/app/src/main/res/values-hi/strings.xml
@@ -71,12 +71,8 @@
बुकमार्क जोड़ें
- वापस
- चैनल: %1$s
- छोड़ें
Nostr के माध्यम से उपलब्ध
अपठित निजी संदेश
- बुकमार्क टॉगल करें
टेलीपोर्ट हुआ
Tor स्थिति
कनेक्टेड पीयर्स
@@ -96,14 +92,12 @@
छवि चुनें
- विकेन्द्रीकृत मेष चैट • एंड‑टू‑एंड एन्क्रिप्शन
ऑफ़लाइन मेष चैट
इंटरनेट या सर्वर के बिना Bluetooth LE के माध्यम से सीधे संवाद करें। संदेशों को पास के उपकरणों के माध्यम से रिले किया जाता है ताकि पहुँच बढ़े।
ऑनलाइन geohash चैनल
geohash आधारित चैनलों के माध्यम से अपने आस‑पास के लोगों से जुड़ें। सार्वजनिक इंटरनेट रिले के माध्यम से मेष का विस्तार करें।
एंड‑टू‑एंड एन्क्रिप्शन
निजी संदेश एन्क्रिप्टेड हैं। चैनल संदेश सार्वजनिक हैं।
- रूप
सिस्टम
लाइट
डार्क
@@ -127,7 +121,6 @@
Tor स्थिति: %1$s, बूटस्ट्रैप %2$d%%
अंतिम: %1$s
आपातकालीन डेटा मिटाना
- संकेत: सभी सहेजे डेटा – संदेश, कुंजियाँ और सेटिंग्स सहित – मिटाने के लिए ऐप शीर्षक पर तीन बार टैप करें।
डिबग सेटिंग्स
ओपन सोर्स • गोपनीयता पहले • विकेन्द्रीकृत
बंद करें
@@ -147,7 +140,6 @@
मानचित्र खोलें
बुकमार्क हटाएँ
टेलीपोर्ट
- चयनित
चैनल छोड़ें
Nostr के माध्यम से उपलब्ध
पसंदीदा (ऑफ़लाइन)
@@ -288,14 +280,12 @@
अनुमतियाँ
अनुमतियाँ दें
bitchat आपकी लोकेशन ट्रैक नहीं करती
- लोग
आस‑पास कोई नहीं…
(आप)
geohash चुनने के लिए पैन/ज़ूम करें
चयन करें
संदेश लिखें…
उल्लेख
- (~%1$s)
v%1$s
image/*
छवि
@@ -309,11 +299,6 @@
चलाएँ
रोकें
- PoW माइनिंग
- PoW सक्षम
- प्रूफ़ ऑफ़ वर्क
- माइनिंग…
- pow: %1$dbit
ब्लूटूथ के माध्यम से bitchat उपयोगकर्ताओं की खोज के लिए आवश्यक
diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml
index 016da92e..6f841971 100644
--- a/app/src/main/res/values-id/strings.xml
+++ b/app/src/main/res/values-id/strings.xml
@@ -71,12 +71,8 @@
Tambah bookmark
- kembali
- Channel: %1$s
- keluar
Dapat dijangkau melalui Nostr
Pesan pribadi yang belum dibaca
- Alihkan bookmark
Diteleportasi
Status Tor
Peer yang terhubung
@@ -96,14 +92,12 @@
Pilih gambar
- chat mesh terdesentralisasi dengan enkripsi end-to-end
Chat Mesh Offline
Berkomunikasi langsung melalui Bluetooth LE tanpa Internet atau server. Pesan diteruskan melalui perangkat terdekat untuk memperluas jangkauan.
Channel Geohash Online
Terhubung dengan orang-orang di area Anda melalui channel berbasis geohash. Perluas mesh melalui relay Internet publik.
Enkripsi end-to-end
Pesan pribadi dienkripsi. Pesan channel bersifat publik.
- Tampilan
Sistem
Terang
Gelap
@@ -127,7 +121,6 @@
Status Tor: %1$s, Bootstrap %2$d%%
Terakhir: %1$s
Hapus data darurat
- Tips: Ketuk tiga kali pada judul aplikasi untuk menghapus semua data tersimpan – termasuk pesan, kunci, dan pengaturan.
Pengaturan debug
Sumber Terbuka • Privasi Pertama • Terdesentralisasi
Tutup
@@ -147,7 +140,6 @@
Buka peta
Hapus bookmark
Teleportasi
- Dipilih
Keluar dari channel
Dapat dijangkau melalui Nostr
Favorit (offline)
@@ -288,14 +280,12 @@
Izin
Berikan izin
bitchat TIDAK melacak lokasi Anda
- ORANG
tidak ada orang di sekitar …
(Anda)
geser dan zoom untuk memilih geohash
pilih
Ketik pesan …
sebut
- (~%1$s)
v%1$s
image/*
Gambar
@@ -309,11 +299,6 @@
Putar
Jeda
- Mining PoW
- PoW diaktifkan
- Proof of Work
- Mining …
- pow: %1$dbit
Diperlukan untuk menemukan pengguna bitchat melalui Bluetooth
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index 9da67074..74b7fc04 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -71,12 +71,8 @@
Aggiungi segnalibro
- indietro
- canale: %1$s
- esci
Raggiungibile via Nostr
Messaggi privati non letti
- Attiva/disattiva segnalibro
Teletrasportato
Stato Tor
Peer connessi
@@ -96,14 +92,12 @@
Scegli immagine
- messaggistica mesh decentralizzata con crittografia end-to-end
Chat mesh offline
Comunica direttamente tramite Bluetooth LE senza internet o server. I messaggi vengono inoltrati attraverso i dispositivi vicini per estendere la portata.
Canali geohash online
Connettiti con persone nella tua area usando canali basati su geohash. Estendi la rete mesh usando relay internet pubblici.
Crittografia end-to-end
I messaggi privati sono crittografati. I messaggi dei canali sono pubblici.
- aspetto
sistema
chiaro
scuro
@@ -127,7 +121,6 @@
Stato Tor: %1$s, bootstrap %2$d%%
Ultimo: %1$s
Eliminazione dati di emergenza
- Suggerimento: fai triplo clic sul titolo dell\'app per eliminare in emergenza tutti i dati memorizzati, inclusi messaggi, chiavi e impostazioni.
Impostazioni di debug
Open Source • Privacy First • Decentralizzato
Chiudi
@@ -147,7 +140,6 @@
Apri mappa
Rimuovi segnalibro
Teletrasporta
- Selezionato
Esci dal canale
Raggiungibile via Nostr
Preferito offline
@@ -288,14 +280,12 @@
permessi
Concedi permessi
bitchat NON traccia la tua posizione
- PERSONE
nessuno nei dintorni…
(tu)
scorri e ingrandisci per selezionare un geohash
seleziona
scrivi un messaggio…
menzione
- (~%1$s)
v%1$s
image/*
Immagine
@@ -309,11 +299,6 @@
Riproduci
Pausa
- Mining PoW
- PoW abilitato
- Proof of Work
- mining…
- pow: %1$dbit
Necessario per scoprire utenti bitchat tramite Bluetooth
diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml
index b88d6316..fe8df64e 100644
--- a/app/src/main/res/values-ja/strings.xml
+++ b/app/src/main/res/values-ja/strings.xml
@@ -71,12 +71,8 @@
ブックマークに追加
- 戻る
- チャンネル: %1$s
- 退出
Nostr 経由で到達可能
未読のプライベートメッセージ
- ブックマークを切り替え
テレポート済み
Tor ステータス
接続中のピア
@@ -96,14 +92,12 @@
画像を選択
- 分散型メッシュチャット・エンドツーエンド暗号化
オフライン・メッシュチャット
インターネットやサーバーなしで、Bluetooth LE 経由で直接通信します。近隣のデバイスを経由してメッセージを中継し、到達範囲を広げます。
オンライン Geohash チャンネル
Geohash ベースのチャンネルで近くの人々とつながります。公開インターネットのリレーを介してメッシュを拡張します。
エンドツーエンド暗号化
プライベートメッセージは暗号化されます。チャンネルメッセージは公開です。
- 表示
システム
ライト
ダーク
@@ -127,7 +121,6 @@
Tor ステータス: %1$s、ブートストラップ %2$d%%
最終: %1$s
緊急データ消去
- ヒント: アプリタイトルを 3 回タップすると、メッセージ・鍵・設定を含む保存データをすべて消去します。
デバッグ設定
オープンソース • プライバシーファースト • 分散型
閉じる
@@ -147,7 +140,6 @@
地図を開く
ブックマークを削除
テレポート
- 選択済み
チャンネルから退出
Nostr 経由で到達可能
お気に入り (オフライン)
@@ -288,14 +280,12 @@
権限
権限を付与
bitchat はあなたの位置情報を追跡しません
- 人々
誰もいません…
(あなた)
パン/ズームして geohash を選択
選択
メッセージを入力…
メンション
- (~%1$s)
v%1$s
image/*
画像
@@ -309,11 +299,6 @@
再生
一時停止
- PoW をマイニング中
- PoW 有効
- Proof of Work
- マイニング中…
- pow: %1$dbit
Bluetooth で bitchat ユーザーを検出するために必要です
diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml
index 1d4d878b..d0fb0569 100644
--- a/app/src/main/res/values-ka/strings.xml
+++ b/app/src/main/res/values-ka/strings.xml
@@ -71,12 +71,8 @@
სანიშნეს დამატება
- უკან
- არხი: %1$s
- გასვლა
მისაწვდომია Nostr-ით
წაუკითხავი პირადი შეტყობინებები
- სანიშნეს გადართვა
ტელეპორტირებული
Tor-ის სტატუსი
დაკავშირებული პირები
@@ -96,14 +92,12 @@
სურათის არჩევა
- დეცენტრალიზებული mesh-ჩათი ბოლო-მდე დაშიფვრით
ოფლაინ mesh-ჩათი
ესაუბრეთ პირდაპირ Bluetooth LE-ის საშუალებით ინტერნეტის ან სერვერების გარეშე. შეტყობინებები გადაიგზავნება ახლომდებარე მოწყობილობებზე, რათა გაიზარდოს დაფარვა.
ონლაინ geohash არხები
დაუკავშირდით ახლომდებარე ხალხს geohash-ზე დაფუძნებული არხებით. გააფართოვეთ mesh საჯარო ინტერნეტ რილეებით.
ბოლო-მდე დაშიფვრა
პირადი შეტყობინებები დაშიფრულია. არხის შეტყობინებები საჯაროა.
- გარეგნობა
სისტემა
ღია
მუქი
@@ -127,7 +121,6 @@
Tor სტატუსი: %1$s, ჩატვირთვა %2$d%%
ბოლო: %1$s
სასწრაფო მონაცემების წაშლა
- რჩევა: დააჭირეთ აპის სათაურს სამჯერ ყველა შენახული მონაცემის – შეტყობინებების, გასაღებებისა და პარამეტრების – წასაშლელად.
დebug პარამეტრები
ღია კოდი • კონფიდენციალობა პირველია • დეცენტრალიზებული
დახურვა
@@ -147,7 +140,6 @@
რუკის გახსნა
სანიშნეს წაშლა
ტელეპორტი
- მონიშნული
არხიდან გამოსვლა
მისაწვდომია Nostr-ით
რჩეული (ოფლაინ)
@@ -288,14 +280,12 @@
ნებართვები
ნებართვების მინიჭება
bitchat არ აკონტროლებს თქვენს მდებარეობას
- ხალხი
არავინ არის ახლოს…
(თქვენ)
პანორამირება/ზუმი geohash-ის ასარჩევად
არჩევა
შეიყვანეთ შეტყობინება…
ხსენება
- (~%1$s)
v%1$s
image/*
სურათი
@@ -309,11 +299,6 @@
დაკვრა
პაუზა
- PoW მაინინგი
- PoW ჩართულია
- Proof of Work
- მაინინგი…
- pow: %1$dbit
საჭიროა bitchat მომხმარებლების Bluetooth-ით აღმოსაჩენად
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index 64bdd9b8..7fa8d91e 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -71,12 +71,8 @@
북마크 추가
- 뒤로
- 채널: %1$s
- 나가기
Nostr로 연결 가능
읽지 않은 개인 메시지
- 북마크 전환
텔레포트됨
Tor 상태
연결된 피어
@@ -96,14 +92,12 @@
이미지 선택
- 종단간 암호화를 갖춘 탈중앙 메쉬 채팅
오프라인 메쉬 채팅
인터넷이나 서버 없이 Bluetooth LE로 직접 소통합니다. 범위를 확장하기 위해 근처 기기를 통해 메시지가 중계됩니다.
온라인 geohash 채널
geohash 기반 채널을 통해 주변 사람들과 연결하세요. 공용 인터넷 릴레이로 메쉬를 확장합니다.
종단간 암호화
개인 메시지는 암호화됩니다. 채널 메시지는 공개입니다.
- 모양
시스템
라이트
다크
@@ -127,7 +121,6 @@
Tor 상태: %1$s, 부트스트랩 %2$d%%
마지막: %1$s
긴급 데이터 삭제
- 팁: 저장된 모든 데이터(메시지, 키, 설정 포함)를 삭제하려면 앱 제목을 세 번 탭하세요.
디버그 설정
오픈 소스 • 프라이버시 우선 • 탈중앙
닫기
@@ -147,7 +140,6 @@
지도 열기
북마크 제거
텔레포트
- 선택됨
채널 나가기
Nostr로 연결 가능
즐겨찾기 (오프라인)
@@ -288,14 +280,12 @@
권한
권한 허용
bitchat은 사용자의 위치를 추적하지 않습니다
- 사람들
주변에 아무도 없음 …
(나)
geohash를 선택하려면 이동하고 확대/축소하세요
선택
메시지 입력 …
멘션
- (~%1$s)
v%1$s
image/*
이미지
@@ -309,11 +299,6 @@
재생
일시정지
- PoW 채굴
- PoW 사용
- 작업증명
- 채굴 중 …
- pow: %1$dbit
블루투스를 통해 bitchat 사용자를 발견하는 데 필요
diff --git a/app/src/main/res/values-mg/strings.xml b/app/src/main/res/values-mg/strings.xml
index f42e46b2..693b7bd8 100644
--- a/app/src/main/res/values-mg/strings.xml
+++ b/app/src/main/res/values-mg/strings.xml
@@ -71,12 +71,8 @@
Hanampy bookmark
- miverina
- fantsona: %1$s
- hiala
Azo tratrarina amin\'ny alalan\'ny Nostr
Hafatra manokana tsy voavaky
- Hanova bookmark
Nafindra toerana
Toe-javatra Tor
Mpiara-miasa mifandray
@@ -96,14 +92,12 @@
Hifidy sary
- hafatra mesh tsy misy foibe misy fandokoana farany amin\'ny farany
Mesh Chat tsy an-tserasera
Mifandraisa mivantana amin\'ny alalan\'ny Bluetooth LE tsy misy Internet na mpizara. Ny hafatra dia mamindra amin\'ny alalan\'ny fitaovana akaiky mba hanitarana ny elanelana.
Fantsona Geohash an-tserasera
Mifandray amin\'ny olona eo amin\'ny faritra misy anao amin\'ny fampiasana fantsona mifototra amin\'ny geohash. Manitatra ny mesh amin\'ny fampiasana relay Internet ho an\'ny daholobe.
Fandokoana Farany amin\'ny Farany
Voafono ny hafatra manokana. Hafatra ho an\'ny daholobe ny hafatra fantsona.
- endrika
rafitra
mazava
maizina
@@ -127,7 +121,6 @@
Toe-javatra Tor: %1$s, bootstrap %2$d%%
Farany: %1$s
Famafana Angona Maika
- Toro-hevitra: Tsindrio in-telo ny anaran\'ny fampiharana mba hamafana maika ny angona rehetra voatahiry ao anatin\'izany ny hafatra, ny lakile ary ny fametrahana.
Fametrahana Debug
Loharanom-baovao misokatra • Fiainana manokana voalohany • Tsy misy foibe
Hanakatona
@@ -147,7 +140,6 @@
Hanokatra sarintany
Hanesorana bookmark
Hamindra toerana
- Voafidy
Hiala amin\'ny fantsona
Azo tratrarina amin\'ny alalan\'ny Nostr
Ankafizina tsy an-tserasera
@@ -292,7 +284,6 @@
Ny bitchat dia TSY manaraka ny toerananao
@
· ⧉
- OLONA
tsy misy olona manodidina...
(ianao)
ahetsika sy ataovy lehibe mba hifidianana geohash
@@ -301,7 +292,6 @@
@%1$s
hanonona
%1$d / %2$d
- (~%1$s)
@%1$s
v%1$s
#
@@ -335,11 +325,6 @@
Hilalao
Hijanona
- Mihaingam-poana PoW
- Alefa ny PoW
- Porofo Asa
- mihaingana...
- pow: %1$dbit
Ilaina mba hahitana mpampiasa bitchat amin\'ny alalan\'ny Bluetooth
diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml
index 9fb050f2..fb72e531 100644
--- a/app/src/main/res/values-ne/strings.xml
+++ b/app/src/main/res/values-ne/strings.xml
@@ -71,12 +71,8 @@
बुकमार्क थप्नुहोस्
- पछाडि
- च्यानल: %1$s
- छोड्नुहोस्
Nostr मार्फत पुग्न सकिने
नपढिएका निजी सन्देश
- बुकमार्क टगल
टेलिपोर्ट भयो
Tor स्थिति
जडान भएका साथीहरू
@@ -96,14 +92,12 @@
तस्वीर छान्नुहोस्
- विकेन्द्रित मेष सन्देश अन्त्यदेखि अन्त्य इन्क्रिप्सनसहित
अफलाइन मेष च्याट
इन्टरनेट वा सर्भर बिना सिधै Bluetooth LE मार्फत सञ्चार गर्नुहोस्। नजिकका यन्त्रहरूबाट सन्देश रिल레이 हुन्छ।
अनलाइन Geohash च्यानलहरू
Geohash आधारित च्यानलमार्फत नजिकका मानिसहरूसँग जोडिनुहोस्। सार्वजनिक इन्टरनेट रिलेलाई प्रयोग गरेर मेष विस्तार गर्नुहोस्।
एन्ड‑टु‑एन्ड इन्क्रिप्सन
निजी सन्देश इन्क्रिप्टेड हुन्छन्। च्यानल सन्देश सार्वजनिक हुन्छन्।
- देखावट
सिस्टम
हल्का
गाढा
@@ -127,7 +121,6 @@
Tor स्थिति: %1$s, बुटस्ट्र्याप %2$d%%
अन्तिम: %1$s
आपतकालीन डेटा मेटाउने
- सुझाव: एप शीर्षक तीन पटक ट्याप गरी सबै डेटा (सन्देश, कुञ्जी, सेटिङ) मेटाउनुहोस्।
डिबग सेटिङ
Open Source • Privacy First • विकेन्द्रित
बन्द गर्नुहोस्
@@ -147,7 +140,6 @@
नक्सा खोल्नुहोस्
बुकमार्क हटाउनुहोस्
टेलिपोर्ट
- छानिएको
च्यानल छोड्नुहोस्
Nostr बाट पहुँचयोग्य
मनपर्ने (अफलाइन)
@@ -283,7 +275,6 @@
bitchat ले तपाईंको स्थान पछ्याउँदैन
@
· ⧉
- मानिसहरू
वरिपरि कोही छैन…
(तपाईं)
geohash छान्न प्यान र जुम गर्नुहोस्
@@ -292,7 +283,6 @@
@%1$s
उल्लेख
%1$d / %2$d
- (~%1$s)
@%1$s
v%1$s
#
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index 6079179d..ec8920a5 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -71,12 +71,8 @@
Bladwijzer toevoegen
- terug
- kanaal: %1$s
- verlaten
Bereikbaar via Nostr
Ongelezen privéberichten
- Bladwijzer omschakelen
Geteleporteerd
Tor-status
Verbonden peers
@@ -96,14 +92,12 @@
Afbeelding kiezen
- gedecentraliseerde meshcommunicatie met end-to-end-versleuteling
Offline meshchat
Communiceer rechtstreeks via Bluetooth LE zonder internet of servers. Berichten worden doorgestuurd via apparaten in de buurt om het bereik te vergroten.
Online geohash-kanalen
Maak contact met mensen in uw omgeving met geohash-gebaseerde kanalen. Breid het mesh uit met openbare internetrelays.
End-to-end-versleuteling
Privéberichten zijn versleuteld. Kanaalberichten zijn openbaar.
- uiterlijk
systeem
licht
donker
@@ -127,7 +121,6 @@
Tor-status: %1$s, bootstrap %2$d%%
Laatste: %1$s
Noodgegevensverwijdering
- Tip: Driedubbel klik op de app-titel om alle opgeslagen gegevens inclusief berichten, sleutels en instellingen noodmatig te verwijderen.
Debug-instellingen
Open Source • Privacy First • Gedecentraliseerd
Sluiten
@@ -147,7 +140,6 @@
Kaart openen
Bladwijzer verwijderen
Teleporteren
- Geselecteerd
Kanaal verlaten
Bereikbaar via Nostr
Offline favoriet
@@ -288,14 +280,12 @@
toestemmingen
Toestemmingen verlenen
bitchat volgt NIET uw locatie
- PERSONEN
niemand in de buurt…
(jij)
pan en zoom om een geohash te selecteren
selecteren
typ een bericht…
vermelden
- (~%1$s)
v%1$s
image/*
Afbeelding
@@ -309,11 +299,6 @@
Afspelen
Pauzeren
- Mining-PoW
- PoW ingeschakeld
- Proof of Work
- minen…
- pow: %1$dbit
Vereist om bitchat-gebruikers via Bluetooth te ontdekken
diff --git a/app/src/main/res/values-pa-rPK/strings.xml b/app/src/main/res/values-pa-rPK/strings.xml
index f38cb62c..b886f350 100644
--- a/app/src/main/res/values-pa-rPK/strings.xml
+++ b/app/src/main/res/values-pa-rPK/strings.xml
@@ -71,12 +71,8 @@
بُک مارک پاؤ
- واپس
- چینل: %1$s
- باہر آؤ
Nostr راہین پہنچ ممکن اے
انہ پڑھیا ذاتی پیغام
- بُک مارک ٹوگل کرو
ٹیلپورٹ کیتا گیا
Tor حالت
جُڑے پیئر
@@ -96,14 +92,12 @@
تصویر چُنو
- اینڈ ٹو اینڈ انکرپشن نال غیر مرکزی میش چیٹ
آف لائن میش چیٹ
انٹرنیٹ یا سرور توں بغیر Bluetooth LE راہین سیدھا رابطہ کرو۔ رینج ودھاؤن لئی پیغام نیڑے ڈیوائساں راہین ریلے ہوندے نیں۔
آن لائن geohash چینل
geohash بیسڈ چینلاں راہین اپنے علاقے دے لوکاں نال جڑو۔ پبلک انٹرنیٹ ریلے راہین میش نوں وسیع کرو۔
اینڈ ٹو اینڈ انکرپشن
ذاتی پیغام انکرپٹڈ ہوندے نیں۔ چینل پیغام پبلک ہوندے نیں۔
- دِکھ
سسٹم
ہلکا
گھُپ
@@ -127,7 +121,6 @@
Tor حالت: %1$s، بوٹ اسٹرَیپ %2$d%%
آخری: %1$s
ایمرجنسی ڈیٹا صاف
- ٹِپ: سارے محفوظ ڈیٹا (پیغام، کُنجیاں، سیٹنگا) مٹاؤن لئی ایپ دے عنوان تے تین وار ٹیپ کرو۔
ڈی بگ سیٹنگا
اوپن سورس • پرائیویسی پہلے • غیر مرکزی
بند کرو
@@ -147,7 +140,6 @@
نقشہ کھولو
بُک مارک ہٹاؤ
ٹیلپورٹ
- چُنے ہوۓ
چینل چھوڑو
Nostr راہین پہنچ ممکن اے
پسندیدہ (آف لائن)
@@ -288,14 +280,12 @@
اجازتاں
اجازت دو
bitchat توانڈی لوکیشن نئیں ٹریک کردا
- لوگ
کوئی وی نیڑے نئیں …
(تُسی)
geohash چُنن لئی پین تے زوم کرو
چُنو
پیغام لکھو …
ذکر
- (~%1$s)
v%1$s
image/*
تصویر
@@ -309,11 +299,6 @@
چلاؤ
روکੋ
- PoW مائننگ
- PoW چالو
- Proof of Work
- مائننگ …
- pow: %1$dbit
بلوٹوتھ راہین bitchat یوزر لبھن لئی ضروری
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index 5874fb8e..1cdc66b5 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -71,12 +71,8 @@
Adicionar favorito
- voltar
- Canal: %1$s
- sair
Acessível via Nostr
Mensagens privadas não lidas
- Alternar favorito
Teletransportado
Status do Tor
Peers conectados
@@ -96,14 +92,12 @@
Escolher imagem
- chat mesh descentralizado com criptografia de ponta a ponta
Chat mesh offline
Comunique diretamente via Bluetooth LE sem internet ou servidores. As mensagens são retransmitidas por dispositivos próximos para ampliar o alcance.
Canais geohash online
Conecte-se com pessoas próximas através de canais baseados em geohash. Expanda a mesh por meio de relays públicos na internet.
Criptografia de ponta a ponta
Mensagens privadas são criptografadas. Mensagens de canal são públicas.
- Aparência
Sistema
Claro
Escuro
@@ -127,7 +121,6 @@
Status do Tor: %1$s, bootstrap %2$d%%
Último: %1$s
Limpeza de dados de emergência
- Dica: Toque 3 vezes no título do app para apagar todos os dados salvos – incluindo mensagens, chaves e configurações.
Configurações de depuração
Código aberto • Privacidade em primeiro lugar • Descentralizado
Fechar
@@ -147,7 +140,6 @@
Abrir mapa
Remover favorito
Teletransportar
- Selecionado
Sair do canal
Acessível via Nostr
Favorito (offline)
@@ -288,14 +280,12 @@
Permissões
Conceder permissões
O bitchat NÃO rastreia sua localização
- PESSOAS
ninguém por perto…
(você)
arraste/amplie para selecionar um geohash
selecionar
Digite uma mensagem…
mencionar
- (~%1$s)
v%1$s
image/*
Imagem
@@ -309,11 +299,6 @@
Reproduzir
Pausar
- Minerando PoW
- PoW ativado
- Prova de Trabalho
- Minerando…
- pow: %1$dbit
Necessário para descobrir usuários do bitchat via Bluetooth
diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml
index ef869f6f..3ea2c30b 100644
--- a/app/src/main/res/values-pt/strings.xml
+++ b/app/src/main/res/values-pt/strings.xml
@@ -71,12 +71,8 @@
Adicionar marcador
- voltar
- Canal: %1$s
- sair
Alcançável via Nostr
Mensagens privadas não lidas
- Alternar marcador
Teletransportado
Estado do Tor
Pares ligados
@@ -96,14 +92,12 @@
Escolher imagem
- chat mesh descentralizado com encriptação ponto a ponto
Chat mesh offline
Comunique diretamente via Bluetooth LE sem internet ou servidores. As mensagens são retransmitidas através de dispositivos próximos para estender o alcance.
Canais geohash online
Conecte-se com pessoas perto de si através de canais baseados em geohash. Estenda a mesh através de repetidores de internet pública.
Encriptação ponto a ponto
Mensagens privadas são encriptadas. Mensagens de canal são públicas.
- Aparência
Sistema
Claro
Escuro
@@ -127,7 +121,6 @@
Estado do Tor: %1$s, bootstrap %2$d%%
Último: %1$s
Limpeza de dados de emergência
- Dica: Toque três vezes no título da aplicação para apagar todos os dados guardados – incluindo mensagens, chaves e definições.
Definições de depuração
Código aberto • Privacidade primeiro • Descentralizado
Fechar
@@ -147,7 +140,6 @@
Abrir mapa
Remover marcador
Teletransportar
- Selecionado
Sair do canal
Alcançável via Nostr
Favorito (offline)
@@ -288,14 +280,12 @@
Permissões
Conceder permissões
O bitchat NÃO rastreia a sua localização
- PESSOAS
ninguém por perto…
(você)
arraste e amplie para selecionar um geohash
selecionar
Digite uma mensagem…
mencionar
- (~%1$s)
v%1$s
image/*
Imagem
@@ -309,11 +299,6 @@
Reproduzir
Pausar
- A minerar PoW
- PoW ativado
- Prova de Trabalho
- A minerar…
- pow: %1$dbit
Necessário para descobrir utilizadores bitchat via Bluetooth
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 4ee98970..8691a9e6 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -63,12 +63,8 @@
Убрать из избранного
Добавить закладку
- назад
- канал: %1$s
- выйти
Доступен через Nostr
Непрочитанные личные
- Переключить закладку
Телепортировано
Статус Tor
Подключённые узлы
@@ -86,14 +82,12 @@
❌ Закрыть
Выбрать изображение
- децентрализованный mesh‑чат со сквозным шифрованием
Оффлайн Mesh‑чат
Общение напрямую по Bluetooth LE без интернета и серверов. Сообщения ретранслируются близлежащими устройствами.
Онлайн‑каналы Geohash
Общайся с людьми поблизости через каналы на основе geohash. Расширяй mesh через публичные ретрансляторы.
Сквозное шифрование
Личные сообщения — зашифрованы. Канальные — публичны.
- оформление
система
светлая
тёмная
@@ -117,7 +111,6 @@
статус Tor: %1$s, загрузка %2$d%%
Последнее: %1$s
Экстренное удаление данных
- Совет: тройной тап по заголовку — удалить все данные (сообщения, ключи, настройки).
Настройки отладки
Open Source • Privacy First • Децентрализация
Закрыть
@@ -136,7 +129,6 @@
Открыть карту
Удалить закладку
Телепорт
- Выбрано
Покинуть канал
Доступен через Nostr
Избранное (оффлайн)
@@ -267,7 +259,6 @@
bitchat НЕ отслеживает вашу геопозицию
@
· ⧉
- ЛЮДИ
никого рядом…
(вы)
прокрутите/масштабируйте для выбора geohash
@@ -276,7 +267,6 @@
@%1$s
упоминание
%1$d / %2$d
- (~%1$s)
@%1$s
v%1$s
#
diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml
index 2890b8a1..e10d033a 100644
--- a/app/src/main/res/values-sv/strings.xml
+++ b/app/src/main/res/values-sv/strings.xml
@@ -63,12 +63,8 @@
Ta bort favorit
Lägg till bokmärke
- tillbaka
- kanal: %1$s
- lämna
Nås via Nostr
Olästa privata meddelanden
- Växla bokmärke
Teleporterad
Tor‑status
Anslutna noder
@@ -86,14 +82,12 @@
❌ Stäng
Välj bild
- decentraliserad mesh‑meddelanden med ända‑till‑ända‑kryptering
Offline Mesh‑chatt
Kommunicera direkt via Bluetooth LE utan internet eller servrar. Meddelanden reläas av enheter i närheten.
Online Geohash‑kanaler
Anslut med personer i närheten via geohash‑kanaler. Utöka mesh via publika internetreläer.
Ända‑till‑ända‑kryptering
Privata meddelanden är krypterade. Kanalmeddelanden är publika.
- utseende
system
ljus
mörk
@@ -117,7 +111,6 @@
Tor‑status: %1$s, bootstrap %2$d%%
Senast: %1$s
Nödradering av data
- Tips: tryck tre gånger på appens titel för att radera alla data (meddelanden, nycklar, inställningar).
Felsökningsinställningar
Open Source • Privacy First • Decentraliserad
Stäng
@@ -136,7 +129,6 @@
Öppna karta
Ta bort bokmärke
Teleportera
- Vald
Lämna kanal
Nås via Nostr
Favorit (offline)
@@ -267,7 +259,6 @@
bitchat spårar INTE din plats
@
· ⧉
- PERSONER
ingen i närheten…
(du)
panorera och zooma för att välja geohash
@@ -276,7 +267,6 @@
@%1$s
nämn
%1$d / %2$d
- (~%1$s)
@%1$s
v%1$s
#
diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml
index 7807df97..5251aeb4 100644
--- a/app/src/main/res/values-th/strings.xml
+++ b/app/src/main/res/values-th/strings.xml
@@ -71,12 +71,8 @@
เพิ่มที่คั่นหน้า
- กลับ
- ช่อง: %1$s
- ออก
ติดต่อได้ผ่าน Nostr
ข้อความส่วนตัวยังไม่ได้อ่าน
- สลับที่คั่นหน้า
เทเลพอร์ตแล้ว
สถานะ Tor
เพียร์ที่เชื่อมต่อ
@@ -96,14 +92,12 @@
เลือกภาพ
- แชทเมชแบบกระจายศูนย์พร้อมการเข้ารหัสแบบปลายทางถึงปลายทาง
แชทเมชออฟไลน์
สื่อสารโดยตรงผ่าน Bluetooth LE โดยไม่ใช้อินเทอร์เน็ตหรือเซิร์ฟเวอร์ ข้อความจะถูกส่งต่อผ่านอุปกรณ์ใกล้เคียงเพื่อขยายระยะ
ช่อง geohash ออนไลน์
เชื่อมต่อกับผู้คนใกล้คุณผ่านช่องที่อ้างอิง geohash ขยายเมชผ่านรีเลย์อินเทอร์เน็ตสาธารณะ
การเข้ารหัสแบบปลายทางถึงปลายทาง
ข้อความส่วนตัวถูกเข้ารหัส ข้อความในช่องเป็นสาธารณะ
- ลักษณะ
ระบบ
สว่าง
มืด
@@ -127,7 +121,6 @@
สถานะ Tor: %1$s, การบูต %2$d%%
ล่าสุด: %1$s
ลบข้อมูลฉุกเฉิน
- เคล็ดลับ: แตะชื่อแอปสามครั้งเพื่อลบข้อมูลที่บันทึกทั้งหมด – รวมทั้งข้อความ กุญแจ และการตั้งค่า
การตั้งค่าดีบัก
โอเพนซอร์ส • ความเป็นส่วนตัวมาก่อน • กระจายศูนย์
ปิด
@@ -147,7 +140,6 @@
เปิดแผนที่
เอาที่คั่นหน้าออก
เทเลพอร์ต
- ที่เลือก
ออกจากช่อง
ติดต่อได้ผ่าน Nostr
รายการโปรด (ออฟไลน์)
@@ -288,14 +280,12 @@
สิทธิ์
ให้สิทธิ์
bitchat ไม่ติดตามตำแหน่งของคุณ
- ผู้คน
ไม่มีใครอยู่ใกล้…
(คุณ)
ลาก/ซูมเพื่อเลือก geohash
เลือก
พิมพ์ข้อความ…
กล่าวถึง
- (~%1$s)
v%1$s
image/*
รูปภาพ
@@ -309,11 +299,6 @@
เล่น
หยุดชั่วคราว
- กำลังขุด PoW
- เปิดใช้ PoW
- Proof of Work
- กำลังขุด…
- pow: %1$dbit
จำเป็นสำหรับการค้นหาผู้ใช้ bitchat ผ่านบลูทูธ
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index d35a99aa..04c27c6e 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -63,12 +63,8 @@
Sık kullanılandan kaldır
Yer imi ekle
- geri
- kanal: %1$s
- ayrıl
Nostr üzerinden ulaşılabilir
Okunmamış özel mesaj
- Yer imini değiştir
Teleport edildi
Tor durumu
Bağlı eşler
@@ -86,14 +82,12 @@
❌ Kapat
Görsel seç
- uçtan uca şifrelemeli merkezsiz mesh mesajlaşma
Çevrimdışı Mesh Sohbeti
İnternet/ sunucu olmadan doğrudan Bluetooth LE ile iletişim kur. Mesajlar yakındaki cihazlarca aktarılır.
Çevrimiçi Geohash Kanalları
Geohash tabanlı kanallarla yakınındaki insanlarla bağlantı kur. Herkese açık internet röleleriyle mesh’i genişlet.
Uçtan Uca Şifreleme
Özel mesajlar şifrelidir. Kanal mesajları herkese açıktır.
- görünüm
sistem
açık
koyu
@@ -117,7 +111,6 @@
Tor durumu: %1$s, önyükleme %2$d%%
Son: %1$s
Acil veri silme
- İpucu: tüm verileri silmek için uygulama başlığına üç kez dokun (mesajlar, anahtarlar, ayarlar).
Hata ayıklama ayarları
Open Source • Privacy First • Merkezsiz
Kapat
@@ -136,7 +129,6 @@
Haritayı aç
Yer imini kaldır
Teleport
- Seçildi
Kanaldan ayrıl
Nostr üzerinden erişilebilir
Favori (çevrimdışı)
@@ -267,7 +259,6 @@
bitchat konumunu takip etmez
@
· ⧉
- KİŞİLER
yakında kimse yok…
(sen)
geohash seçmek için kaydır/zoom yap
@@ -276,7 +267,6 @@
@%1$s
bahset
%1$d / %2$d
- (~%1$s)
@%1$s
v%1$s
#
diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml
index dd54b239..23266773 100644
--- a/app/src/main/res/values-ur/strings.xml
+++ b/app/src/main/res/values-ur/strings.xml
@@ -71,12 +71,8 @@
بک مارک شامل کریں
- واپس
- چینل: %1$s
- چھوڑیں
Nostr کے ذریعے رسائی
غیر پڑھے ہوئے نجی پیغامات
- بک مارک تبدیل کریں
ٹیلی پورٹ
Tor کی کیفیت
منسلک پیئرز
@@ -96,14 +92,12 @@
تصویر منتخب کریں
- غیر مرکزی میش چیٹ • اینڈ‑ٹو‑اینڈ انکرپشن
آف لائن میش چیٹ
انٹرنیٹ یا سرورز کے بغیر Bluetooth LE کے ذریعے براہ راست بات چیت کریں۔ پیغامات قریبی آلات کے ذریعے ریلے ہوتے ہیں تاکہ رسائی بڑھے۔
آن لائن geohash چینلز
geohash پر مبنی چینلز کے ذریعے اپنے قریب کے لوگوں سے جڑیں۔ عوامی انٹرنیٹ ریلے کے ذریعے میش کو پھیلائیں۔
اینڈ‑ٹو‑اینڈ انکرپشن
نجی پیغامات انکرپٹڈ ہیں۔ چینل پیغامات عوامی ہیں۔
- ظاہری شکل
سسٹم
ہلکا
گہرا
@@ -127,7 +121,6 @@
Tor کی کیفیت: %1$s، بوٹ اسٹریپ %2$d%%
آخری: %1$s
ہنگامی ڈیٹا صافی
- تجویز: تمام محفوظ شدہ ڈیٹا – پیغامات، کلیدیں اور سیٹنگز سمیت – صاف کرنے کے لیے ایپ کے ٹائٹل پر تین بار ٹیپ کریں۔
ڈیبگ سیٹنگز
اوپن سورس • پرائیویسی پہلے • غیر مرکزی
بند کریں
@@ -147,7 +140,6 @@
نقشہ کھولیں
بک مارک ہٹائیں
ٹیلی پورٹ
- منتخب
چینل چھوڑیں
Nostr کے ذریعے رسائی
پسندیدہ (آف لائن)
@@ -288,14 +280,12 @@
اجازات
اجازات دیں
bitchat آپ کا مقام ٹریک نہیں کرتا
- لوگ
یہاں کوئی نہیں…
(آپ)
geohash منتخب کرنے کے لیے پین/زوم کریں
منتخب کریں
پیغام لکھیں…
ذکر
- (~%1$s)
v%1$s
image/*
تصویر
@@ -309,11 +299,6 @@
چلائیں
روکیں
- PoW مائننگ
- PoW فعال
- پروف آف ورک
- مائننگ…
- pow: %1$dbit
بلوٹوتھ کے ذریعے bitchat صارفین کی دریافت کے لیے ضروری
diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml
index a65d1a1f..187645a4 100644
--- a/app/src/main/res/values-vi/strings.xml
+++ b/app/src/main/res/values-vi/strings.xml
@@ -71,12 +71,8 @@
Thêm bookmark
- quay lại
- Kênh: %1$s
- rời khỏi
Có thể tiếp cận qua Nostr
Tin nhắn riêng chưa đọc
- Bật/tắt bookmark
Đã dịch chuyển
Trạng thái Tor
Peers đã kết nối
@@ -96,14 +92,12 @@
Chọn hình ảnh
- trò chuyện mesh phi tập trung với mã hóa đầu cuối
Trò chuyện Mesh ngoại tuyến
Liên lạc trực tiếp qua Bluetooth LE mà không cần Internet hoặc máy chủ. Tin nhắn được chuyển tiếp qua các thiết bị gần để mở rộng phạm vi.
Kênh Geohash trực tuyến
Kết nối với những người trong khu vực của bạn qua các kênh dựa trên geohash. Mở rộng mesh qua các relay Internet công cộng.
Mã hóa đầu cuối
Tin nhắn riêng được mã hóa. Tin nhắn kênh là công khai.
- Giao diện
Hệ thống
Sáng
Tối
@@ -127,7 +121,6 @@
Trạng thái Tor: %1$s, Bootstrap %2$d%%
Cuối cùng: %1$s
Xóa dữ liệu khẩn cấp
- Mẹo: Nhấn ba lần vào tiêu đề ứng dụng để xóa tất cả dữ liệu đã lưu – bao gồm tin nhắn, khóa và cài đặt.
Cài đặt debug
Mã nguồn mở • Quyền riêng tư trước tiên • Phi tập trung
Đóng
@@ -147,7 +140,6 @@
Mở bản đồ
Xóa bookmark
Dịch chuyển
- Đã chọn
Rời khỏi kênh
Có thể tiếp cận qua Nostr
Yêu thích (ngoại tuyến)
@@ -288,14 +280,12 @@
Quyền
Cấp quyền
bitchat KHÔNG theo dõi vị trí của bạn
- MỌI NGƯỜI
không có ai xung quanh …
(bạn)
di chuyển và zoom để chọn geohash
chọn
Nhập tin nhắn …
nhắc đến
- (~%1$s)
v%1$s
image/*
Hình ảnh
@@ -309,11 +299,6 @@
Phát
Tạm dừng
- Đang mining PoW
- PoW đã bật
- Proof of Work
- Đang mining …
- pow: %1$dbit
Cần thiết để khám phá người dùng bitchat qua Bluetooth
diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml
index 7342d6ae..66c45e53 100644
--- a/app/src/main/res/values-zh/strings.xml
+++ b/app/src/main/res/values-zh/strings.xml
@@ -71,12 +71,8 @@
添加书签
- 返回
- 频道:%1$s
- 离开
可通过 Nostr 联系
未读私信
- 切换书签
已传送
Tor 状态
已连接节点
@@ -96,14 +92,12 @@
选择图片
- 去中心化 mesh 消息,端到端加密
离线 Mesh 聊天
无需网络或服务器,通过 Bluetooth LE 直接通信。利用附近设备中继消息以扩展范围。
在线 Geohash 频道
使用基于 geohash 的频道与附近的人交流。通过公共互联网中继扩展 mesh。
端到端加密
私信加密;频道消息为公开。
- 外观
系统
浅色
深色
@@ -127,7 +121,6 @@
Tor 状态:%1$s,引导 %2$d%%
最近:%1$s
紧急数据删除
- 提示:三击应用标题以删除所有数据,包括消息、密钥和设置。
调试设置
Open Source • Privacy First • 去中心化
关闭
@@ -147,7 +140,6 @@
打开地图
移除书签
传送
- 已选择
离开频道
可通过 Nostr 联系
收藏(离线)
@@ -283,7 +275,6 @@
bitchat 不会跟踪你的位置
@
· ⧉
- 成员
附近无人…
(你)
拖动/缩放以选择 geohash
@@ -292,7 +283,6 @@
@%1$s
提及
%1$d / %2$d
- (~%1$s)
@%1$s
v%1$s
#
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 0629333e..92583001 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -4,8 +4,8 @@
Bluetooth permission is required for peer-to-peer messaging without internet.
Location permission is required to discover nearby devices via Bluetooth.
Notification permission is required to alert you of new messages.
- nickname
- type a message…
+ Nickname
+ Type a message…
Password
Join Channel
Leave
@@ -54,7 +54,7 @@
Messages in %1$s
%1$s joined the conversation
bitchat - %1$d mentions
- bitchat - location chats
+ bitchat - Location chats
%1$d messages from %2$d locations
Mentioned in Mesh Chat
%1$d mentions in Mesh Chat
@@ -72,12 +72,8 @@
Add bookmark
- back
- channel: %1$s
- leave
Nostr reachable
- Unread private messages
- Toggle bookmark
+ Messages
Location notes
Teleported
Tor status
@@ -109,43 +105,75 @@
Pick file
- decentralized mesh messaging with end-to-end encryption
- Offline Mesh Chat
- Communicate directly via Bluetooth LE without internet or servers. Messages relay through nearby devices to extend range.
- Online Geohash Channels
- Connect with people in your area using geohash-based channels. Extend the mesh using public internet relays.
+ The sidegroup chat
+
+
+ Info
+ Settings
+
+
+ How To Use
+ Set your nickname by tapping it
+ Tap the channel name to change channels
+ Tap the people icon to see who\'s in the channel
+ Bookmark a channel to keep it in your list
+ Type @ to mention someone
+ Type / to see the available commands
+ Triple-tap the logo to wipe all data
+
+
+ About
+ Theme
+ Settings
+
+
+ No Tracking
+ No servers, accounts, or data collection.
+ Ephemeral Identity
+ A new peer ID is generated regularly.
+ Triple-tap the logo to instantly clear all data.
+
+
+ Tor Network
+ Difficulty
+ %1$d bits • %2$s
+ Connected (%1$d%%)
+ Disconnected
+
+ Offline Communication
+ Chat over a Bluetooth mesh with no internet and no servers. Messages relay through nearby devices to extend range.
+ Local Channels
+ Geohash channels connect you with people nearby, extending the mesh over public relays.
End-to-End Encryption
Private messages are encrypted. Channel messages are public.
- appearance
- system
- light
- dark
- proof of work
- pow off
- pow on
- add proof of work to geohash messages for spam deterrence.
- difficulty: %1$d bits (~%2$s)
- difficulty %1$d requires ~%2$s hash attempts
- no proof of work required
- very low - minimal spam protection
- low - basic spam protection
- medium - good spam protection
- high - strong spam protection
- very high - may cause delays
- extreme - significant computation required
- network
- tor off
- tor on
- route internet over tor for enhanced privacy.
- tor Status: %1$s, bootstrap %2$d%%
+ System
+ Light
+ Dark
+ Proof of Work
+ PoW Off
+ PoW On
+ Add proof of work to geohash messages for spam deterrence.
+ Difficulty: %1$d bits (~%2$s)
+ Difficulty %1$d requires ~%2$s hash attempts
+ No proof of work required
+ Very low – minimal spam protection
+ Low – basic spam protection
+ Medium – good spam protection
+ High – strong spam protection
+ Very high – may cause delays
+ Extreme – significant computation required
+ Network
+ Tor Off
+ Tor On
+ Route internet over Tor for enhanced privacy.
+ Tor Status: %1$s, bootstrap %2$d%%
Last: %1$s
- Emergency Data Deletion
- Tip: Triple-click the app title to emergency delete all stored data including messages, keys, and settings.
+ Panic Mode
Debug Settings
Open Source • Privacy First • Decentralized
Close
Privacy Protected
- cancel
+ Cancel
Share BitChat
@@ -226,10 +254,9 @@
Open map
Remove bookmark
Teleport
- Selected
Quit bitchat
- run in background
- keep mesh active when app is closed (foreground service)
+ Run in Background
+ Keep mesh active when app is closed (foreground service)
Leave channel
Reachable via Nostr
Offline favorite
@@ -243,15 +270,11 @@
Link
Record voice note
Pick media
- Mining PoW
- PoW Enabled
- Proof of Work
- mining...
- pow: %1$dbit
Offline Mesh Chat
Online Geohash Channels
End-to-End Encryption
- #bluetooth • %1$s
+ Bluetooth • %1$s
+ Bluetooth • Wifi • %1$s
Image %1$d of %2$d
Image unavailable
@@ -259,46 +282,63 @@
Failed to save image
Pick file
[file unavailable]
- unknown
+ Unknown
- choose an action for this message or user
- choose an action for this user
- copy message
- copy this message to clipboard
- slap %1$s
- send a playful slap message
- hug %1$s
- send a friendly hug message
- block %1$s
+ Choose an action for this message or user
+ Choose an action for this user
+ Copy message
+ Copy this message to clipboard
+ Slap %1$s
+ Send a playful slap message
+ Hug %1$s
+ Send a friendly hug message
+ Block %1$s
+ Block all messages from this user
+ Message %1$s
+ Send a private message
+ Delete note
+ Sends a nostr deletion request to relays
- block all messages from this user
- message %1$s
- send a private message
- delete note
- sends a nostr deletion request to relays
- #location channels
- chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps. do not screenshot or share this screen to protect your privacy.
- grant location permission
- location permission denied. enable in settings to use location channels.
+ Channels
+ Location Channels
+ Chat by coarse location. Never shares exact GPS.
+ Nearby
+ Offline mesh. No internet required.
- \u2713 location permission granted
- checking permissions...
- finding nearby channels…
- bookmarked
+
+ Nearby
+ Tor Routing
+ Hides your IP for location channels.
+ Recommended
+
+
+ People (%1$d)
+ People
+ On location
+ Teleported in
+
+ Grant location permission
+ Location permission denied. Enable in settings to use location channels.
+
+ \u2713 Location permission granted
+ Checking permissions…
+ Finding nearby channels…
+ Bookmarked
geohash
- invalid geohash
- teleport
- disable location services
- enable location services
+ Invalid geohash
+ Teleport
+ Remove location access
+ Enable location services
mesh
- block
- neighborhood
- city
- province
- region
+ Mesh
+ Block
+ Neighborhood
+ City
+ Province
+ Region
check for notes left here
@@ -309,89 +349,89 @@
- #%1$s ± 1 • %2$d note
- #%1$s ± 1 • %2$d notes
- add short permanent notes to this location for other visitors to find.
- geo relays unavailable; notes paused
- no geo relays nearby
- notes rely on geo relays. check connection and try again.
- loading notes…
- no notes yet
- be the first to add one for this spot.
- dismiss
- add a note for this place
+ Add short permanent notes to this location for other visitors to find.
+ Geo relays unavailable; notes paused
+ No geo relays nearby
+ Notes rely on geo relays. Check connection and try again.
+ Loading notes…
+ No notes yet
+ Be the first to add one for this spot.
+ Dismiss
+ Add a note for this place
- debug tools
- developer utilities for diagnostics and control
- verbose logging
- logs peer joins/leaves, connection direction, packet routing and relays
- bluetooth roles
- gatt server
- connections: %1$d / %2$d
- max server
- gatt client
- max client
- connections: %1$d / %2$d
- max overall
- packet relay
- since start: %1$d
- turn roles on/off and close all connections when disabled
- sync settings
- max packets per sync: %1$d
- max GCS filter size: %1$d bytes (128–1024)
- target FPR: %1$.2f%%
- connected devices
- our device id: %1$s
- none
- disconnect
- recent scan results
+ Debug Tools
+ Developer utilities for diagnostics and control
+ Verbose logging
+ Logs peer joins/leaves, connection direction, packet routing and relays
+ Bluetooth roles
+ GATT server
+ Connections: %1$d / %2$d
+ Max server
+ GATT client
+ Max client
+ Connections: %1$d / %2$d
+ Max overall
+ Packet relay
+ Since start: %1$d
+ Turn roles on/off and close all connections when disabled
+ Sync settings
+ Max packets per sync: %1$d
+ Max GCS filter size: %1$d bytes (128–1024)
+ Target FPR: %1$.2f%%
+ Connected devices
+ Our device ID: %1$s
+ None
+ Disconnect
+ Recent scan results
- verify
- scan to verify me
- scan someone elses qr
- scan someone elses qr
- show my qr
- remove verification
- qr unavailable
- camera permission is needed to scan qr codes
- enable camera
- paste verification url
- validate
- verification requested
- security verification
- their fingerprint
- your fingerprint
- handshake pending
- open a private chat to view fingerprints
- encrypted & verified
- encrypted
- handshaking
- handshake failed
- not encrypted
- verified
- you have verified this persons identity.
- not verified
- compare these fingerprints with %1$s using a secure channel.
- mark as verified
- start handshake
- copy
+ Verify
+ Scan to verify me
+ Scan someone else\'s QR
+ Scan someone else\'s QR
+ Show my QR
+ Remove verification
+ QR unavailable
+ Camera permission is needed to scan QR codes
+ Enable camera
+ Paste verification URL
+ Validate
+ Verification requested
+ Security verification
+ Their fingerprint
+ Your fingerprint
+ Handshake pending
+ Open a private chat to view fingerprints
+ Encrypted & verified
+ Encrypted
+ Handshaking
+ Handshake failed
+ Not encrypted
+ Verified
+ You have verified this person\'s identity.
+ Not verified
+ Compare these fingerprints with %1$s using a secure channel.
+ Mark as verified
+ Start handshake
+ Copy
Mutual verification
You and %1$s verified each other
- mutual verification with %1$s
+ Mutual verification with %1$s
Verified
You verified %1$s
- verified %1$s
+ Verified %1$s
- connect
- debug console
- clear
- last 10s: %1$d • 1m: %2$d • 15m: %3$d
- derived P: %1$s • est. max elements: %2$s
+ Connect
+ Debug console
+ Clear
+ Last 10s: %1$d • 1m: %2$d • 15m: %3$d
+ Derived P: %1$s • est. max elements: %2$s
• direct
RSSI: %1$s
?
- as server (we host)
- as client (we connect)
+ As server (we host)
+ As client (we connect)
Location Services Required
@@ -400,11 +440,11 @@
bitchat needs location services for:
• Bluetooth device scanning\n• Discovering nearby users on mesh network\n• Geohash chat feature\n• No tracking or location collection
Background Location Recommended
- optional, improves mesh reliability
+ Optional, improves mesh reliability
Android recommends background location so bitchat can scan for nearby devices while the app is not open. This keeps the mesh alive after reboot.
When settings opens, choose "Allow all the time".
bitchat uses background location for:
- - scan for nearby devices while the app is closed\n- reconnect after reboot\n- keep the mesh running in the background
+ - Scan for nearby devices while the app is closed\n- Reconnect after reboot\n- Keep the mesh running in the background
We NEVER collect or store your location. Your privacy is safe.
Allow Background Location
Continue without background location
@@ -420,13 +460,13 @@
Bluetooth Not Supported
This device doesn\'t support Bluetooth Low Energy (BLE), which is required for bitchat to function.\n\nbitchat needs BLE to create mesh networks and communicate with nearby devices without internet.
Checking Bluetooth status...
- battery optimization detected
+ Battery optimization detected
Battery Optimization Enabled
- bitchat needs to run in the background to maintain mesh connections. battery optimization can interrupt these connections.
+ bitchat needs to run in the background to maintain mesh connections. Battery optimization can interrupt these connections.
Benefits of Disabling
- • reliable message delivery\n• maintains mesh connectivity\n• prevents connection drops
+ • Reliable message delivery\n• Maintains mesh connectivity\n• Prevents connection drops
Disable Battery Optimization
- battery optimization disabled
+ Battery optimization disabled
Continue
Initializing mesh network
.
@@ -437,23 +477,21 @@
Try Again
Open Settings
Your Privacy is Protected
- • no tracking or data collection\n• Bluetooth mesh chats are fully offline\n• Geohash chats use the internet
- permissions
+ • No tracking or data collection\n• Bluetooth mesh chats are fully offline\n• Geohash chats use the internet
+ Permissions
Grant Permissions
bitchat does NOT track your location
@
Open About
· ⧉
- PEOPLE
- nobody around...
+ Nobody around…
(you)
- pan and zoom to select a geohash
- select
- type a message...
+ Pan and zoom to select a geohash
+ Select
+ Type a message…
@%1$s
- mention
+ Mention
%1$d / %2$d
- (~%1$s)
@%1$s
v%1$s
#
diff --git a/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt b/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt
index c1b2327b..f6a1042d 100644
--- a/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt
+++ b/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt
@@ -1,5 +1,6 @@
package com.bitchat.android.protocol
+import com.bitchat.android.model.BitchatFilePacket
import org.junit.Assert.assertEquals
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertFalse
@@ -7,9 +8,11 @@ import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
+import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.Random
+import java.util.zip.Deflater
class BinaryProtocolTest {
@@ -987,6 +990,315 @@ class BinaryProtocolTest {
assertNull("v2 compression bomb (ratio > 50,000:1) must be rejected", result)
}
+ @Test
+ fun `v2 expanded payload at exact maximum passes bound without allocating output`() {
+ val max = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
+ val raw = compressedPacket(
+ version = 2u,
+ originalSize = max,
+ compressedData = ByteArray(256) { it.toByte() }
+ )
+ var calls = 0
+
+ val result = BinaryProtocol.decodeForTesting(raw) { _, requestedSize ->
+ calls += 1
+ assertEquals(max, requestedSize)
+ null // Prove the boundary reached this seam without allocating a 10 MiB result.
+ }
+
+ assertNull("The test decompressor deliberately returns no payload", result)
+ assertEquals(1, calls)
+ }
+
+ @Test
+ fun `v2 expanded payload above maximum never reaches decompressor`() {
+ val max = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
+ val raw = compressedPacket(
+ version = 2u,
+ originalSize = max + 1,
+ compressedData = ByteArray(256) { it.toByte() }
+ )
+ var calls = 0
+
+ val result = BinaryProtocol.decodeForTesting(raw) { _, _ ->
+ calls += 1
+ byteArrayOf(0x42)
+ }
+
+ assertNull("An oversized expansion must be rejected before inflation", result)
+ assertEquals("The decompressor must not be invoked", 0, calls)
+ }
+
+ @Test
+ fun `v2 negative expanded payload never reaches decompressor`() {
+ val raw = compressedPacket(
+ version = 2u,
+ originalSize = -1,
+ compressedData = byteArrayOf(0x03)
+ )
+ var calls = 0
+
+ val result = BinaryProtocol.decodeForTesting(raw) { _, _ ->
+ calls += 1
+ byteArrayOf(0x42)
+ }
+
+ assertNull("A negative expansion must be rejected before inflation", result)
+ assertEquals("The decompressor must not be invoked", 0, calls)
+ }
+
+ @Test
+ fun `zero expanded payload never reaches decompressor`() {
+ val raw = compressedPacket(
+ version = 2u,
+ originalSize = 0,
+ compressedData = rawDeflate(ByteArray(0))
+ )
+ var calls = 0
+
+ val result = BinaryProtocol.decodeForTesting(raw) { _, _ ->
+ calls += 1
+ ByteArray(0)
+ }
+
+ assertNull("Compressed zero-length payloads are non-canonical and must be rejected", result)
+ assertEquals("The decompressor must not be invoked", 0, calls)
+ }
+
+ @Test
+ fun `empty compressed body never reaches decompressor`() {
+ val raw = compressedPacket(
+ version = 2u,
+ originalSize = 128,
+ compressedData = ByteArray(0)
+ )
+ var calls = 0
+
+ val result = BinaryProtocol.decodeForTesting(raw) { _, _ ->
+ calls += 1
+ ByteArray(128)
+ }
+
+ assertNull("A compressed payload must contain deflate bytes", result)
+ assertEquals("The decompressor must not be invoked", 0, calls)
+ }
+
+ @Test
+ fun `v1 unsigned maximum expanded size reaches decompressor`() {
+ val originalSize = 0xFFFF
+ val raw = compressedPacket(
+ version = 1u,
+ originalSize = originalSize,
+ compressedData = byteArrayOf(0x01, 0x02)
+ )
+ var calls = 0
+
+ val result = BinaryProtocol.decodeForTesting(raw) { _, requestedSize ->
+ calls += 1
+ assertEquals(originalSize, requestedSize)
+ null
+ }
+
+ assertNull("The test decompressor deliberately returns no payload", result)
+ assertEquals(1, calls)
+ }
+
+ @Test
+ fun `compression utility rejects invalid expansion sizes directly`() {
+ val max = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
+
+ assertNull(CompressionUtil.decompress(ByteArray(0), 1))
+ assertNull(CompressionUtil.decompress(rawDeflate(ByteArray(0)), 0))
+ assertNull(CompressionUtil.decompress(byteArrayOf(0x03), -1))
+ assertNull(CompressionUtil.decompress(byteArrayOf(0x03), max + 1))
+ }
+
+ @Test
+ fun `raw deflate expands only when size and stream completion are exact`() {
+ val payload = ByteArray(4_096) { index -> (index % 17).toByte() }
+ val compressed = rawDeflate(payload)
+
+ val decoded = BinaryProtocol.decode(
+ compressedPacket(version = 2u, originalSize = payload.size, compressedData = compressed)
+ )
+
+ assertNotNull(decoded)
+ assertArrayEquals(payload, decoded!!.payload)
+ }
+
+ @Test
+ fun `zlib wrapped payload remains compatible when size and stream completion are exact`() {
+ val payload = ByteArray(4_096) { index -> (index % 23).toByte() }
+ val compressed = zlibDeflate(payload)
+
+ val decoded = BinaryProtocol.decode(
+ compressedPacket(version = 2u, originalSize = payload.size, compressedData = compressed)
+ )
+
+ assertNotNull(decoded)
+ assertArrayEquals(payload, decoded!!.payload)
+ }
+
+ @Test
+ fun `raw deflate with zlib-looking prefix falls back after non-exact zlib parse`() {
+ val payload = ByteArray(29) { index -> (index + 1).toByte() }
+ val compressed = byteArrayOf(
+ 0x08, // non-final raw stored block; also zlib CMF
+ 0x1d, 0x00, // LEN = 29; 0x08 0x1d passes the RFC 1950 header check
+ 0xe2.toByte(), 0xff.toByte() // one's complement of LEN
+ ) + payload + byteArrayOf(0x03, 0x00) // final empty fixed-Huffman block
+
+ assertArrayEquals(payload, CompressionUtil.decompress(compressed, payload.size))
+ }
+
+ @Test
+ fun `under-declared zlib expansion is rejected by fallback`() {
+ val payload = ByteArray(4_096) { 0x51 }
+ val compressed = zlibDeflate(payload)
+
+ val decoded = BinaryProtocol.decode(
+ compressedPacket(version = 2u, originalSize = 128, compressedData = compressed)
+ )
+
+ assertNull("Zlib fallback must reject output beyond the declaration", decoded)
+ }
+
+ @Test
+ fun `over-declared zlib expansion is rejected by fallback`() {
+ val payload = ByteArray(128) { 0x52 }
+ val compressed = zlibDeflate(payload)
+
+ val decoded = BinaryProtocol.decode(
+ compressedPacket(version = 2u, originalSize = 256, compressedData = compressed)
+ )
+
+ assertNull("Zlib fallback must reject output shorter than the declaration", decoded)
+ }
+
+ @Test
+ fun `truncated zlib stream is rejected by fallback`() {
+ val payload = ByteArray(4_096) { index -> (index % 29).toByte() }
+ val compressed = zlibDeflate(payload)
+ val truncated = compressed.copyOf(compressed.size - 1)
+
+ val decoded = BinaryProtocol.decode(
+ compressedPacket(version = 2u, originalSize = payload.size, compressedData = truncated)
+ )
+
+ assertNull("Zlib fallback must require the stream end marker and checksum", decoded)
+ }
+
+ @Test
+ fun `zlib stream with trailing bytes is rejected by fallback`() {
+ val payload = ByteArray(4_096) { index -> (index % 13).toByte() }
+ val compressedWithTrailingByte = zlibDeflate(payload) + byteArrayOf(0x00)
+
+ val decoded = BinaryProtocol.decode(
+ compressedPacket(
+ version = 2u,
+ originalSize = payload.size,
+ compressedData = compressedWithTrailingByte
+ )
+ )
+
+ assertNull("Zlib fallback must consume the complete input and nothing more", decoded)
+ }
+
+ @Test
+ fun `under-declared raw expansion is rejected even when output buffer fills`() {
+ val payload = ByteArray(4_096) { 0x41 }
+ val compressed = rawDeflate(payload)
+
+ val decoded = BinaryProtocol.decode(
+ compressedPacket(version = 2u, originalSize = 128, compressedData = compressed)
+ )
+
+ assertNull("Inflater must be finished, not merely fill the declared buffer", decoded)
+ }
+
+ @Test
+ fun `over-declared raw expansion is rejected instead of returning a prefix`() {
+ val payload = ByteArray(128) { 0x42 }
+ val compressed = rawDeflate(payload)
+
+ val decoded = BinaryProtocol.decode(
+ compressedPacket(version = 2u, originalSize = 256, compressedData = compressed)
+ )
+
+ assertNull("The expanded byte count must equal the declaration", decoded)
+ }
+
+ @Test
+ fun `truncated raw stream is rejected even if all declared bytes were emitted`() {
+ val payload = ByteArray(4_096) { index -> (index % 31).toByte() }
+ val compressed = rawDeflate(payload)
+ val truncated = compressed.copyOf(compressed.size - 1)
+
+ val decoded = BinaryProtocol.decode(
+ compressedPacket(version = 2u, originalSize = payload.size, compressedData = truncated)
+ )
+
+ assertNull("A stream without its end marker must not be accepted", decoded)
+ }
+
+ @Test
+ fun `raw stream with trailing bytes is rejected`() {
+ val payload = ByteArray(4_096) { index -> (index % 19).toByte() }
+ val compressedWithTrailingByte = rawDeflate(payload) + byteArrayOf(0x00)
+
+ val decoded = BinaryProtocol.decode(
+ compressedPacket(
+ version = 2u,
+ originalSize = payload.size,
+ compressedData = compressedWithTrailingByte
+ )
+ )
+
+ assertNull("Trailing bytes after a complete stream must not be accepted", decoded)
+ }
+
+ @Test
+ fun `decoder rejects a decompressor result shorter than its declaration`() {
+ val raw = compressedPacket(
+ version = 2u,
+ originalSize = 128,
+ compressedData = ByteArray(16) { it.toByte() }
+ )
+
+ val decoded = BinaryProtocol.decodeForTesting(raw) { _, _ -> byteArrayOf(0x01) }
+
+ assertNull("BinaryProtocol must independently enforce the declared expanded size", decoded)
+ }
+
+ @Test
+ fun `new sender refuses legacy 11 MiB public file transfer before transmission`() {
+ val content = ByteArray(11 * 1024 * 1024) { 0x41 }
+ val filePayload = BitchatFilePacket(
+ fileName = "legacy-11m.bin",
+ fileSize = content.size.toLong(),
+ mimeType = "application/octet-stream",
+ content = content
+ ).encode()
+ assertNotNull(filePayload)
+
+ val encoded = BinaryProtocol.encode(
+ BitchatPacket(
+ version = 2u,
+ type = MessageType.FILE_TRANSFER.value,
+ senderID = hexToBytes(senderHex),
+ recipientID = SpecialRecipients.BROADCAST,
+ timestamp = fixedTimestamp,
+ payload = filePayload!!,
+ ttl = 5u
+ ),
+ padding = false
+ )
+ assertNull(
+ "Sender and receiver must enforce the same expanded-payload ceiling",
+ encoded
+ )
+ }
+
/**
* Compression bomb is rejected
*
@@ -1163,6 +1475,59 @@ class BinaryProtocolTest {
return result
}
+ private fun compressedPacket(
+ version: UByte,
+ originalSize: Int,
+ compressedData: ByteArray,
+ type: UByte = MessageType.MESSAGE.value
+ ): ByteArray {
+ val originalSizeFieldBytes = if (version >= 2u.toUByte()) 4 else 2
+ val payloadLength = originalSizeFieldBytes + compressedData.size
+ val headerSize = if (version >= 2u.toUByte()) 16 else 14
+ val buffer = ByteBuffer.allocate(headerSize + 8 + payloadLength).apply {
+ order(ByteOrder.BIG_ENDIAN)
+ put(version.toByte())
+ put(type.toByte())
+ put(5.toByte())
+ putLong(fixedTimestamp.toLong())
+ put(BinaryProtocol.Flags.IS_COMPRESSED.toByte())
+ if (version >= 2u.toUByte()) {
+ putInt(payloadLength)
+ } else {
+ putShort(payloadLength.toShort())
+ }
+ put(hexToBytes(senderHex))
+ if (version >= 2u.toUByte()) {
+ putInt(originalSize)
+ } else {
+ putShort(originalSize.toShort())
+ }
+ put(compressedData)
+ }
+ return buffer.array()
+ }
+
+ private fun rawDeflate(data: ByteArray): ByteArray = deflate(data, nowrap = true)
+
+ private fun zlibDeflate(data: ByteArray): ByteArray = deflate(data, nowrap = false)
+
+ private fun deflate(data: ByteArray, nowrap: Boolean): ByteArray {
+ val deflater = Deflater(Deflater.DEFAULT_COMPRESSION, nowrap)
+ return try {
+ deflater.setInput(data)
+ deflater.finish()
+ val output = ByteArrayOutputStream()
+ val buffer = ByteArray(1_024)
+ while (!deflater.finished()) {
+ val count = deflater.deflate(buffer)
+ output.write(buffer, 0, count)
+ }
+ output.toByteArray()
+ } finally {
+ deflater.end()
+ }
+ }
+
private fun makePacket(
version: UByte = 1u,
type: UByte = MessageType.MESSAGE.value,
diff --git a/app/src/test/java/com/bitchat/android/protocol/DecompressionResourcePoolTest.kt b/app/src/test/java/com/bitchat/android/protocol/DecompressionResourcePoolTest.kt
new file mode 100644
index 00000000..843d88a2
--- /dev/null
+++ b/app/src/test/java/com/bitchat/android/protocol/DecompressionResourcePoolTest.kt
@@ -0,0 +1,117 @@
+package com.bitchat.android.protocol
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+
+class DecompressionResourcePoolTest {
+ @Test
+ fun `small reservations run concurrently and next waits only when budget is full`() {
+ val pool = DecompressionResourcePool(
+ budgetBytes = 2_000,
+ unitBytes = 1_000,
+ waitTimeoutMs = 2_000
+ )
+ val executor = Executors.newFixedThreadPool(3)
+ val entered = CountDownLatch(2)
+ val release = CountDownLatch(1)
+ val thirdEntered = CountDownLatch(1)
+
+ try {
+ repeat(2) {
+ executor.submit {
+ pool.withReservation(1_000) {
+ entered.countDown()
+ release.await()
+ }
+ }
+ }
+ assertTrue(entered.await(1, TimeUnit.SECONDS))
+
+ executor.submit {
+ pool.withReservation(1_000) {
+ thirdEntered.countDown()
+ }
+ }
+ assertFalse("third reservation must wait while budget is full", thirdEntered.await(100, TimeUnit.MILLISECONDS))
+
+ release.countDown()
+ assertTrue("third reservation must proceed after release", thirdEntered.await(1, TimeUnit.SECONDS))
+ } finally {
+ release.countDown()
+ executor.shutdownNow()
+ }
+ }
+
+ @Test
+ fun `timed admission drops work instead of waiting indefinitely`() {
+ val pool = DecompressionResourcePool(
+ budgetBytes = 1_000,
+ unitBytes = 1_000,
+ waitTimeoutMs = 50
+ )
+ val entered = CountDownLatch(1)
+ val release = CountDownLatch(1)
+ val executor = Executors.newSingleThreadExecutor()
+
+ try {
+ executor.submit {
+ pool.withReservation(1_000) {
+ entered.countDown()
+ release.await()
+ }
+ }
+ assertTrue(entered.await(1, TimeUnit.SECONDS))
+ assertNull(pool.withReservation(1_000) { "unexpected" })
+ } finally {
+ release.countDown()
+ executor.shutdownNow()
+ }
+ }
+
+ @Test
+ fun `permits are released when decode throws`() {
+ val pool = DecompressionResourcePool(2_000, 1_000, 50)
+
+ try {
+ pool.withReservation(2_000) { error("boom") }
+ } catch (_: IllegalStateException) {
+ // Expected.
+ }
+
+ assertEquals(2, pool.availablePermits)
+ assertEquals("ok", pool.withReservation(2_000) { "ok" })
+ }
+
+ @Test
+ fun `runtime budget is based on heap memory and always admits one maximum packet`() {
+ val maxPacketResources = 20L * 1024 * 1024
+
+ assertEquals(
+ maxPacketResources,
+ DecompressionResourcePool.recommendedBudgetBytes(
+ maxHeapBytes = 64L * 1024 * 1024,
+ maxPacketResourceBytes = maxPacketResources
+ )
+ )
+ assertEquals(
+ 32L * 1024 * 1024,
+ DecompressionResourcePool.recommendedBudgetBytes(
+ maxHeapBytes = 256L * 1024 * 1024,
+ maxPacketResourceBytes = maxPacketResources
+ )
+ )
+ assertEquals(
+ 64L * 1024 * 1024,
+ DecompressionResourcePool.recommendedBudgetBytes(
+ maxHeapBytes = 2L * 1024 * 1024 * 1024,
+ maxPacketResourceBytes = maxPacketResources
+ )
+ )
+ }
+}
diff --git a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt
index 304166c0..69693b7e 100644
--- a/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt
+++ b/app/src/test/java/com/bitchat/android/ui/ChatUIUtilsTest.kt
@@ -1,44 +1,565 @@
package com.bitchat.android.ui
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
import com.bitchat.android.model.BitchatMessage
+import com.bitchat.android.ui.theme.BitchatFontFamily
+import com.bitchat.android.ui.theme.ChatVisualTokens
+import com.bitchat.android.ui.theme.DarkBitchatColorScheme
+import com.bitchat.android.ui.theme.DarkBitchatPalette
+import com.bitchat.android.ui.theme.LightBitchatColorScheme
+import com.bitchat.android.ui.theme.LightBitchatPalette
+import com.bitchat.android.ui.theme.MessageBodyTextStyle
+import com.bitchat.android.ui.theme.MessageSenderTextStyle
+import com.bitchat.android.ui.theme.PeerColorStyle
+import com.bitchat.android.ui.theme.colorForPeer
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+/**
+ * Runs under Robolectric because URL detection in message bodies goes through
+ * `android.util.Patterns.WEB_URL`, which is null on a bare JVM.
+ */
+@RunWith(RobolectricTestRunner::class)
class ChatUIUtilsTest {
- private val timeFormatter = SimpleDateFormat("HH:mm:ss", Locale.ROOT).apply {
+ private val timeFormatter = SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.ROOT).apply {
timeZone = java.util.TimeZone.getTimeZone("UTC")
}
+ private val palette = DarkBitchatPalette
+ private val colorScheme = DarkBitchatColorScheme
+
+ private fun message(
+ content: String,
+ sender: String = "alice",
+ powDifficulty: Int? = null,
+ ) = BitchatMessage(
+ sender = sender,
+ content = content,
+ timestamp = Date(0),
+ powDifficulty = powDifficulty,
+ )
+
+ // MARK: - Timestamp metadata
+
@Test
fun `text message metadata separates PoW badge with one space`() {
- val message = BitchatMessage(
- sender = "alice",
- content = "hello",
- timestamp = Date(0),
- powDifficulty = 12,
- )
-
assertEquals(
- "00:00:00 ⛨12b",
- formatTextMessageMetadata(message, timeFormatter).text,
+ "00:00 ⛨12b",
+ formatTextMessageMetadata(message("hello", powDifficulty = 12), timeFormatter).text,
)
}
@Test
fun `text message metadata omits non-positive PoW difficulty`() {
- val message = BitchatMessage(
- sender = "alice",
- content = "hello",
- timestamp = Date(0),
- powDifficulty = 0,
- )
-
assertEquals(
- "00:00:00",
- formatTextMessageMetadata(message, timeFormatter).text,
+ "00:00",
+ formatTextMessageMetadata(message("hello", powDifficulty = 0), timeFormatter).text,
)
}
+
+ // MARK: - Body with inline trailing timestamp
+
+ @Test
+ fun `body appends timestamp inline after the message text`() {
+ val body = formatTextMessageBody(
+ message = message("hello there"),
+ currentUserNickname = "bob",
+ palette = palette,
+ contentColor = colorScheme.onSurface,
+ linkColor = colorScheme.secondary,
+ timeFormatter = timeFormatter,
+ )
+
+ assertEquals("hello there 00:00", body.text)
+ val timestamp = body.spanStyles.first {
+ body.text.substring(it.start, it.end) == " 00:00"
+ }.item
+ assertEquals(10.sp, timestamp.fontSize)
+ assertEquals(FontWeight.Normal, timestamp.fontWeight)
+ assertEquals(palette.textTertiary, timestamp.color)
+ }
+
+ @Test
+ fun `body appends PoW badge after the inline timestamp`() {
+ val body = formatTextMessageBody(
+ message = message("mined", powDifficulty = 8),
+ currentUserNickname = "bob",
+ palette = palette,
+ contentColor = colorScheme.onSurface,
+ linkColor = colorScheme.secondary,
+ timeFormatter = timeFormatter,
+ )
+
+ assertEquals("mined 00:00 ⛨8b", body.text)
+ val timestampAndPow = body.spanStyles.first {
+ body.text.substring(it.start, it.end) == " 00:00 ⛨8b"
+ }.item
+ assertEquals(10.sp, timestampAndPow.fontSize)
+ assertEquals(palette.textTertiary, timestampAndPow.color)
+ }
+
+ @Test
+ fun `body can omit the timestamp for callers that render it separately`() {
+ val body = formatTextMessageBody(
+ message = message("hello there"),
+ currentUserNickname = "bob",
+ palette = palette,
+ contentColor = colorScheme.onSurface,
+ linkColor = colorScheme.secondary,
+ timeFormatter = timeFormatter,
+ includeTimestamp = false,
+ )
+
+ assertEquals("hello there", body.text)
+ }
+
+ @Test
+ fun `body renders plain text in the neutral palette color, not terminal green`() {
+ val body = formatTextMessageBody(
+ message = message("plain words"),
+ currentUserNickname = "bob",
+ palette = palette,
+ contentColor = colorScheme.onSurface,
+ linkColor = colorScheme.secondary,
+ timeFormatter = timeFormatter,
+ includeTimestamp = false,
+ )
+
+ val textStyle = body.spanStyles.first { it.start == 0 }
+ assertEquals(colorScheme.onSurface, textStyle.item.color)
+ }
+
+ @Test
+ fun `chat text styles match the exported type scale`() {
+ assertEquals(14.sp, MessageBodyTextStyle.fontSize)
+ assertEquals(20.sp, MessageBodyTextStyle.lineHeight)
+ assertEquals(FontWeight.Normal, MessageBodyTextStyle.fontWeight)
+ assertEquals(BitchatFontFamily, MessageBodyTextStyle.fontFamily)
+ assertEquals(14.sp, MessageSenderTextStyle.fontSize)
+ assertEquals(16.sp, MessageSenderTextStyle.lineHeight)
+ assertEquals(FontWeight.SemiBold, MessageSenderTextStyle.fontWeight)
+ }
+
+ @Test
+ fun `body does not bold plain text for the sender's own messages`() {
+ // Regression guard: the old renderer bolded the entire body when the message was yours,
+ // and again when you were mentioned, which is what made busy channels unreadable.
+ val body = formatTextMessageBody(
+ message = message("my own words", sender = "bob"),
+ currentUserNickname = "bob",
+ palette = palette,
+ contentColor = colorScheme.onSurface,
+ linkColor = colorScheme.secondary,
+ timeFormatter = timeFormatter,
+ includeTimestamp = false,
+ )
+
+ assertTrue(
+ "no span should force a bold weight",
+ body.spanStyles.none { it.item.fontWeight?.weight?.let { w -> w >= 700 } == true }
+ )
+ }
+
+ // MARK: - Mention chips
+
+ private fun mentionChipSpans(body: androidx.compose.ui.text.AnnotatedString) =
+ body.spanStyles.filter { it.item.background.isSpecified() }
+
+ private fun androidx.compose.ui.graphics.Color.isSpecified() =
+ this != androidx.compose.ui.graphics.Color.Unspecified && alpha > 0f
+
+ @Test
+ fun `mention renders as a single contiguous background chip`() {
+ val body = formatTextMessageBody(
+ message = message("hey @carol#04af what's up"),
+ currentUserNickname = "bob",
+ palette = palette,
+ contentColor = colorScheme.onSurface,
+ linkColor = colorScheme.secondary,
+ timeFormatter = timeFormatter,
+ includeTimestamp = false,
+ )
+
+ val chips = mentionChipSpans(body)
+ assertEquals("expected exactly one chip", 1, chips.size)
+
+ // The chip must cover "@carol#04af" as one run so it paints without seams.
+ val chip = chips.single()
+ assertEquals("@carol#04af", body.text.substring(chip.start, chip.end))
+ }
+
+ @Test
+ fun `mention targeting the current user uses the orange accent`() {
+ val body = formatTextMessageBody(
+ message = message("ping @bob now"),
+ currentUserNickname = "bob",
+ palette = palette,
+ contentColor = colorScheme.onSurface,
+ linkColor = colorScheme.secondary,
+ timeFormatter = timeFormatter,
+ includeTimestamp = false,
+ )
+
+ val chip = mentionChipSpans(body).single()
+ assertEquals(palette.accentOrange.copy(alpha = MENTION_CHIP_ALPHA_SELF), chip.item.background)
+ assertEquals(0.2f, chip.item.background.alpha)
+
+ val nameStyle: SpanStyle = body.spanStyles
+ .first { it.start == chip.start && it.item.color == palette.accentOrange }
+ .item
+ assertEquals(palette.accentOrange, nameStyle.color)
+ }
+
+ @Test
+ fun `mention of another user is tinted by that user's own peer color`() {
+ val pubkey = "0123456789abcdef".repeat(4)
+ val identity = PeerIdentity.nostr(pubkey)
+ val mentionPeerIdentities = buildMentionPeerIdentityMap(
+ messages = listOf(
+ BitchatMessage(
+ sender = "carol#04af",
+ content = "hello",
+ timestamp = Date(0),
+ senderPeerID = "nostr:${pubkey.take(8)}",
+ senderNostrPubkey = pubkey,
+ )
+ )
+ )
+ val body = formatTextMessageBody(
+ message = message("cc @carol#04af"),
+ currentUserNickname = "bob",
+ palette = palette,
+ contentColor = colorScheme.onSurface,
+ linkColor = colorScheme.secondary,
+ mentionPeerIdentities = mentionPeerIdentities,
+ timeFormatter = timeFormatter,
+ includeTimestamp = false,
+ )
+
+ val expected = colorForPeer(identity, palette)
+ val chip = mentionChipSpans(body).single()
+ assertEquals(expected.copy(alpha = MENTION_CHIP_ALPHA), chip.item.background)
+ assertTrue(body.spanStyles.any { it.item.color == expected })
+ }
+
+ @Test
+ fun `composer colors nickname and hash suffix from the mentioned peer identity`() {
+ val pubkey = "0123456789abcdef".repeat(4)
+ val identity = PeerIdentity.nostr(pubkey)
+ val token = "@carol#04af"
+ val input = "ping $token now"
+ val transformed = MentionVisualTransformation(
+ mentionPeerIdentities = mapOf("carol#04af" to identity),
+ palette = palette,
+ ).filter(AnnotatedString(input)).text
+
+ val expectedColor = colorForPeer(identity, palette)
+ val tokenStart = input.indexOf(token)
+ val suffixStart = input.indexOf("#04af")
+ val tokenEnd = tokenStart + token.length
+
+ assertEquals(input, transformed.text)
+ assertTrue(transformed.spanStyles.any {
+ it.start == tokenStart &&
+ it.end == tokenEnd &&
+ it.item.background == expectedColor.copy(alpha = MENTION_CHIP_ALPHA)
+ })
+ assertTrue(transformed.spanStyles.any {
+ it.start == tokenStart &&
+ it.end == suffixStart &&
+ it.item.color == expectedColor
+ })
+ assertTrue(transformed.spanStyles.any {
+ it.start == suffixStart &&
+ it.end == tokenEnd &&
+ it.item.color == expectedColor.copy(alpha = SUFFIX_ALPHA)
+ })
+ }
+
+ @Test
+ fun `ambiguous base nickname is not assigned to the wrong peer`() {
+ val firstIdentity = PeerIdentity.nostr("11111111".repeat(8))
+ val secondIdentity = PeerIdentity.nostr("22222222".repeat(8))
+ val identities = buildMentionPeerIdentityMap(
+ messages = emptyList(),
+ knownPeers = listOf(
+ "alice#1111" to firstIdentity,
+ "alice#2222" to secondIdentity,
+ )
+ )
+
+ assertEquals(firstIdentity, identities["alice#1111"])
+ assertEquals(secondIdentity, identities["alice#2222"])
+ assertFalse(identities.containsKey("alice"))
+ assertEquals(
+ firstIdentity,
+ resolveMentionPeerIdentity("@alice#1111", identities)
+ )
+ assertEquals(null, resolveMentionPeerIdentity("@alice", identities))
+ }
+
+ @Test
+ fun `message without mentions has no background chips`() {
+ val body = formatTextMessageBody(
+ message = message("no mentions in here"),
+ currentUserNickname = "bob",
+ palette = palette,
+ contentColor = colorScheme.onSurface,
+ linkColor = colorScheme.secondary,
+ timeFormatter = timeFormatter,
+ includeTimestamp = false,
+ )
+
+ assertTrue(mentionChipSpans(body).isEmpty())
+ }
+
+ // MARK: - System / action messages
+
+ @Test
+ fun `system message uses a double-slash prefix and no brackets`() {
+ val text = formatSystemMessage(
+ message = message("Tor started. Routing all chats via Tor", sender = "system"),
+ contentColor = colorScheme.onSurface,
+ timeFormatter = timeFormatter,
+ ).text
+
+ assertEquals("// Tor started. Routing all chats via Tor 00:00", text)
+ }
+
+ @Test
+ fun `system message is not italic`() {
+ // The old treatment was `* italic asterisks *`, which competed visually with real
+ // messages despite being lower-priority narration.
+ val annotated = formatSystemMessage(
+ message = message("tor restarting", sender = "system"),
+ contentColor = colorScheme.onSurface,
+ timeFormatter = timeFormatter,
+ )
+
+ assertTrue(annotated.spanStyles.all { it.item.fontStyle == null })
+ }
+
+ @Test
+ fun `system action and timestamp use their exported weights sizes and opacity`() {
+ val annotated = formatSystemMessage(
+ message = message("tor restarting", sender = "system"),
+ contentColor = colorScheme.onSurface,
+ timeFormatter = timeFormatter,
+ )
+
+ val action = annotated.spanStyles.first {
+ annotated.text.substring(it.start, it.end) == "// tor restarting"
+ }.item
+ val time = annotated.spanStyles.first {
+ annotated.text.substring(it.start, it.end) == " 00:00"
+ }.item
+
+ assertEquals(12.sp, action.fontSize)
+ assertEquals(FontWeight.Medium, action.fontWeight)
+ assertEquals(colorScheme.onSurface.copy(alpha = 0.5f), action.color)
+ assertEquals(10.sp, time.fontSize)
+ assertEquals(FontWeight.Normal, time.fontWeight)
+ assertEquals(colorScheme.onSurface.copy(alpha = 0.5f), time.color)
+ }
+
+ // MARK: - Sender label
+
+ @Test
+ fun `sender label drops angle brackets and dims the hash suffix`() {
+ val sender = formatTextMessageSender(
+ message = message("hi", sender = "carol#04af"),
+ currentUserNickname = "bob",
+ myPeerID = "peer-me",
+ palette = palette,
+ )
+
+ assertEquals("@carol#04af", sender.text)
+
+ val suffixSpan = sender.spanStyles.first { sender.text.substring(it.start, it.end) == "#04af" }
+ val nameSpan = sender.spanStyles.first { sender.text.substring(it.start, it.end) == "@carol" }
+ assertNotNull(suffixSpan.item.color)
+ assertEquals(14.sp, nameSpan.item.fontSize)
+ assertEquals(FontWeight.SemiBold, nameSpan.item.fontWeight)
+ assertEquals(14.sp, suffixSpan.item.fontSize)
+ assertEquals(FontWeight.Normal, suffixSpan.item.fontWeight)
+ assertEquals(ChatVisualTokens.SenderSuffixAlpha, suffixSpan.item.color.alpha)
+ assertTrue(
+ "suffix must be dimmer than the name",
+ suffixSpan.item.color.alpha < nameSpan.item.color.alpha
+ )
+ }
+
+ @Test
+ fun `sender label annotates the nickname for others but not for yourself`() {
+ val other = formatTextMessageSender(
+ message = message("hi", sender = "carol#04af"),
+ currentUserNickname = "bob",
+ myPeerID = "peer-me",
+ palette = palette,
+ )
+ assertEquals(1, other.getStringAnnotations("nickname_click", 0, other.length).size)
+
+ val mine = formatTextMessageSender(
+ message = message("hi", sender = "bob"),
+ currentUserNickname = "bob",
+ myPeerID = "peer-me",
+ palette = palette,
+ )
+ assertTrue(mine.getStringAnnotations("nickname_click", 0, mine.length).isEmpty())
+ }
+
+ // MARK: - Peer colors
+
+ @Test
+ fun `peer identity factories normalize stable IDs without resolving UI colors`() {
+ assertEquals(
+ PeerIdentity.mesh("abcdef"),
+ PeerIdentity.mesh("ABCDEF")
+ )
+ assertEquals(
+ PeerIdentity.nostr("abcdef"),
+ PeerIdentity.nostr("ABCDEF")
+ )
+ assertEquals(
+ PeerIdentity.nostr("abcdef"),
+ PeerIdentity.nostr("nostr:nostr_ABCDEF")
+ )
+ assertEquals(
+ "nostr:nostr:abcdef01",
+ PeerIdentity.nostr("ABCDEF0123456789").stableKey
+ )
+ assertEquals("alice#1234", PeerIdentity.nickname("ALICE#1234").stableKey)
+ }
+
+ @Test
+ fun `peer color hue is stable across light and dark, only chroma differs`() {
+ // Hue derivation must stay byte-identical to iOS; only saturation/value are tuned per
+ // theme so dark mode stays muted-but-bright and light mode stays deep-but-readable.
+ val identity = PeerIdentity.mesh("abc")
+ val dark = colorForPeer(identity, DarkBitchatPalette)
+ val light = colorForPeer(identity, LightBitchatPalette)
+
+ val darkHsv = FloatArray(3)
+ val lightHsv = FloatArray(3)
+ rgbToHsv(dark.red, dark.green, dark.blue, darkHsv)
+ rgbToHsv(light.red, light.green, light.blue, lightHsv)
+
+ assertEquals(darkHsv[0].toDouble(), lightHsv[0].toDouble(), 1.0)
+ assertEquals(PeerColorStyle.Dark.saturation.toDouble(), darkHsv[1].toDouble(), 0.01)
+ assertEquals(PeerColorStyle.Dark.value.toDouble(), darkHsv[2].toDouble(), 0.01)
+ assertEquals(PeerColorStyle.Light.saturation.toDouble(), lightHsv[1].toDouble(), 0.01)
+ assertEquals(PeerColorStyle.Light.value.toDouble(), lightHsv[2].toDouble(), 0.01)
+ // Dark theme: muted chroma, never dark (readable on near-black).
+ assertTrue(darkHsv[1] < 0.75f)
+ assertTrue(darkHsv[2] >= 0.75f)
+ // Light theme: avoid neon / near-white peer labels.
+ assertTrue(lightHsv[1] < 0.85f)
+ assertTrue(lightHsv[2] <= 0.55f)
+ }
+
+ @Test
+ fun `peer color avoids the orange hue reserved for self`() {
+ // Sweep a range of seeds; none may land within the reserved orange band.
+ repeat(500) { i ->
+ val color = colorForPeer(
+ PeerIdentity.mesh("seed$i"),
+ DarkBitchatPalette
+ )
+ val hsv = FloatArray(3)
+ rgbToHsv(color.red, color.green, color.blue, hsv)
+ val distanceFromOrange = kotlin.math.abs(hsv[0] - 30f)
+ assertTrue(
+ "seed$i resolved to ${hsv[0]}°, inside the reserved orange band",
+ distanceFromOrange >= 17f || hsv[1] < 0.01f
+ )
+ }
+ }
+
+ @Test
+ fun `material owns standard text while Bitchat palette owns peer chroma`() {
+ assertEquals(Color(0xFFF5F5F5), DarkBitchatColorScheme.onSurface)
+ assertTrue(LightBitchatColorScheme.onSurface != DarkBitchatColorScheme.onSurface)
+ assertTrue(
+ LightBitchatPalette.peerColors != DarkBitchatPalette.peerColors
+ )
+ }
+
+ @Test
+ fun `geohash chat and people sheet resolve the same full Nostr identity`() {
+ val pubkey = "ABCDEF0123456789".repeat(4)
+ val peopleIdentity = PeerIdentity.nostr(pubkey)
+ val chatIdentity = peerIdentityForMessage(
+ BitchatMessage(
+ sender = "alice#1234",
+ content = "hello",
+ timestamp = Date(0),
+ senderPeerID = "nostr:${pubkey.take(8)}",
+ senderNostrPubkey = pubkey,
+ )
+ )
+
+ assertEquals(peopleIdentity, chatIdentity)
+ assertEquals(
+ colorForPeer(peopleIdentity, palette),
+ colorForPeer(chatIdentity, palette)
+ )
+ }
+
+ @Test
+ fun `mesh chat and people sheet resolve the same peer identity`() {
+ val peerID = "ABCDEF0123456789"
+ val peopleIdentity = PeerIdentity.mesh(peerID)
+ val chatIdentity = peerIdentityForMessage(
+ BitchatMessage(
+ sender = "alice#1234",
+ content = "hello",
+ timestamp = Date(0),
+ senderPeerID = peerID,
+ )
+ )
+
+ assertEquals(peopleIdentity, chatIdentity)
+ }
+
+ @Test
+ fun `full Nostr identity wins over a truncated routing alias`() {
+ val pubkey = "0123456789ABCDEF".repeat(4)
+ val identity = peerIdentityForMessage(
+ BitchatMessage(
+ sender = "alice",
+ content = "hello",
+ timestamp = Date(0),
+ senderPeerID = "nostr_${pubkey.take(16)}",
+ senderNostrPubkey = pubkey,
+ )
+ )
+
+ assertEquals(PeerIdentity.nostr(pubkey), identity)
+ }
+
+ private fun rgbToHsv(r: Float, g: Float, b: Float, out: FloatArray) {
+ val max = maxOf(r, g, b)
+ val min = minOf(r, g, b)
+ val delta = max - min
+ out[0] = when {
+ delta == 0f -> 0f
+ max == r -> (60f * (((g - b) / delta) % 6f) + 360f) % 360f
+ max == g -> 60f * (((b - r) / delta) + 2f)
+ else -> 60f * (((r - g) / delta) + 4f)
+ }
+ out[1] = if (max == 0f) 0f else delta / max
+ out[2] = max
+ }
}
diff --git a/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt
index 431d8500..9eff027c 100644
--- a/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt
+++ b/app/src/test/java/com/bitchat/android/ui/CommandProcessorTest.kt
@@ -5,6 +5,7 @@ import androidx.test.core.app.ApplicationProvider
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatMessage
import junit.framework.TestCase.assertEquals
+import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@@ -14,6 +15,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito
import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner
import java.util.Date
@@ -95,4 +97,43 @@ class CommandProcessorTest() {
assertEquals(result, true)
}
+
+ @Test
+ fun `msg command persists incoming messages as locally read through shared chat opening`() {
+ val peerID = "0102030405060708"
+ val message = BitchatMessage(
+ id = "message-opened-by-command",
+ sender = "alice",
+ content = "hello",
+ timestamp = Date(1),
+ isPrivate = true,
+ senderPeerID = peerID
+ )
+ val locallyRead = mutableListOf()
+ chatState.setPrivateChats(mapOf(peerID to listOf(message)))
+ whenever(meshService.getPeerNicknames()).thenReturn(mapOf(peerID to "alice"))
+
+ commandProcessor = CommandProcessor(
+ state = chatState,
+ messageManager = messageManager,
+ channelManager = channelManager,
+ privateChatManager = PrivateChatManager(
+ state = chatState,
+ messageManager = messageManager,
+ dataManager = DataManager(context = context),
+ noiseSessionDelegate = mock(),
+ markMessageReadLocally = locallyRead::add
+ )
+ )
+
+ commandProcessor.processCommand(
+ command = "/msg alice",
+ meshService = meshService,
+ myPeerID = "self",
+ onSendMessage = { _, _, _ -> },
+ viewModel = null
+ )
+
+ assertTrue(locallyRead.contains(message.id))
+ }
}
diff --git a/app/src/test/java/com/bitchat/android/ui/GeohashPresenceGroupingTest.kt b/app/src/test/java/com/bitchat/android/ui/GeohashPresenceGroupingTest.kt
new file mode 100644
index 00000000..869ae924
--- /dev/null
+++ b/app/src/test/java/com/bitchat/android/ui/GeohashPresenceGroupingTest.kt
@@ -0,0 +1,130 @@
+package com.bitchat.android.ui
+
+import java.util.Date
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class GeohashPresenceGroupingTest {
+
+ private fun person(id: String, name: String = id, lastSeen: Long = 0) = GeoPerson(
+ id = id,
+ displayName = name,
+ lastSeen = Date(lastSeen)
+ )
+
+ @Test
+ fun `heartbeat anon is hidden while announced anon name remains`() {
+ val people = listOf(
+ person("local-heartbeat", "anon"),
+ person("named", "alice"),
+ person("teleported-named", "anon7674"),
+ person("teleported-heartbeat", "anon#04af")
+ )
+
+ val sections = sectionGeohashPeople(
+ people = people,
+ myId = null,
+ selfIsTeleported = false,
+ teleportedIds = setOf("teleported-named", "teleported-heartbeat")
+ )
+
+ assertEquals(listOf("alice"), sections.onLocation.map { it.displayName })
+ assertEquals(listOf("anon7674"), sections.teleportedIn.map { it.displayName })
+ assertEquals(2, sections.onLocation.size + sections.teleportedIn.size)
+ }
+
+ @Test
+ fun `self is first in on-location section when not teleported`() {
+ val sections = sectionGeohashPeople(
+ people = listOf(
+ person("recent", lastSeen = 3_000),
+ person("me", name = "anon", lastSeen = 0),
+ person("older", lastSeen = 1_000)
+ ),
+ myId = "me",
+ selfIsTeleported = false,
+ teleportedIds = emptySet()
+ )
+
+ assertEquals(listOf("me", "recent", "older"), sections.onLocation.map { it.id })
+ assertTrue(sections.teleportedIn.isEmpty())
+ }
+
+ @Test
+ fun `self is first in teleported section when teleported`() {
+ val sections = sectionGeohashPeople(
+ people = listOf(
+ person("remote-teleport", lastSeen = 3_000),
+ person("me", lastSeen = 0),
+ person("local", lastSeen = 2_000)
+ ),
+ myId = "ME",
+ selfIsTeleported = true,
+ teleportedIds = setOf("remote-teleport")
+ )
+
+ assertEquals(listOf("local"), sections.onLocation.map { it.id })
+ assertEquals(listOf("me", "remote-teleport"), sections.teleportedIn.map { it.id })
+ }
+
+ @Test
+ fun `remote teleport matching is case insensitive`() {
+ val sections = sectionGeohashPeople(
+ people = listOf(person("ABCDEF")),
+ myId = null,
+ selfIsTeleported = false,
+ teleportedIds = setOf("abcdef")
+ )
+
+ assertTrue(sections.onLocation.isEmpty())
+ assertEquals(listOf("ABCDEF"), sections.teleportedIn.map { it.id })
+ }
+
+ @Test
+ fun `duplicate nicknames are detected across case and sections`() {
+ val duplicates = duplicateGeohashBaseNames(
+ listOf(
+ person("first", "Alice"),
+ person("second", "alice"),
+ person("third", "bob")
+ )
+ )
+
+ assertEquals(setOf("alice"), duplicates)
+ }
+
+ @Test
+ fun `duplicate nickname gets the same last-four ID suffix as chat`() {
+ assertEquals(
+ "#cdef",
+ geohashIdentitySuffix(person("0123456789abcdef", "alice"), showHashSuffix = true)
+ )
+ assertEquals(
+ "",
+ geohashIdentitySuffix(person("0123456789abcdef", "alice"), showHashSuffix = false)
+ )
+ }
+
+ @Test
+ fun `existing chat-style suffix is preserved`() {
+ assertEquals(
+ "#04af",
+ geohashIdentitySuffix(person("0123456789abcdef", "alice#04af"), showHashSuffix = true)
+ )
+ }
+
+ @Test
+ fun `disambiguated display name matches the mention token used by chat`() {
+ val alice = person("0123456789abcdef", "alice")
+
+ assertEquals(
+ "alice#cdef",
+ disambiguatedGeohashDisplayName(alice, duplicateBaseNames = setOf("alice"))
+ )
+ assertEquals(
+ "alice",
+ disambiguatedGeohashDisplayName(alice, duplicateBaseNames = emptySet())
+ )
+ }
+}
diff --git a/app/src/test/java/com/bitchat/android/ui/LocationChannelsSheetTest.kt b/app/src/test/java/com/bitchat/android/ui/LocationChannelsSheetTest.kt
new file mode 100644
index 00000000..e53665ca
--- /dev/null
+++ b/app/src/test/java/com/bitchat/android/ui/LocationChannelsSheetTest.kt
@@ -0,0 +1,49 @@
+package com.bitchat.android.ui
+
+import com.bitchat.android.geohash.ChannelID
+import com.bitchat.android.geohash.GeohashChannel
+import com.bitchat.android.geohash.GeohashChannelLevel
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class LocationChannelsSheetTest {
+
+ @Test
+ fun `teleported channel gets a standalone row`() {
+ val nearby = listOf(channel("u33dc"))
+ val teleported = channel("dr5ru")
+
+ assertEquals(
+ teleported,
+ selectedLocationChannelOutsideNearby(ChannelID.Location(teleported), nearby)
+ )
+ }
+
+ @Test
+ fun `nearby selected channel is not duplicated`() {
+ val nearby = channel("u33dc")
+
+ assertNull(
+ selectedLocationChannelOutsideNearby(
+ ChannelID.Location(channel("U33DC")),
+ listOf(nearby)
+ )
+ )
+ }
+
+ @Test
+ fun `mesh selection has no standalone location row`() {
+ assertNull(
+ selectedLocationChannelOutsideNearby(
+ ChannelID.Mesh,
+ listOf(channel("u33dc"))
+ )
+ )
+ }
+
+ private fun channel(geohash: String) = GeohashChannel(
+ level = GeohashChannelLevel.CITY,
+ geohash = geohash
+ )
+}
diff --git a/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt b/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt
new file mode 100644
index 00000000..607aaf1e
--- /dev/null
+++ b/app/src/test/java/com/bitchat/android/ui/MentionSuggestionsTest.kt
@@ -0,0 +1,54 @@
+package com.bitchat.android.ui
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class MentionSuggestionsTest {
+
+ @Test
+ fun `only users without an announced nickname are excluded from mentions`() {
+ val suggestions = filterMentionCandidates(
+ candidates = listOf(
+ "anon",
+ "anon#04af",
+ "anon7674#df5b",
+ "alice#1234",
+ "anonymous",
+ "anonracer#04af"
+ ),
+ query = ""
+ )
+
+ assertEquals(
+ listOf("alice#1234", "anon7674#df5b", "anonracer#04af", "anonymous"),
+ suggestions
+ )
+ assertTrue(suggestions.none(::isUnannouncedNickname))
+ }
+
+ @Test
+ fun `mention filtering is case insensitive and removes duplicates`() {
+ val suggestions = filterMentionCandidates(
+ candidates = listOf("Bob#1234", "bob#1234", "bobby#5678", "alice#9999"),
+ query = "BO"
+ )
+
+ assertEquals(listOf("Bob#1234", "bobby#5678"), suggestions)
+ }
+
+ @Test
+ fun `announced names beginning with anon stay mentionable`() {
+ assertTrue(isUnannouncedNickname("anon"))
+ assertTrue(isUnannouncedNickname("anon#04af"))
+ assertFalse(isUnannouncedNickname("anon1234#04af"))
+ assertFalse(isUnannouncedNickname("anonymous#04af"))
+ assertFalse(isUnannouncedNickname("anonracer"))
+ }
+
+ @Test
+ fun `mention popup viewport is capped at five rows`() {
+ assertEquals(5, MaxVisibleMentionSuggestions)
+ }
+}
diff --git a/app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt b/app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt
new file mode 100644
index 00000000..fb534e69
--- /dev/null
+++ b/app/src/test/java/com/bitchat/android/ui/MessageArrivalTrackerTest.kt
@@ -0,0 +1,186 @@
+package com.bitchat.android.ui
+
+import com.bitchat.android.model.BitchatMessage
+import java.util.Date
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * The arrival tracker decides which messages animate in.
+ *
+ * Getting it wrong is not subtle: animate too eagerly and every old message replays its entrance
+ * whenever `LazyColumn` recycles it back into view, which looks broken during a scroll.
+ */
+class MessageArrivalTrackerTest {
+
+ private var seq = 0
+
+ private fun msg(id: String = "m${seq++}") = BitchatMessage(
+ id = id,
+ sender = "alice",
+ content = "hello",
+ timestamp = Date(0),
+ )
+
+ @Test
+ fun `first load adopts everything silently`() {
+ val tracker = MessageArrivalTracker()
+ val initial = listOf(msg("a"), msg("b"), msg("c"))
+
+ assertTrue(
+ "a whole screenful animating on open reads as a glitch",
+ tracker.arrivals(initial).isEmpty()
+ )
+ }
+
+ @Test
+ fun `a message appended after the first load animates`() {
+ val tracker = MessageArrivalTracker()
+ val messages = mutableListOf(msg("a"), msg("b"))
+ tracker.arrivals(messages)
+
+ messages += msg("c")
+ assertEquals(setOf("c"), tracker.arrivals(messages))
+ }
+
+ @Test
+ fun `an empty first load still lets the first real message animate`() {
+ // A brand new conversation seeds with nothing, so its opening message is a genuine arrival.
+ val tracker = MessageArrivalTracker()
+ tracker.arrivals(emptyList())
+
+ assertEquals(setOf("a"), tracker.arrivals(listOf(msg("a"))))
+ }
+
+ @Test
+ fun `re-presenting the same list animates nothing`() {
+ val tracker = MessageArrivalTracker()
+ val messages = listOf(msg("a"), msg("b"))
+ tracker.arrivals(messages)
+
+ assertTrue(tracker.arrivals(messages).isEmpty())
+ assertTrue(tracker.arrivals(messages).isEmpty())
+ }
+
+ @Test
+ fun `an already-seen message never animates again`() {
+ // Stands in for LazyColumn recycling an old row back into view mid-scroll.
+ val tracker = MessageArrivalTracker()
+ val messages = mutableListOf(msg("a"))
+ tracker.arrivals(messages)
+ messages += msg("b")
+ tracker.arrivals(messages)
+
+ assertTrue(tracker.arrivals(messages).isEmpty())
+ }
+
+ @Test
+ fun `only the new messages in a batch animate`() {
+ val tracker = MessageArrivalTracker()
+ val messages = mutableListOf(msg("a"))
+ tracker.arrivals(messages)
+
+ messages += listOf(msg("b"), msg("c"))
+ assertEquals(setOf("b", "c"), tracker.arrivals(messages))
+ }
+
+ @Test
+ fun `a burst larger than the cap is adopted silently`() {
+ // History sync: animating hundreds of rows would spend the whole frame budget on motion
+ // nobody asked to see.
+ val tracker = MessageArrivalTracker()
+ val messages = mutableListOf(msg("seed"))
+ tracker.arrivals(messages)
+
+ repeat(MaxAnimatedArrivals + 1) { messages += msg("burst$it") }
+ assertTrue(tracker.arrivals(messages).isEmpty())
+ }
+
+ @Test
+ fun `a burst exactly at the cap still animates`() {
+ val tracker = MessageArrivalTracker()
+ val messages = mutableListOf(msg("seed"))
+ tracker.arrivals(messages)
+
+ repeat(MaxAnimatedArrivals) { messages += msg("b$it") }
+ assertEquals(MaxAnimatedArrivals, tracker.arrivals(messages).size)
+ }
+
+ @Test
+ fun `clearing the conversation prunes the known set`() {
+ val tracker = MessageArrivalTracker()
+ val messages = mutableListOf(msg("a"), msg("b"), msg("c"))
+ tracker.arrivals(messages)
+
+ tracker.arrivals(emptyList())
+ assertTrue("stale ids would leak and grow without bound", tracker.known.isEmpty())
+ }
+
+ @Test
+ fun `a message re-added after a clear animates again`() {
+ val tracker = MessageArrivalTracker()
+ tracker.arrivals(listOf(msg("a")))
+ tracker.arrivals(emptyList())
+
+ assertEquals(setOf("a"), tracker.arrivals(listOf(msg("a"))))
+ }
+
+ @Test
+ fun `a wholesale replacement animates nothing`() {
+ // Switching channels, or /clear followed by fresh content: the incoming list shares no ids
+ // with the outgoing one, so it is a different conversation rather than a burst of arrivals.
+ val tracker = MessageArrivalTracker()
+ tracker.arrivals(listOf(msg("a"), msg("b")))
+
+ val other = listOf(msg("x"), msg("y"))
+ assertTrue(
+ "a different conversation must not slide every message in",
+ tracker.arrivals(other).isEmpty()
+ )
+ }
+
+ @Test
+ fun `a small replacement below the burst cap still animates nothing`() {
+ // The burst cap alone would not catch this: two messages is well under it.
+ val tracker = MessageArrivalTracker()
+ tracker.arrivals(listOf(msg("a"), msg("b"), msg("c")))
+
+ assertTrue(tracker.arrivals(listOf(msg("x"), msg("y"))).isEmpty())
+ }
+
+ @Test
+ fun `a replacement that overlaps is treated as normal arrivals`() {
+ // Still the same conversation if anything carries over, so genuine new messages animate.
+ val tracker = MessageArrivalTracker()
+ val kept = msg("a")
+ tracker.arrivals(listOf(kept, msg("b")))
+
+ assertEquals(setOf("c"), tracker.arrivals(listOf(kept, msg("c"))))
+ }
+
+ @Test
+ fun `after a wholesale replacement, later arrivals animate normally`() {
+ val tracker = MessageArrivalTracker()
+ tracker.arrivals(listOf(msg("a")))
+
+ val switched = mutableListOf(msg("x"))
+ tracker.arrivals(switched) // adopted silently
+
+ switched += msg("y")
+ assertEquals(setOf("y"), tracker.arrivals(switched))
+ }
+
+ @Test
+ fun `the known set never outgrows the conversation`() {
+ val tracker = MessageArrivalTracker()
+ val messages = mutableListOf(msg("a"), msg("b"))
+ tracker.arrivals(messages)
+
+ // Simulate a channel switch: entirely different messages, same list size.
+ val switched = listOf(msg("x"), msg("y"))
+ tracker.arrivals(switched)
+
+ assertEquals(setOf("x", "y"), tracker.known)
+ }
+}
diff --git a/app/src/test/java/com/bitchat/android/ui/MessageGroupingTest.kt b/app/src/test/java/com/bitchat/android/ui/MessageGroupingTest.kt
new file mode 100644
index 00000000..eea760f7
--- /dev/null
+++ b/app/src/test/java/com/bitchat/android/ui/MessageGroupingTest.kt
@@ -0,0 +1,154 @@
+package com.bitchat.android.ui
+
+import androidx.compose.ui.unit.dp
+import com.bitchat.android.model.BitchatMessage
+import java.util.Date
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Grouping decides whether a message repeats its author's name. Getting it wrong either spams
+ * the transcript with redundant labels or, worse, silently attributes one person's message to
+ * another — hence the emphasis on the negative cases here.
+ */
+class MessageGroupingTest {
+
+ private val base = 1_700_000_000_000L
+
+ private fun message(
+ sender: String = "alice",
+ senderPeerId: String? = "peer-alice",
+ offsetMs: Long = 0L,
+ isPrivate: Boolean = false,
+ channel: String? = null,
+ content: String = "hello",
+ ) = BitchatMessage(
+ sender = sender,
+ content = content,
+ timestamp = Date(base + offsetMs),
+ isPrivate = isPrivate,
+ senderPeerID = senderPeerId,
+ channel = channel,
+ )
+
+ @Test
+ fun `first message in a list never groups`() {
+ assertFalse(MessageGrouping.shouldGroup(previous = null, current = message()))
+ }
+
+ @Test
+ fun `consecutive messages from the same peer group`() {
+ val first = message(offsetMs = 0)
+ val second = message(offsetMs = 30_000)
+
+ assertTrue(MessageGrouping.shouldGroup(first, second))
+ }
+
+ @Test
+ fun `messages from different peers do not group`() {
+ val first = message(sender = "alice", senderPeerId = "peer-alice")
+ val second = message(sender = "bob", senderPeerId = "peer-bob", offsetMs = 1_000)
+
+ assertFalse(MessageGrouping.shouldGroup(first, second))
+ }
+
+ @Test
+ fun `same nickname but different peer id does not group`() {
+ // Two people can pick the same nickname; peer ID is the authority.
+ val first = message(sender = "alice", senderPeerId = "peer-one")
+ val second = message(sender = "alice", senderPeerId = "peer-two", offsetMs = 1_000)
+
+ assertFalse(MessageGrouping.shouldGroup(first, second))
+ }
+
+ @Test
+ fun `falls back to display name when peer ids are unavailable`() {
+ val first = message(sender = "alice#04af", senderPeerId = null)
+ val second = message(sender = "alice#04af", senderPeerId = null, offsetMs = 1_000)
+ val other = message(sender = "bob#1122", senderPeerId = null, offsetMs = 2_000)
+
+ assertTrue(MessageGrouping.shouldGroup(first, second))
+ assertFalse(MessageGrouping.shouldGroup(second, other))
+ }
+
+ @Test
+ fun `messages outside the grouping window start a new group`() {
+ val first = message(offsetMs = 0)
+ val justInside = message(offsetMs = MessageGrouping.GROUPING_WINDOW_MS)
+ val justOutside = message(offsetMs = MessageGrouping.GROUPING_WINDOW_MS + 1)
+
+ assertTrue(MessageGrouping.shouldGroup(first, justInside))
+ assertFalse(MessageGrouping.shouldGroup(first, justOutside))
+ }
+
+ @Test
+ fun `out of order timestamps do not group`() {
+ // Mesh delivery can surface messages out of order; a negative delta means we cannot
+ // reason about adjacency, so fall back to showing the sender.
+ val later = message(offsetMs = 60_000)
+ val earlier = message(offsetMs = 0)
+
+ assertFalse(MessageGrouping.shouldGroup(later, earlier))
+ }
+
+ @Test
+ fun `system messages never group in either direction`() {
+ val system = message(sender = "system", senderPeerId = null, content = "tor started")
+ val user = message(offsetMs = 1_000)
+
+ assertFalse(MessageGrouping.shouldGroup(system, user))
+ assertFalse(MessageGrouping.shouldGroup(user, system.copyWithOffset(2_000)))
+ assertFalse(MessageGrouping.shouldGroup(system, system.copyWithOffset(1_000)))
+ }
+
+ @Test
+ fun `grouping does not cross the public private boundary`() {
+ val public = message(isPrivate = false)
+ val private = message(isPrivate = true, offsetMs = 1_000)
+
+ assertFalse(MessageGrouping.shouldGroup(public, private))
+ }
+
+ @Test
+ fun `grouping does not cross channels`() {
+ val inGeneral = message(channel = "#general")
+ val inRandom = message(channel = "#random", offsetMs = 1_000)
+ val inNoChannel = message(channel = null, offsetMs = 2_000)
+
+ assertFalse(MessageGrouping.shouldGroup(inGeneral, inRandom))
+ assertFalse(MessageGrouping.shouldGroup(inGeneral, inNoChannel))
+ }
+
+ @Test
+ fun `peer id comparison ignores case`() {
+ val first = message(senderPeerId = "PEER-Alice")
+ val second = message(senderPeerId = "peer-alice", offsetMs = 1_000)
+
+ assertTrue(MessageGrouping.shouldGroup(first, second))
+ }
+
+ // MARK: - Spacing
+
+ @Test
+ fun `spacing is zero for the very first row`() {
+ assertEquals(0.dp, MessageGrouping.topSpacingFor(isGrouped = false, isFirstInList = true))
+ assertEquals(0.dp, MessageGrouping.topSpacingFor(isGrouped = true, isFirstInList = true))
+ }
+
+ @Test
+ fun `all transcript rows use the exported eight dp rhythm`() {
+ val grouped = MessageGrouping.topSpacingFor(isGrouped = true, isFirstInList = false)
+ val newGroup = MessageGrouping.topSpacingFor(isGrouped = false, isFirstInList = false)
+
+ assertEquals(8.dp, grouped)
+ assertEquals(8.dp, newGroup)
+ assertEquals(grouped, newGroup)
+ assertEquals(8.dp, MessageGrouping.SENDER_TOP_PADDING)
+ assertEquals(4.dp, MessageGrouping.SENDER_TO_BODY_SPACING)
+ }
+
+ private fun BitchatMessage.copyWithOffset(offsetMs: Long) =
+ copy(timestamp = Date(base + offsetMs))
+}
diff --git a/app/src/test/kotlin/com/bitchat/FileTransferTest.kt b/app/src/test/kotlin/com/bitchat/FileTransferTest.kt
index 31131540..8a136e69 100644
--- a/app/src/test/kotlin/com/bitchat/FileTransferTest.kt
+++ b/app/src/test/kotlin/com/bitchat/FileTransferTest.kt
@@ -237,7 +237,7 @@ class FileTransferTest {
// Given: Large file size (simulated)
val largeFileSize = 100L * 1024 * 1024 // 100MB
- val maxAllowedSize = 50L * 1024 * 1024 // 50MB
+ val maxAllowedSize = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES
// When: Checking if file can be transferred
val isAllowed = largeFileSize <= maxAllowedSize
diff --git a/app/src/test/kotlin/com/bitchat/android/contracts/ClientRewriteWireContractTest.kt b/app/src/test/kotlin/com/bitchat/android/contracts/ClientRewriteWireContractTest.kt
new file mode 100644
index 00000000..d1381170
--- /dev/null
+++ b/app/src/test/kotlin/com/bitchat/android/contracts/ClientRewriteWireContractTest.kt
@@ -0,0 +1,301 @@
+package com.bitchat.android.contracts
+
+import com.bitchat.android.model.BitchatFilePacket
+import com.bitchat.android.model.BitchatMessage
+import com.bitchat.android.model.FragmentPayload
+import com.bitchat.android.model.IdentityAnnouncement
+import com.bitchat.android.model.NoisePayload
+import com.bitchat.android.model.NoisePayloadType
+import com.bitchat.android.model.PeerCapabilities
+import com.bitchat.android.model.PrivateMessagePacket
+import com.bitchat.android.model.RequestSyncPacket
+import com.bitchat.android.model.UnknownAnnouncementTLV
+import com.bitchat.android.protocol.BinaryProtocol
+import com.bitchat.android.protocol.BitchatPacket
+import com.bitchat.android.protocol.MessageType
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.util.Date
+
+/**
+ * Golden wire vectors for formats that a from-scratch client must reproduce.
+ *
+ * These assertions deliberately compare literal bytes rather than relying only
+ * on encode/decode round trips, which can hide matching bugs in both methods.
+ */
+class ClientRewriteWireContractTest {
+
+ @Test
+ fun `v1 packet matches canonical unpadded bytes`() {
+ val packet = BitchatPacket(
+ version = 1u,
+ type = MessageType.MESSAGE.value,
+ senderID = hex("1011121314151617"),
+ recipientID = null,
+ timestamp = 0x0102030405060708uL,
+ payload = hex("aabbcc"),
+ signature = null,
+ ttl = 7u
+ )
+
+ val encoded = BinaryProtocol.encode(packet, padding = false)
+
+ assertArrayEquals(
+ hex("01020701020304050607080000031011121314151617aabbcc"),
+ encoded
+ )
+ assertEquals(packet, BinaryProtocol.decode(encoded!!))
+ }
+
+ @Test
+ fun `v2 routed signed packet matches canonical section order`() {
+ val signature = ByteArray(64) { 0x5a }
+ val packet = BitchatPacket(
+ version = 2u,
+ type = MessageType.NOISE_ENCRYPTED.value,
+ senderID = hex("0102030405060708"),
+ recipientID = hex("1112131415161718"),
+ timestamp = 42uL,
+ payload = hex("dead"),
+ signature = signature,
+ ttl = 5u,
+ route = listOf(
+ hex("2122232425262728"),
+ hex("3132333435363738")
+ )
+ )
+
+ val encoded = BinaryProtocol.encode(packet, padding = false)!!
+ val expectedPrefix = hex(
+ "021105000000000000002a0b00000002" +
+ "0102030405060708" +
+ "1112131415161718" +
+ "02" +
+ "2122232425262728" +
+ "3132333435363738" +
+ "dead"
+ )
+
+ assertArrayEquals(expectedPrefix + signature, encoded)
+ assertEquals(packet, BinaryProtocol.decode(encoded))
+ }
+
+ @Test
+ fun `minimal chat message matches canonical binary payload`() {
+ val message = BitchatMessage(
+ id = "id",
+ sender = "bob",
+ content = "hi",
+ timestamp = Date(0x0102030405060708L)
+ )
+
+ val encoded = message.toBinaryPayload()
+
+ assertArrayEquals(
+ hex("00010203040506070802696403626f6200026869"),
+ encoded
+ )
+ assertEquals(message, BitchatMessage.fromBinaryPayload(encoded!!))
+ }
+
+ @Test
+ fun `chat message optional fields use flags and UTF-8 byte lengths`() {
+ val message = BitchatMessage(
+ id = "m",
+ sender = "é",
+ content = "hello",
+ timestamp = Date(42L),
+ isRelay = true,
+ originalSender = "o",
+ isPrivate = true,
+ recipientNickname = "r",
+ senderPeerID = "p",
+ mentions = listOf("a", "β"),
+ channel = "c"
+ )
+
+ val encoded = message.toBinaryPayload()!!
+
+ assertArrayEquals(
+ hex(
+ "7f000000000000002a" +
+ "016d" +
+ "02c3a9" +
+ "000568656c6c6f" +
+ "016f" +
+ "0172" +
+ "0170" +
+ "02" +
+ "0161" +
+ "02ceb2" +
+ "0163"
+ ),
+ encoded
+ )
+ assertEquals(message, BitchatMessage.fromBinaryPayload(encoded))
+ }
+
+ @Test
+ fun `encrypted chat payload carries ciphertext instead of placeholder content`() {
+ val message = BitchatMessage(
+ id = "e",
+ sender = "alice",
+ content = "must-not-be-on-wire",
+ timestamp = Date(1L),
+ encryptedContent = hex("000102ff"),
+ isEncrypted = true,
+ isPrivate = true
+ )
+
+ val decoded = BitchatMessage.fromBinaryPayload(message.toBinaryPayload()!!)!!
+
+ assertEquals("", decoded.content)
+ assertArrayEquals(hex("000102ff"), decoded.encryptedContent)
+ assertTrue(decoded.isEncrypted)
+ assertTrue(decoded.isPrivate)
+ assertFalse(message.toBinaryPayload()!!.toString(Charsets.ISO_8859_1).contains("must-not-be-on-wire"))
+ }
+
+ @Test
+ fun `private message and Noise envelopes match deployed type bytes`() {
+ val privateMessage = PrivateMessagePacket(messageID = "m1", content = "hi")
+ val privateMessageBytes = hex("00026d3101026869")
+
+ assertArrayEquals(privateMessageBytes, privateMessage.encode())
+ assertEquals(privateMessage, PrivateMessagePacket.decode(privateMessageBytes))
+ assertArrayEquals(
+ hex("0100026d3101026869"),
+ NoisePayload(NoisePayloadType.PRIVATE_MESSAGE, privateMessageBytes).encode()
+ )
+ assertEquals(
+ NoisePayloadType.FILE_TRANSFER,
+ NoisePayload.decode(hex("09cafe"))?.type
+ )
+ assertArrayEquals(
+ hex("20cafe"),
+ NoisePayload.decode(hex("09cafe"))!!.encode()
+ )
+ }
+
+ @Test
+ fun `fragment payload matches the thirteen byte iOS header`() {
+ val fragment = FragmentPayload(
+ fragmentID = hex("0001020304050607"),
+ index = 1,
+ total = 3,
+ originalType = MessageType.MESSAGE.value,
+ data = hex("aabb")
+ )
+ val wire = hex("00010203040506070001000302aabb")
+
+ assertArrayEquals(wire, fragment.encode())
+ assertEquals(fragment, FragmentPayload.decode(wire))
+ assertTrue(fragment.isValid())
+ }
+
+ @Test
+ fun `sync request matches canonical TLV bytes and skips extensions`() {
+ val request = RequestSyncPacket(
+ p = 19,
+ m = 0x01020304L,
+ data = hex("aabb")
+ )
+ val wire = hex("0100011302000401020304030002aabb")
+
+ assertArrayEquals(wire, request.encode())
+ assertSyncRequestEquals(request, RequestSyncPacket.decode(wire))
+
+ val withExtension = hex("7f0002cafe") + wire
+ assertSyncRequestEquals(request, RequestSyncPacket.decode(withExtension))
+ }
+
+ @Test
+ fun `identity announcement matches canonical TLV order and preserves extensions`() {
+ val announcement = IdentityAnnouncement(
+ nickname = "bob",
+ noisePublicKey = ByteArray(32) { 0x11 },
+ signingPublicKey = ByteArray(32) { 0x22 },
+ capabilities = PeerCapabilities.PRIVATE_MEDIA,
+ unknownTLVs = listOf(UnknownAnnouncementTLV(0x7f, hex("cafe")))
+ )
+ val expected =
+ hex("0103626f620220") +
+ ByteArray(32) { 0x11 } +
+ hex("0320") +
+ ByteArray(32) { 0x22 } +
+ hex("050200017f02cafe")
+
+ val encoded = announcement.encode()
+
+ assertArrayEquals(expected, encoded)
+ assertEquals(announcement, IdentityAnnouncement.decode(encoded!!))
+ }
+
+ @Test
+ fun `file transfer matches deployed mixed-width TLV vector`() {
+ val packet = BitchatFilePacket(
+ fileName = "a",
+ fileSize = 2,
+ mimeType = "m",
+ content = hex("dead")
+ )
+ val wire = hex("01000161020004000000020300016d0400000002dead")
+
+ assertArrayEquals(wire, packet.encode())
+
+ val decoded = BitchatFilePacket.decode(wire)
+ assertNotNull(decoded)
+ assertEquals(packet.fileName, decoded!!.fileName)
+ assertEquals(packet.fileSize, decoded.fileSize)
+ assertEquals(packet.mimeType, decoded.mimeType)
+ assertArrayEquals(packet.content, decoded.content)
+ }
+
+ @Test
+ fun `required message prefixes reject every truncation`() {
+ val wire = BitchatMessage(
+ id = "id",
+ sender = "bob",
+ content = "hello",
+ timestamp = Date(1L)
+ ).toBinaryPayload()!!
+
+ for (length in 0 until wire.size) {
+ assertNull(
+ "Accepted required message prefix of $length/${wire.size} bytes",
+ BitchatMessage.fromBinaryPayload(wire.copyOf(length))
+ )
+ }
+ assertNotNull(BitchatMessage.fromBinaryPayload(wire))
+ }
+
+ @Test
+ fun `TLV decoders reject missing required fields and truncated values`() {
+ assertNull(PrivateMessagePacket.decode(hex("00026d31")))
+ assertNull(PrivateMessagePacket.decode(hex("00026d3101036869")))
+ assertNull(RequestSyncPacket.decode(hex("0100011302000401020304")))
+ assertNull(IdentityAnnouncement.decode(hex("0103626f62022011")))
+ assertNull(BitchatFilePacket.decode(hex("010001610400000002de")))
+ assertNull(FragmentPayload.decode(ByteArray(FragmentPayload.HEADER_SIZE - 1)))
+ }
+
+ private fun hex(value: String): ByteArray {
+ require(value.length % 2 == 0)
+ return value.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
+ }
+
+ private fun assertSyncRequestEquals(
+ expected: RequestSyncPacket,
+ actual: RequestSyncPacket?
+ ) {
+ assertNotNull(actual)
+ assertEquals(expected.p, actual!!.p)
+ assertEquals(expected.m, actual.m)
+ assertArrayEquals(expected.data, actual.data)
+ }
+}
diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/AuthenticatedBleLinkPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/AuthenticatedBleLinkPolicyTest.kt
deleted file mode 100644
index 81ba5f7a..00000000
--- a/app/src/test/kotlin/com/bitchat/android/mesh/AuthenticatedBleLinkPolicyTest.kt
+++ /dev/null
@@ -1,40 +0,0 @@
-package com.bitchat.android.mesh
-
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertTrue
-import org.junit.Test
-
-class AuthenticatedBleLinkPolicyTest {
- private val claim = AuthenticatedBleLinkPolicy.Claim(
- deviceAddress = "AA:BB:CC:DD:EE:FF",
- linkID = "connection-a"
- )
-
- @Test
- fun `accepts completion from exact claimed connection`() {
- assertTrue(
- AuthenticatedBleLinkPolicy.matches(
- claim,
- authenticatedAddress = claim.deviceAddress,
- authenticatedLinkID = claim.linkID
- )
- )
- }
-
- @Test
- fun `rejects replacement connection reusing device address`() {
- assertFalse(
- AuthenticatedBleLinkPolicy.matches(
- claim,
- authenticatedAddress = claim.deviceAddress,
- authenticatedLinkID = "connection-b"
- )
- )
- }
-
- @Test
- fun `rejects completion on another address or without a claim`() {
- assertFalse(AuthenticatedBleLinkPolicy.matches(claim, "11:22:33:44:55:66", claim.linkID))
- assertFalse(AuthenticatedBleLinkPolicy.matches(null, claim.deviceAddress, claim.linkID))
- }
-}
diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkIdentityTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkObservationTest.kt
similarity index 52%
rename from app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkIdentityTest.kt
rename to app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkObservationTest.kt
index 16d2d286..bde80cdc 100644
--- a/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkIdentityTest.kt
+++ b/app/src/test/kotlin/com/bitchat/android/mesh/BluetoothConnectionTrackerLinkObservationTest.kt
@@ -14,7 +14,7 @@ import org.junit.Test
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
-class BluetoothConnectionTrackerLinkIdentityTest {
+class BluetoothConnectionTrackerLinkObservationTest {
private val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob())
private val tracker = BluetoothConnectionTracker(scope, mock())
@@ -46,8 +46,40 @@ class BluetoothConnectionTrackerLinkIdentityTest {
assertFalse(tracker.cleanupDeviceConnectionIfCurrent(address, "link-a"))
assertEquals("link-b", tracker.getCurrentLinkID(address))
- assertTrue(tracker.bindPeerIfCurrent(address, "link-b", "0011223344556677"))
+ assertTrue(tracker.observePeerIfCurrent(address, "link-b", "0011223344556677"))
assertEquals("0011223344556677", tracker.addressPeerMap[address])
assertSame(device, tracker.getDeviceConnection(address)?.device)
}
+
+ @Test
+ fun `one peer can remain directly observed over multiple current links`() {
+ val firstAddress = "AA:BB:CC:DD:EE:01"
+ val secondAddress = "AA:BB:CC:DD:EE:02"
+ val firstDevice = mock()
+ val secondDevice = mock()
+ whenever(firstDevice.address).thenReturn(firstAddress)
+ whenever(secondDevice.address).thenReturn(secondAddress)
+
+ tracker.addDeviceConnection(
+ firstAddress,
+ BluetoothConnectionTracker.DeviceConnection(device = firstDevice, linkID = "link-a")
+ )
+ tracker.addDeviceConnection(
+ secondAddress,
+ BluetoothConnectionTracker.DeviceConnection(device = secondDevice, linkID = "link-b")
+ )
+
+ assertTrue(tracker.observePeerIfCurrent(firstAddress, "link-a", PEER_ID))
+ assertTrue(tracker.observePeerIfCurrent(secondAddress, "link-b", PEER_ID))
+ assertTrue(tracker.observePeerIfCurrent(secondAddress, "link-b", PEER_ID))
+ assertEquals(2, tracker.addressPeerMap.values.count { it == PEER_ID })
+
+ assertTrue(tracker.cleanupDeviceConnectionIfCurrent(firstAddress, "link-a"))
+ assertEquals(PEER_ID, tracker.addressPeerMap[secondAddress])
+ assertTrue(tracker.addressPeerMap.containsValue(PEER_ID))
+ }
+
+ private companion object {
+ const val PEER_ID = "0011223344556677"
+ }
}
diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/DirectLinkAnnouncementPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/DirectLinkAnnouncementPolicyTest.kt
new file mode 100644
index 00000000..8c0f2ad4
--- /dev/null
+++ b/app/src/test/kotlin/com/bitchat/android/mesh/DirectLinkAnnouncementPolicyTest.kt
@@ -0,0 +1,64 @@
+package com.bitchat.android.mesh
+
+import com.bitchat.android.model.RoutedPacket
+import com.bitchat.android.protocol.BitchatPacket
+import com.bitchat.android.protocol.MessageType
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class DirectLinkAnnouncementPolicyTest {
+ @Test
+ fun `accepted max ttl announce is a direct routing observation`() {
+ val routed = announce(ttl = MAX_TTL)
+
+ assertEquals(
+ DirectLinkAnnouncementPolicy.Observation(PEER_ID, RELAY_ADDRESS, LINK_ID),
+ DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL)
+ )
+ }
+
+ @Test
+ fun `relayed announce is not a direct routing observation`() {
+ assertNull(
+ DirectLinkAnnouncementPolicy.observationFor(
+ announce(ttl = (MAX_TTL - 1u).toUByte()),
+ MAX_TTL
+ )
+ )
+ }
+
+ @Test
+ fun `repeated announce remains the same observation without transport authentication state`() {
+ val routed = announce(ttl = MAX_TTL)
+
+ val first = DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL)
+ val second = DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL)
+
+ assertEquals(first, second)
+ }
+
+ private fun announce(ttl: UByte) = RoutedPacket(
+ packet = BitchatPacket(
+ version = 1u,
+ type = MessageType.ANNOUNCE.value,
+ senderID = PEER_ID.hexToBytes(),
+ timestamp = 1u,
+ payload = byteArrayOf(1),
+ ttl = ttl
+ ),
+ peerID = PEER_ID,
+ relayAddress = RELAY_ADDRESS,
+ ingressLinkID = LINK_ID
+ )
+
+ private fun String.hexToBytes(): ByteArray =
+ chunked(2).map { it.toInt(16).toByte() }.toByteArray()
+
+ private companion object {
+ const val PEER_ID = "0011223344556677"
+ const val RELAY_ADDRESS = "transport-neighbor"
+ const val LINK_ID = "current-link"
+ val MAX_TTL: UByte = 7u
+ }
+}
diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt
index 945155fd..79bc4c2d 100644
--- a/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt
+++ b/app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt
@@ -374,6 +374,77 @@ class MessageHandlerTest {
Unit
}
+ @Test
+ fun `repeated decrypt failures reset stale session and re-handshake`() = runBlocking {
+ whenever(delegate.decryptFromPeer(any(), eq(peerID))).thenReturn(null)
+ whenever(delegate.hasNoiseSession(peerID)).thenReturn(true)
+ val packet = encryptedPacket()
+
+ repeat(2) {
+ assertFalse(handler.handleNoiseEncrypted(RoutedPacket(packet, peerID, "direct-link")))
+ }
+ verify(delegate, never()).removeNoiseSession(any())
+ verify(delegate, never()).initiateNoiseHandshake(any())
+
+ assertFalse(handler.handleNoiseEncrypted(RoutedPacket(packet, peerID, "direct-link")))
+ verify(delegate).removeNoiseSession(peerID)
+ verify(delegate).initiateNoiseHandshake(peerID)
+ }
+
+ @Test
+ fun `successful decrypt resets the failure counter`() = runBlocking {
+ whenever(delegate.hasNoiseSession(peerID)).thenReturn(true)
+ val plaintext = NoisePayload(NoisePayloadType.DELIVERED, "id-1".toByteArray()).encode()
+ whenever(delegate.decryptFromPeer(any(), eq(peerID)))
+ .thenReturn(null)
+ .thenReturn(null)
+ .thenReturn(NoiseDecryptionResult(plaintext, authenticatedSession))
+ .thenReturn(null)
+ .thenReturn(null)
+ val packet = encryptedPacket()
+
+ repeat(5) {
+ handler.handleNoiseEncrypted(RoutedPacket(packet, peerID, "direct-link"))
+ }
+
+ verify(delegate, never()).removeNoiseSession(any())
+ verify(delegate, never()).initiateNoiseHandshake(any())
+ }
+
+ @Test
+ fun `decrypt failures without an established session never reset`() = runBlocking {
+ whenever(delegate.decryptFromPeer(any(), eq(peerID))).thenReturn(null)
+ whenever(delegate.hasNoiseSession(peerID)).thenReturn(false)
+ val packet = encryptedPacket()
+
+ repeat(4) {
+ assertFalse(handler.handleNoiseEncrypted(RoutedPacket(packet, peerID, "direct-link")))
+ }
+
+ verify(delegate, never()).removeNoiseSession(any())
+ verify(delegate, never()).initiateNoiseHandshake(any())
+ }
+
+ @Test
+ fun `successful decrypt reports the packet as valid`() = runBlocking {
+ val plaintext = NoisePayload(NoisePayloadType.DELIVERED, "id-2".toByteArray()).encode()
+ whenever(delegate.decryptFromPeer(any(), eq(peerID))).thenReturn(
+ NoiseDecryptionResult(plaintext, authenticatedSession)
+ )
+
+ assertTrue(handler.handleNoiseEncrypted(RoutedPacket(encryptedPacket(), peerID, "direct-link")))
+ }
+
+ private fun encryptedPacket(): BitchatPacket = BitchatPacket(
+ version = 1u,
+ type = MessageType.NOISE_ENCRYPTED.value,
+ senderID = peerID.hexToBytes(),
+ recipientID = myPeerID.hexToBytes(),
+ timestamp = System.currentTimeMillis().toULong(),
+ payload = byteArrayOf(0x41, 0x42, 0x43),
+ ttl = 7u
+ )
+
private fun announcePacket(
ageMs: Long,
ttl: UByte = (AppConstants.MESSAGE_TTL_HOPS.toInt() - 1).toUByte(),
diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt
index df0344cf..3a676382 100644
--- a/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt
+++ b/app/src/test/kotlin/com/bitchat/android/mesh/PacketProcessorAnnounceSideEffectTest.kt
@@ -116,7 +116,7 @@ class PacketProcessorAnnounceSideEffectTest {
handshakeHandled.complete(Unit)
return acceptHandshake
}
- override fun handleNoiseEncrypted(routed: RoutedPacket) = Unit
+ override fun handleNoiseEncrypted(routed: RoutedPacket) = true
override suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
handled.complete(Unit)
return acceptAnnounce
diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/PowerProfileResolverTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/PowerProfileResolverTest.kt
new file mode 100644
index 00000000..8d4df343
--- /dev/null
+++ b/app/src/test/kotlin/com/bitchat/android/mesh/PowerProfileResolverTest.kt
@@ -0,0 +1,102 @@
+package com.bitchat.android.mesh
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class PowerProfileResolverTest {
+
+ @Test
+ fun `background normal without peers scans one second per minute`() {
+ val profile = resolve(battery = 80, background = true, peers = false)
+
+ assertEquals(PowerManager.BatteryBand.NORMAL, profile.batteryBand)
+ assertEquals(1_000L, profile.ble.scanOnMs)
+ assertEquals(59_000L, profile.ble.scanOffMs)
+ assertFalse(profile.ble.continuousScan)
+ assertEquals(8, profile.ble.maxConnections)
+ assertEquals(60_000L, profile.meshAnnouncementIntervalMs)
+ }
+
+ @Test
+ fun `any direct transport peer accelerates background BLE discovery to thirty seconds`() {
+ val profile = resolve(battery = 80, background = true, peers = true)
+
+ assertEquals(1_000L, profile.ble.scanOnMs)
+ assertEquals(29_000L, profile.ble.scanOffMs)
+ }
+
+ @Test
+ fun `background low profile uses selected wifi and nostr cadence`() {
+ val profile = resolve(battery = 20, background = true)
+
+ assertEquals(PowerManager.BatteryBand.LOW, profile.batteryBand)
+ assertEquals(20_000L, profile.wifiAware.tcpKeepAliveMs)
+ assertEquals(120_000L, profile.wifiAware.discoveryKeepAliveMs)
+ assertEquals(120_000L, profile.wifiAware.connectionMaintenanceMs)
+ assertEquals(600_000L, profile.wifiAware.discoveryIdleRefreshMs)
+ assertEquals(900_000L, profile.wifiAware.discoveryStaleMs)
+ assertEquals(600_000L, profile.nostr.presenceHeartbeatMinMs)
+ assertEquals(600_000L, profile.nostr.subscriptionValidationMs)
+ assertEquals(120_000L, profile.meshAnnouncementIntervalMs)
+ }
+
+ @Test
+ fun `background critical profile uses slowest wifi and nostr cadence without reducing connections`() {
+ val profile = resolve(battery = 10, background = true, peers = true)
+
+ assertEquals(PowerManager.BatteryBand.CRITICAL, profile.batteryBand)
+ assertEquals(30_000L, profile.wifiAware.tcpKeepAliveMs)
+ assertEquals(180_000L, profile.wifiAware.discoveryKeepAliveMs)
+ assertEquals(180_000L, profile.wifiAware.connectionMaintenanceMs)
+ assertEquals(900_000L, profile.wifiAware.discoveryIdleRefreshMs)
+ assertEquals(1_200_000L, profile.wifiAware.discoveryStaleMs)
+ assertEquals(900_000L, profile.nostr.presenceHeartbeatMinMs)
+ assertEquals(900_000L, profile.nostr.subscriptionValidationMs)
+ assertEquals(300_000L, profile.meshAnnouncementIntervalMs)
+ assertEquals(8, profile.ble.maxConnections)
+ }
+
+ @Test
+ fun `foreground normal preserves balanced BLE cadence`() {
+ val profile = resolve(battery = 80, background = false)
+
+ assertEquals(PowerManager.PowerMode.BALANCED, profile.mode)
+ assertEquals(8_000L, profile.ble.scanOnMs)
+ assertEquals(2_000L, profile.ble.scanOffMs)
+ assertEquals(5_000L, profile.wifiAware.tcpKeepAliveMs)
+ assertEquals(30_000L, profile.meshAnnouncementIntervalMs)
+ }
+
+ @Test
+ fun `foreground charging is performance but background charging stays power limited`() {
+ val foreground = PowerProfileResolver.resolve(80, true, false, false)
+ val background = PowerProfileResolver.resolve(80, true, true, false)
+
+ assertEquals(PowerManager.PowerMode.PERFORMANCE, foreground.mode)
+ assertTrue(foreground.ble.continuousScan)
+ assertEquals(PowerManager.PowerMode.POWER_SAVER, background.mode)
+ assertFalse(background.ble.continuousScan)
+ assertEquals(59_000L, background.ble.scanOffMs)
+ }
+
+ @Test
+ fun `battery thresholds are inclusive`() {
+ assertEquals(PowerManager.BatteryBand.NORMAL, resolve(21, true).batteryBand)
+ assertEquals(PowerManager.BatteryBand.LOW, resolve(20, true).batteryBand)
+ assertEquals(PowerManager.BatteryBand.LOW, resolve(11, true).batteryBand)
+ assertEquals(PowerManager.BatteryBand.CRITICAL, resolve(10, true).batteryBand)
+ }
+
+ private fun resolve(
+ battery: Int,
+ background: Boolean,
+ peers: Boolean = false
+ ) = PowerProfileResolver.resolve(
+ batteryLevel = battery,
+ isCharging = false,
+ isBackground = background,
+ hasDirectPeers = peers
+ )
+}
diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/RetryingControlPacketSenderTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/RetryingControlPacketSenderTest.kt
new file mode 100644
index 00000000..7503b6bb
--- /dev/null
+++ b/app/src/test/kotlin/com/bitchat/android/mesh/RetryingControlPacketSenderTest.kt
@@ -0,0 +1,121 @@
+package com.bitchat.android.mesh
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class RetryingControlPacketSenderTest {
+ @Test
+ fun `control packet is sent for the full redundant retry window`() = runTest {
+ val attempts = mutableListOf()
+ val sender = RetryingControlPacketSender(
+ scope = this,
+ maxAttempts = 3,
+ retryDelayMs = 10,
+ interSendDelayMs = 1
+ )
+
+ sender.enqueue(
+ key = "peer:message",
+ sendAttempt = { attempt ->
+ attempts += attempt
+ true
+ }
+ )
+ advanceUntilIdle()
+
+ assertEquals(listOf(1, 2, 3), attempts)
+ }
+
+ @Test
+ fun `duplicate enqueue is coalesced while receipt retry is active`() = runTest {
+ var firstRequestAttempts = 0
+ var duplicateRequestAttempts = 0
+ val sender = RetryingControlPacketSender(
+ scope = this,
+ maxAttempts = 3,
+ retryDelayMs = 10,
+ interSendDelayMs = 1
+ )
+
+ sender.enqueue(
+ key = "peer:message",
+ sendAttempt = {
+ firstRequestAttempts += 1
+ true
+ }
+ )
+ sender.enqueue(
+ key = "peer:message",
+ sendAttempt = {
+ duplicateRequestAttempts += 1
+ true
+ }
+ )
+ advanceUntilIdle()
+
+ assertEquals(3, firstRequestAttempts)
+ assertEquals(0, duplicateRequestAttempts)
+ }
+
+ @Test
+ fun `transport writes for different receipts are serialized`() = runTest {
+ var activeWrites = 0
+ var maximumActiveWrites = 0
+ val sender = RetryingControlPacketSender(
+ scope = this,
+ maxAttempts = 1,
+ retryDelayMs = 0,
+ interSendDelayMs = 0
+ )
+
+ fun enqueue(key: String) {
+ sender.enqueue(
+ key = key,
+ sendAttempt = {
+ activeWrites += 1
+ maximumActiveWrites = maxOf(maximumActiveWrites, activeWrites)
+ delay(10)
+ activeWrites -= 1
+ true
+ }
+ )
+ }
+
+ enqueue("peer:first")
+ enqueue("peer:second")
+ advanceUntilIdle()
+
+ assertEquals(1, maximumActiveWrites)
+ }
+
+ @Test
+ fun `completion reports whether transport accepted any attempt`() = runTest {
+ val completions = mutableListOf()
+ val sender = RetryingControlPacketSender(
+ scope = this,
+ maxAttempts = 3,
+ retryDelayMs = 1,
+ interSendDelayMs = 0
+ )
+
+ sender.enqueue(
+ key = "peer:rejected",
+ sendAttempt = { false },
+ onComplete = completions::add
+ )
+ sender.enqueue(
+ key = "peer:eventually-accepted",
+ sendAttempt = { attempt -> attempt == 2 },
+ onComplete = completions::add
+ )
+ advanceUntilIdle()
+
+ assertEquals(2, completions.size)
+ assertEquals(setOf(false, true), completions.toSet())
+ }
+}
diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt
index 17156e00..2a56e708 100644
--- a/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt
+++ b/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt
@@ -542,6 +542,28 @@ class SecurityManagerTest {
)
}
+ @Test
+ fun `handshake dedup entries expire by time and allow delayed retry`() = runBlocking {
+ val payload = byteArrayOf(0x71, 0x72, 0x73)
+ val routed = handshakePacket(payload)
+
+ assertTrue(securityManager.handleNoiseHandshake(routed))
+ assertTrue(fakeEncryptionService.handshakeCalls == 1)
+
+ assertFalse("Identical frame inside the dedup window must be dropped",
+ securityManager.handleNoiseHandshake(routed))
+ assertTrue(fakeEncryptionService.handshakeCalls == 1)
+
+ securityManager.cleanupOldData(
+ System.currentTimeMillis() +
+ com.bitchat.android.util.AppConstants.Security.KEY_EXCHANGE_DEDUP_TIMEOUT_MS + 1_000
+ )
+
+ assertTrue("Expired dedup entries must not block a delayed retry",
+ securityManager.handleNoiseHandshake(routed))
+ assertTrue(fakeEncryptionService.handshakeCalls == 2)
+ }
+
private fun setupKnownPeer(peerID: String, signingKey: ByteArray) {
val info = PeerInfo(
id = peerID,
diff --git a/app/src/test/kotlin/com/bitchat/android/noise/NoiseExternalVectorTest.kt b/app/src/test/kotlin/com/bitchat/android/noise/NoiseExternalVectorTest.kt
new file mode 100644
index 00000000..70badb96
--- /dev/null
+++ b/app/src/test/kotlin/com/bitchat/android/noise/NoiseExternalVectorTest.kt
@@ -0,0 +1,261 @@
+package com.bitchat.android.noise
+
+import com.bitchat.android.noise.southernstorm.protocol.CipherState
+import com.bitchat.android.noise.southernstorm.protocol.HandshakeState
+import com.bitchat.android.noise.southernstorm.protocol.Noise
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertThrows
+import org.junit.Test
+
+/**
+ * Cacophony/Noise-C vector for Noise_XX_25519_ChaChaPoly_SHA256.
+ *
+ * This exercises the vendored Noise state machine directly, independent of managers, Android
+ * storage, and generated keys.
+ */
+class NoiseExternalVectorTest {
+ private val messages = listOf(
+ VectorMessage(
+ "4c756477696720766f6e204d69736573",
+ "ca35def5ae56cec33dc2036731ab14896bc4c75dbb07a61f879f8e3afa4c7944" +
+ "4c756477696720766f6e204d69736573"
+ ),
+ VectorMessage(
+ "4d757272617920526f746862617264",
+ "95ebc60d2b1fa672c1f46a8aa265ef51bfe38e7ccb39ec5be34069f144808843" +
+ "81cbad1f276e038c48378ffce2b65285e08d6b68aaa3629a5a8639392490e5b9" +
+ "bd5269c2f1e4f488ed8831161f19b7815528f8982ffe09be9b5c412f8a0db50f" +
+ "8814c7194e83f23dbd8d162c9326ad"
+ ),
+ VectorMessage(
+ "462e20412e20486179656b",
+ "c7195ffacac1307ff99046f219750fc47693e23c3cb08b89c2af808b444850a8" +
+ "0ae475b9df0f169ae80a89be0865b57f58c9fea0d4ec82a286427402f113e4b6" +
+ "ae769a1d95941d49b25030"
+ ),
+ VectorMessage(
+ "4361726c204d656e676572",
+ "96763ed773f8e47bb3712f0e29b3060ffc956ffc146cee53d5e1df"
+ ),
+ VectorMessage(
+ "4a65616e2d426170746973746520536179",
+ "3e40f15f6f3a46ae446b253bf8b1d9ffb6ed9b174d272328ff91a7e2e5c79c07f5"
+ ),
+ VectorMessage(
+ "457567656e2042f6686d20766f6e2042617765726b",
+ "eb3f3515110702e047a6c9da4478b6ead94873c11c0f2d710ddb3f09fce024b3" +
+ "a58502ae3f"
+ )
+ )
+
+ @Test
+ fun `Noise-C XX transcript matches every handshake and transport byte`() {
+ val initiator = vectorState(HandshakeState.INITIATOR)
+ val responder = vectorState(HandshakeState.RESPONDER)
+ try {
+ val states = listOf(
+ initiator to responder,
+ responder to initiator,
+ initiator to responder
+ )
+ messages.take(3).zip(states).forEach { (message, peers) ->
+ assertHandshakeMessage(peers.first, peers.second, message)
+ }
+
+ assertEquals(HandshakeState.SPLIT, initiator.action)
+ assertEquals(HandshakeState.SPLIT, responder.action)
+ assertArrayEquals(initiator.handshakeHash, responder.handshakeHash)
+
+ val initiatorCiphers = initiator.split()
+ val responderCiphers = responder.split()
+ assertTransportMessage(
+ responderCiphers.sender,
+ initiatorCiphers.receiver,
+ messages[3]
+ )
+ assertTransportMessage(
+ initiatorCiphers.sender,
+ responderCiphers.receiver,
+ messages[4]
+ )
+ assertTransportMessage(
+ responderCiphers.sender,
+ initiatorCiphers.receiver,
+ messages[5]
+ )
+ initiatorCiphers.sender.destroy()
+ initiatorCiphers.receiver.destroy()
+ responderCiphers.sender.destroy()
+ responderCiphers.receiver.destroy()
+ } finally {
+ initiator.destroy()
+ responder.destroy()
+ }
+ }
+
+ @Test
+ fun `Noise state machine rejects invalid actions and a tampered handshake tag`() {
+ val initiator = vectorState(HandshakeState.INITIATOR)
+ val responder = vectorState(HandshakeState.RESPONDER)
+ try {
+ assertThrows(IllegalStateException::class.java) { initiator.start() }
+ assertThrows(IllegalStateException::class.java) {
+ responder.writeMessage(ByteArray(256), 0, null, 0, 0)
+ }
+
+ assertHandshakeMessage(initiator, responder, messages[0])
+ val message2 = write(responder, messages[1].payload)
+ val tampered = message2.copyOf()
+ tampered[tampered.lastIndex] = (tampered.last().toInt() xor 1).toByte()
+
+ assertThrows(Exception::class.java) {
+ initiator.readMessage(tampered, 0, tampered.size, ByteArray(256), 0)
+ }
+ assertEquals(HandshakeState.FAILED, initiator.action)
+ } finally {
+ initiator.destroy()
+ responder.destroy()
+ }
+ }
+
+ @Test
+ fun `ChaChaPoly authentication binds nonce ciphertext tag and associated data`() {
+ val key = ByteArray(32) { it.toByte() }
+ val plaintext = "associated".toByteArray()
+ val associatedData = "header".toByteArray()
+ val sender = Noise.createCipher("ChaChaPoly")
+ val receiver = Noise.createCipher("ChaChaPoly")
+ val wrongAdReceiver = Noise.createCipher("ChaChaPoly")
+ try {
+ sender.initializeKey(key, 0)
+ receiver.initializeKey(key, 0)
+ wrongAdReceiver.initializeKey(key, 0)
+ sender.setNonce(7)
+ receiver.setNonce(7)
+ wrongAdReceiver.setNonce(7)
+ val ciphertext = ByteArray(plaintext.size + sender.macLength)
+ val length = sender.encryptWithAd(
+ associatedData,
+ plaintext,
+ 0,
+ ciphertext,
+ 0,
+ plaintext.size
+ )
+
+ assertThrows(Exception::class.java) {
+ wrongAdReceiver.decryptWithAd(
+ "wrong".toByteArray(),
+ ciphertext,
+ 0,
+ ByteArray(length),
+ 0,
+ length
+ )
+ }
+ val output = ByteArray(length)
+ val outputLength = receiver.decryptWithAd(
+ associatedData,
+ ciphertext,
+ 0,
+ output,
+ 0,
+ length
+ )
+ assertArrayEquals(plaintext, output.copyOf(outputLength))
+ } finally {
+ sender.destroy()
+ receiver.destroy()
+ wrongAdReceiver.destroy()
+ }
+ }
+
+ private fun vectorState(role: Int): HandshakeState {
+ val state = HandshakeState(PROTOCOL, role)
+ val prologue = hex("4a6f686e2047616c74")
+ state.setPrologue(prologue, 0, prologue.size)
+ val staticPrivate = if (role == HandshakeState.INITIATOR) {
+ hex("e61ef9919cde45dd5f82166404bd08e38bceb5dfdfded0a34c8df7ed542214d1")
+ } else {
+ hex("4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893")
+ }
+ val ephemeralPrivate = if (role == HandshakeState.INITIATOR) {
+ hex("893e28b9dc6ca8d611ab664754b8ceb7bac5117349a4439a6b0569da977c464a")
+ } else {
+ hex("bbdb4cdbd309f1a1f2e1456967fe288cadd6f712d65dc7b7793d5e63da6b375b")
+ }
+ state.localKeyPair.setPrivateKey(staticPrivate, 0)
+ state.fixedEphemeralKey.setPrivateKey(ephemeralPrivate, 0)
+ state.start()
+ return state
+ }
+
+ private fun assertHandshakeMessage(
+ writer: HandshakeState,
+ reader: HandshakeState,
+ message: VectorMessage
+ ) {
+ val actualCiphertext = write(writer, message.payload)
+ assertArrayEquals(message.ciphertext, actualCiphertext)
+
+ val plaintext = ByteArray(256)
+ val length = reader.readMessage(
+ actualCiphertext,
+ 0,
+ actualCiphertext.size,
+ plaintext,
+ 0
+ )
+ assertArrayEquals(message.payload, plaintext.copyOf(length))
+ }
+
+ private fun write(state: HandshakeState, payload: ByteArray): ByteArray {
+ val output = ByteArray(512)
+ val length = state.writeMessage(output, 0, payload, 0, payload.size)
+ return output.copyOf(length)
+ }
+
+ private fun assertTransportMessage(
+ sender: CipherState,
+ receiver: CipherState,
+ message: VectorMessage
+ ) {
+ val encrypted = ByteArray(message.payload.size + sender.macLength)
+ val encryptedLength = sender.encryptWithAd(
+ null,
+ message.payload,
+ 0,
+ encrypted,
+ 0,
+ message.payload.size
+ )
+ assertArrayEquals(message.ciphertext, encrypted.copyOf(encryptedLength))
+
+ val decrypted = ByteArray(encryptedLength)
+ val decryptedLength = receiver.decryptWithAd(
+ null,
+ encrypted,
+ 0,
+ decrypted,
+ 0,
+ encryptedLength
+ )
+ assertArrayEquals(message.payload, decrypted.copyOf(decryptedLength))
+ }
+
+ private data class VectorMessage(
+ private val payloadHex: String,
+ private val ciphertextHex: String
+ ) {
+ val payload: ByteArray get() = hex(payloadHex)
+ val ciphertext: ByteArray get() = hex(ciphertextHex)
+ }
+
+ companion object {
+ private const val PROTOCOL = "Noise_XX_25519_ChaChaPoly_SHA256"
+
+ private fun hex(value: String): ByteArray =
+ value.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
+ }
+}
diff --git a/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerHandshakeTimeoutTest.kt b/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerHandshakeTimeoutTest.kt
new file mode 100644
index 00000000..f180ea8e
--- /dev/null
+++ b/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerHandshakeTimeoutTest.kt
@@ -0,0 +1,153 @@
+package com.bitchat.android.noise
+
+import com.bitchat.android.noise.southernstorm.protocol.Noise
+import org.junit.After
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.util.concurrent.atomic.AtomicReference
+
+class NoiseSessionManagerHandshakeTimeoutTest {
+ private data class TestIdentity(
+ val privateKey: ByteArray,
+ val publicKey: ByteArray,
+ val peerID: String
+ )
+
+ private val managers = mutableListOf()
+
+ @After
+ fun tearDown() {
+ managers.forEach(NoiseSessionManager::shutdown)
+ }
+
+ @Test
+ fun `stale initiator handshake is expired and reported as timeout`() {
+ val alice = identity()
+ val bob = identity()
+ val aliceManager = manager(alice)
+ val failure = AtomicReference()
+ aliceManager.onSessionFailed = { peerID, error ->
+ if (peerID == bob.peerID) failure.set(error)
+ }
+
+ assertNotNull(aliceManager.initiateHandshake(bob.peerID))
+ assertTrue(aliceManager.getSession(bob.peerID)!!.isHandshaking())
+
+ aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 11_000)
+
+ assertNull(aliceManager.getSession(bob.peerID))
+ assertTrue(failure.get() is NoiseSessionError.HandshakeTimeout)
+ }
+
+ @Test
+ fun `fresh handshake is not expired`() {
+ val alice = identity()
+ val bob = identity()
+ val aliceManager = manager(alice)
+
+ assertNotNull(aliceManager.initiateHandshake(bob.peerID))
+
+ aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 9_000)
+
+ assertTrue(aliceManager.getSession(bob.peerID)!!.isHandshaking())
+ }
+
+ @Test
+ fun `expired handshake can be re-initiated immediately`() {
+ val alice = identity()
+ val bob = identity()
+ val aliceManager = manager(alice)
+
+ assertNotNull(aliceManager.initiateHandshake(bob.peerID))
+ aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 11_000)
+
+ assertNotNull("Handshake must restart after expiry", aliceManager.initiateHandshake(bob.peerID))
+ assertTrue(aliceManager.getSession(bob.peerID)!!.isHandshaking())
+ }
+
+ @Test
+ fun `established session is never expired by the sweep`() {
+ val alice = identity()
+ val bob = identity()
+ val aliceManager = manager(alice)
+ val bobManager = manager(bob)
+ completeHandshake(aliceManager, alice.peerID, bobManager, bob.peerID)
+ val aliceSession = aliceManager.getSession(bob.peerID)
+ val bobSession = bobManager.getSession(alice.peerID)
+
+ aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 60_000)
+ bobManager.cleanupStaleHandshakes(System.currentTimeMillis() + 60_000)
+
+ assertSame(aliceSession, aliceManager.getSession(bob.peerID))
+ assertSame(bobSession, bobManager.getSession(alice.peerID))
+ assertTrue(aliceManager.hasEstablishedSession(bob.peerID))
+ assertTrue(bobManager.hasEstablishedSession(alice.peerID))
+
+ val plaintext = "still alive".toByteArray()
+ val ciphertext = aliceManager.encrypt(plaintext, bob.peerID)
+ assertArrayEquals(plaintext, bobManager.decrypt(ciphertext, alice.peerID))
+ }
+
+ @Test
+ fun `stale responder candidate expires while established session survives`() {
+ val alice = identity()
+ val bob = identity()
+ val aliceManager = manager(alice)
+ val bobManager = manager(bob)
+ completeHandshake(aliceManager, alice.peerID, bobManager, bob.peerID)
+ val established = aliceManager.getSession(bob.peerID)
+
+ // Bob comes back and starts a replacement handshake: alice keeps the established
+ // session and parks the new handshake as a responder candidate.
+ val replacementManager = manager(bob)
+ val message1 = replacementManager.initiateHandshake(alice.peerID)!!
+ assertNotNull(aliceManager.processHandshakeMessage(bob.peerID, message1))
+ assertSame(established, aliceManager.getSession(bob.peerID))
+
+ // Bob never finishes (message 2 lost): the candidate must expire without touching
+ // the working session.
+ aliceManager.cleanupStaleHandshakes(System.currentTimeMillis() + 11_000)
+
+ assertSame(established, aliceManager.getSession(bob.peerID))
+ assertTrue(aliceManager.hasEstablishedSession(bob.peerID))
+ val plaintext = "unharmed".toByteArray()
+ val ciphertext = aliceManager.encrypt(plaintext, bob.peerID)
+ assertArrayEquals(plaintext, bobManager.decrypt(ciphertext, alice.peerID))
+ }
+
+ private fun completeHandshake(
+ initiator: NoiseSessionManager,
+ initiatorPeerID: String,
+ responder: NoiseSessionManager,
+ responderPeerID: String
+ ) {
+ val message1 = initiator.initiateHandshake(responderPeerID)!!
+ val message2 = responder.processHandshakeMessage(initiatorPeerID, message1)!!
+ val message3 = initiator.processHandshakeMessage(responderPeerID, message2)!!
+ assertNull(responder.processHandshakeMessage(initiatorPeerID, message3))
+ }
+
+ private fun manager(identity: TestIdentity): NoiseSessionManager = NoiseSessionManager(
+ localStaticPrivateKey = identity.privateKey,
+ localStaticPublicKey = identity.publicKey,
+ localPeerID = identity.peerID
+ ).also { managers += it }
+
+ private fun identity(): TestIdentity {
+ val dh = Noise.createDH("25519")
+ return try {
+ dh.generateKeyPair()
+ val privateKey = ByteArray(32)
+ val publicKey = ByteArray(32)
+ dh.getPrivateKey(privateKey, 0)
+ dh.getPublicKey(publicKey, 0)
+ TestIdentity(privateKey, publicKey, NoisePeerIdentity.derivePeerID(publicKey)!!)
+ } finally {
+ dh.destroy()
+ }
+ }
+}
diff --git a/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerIdentityBindingTest.kt b/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerIdentityBindingTest.kt
index 63f5e3c1..1e18fd6a 100644
--- a/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerIdentityBindingTest.kt
+++ b/app/src/test/kotlin/com/bitchat/android/noise/NoiseSessionManagerIdentityBindingTest.kt
@@ -330,6 +330,51 @@ class NoiseSessionManagerIdentityBindingTest {
assertArrayEquals(plaintext, bobManager.decrypt(ciphertext, alice.peerID))
}
+ @Test
+ fun `simultaneous handshake collision matrix has one deterministic winner`() {
+ val identities = listOf(
+ identity("e61ef9919cde45dd5f82166404bd08e38bceb5dfdfded0a34c8df7ed542214d1"),
+ identity("4a3acbfdb163dec651dfa3194dece676d437029c62a408b4c5ea9114246e4893"),
+ identity("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a"),
+ identity("5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb")
+ )
+
+ identities.indices.forEach { leftIndex ->
+ ((leftIndex + 1) until identities.size).forEach { rightIndex ->
+ val left = identities[leftIndex]
+ val right = identities[rightIndex]
+ val leftManager = manager(left)
+ val rightManager = manager(right)
+ val leftMessage1 = leftManager.initiateHandshake(right.peerID)!!
+ val rightMessage1 = rightManager.initiateHandshake(left.peerID)!!
+
+ val leftResponse = leftManager.processHandshakeMessage(right.peerID, rightMessage1)
+ val rightResponse = rightManager.processHandshakeMessage(left.peerID, leftMessage1)
+
+ if (left.peerID < right.peerID) {
+ assertNull(leftResponse)
+ val message3 = leftManager.processHandshakeMessage(right.peerID, rightResponse!!)!!
+ assertNull(rightManager.processHandshakeMessage(left.peerID, message3))
+ } else {
+ assertNull(rightResponse)
+ val message3 = rightManager.processHandshakeMessage(left.peerID, leftResponse!!)!!
+ assertNull(leftManager.processHandshakeMessage(right.peerID, message3))
+ }
+
+ assertTrue(leftManager.hasEstablishedSession(right.peerID))
+ assertTrue(rightManager.hasEstablishedSession(left.peerID))
+ val payload = "matrix-$leftIndex-$rightIndex".toByteArray()
+ assertArrayEquals(
+ payload,
+ rightManager.decrypt(
+ leftManager.encrypt(payload, right.peerID),
+ left.peerID
+ )
+ )
+ }
+ }
+ }
+
@Test
fun `peer ID derivation rejects malformed keys and non-wire claims`() {
val peer = identity()
@@ -381,4 +426,19 @@ class NoiseSessionManagerIdentityBindingTest {
dh.destroy()
}
}
+
+ private fun identity(privateKeyHex: String): TestIdentity {
+ val privateKey = privateKeyHex.chunked(2)
+ .map { it.toInt(16).toByte() }
+ .toByteArray()
+ val dh = Noise.createDH("25519")
+ return try {
+ dh.setPrivateKey(privateKey, 0)
+ val publicKey = ByteArray(32)
+ dh.getPublicKey(publicKey, 0)
+ TestIdentity(privateKey, publicKey, NoisePeerIdentity.derivePeerID(publicKey)!!)
+ } finally {
+ dh.destroy()
+ }
+ }
}
diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrBackgroundEventProcessorTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrBackgroundEventProcessorTest.kt
new file mode 100644
index 00000000..d7fffc2c
--- /dev/null
+++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrBackgroundEventProcessorTest.kt
@@ -0,0 +1,65 @@
+package com.bitchat.android.nostr
+
+import android.app.Application
+import androidx.test.core.app.ApplicationProvider
+import com.bitchat.android.services.AppStateStore
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class NostrBackgroundEventProcessorTest {
+ private lateinit var scope: CoroutineScope
+
+ @Before
+ fun setUp() {
+ AppStateStore.clear()
+ scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ }
+
+ @After
+ fun tearDown() {
+ scope.cancel()
+ AppStateStore.clear()
+ }
+
+ @Test
+ fun `cold start processes more events than the removed handoff queue capacity`() = runBlocking {
+ val application = ApplicationProvider.getApplicationContext()
+ val processor = NostrBackgroundEventProcessor(application, scope)
+
+ repeat(300) { index ->
+ processor.onGeohashMessage(
+ event = NostrEvent(
+ id = "cold-start-$index",
+ pubkey = index.toString(16).padStart(64, '0'),
+ createdAt = 1,
+ kind = NostrKind.EPHEMERAL_EVENT,
+ tags = listOf(listOf("g", "u4pruy")),
+ content = "message-$index"
+ ),
+ geohash = "u4pruy"
+ )
+ }
+
+ withTimeout(5_000) {
+ while (AppStateStore.channelMessages.value["geo:u4pruy"].orEmpty().size < 300) {
+ kotlinx.coroutines.yield()
+ }
+ }
+
+ assertEquals(
+ (0 until 300).map { "cold-start-$it" }.toSet(),
+ AppStateStore.channelMessages.value["geo:u4pruy"].orEmpty().map { it.id }.toSet()
+ )
+ }
+}
diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt
new file mode 100644
index 00000000..f756447a
--- /dev/null
+++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt
@@ -0,0 +1,178 @@
+package com.bitchat.android.nostr
+
+import android.os.Build
+import com.bitchat.android.services.AppStateStore
+import com.bitchat.android.services.SeenMessageStore
+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 com.google.gson.Gson
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import kotlinx.coroutines.withTimeout
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE)
+@OptIn(ExperimentalCoroutinesApi::class)
+class NostrDirectMessageHandlerTest {
+ private val gson = Gson()
+ private lateinit var scope: CoroutineScope
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(UnconfinedTestDispatcher())
+ scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
+ AppStateStore.clear()
+ }
+
+ @After
+ fun tearDown() {
+ AppStateStore.clear()
+ scope.cancel()
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun `private messages use authenticated rumor time instead of randomized gift wrap time`() {
+ val application = RuntimeEnvironment.getApplication()
+ val state = ChatState(scope).apply { setNickname("recipient") }
+ val dataManager = DataManager(application)
+ val messageManager = MessageManager(state)
+ val privateChatManager = PrivateChatManager(
+ state = state,
+ messageManager = messageManager,
+ dataManager = dataManager,
+ noiseSessionDelegate = mock()
+ )
+ val seenStore = mock()
+ whenever(seenStore.hasDelivered(any())).thenReturn(true)
+ whenever(seenStore.hasBeenReadLocally(any())).thenReturn(false)
+ val handler = NostrDirectMessageHandler(
+ application = application,
+ state = state,
+ privateChatManager = privateChatManager,
+ updateDeliveryStatus = { _, _ -> },
+ scope = scope,
+ repo = GeohashRepository(application, state, dataManager),
+ dataManager = dataManager,
+ seenStoreProvider = { seenStore }
+ )
+ val sender = NostrIdentity.generate()
+ val recipient = NostrIdentity.generate()
+ val now = (System.currentTimeMillis() / 1000).toInt()
+ val firstRumorTime = now - 120
+ val secondRumorTime = now - 60
+ val firstId = "first-real-time"
+ val secondId = "second-real-time"
+
+ val first = privateMessageGiftWrap(
+ content = requireNotNull(
+ NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
+ content = "first",
+ messageID = firstId,
+ senderPeerID = "0011223344556677"
+ )
+ ),
+ sender = sender,
+ recipient = recipient,
+ rumorCreatedAt = firstRumorTime,
+ giftWrapCreatedAt = now - 5
+ )
+ val second = privateMessageGiftWrap(
+ content = requireNotNull(
+ NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
+ content = "second",
+ messageID = secondId,
+ senderPeerID = "0011223344556677"
+ )
+ ),
+ sender = sender,
+ recipient = recipient,
+ rumorCreatedAt = secondRumorTime,
+ giftWrapCreatedAt = now - 86_400
+ )
+
+ handler.onGiftWrap(first, "", recipient)
+ waitForMessage(state, firstId)
+ handler.onGiftWrap(second, "", recipient)
+ waitForMessage(state, secondId)
+
+ val messages = state.getPrivateChatsValue().values.single()
+ assertEquals(listOf(firstId, secondId), messages.map { it.id })
+ assertEquals(firstRumorTime * 1000L, messages[0].timestamp.time)
+ assertEquals(secondRumorTime * 1000L, messages[1].timestamp.time)
+ }
+
+ private fun waitForMessage(state: ChatState, messageId: String) {
+ kotlinx.coroutines.runBlocking {
+ withTimeout(5_000) {
+ while (state.getPrivateChatsValue().values.flatten().none { it.id == messageId }) {
+ delay(10)
+ }
+ }
+ }
+ }
+
+ private fun privateMessageGiftWrap(
+ content: String,
+ sender: NostrIdentity,
+ recipient: NostrIdentity,
+ rumorCreatedAt: Int,
+ giftWrapCreatedAt: Int
+ ): NostrEvent {
+ val rumorBase = NostrEvent(
+ pubkey = sender.publicKeyHex,
+ createdAt = rumorCreatedAt,
+ kind = NostrKind.DIRECT_MESSAGE,
+ tags = listOf(listOf("p", recipient.publicKeyHex)),
+ content = content
+ )
+ val rumor = rumorBase.copy(id = rumorBase.computeEventIdHex())
+ val sealContent = NostrCrypto.encryptNIP44(
+ plaintext = gson.toJson(rumor),
+ recipientPublicKeyHex = recipient.publicKeyHex,
+ senderPrivateKeyHex = sender.privateKeyHex
+ )
+ val seal = NostrEvent(
+ pubkey = sender.publicKeyHex,
+ createdAt = giftWrapCreatedAt,
+ kind = NostrKind.SEAL,
+ tags = emptyList(),
+ content = sealContent
+ ).sign(sender.privateKeyHex)
+
+ val (wrapPrivateKey, wrapPublicKey) = NostrCrypto.generateKeyPair()
+ val giftWrapContent = NostrCrypto.encryptNIP44(
+ plaintext = gson.toJson(seal),
+ recipientPublicKeyHex = recipient.publicKeyHex,
+ senderPrivateKeyHex = wrapPrivateKey
+ )
+ return NostrEvent(
+ pubkey = wrapPublicKey,
+ createdAt = giftWrapCreatedAt,
+ kind = NostrKind.GIFT_WRAP,
+ tags = listOf(listOf("p", recipient.publicKeyHex)),
+ content = giftWrapContent
+ ).sign(wrapPrivateKey)
+ }
+}
diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrPendingEventQueueTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrPendingEventQueueTest.kt
new file mode 100644
index 00000000..8954cbe3
--- /dev/null
+++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrPendingEventQueueTest.kt
@@ -0,0 +1,81 @@
+package com.bitchat.android.nostr
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class NostrPendingEventQueueTest {
+ @Test
+ fun `empty relay set is not queued`() {
+ val queue = NostrPendingEventQueue(capacity = 2)
+
+ assertNull(queue.enqueue(event("empty"), emptyList(), liveLocationToken = null))
+ assertEquals(0, queue.size())
+ }
+
+ @Test
+ fun `capacity evicts the oldest publish`() {
+ val queue = NostrPendingEventQueue(capacity = 2)
+ queue.enqueue(event("one"), listOf("relay"), liveLocationToken = null)
+ queue.enqueue(event("two"), listOf("relay"), liveLocationToken = null)
+ queue.enqueue(event("three"), listOf("relay"), liveLocationToken = null)
+
+ assertEquals(
+ listOf("two", "three"),
+ queue.pendingForRelay("relay").map { it.event.content }
+ )
+ }
+
+ @Test
+ fun `duplicate event publishes retain independent delivery state`() {
+ val queue = NostrPendingEventQueue(capacity = 4)
+ val signedEvent = event("same")
+ val firstId = requireNotNull(
+ queue.enqueue(signedEvent, listOf("relay-a", "relay-b"), liveLocationToken = null)
+ )
+ val secondId = requireNotNull(
+ queue.enqueue(signedEvent, listOf("relay-a"), liveLocationToken = null)
+ )
+ assertNotEquals(firstId, secondId)
+
+ queue.markDelivered(firstId, "relay-a")
+
+ assertEquals(
+ listOf(secondId),
+ queue.pendingForRelay("relay-a").map { it.queueId }
+ )
+ assertEquals(
+ listOf(firstId),
+ queue.pendingForRelay("relay-b").map { it.queueId }
+ )
+
+ queue.markDelivered(firstId, "relay-b")
+ assertEquals(1, queue.size())
+ }
+
+ @Test
+ fun `privacy purge retains non-live publishes`() {
+ val queue = NostrPendingEventQueue(capacity = 4)
+ queue.enqueue(event("manual"), listOf("relay"), liveLocationToken = null)
+ queue.enqueue(event("live"), listOf("relay"), liveLocationToken = 42L)
+
+ queue.removeLiveLocationEvents()
+
+ assertEquals(
+ listOf("manual"),
+ queue.pendingForRelay("relay").map { it.event.content }
+ )
+ }
+
+ private fun event(content: String): NostrEvent {
+ val privateKey = "0".repeat(63) + "1"
+ return NostrEvent(
+ pubkey = NostrCrypto.derivePublicKey(privateKey),
+ createdAt = 1,
+ kind = NostrKind.TEXT_NOTE,
+ tags = emptyList(),
+ content = content
+ ).sign(privateKey)
+ }
+}
diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayManagerLifecycleSmokeTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayManagerLifecycleSmokeTest.kt
new file mode 100644
index 00000000..de53a579
--- /dev/null
+++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelayManagerLifecycleSmokeTest.kt
@@ -0,0 +1,79 @@
+package com.bitchat.android.nostr
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class NostrRelayManagerLifecycleSmokeTest {
+ @Test
+ fun `owner teardown is synchronous and preserves other subscription owners`() {
+ val manager = NostrRelayManager.shared
+ manager.disconnect()
+ manager.clearAllSubscriptions()
+ val filter = NostrFilter(kinds = listOf(NostrKind.TEXT_NOTE))
+
+ manager.subscribe(
+ filter = filter,
+ id = "background-contract",
+ handler = {},
+ targetRelayUrls = emptyList(),
+ owner = NostrRelayManager.OWNER_BACKGROUND
+ )
+ manager.subscribe(
+ filter = filter,
+ id = "ui-contract",
+ handler = {},
+ targetRelayUrls = emptyList(),
+ owner = "test-ui"
+ )
+
+ manager.unsubscribeOwner("test-ui")
+
+ assertEquals(setOf("background-contract"), manager.getActiveSubscriptions().keys)
+ manager.clearAllSubscriptions()
+ }
+
+ @Test
+ fun `disconnected manager maintains subscription and empty publish invariants locally`() {
+ val manager = NostrRelayManager.shared
+ manager.disconnect()
+ manager.clearAllSubscriptions()
+
+ val id = manager.subscribe(
+ filter = NostrFilter(kinds = listOf(NostrKind.TEXT_NOTE)),
+ id = "local-contract",
+ handler = {},
+ targetRelayUrls = emptyList()
+ )
+
+ assertEquals("local-contract", id)
+ assertEquals(1, manager.getActiveSubscriptionCount())
+ assertTrue(manager.getActiveSubscriptions().containsKey(id))
+ assertTrue(manager.validateSubscriptionConsistency().isConsistent)
+ manager.sendEvent(signedEvent(), relayUrls = emptyList())
+ manager.retryConnection("wss://not-configured.example")
+
+ manager.unsubscribe(id)
+ assertEquals(0, manager.getActiveSubscriptionCount())
+ assertFalse(manager.isConnected.value)
+ assertTrue(manager.getRelayStatuses().none { it.isConnected })
+
+ manager.disconnect()
+ assertFalse(manager.isConnected.value)
+ }
+
+ private fun signedEvent(): NostrEvent {
+ val privateKey = "0".repeat(63) + "1"
+ return NostrEvent(
+ pubkey = NostrCrypto.derivePublicKey(privateKey),
+ createdAt = 1,
+ kind = NostrKind.TEXT_NOTE,
+ tags = emptyList(),
+ content = "local"
+ ).sign(privateKey)
+ }
+}
diff --git a/app/src/test/kotlin/com/bitchat/android/onboarding/SystemStateManagerContractTest.kt b/app/src/test/kotlin/com/bitchat/android/onboarding/SystemStateManagerContractTest.kt
new file mode 100644
index 00000000..a3f0341f
--- /dev/null
+++ b/app/src/test/kotlin/com/bitchat/android/onboarding/SystemStateManagerContractTest.kt
@@ -0,0 +1,87 @@
+package com.bitchat.android.onboarding
+
+import android.app.Application
+import android.bluetooth.BluetoothManager
+import android.content.Context
+import android.location.LocationManager
+import androidx.activity.ComponentActivity
+import androidx.test.core.app.ApplicationProvider
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [35], application = Application::class)
+class SystemStateManagerContractTest {
+ @Test
+ fun `Bluetooth disabled and enabled states are observable without throwing`() {
+ val app = ApplicationProvider.getApplicationContext()
+ val controller = Robolectric.buildActivity(ComponentActivity::class.java).create()
+ val adapter = app.getSystemService(BluetoothManager::class.java).adapter
+ val manager = BluetoothStatusManager(
+ activity = controller.get(),
+ context = app,
+ onBluetoothEnabled = {},
+ onBluetoothDisabled = {}
+ )
+
+ shadowOf(adapter).setEnabled(false)
+ assertEquals(BluetoothStatus.DISABLED, manager.checkBluetoothStatus())
+ shadowOf(adapter).setEnabled(true)
+ assertEquals(BluetoothStatus.ENABLED, manager.checkBluetoothStatus())
+ controller.destroy()
+ }
+
+ @Test
+ fun `location disabled and enabled states are observable and receiver is cleaned up`() {
+ val app = ApplicationProvider.getApplicationContext()
+ val controller = Robolectric.buildActivity(ComponentActivity::class.java).create()
+ val locationManager = app.getSystemService(Context.LOCATION_SERVICE) as LocationManager
+ val manager = LocationStatusManager(
+ activity = controller.get(),
+ context = app,
+ onLocationEnabled = {},
+ onLocationDisabled = {}
+ )
+
+ shadowOf(locationManager).setLocationEnabled(false)
+ assertEquals(LocationStatus.DISABLED, manager.checkLocationStatus())
+ shadowOf(locationManager).setLocationEnabled(true)
+ assertEquals(LocationStatus.ENABLED, manager.checkLocationStatus())
+
+ manager.cleanup()
+ manager.cleanup()
+ controller.destroy()
+ }
+
+ @Test
+ fun `location status routing and recovery messages remain exact`() {
+ val app = ApplicationProvider.getApplicationContext()
+ val controller = Robolectric.buildActivity(ComponentActivity::class.java).create()
+ var enabled = 0
+ val disabled = mutableListOf()
+ val manager = LocationStatusManager(
+ activity = controller.get(),
+ context = app,
+ onLocationEnabled = { enabled++ },
+ onLocationDisabled = disabled::add
+ )
+
+ manager.handleLocationStatus(LocationStatus.ENABLED)
+ manager.handleLocationStatus(LocationStatus.NOT_AVAILABLE)
+
+ assertEquals(1, enabled)
+ assertEquals(
+ listOf("Location services are not available on this device."),
+ disabled
+ )
+ assertTrue(manager.getStatusMessage(LocationStatus.DISABLED).contains("Bluetooth scanning"))
+ manager.cleanup()
+ controller.destroy()
+ }
+}
diff --git a/app/src/test/kotlin/com/bitchat/android/service/TransportBridgeServiceTest.kt b/app/src/test/kotlin/com/bitchat/android/service/TransportBridgeServiceTest.kt
index 7e774dc3..cd8bf40d 100644
--- a/app/src/test/kotlin/com/bitchat/android/service/TransportBridgeServiceTest.kt
+++ b/app/src/test/kotlin/com/bitchat/android/service/TransportBridgeServiceTest.kt
@@ -4,8 +4,10 @@ import android.os.Build
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
+import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -33,6 +35,11 @@ class TransportBridgeServiceTest {
override fun send(packet: RoutedPacket) {
captured = packet
}
+
+ override suspend fun sendAndReport(packet: RoutedPacket): Boolean {
+ captured = packet
+ return true
+ }
}
)
val packet = BitchatPacket(
@@ -65,4 +72,41 @@ class TransportBridgeServiceTest {
assertEquals(original.type, actual.type)
}
}
+
+ @Test
+ fun `rejected bridge send remains eligible after transport reconnects`() = runTest {
+ var transportConnected = false
+ var attempts = 0
+ TransportBridgeService.register(
+ targetId,
+ object : TransportBridgeService.TransportLayer {
+ override fun send(packet: RoutedPacket) = Unit
+
+ override suspend fun sendAndReport(packet: RoutedPacket): Boolean {
+ attempts += 1
+ return transportConnected
+ }
+ }
+ )
+ val packet = BitchatPacket(
+ version = 1u,
+ type = MessageType.NOISE_ENCRYPTED.value,
+ senderID = ByteArray(8) { 1 },
+ recipientID = ByteArray(8) { 2 },
+ timestamp = System.nanoTime().toULong(),
+ payload = byteArrayOf(3, 4, 5),
+ signature = ByteArray(64) { 6 },
+ ttl = 7u
+ )
+ val sourceId = "source-${UUID.randomUUID()}"
+
+ assertFalse(
+ TransportBridgeService.broadcastAndReport(sourceId, RoutedPacket(packet))
+ )
+ transportConnected = true
+ assertTrue(
+ TransportBridgeService.broadcastAndReport(sourceId, RoutedPacket(packet))
+ )
+ assertEquals(2, attempts)
+ }
}
diff --git a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt
index 577823ac..90ab0034 100644
--- a/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt
+++ b/app/src/test/kotlin/com/bitchat/android/services/AppStateStoreTest.kt
@@ -1,8 +1,10 @@
package com.bitchat.android.services
import com.bitchat.android.model.BitchatMessage
+import com.bitchat.android.model.DeliveryStatus
import org.junit.After
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.util.Date
@@ -77,6 +79,10 @@ class AppStateStoreTest {
setOf("ble-1", "wifi-1", "shared"),
AppStateStore.getDirectPeers()
)
+ assertEquals(
+ setOf("ble-1", "wifi-1", "shared"),
+ AppStateStore.directPeers.value
+ )
}
@Test
@@ -126,15 +132,61 @@ class AppStateStoreTest {
}
@Test
- fun `canonicalized private chat history is chronological after alias merge`() {
+ fun `canonicalized private chat history keeps arrival order when peer clocks differ`() {
val noiseKeyHex = "01".repeat(32)
val contactID = ContactIdentityResolver.contactConversationIdForNoiseKey(ByteArray(32) { 1 })
- val later = BitchatMessage(id = "later", sender = "alice", content = "later", timestamp = Date(3))
- val earlier = BitchatMessage(id = "earlier", sender = "alice", content = "earlier", timestamp = Date(1))
+ val firstArrival = BitchatMessage(
+ id = "first-arrival",
+ sender = "alice",
+ content = "sent from a clock that is ahead",
+ timestamp = Date(3)
+ )
+ val secondArrival = BitchatMessage(
+ id = "second-arrival",
+ sender = "alice",
+ content = "sent from a clock that is behind",
+ timestamp = Date(1)
+ )
- AppStateStore.addPrivateMessage(contactID, later)
- AppStateStore.addPrivateMessage(noiseKeyHex, earlier)
+ AppStateStore.addPrivateMessage(contactID, firstArrival)
+ AppStateStore.addPrivateMessage(noiseKeyHex, secondArrival)
- assertEquals(listOf(earlier, later), AppStateStore.privateMessages.value[contactID])
+ assertEquals(
+ listOf(firstArrival, secondArrival),
+ AppStateStore.privateMessages.value[contactID]
+ )
+ }
+
+ @Test
+ fun `background receipt status persists and cannot be downgraded`() {
+ val message = BitchatMessage(
+ id = "outgoing-message",
+ sender = "bob",
+ content = "hello",
+ timestamp = Date(1),
+ isPrivate = true,
+ deliveryStatus = DeliveryStatus.Sending
+ )
+ AppStateStore.addPrivateMessage("peer-a", message)
+
+ AppStateStore.updatePrivateMessageStatus(
+ message.id,
+ DeliveryStatus.Delivered("peer-a", Date(2))
+ )
+ AppStateStore.updatePrivateMessageStatus(
+ message.id,
+ DeliveryStatus.Read("peer-a", Date(3))
+ )
+ AppStateStore.updatePrivateMessageStatus(
+ message.id,
+ DeliveryStatus.Delivered("peer-a", Date(4))
+ )
+
+ val status = AppStateStore.privateMessages.value
+ .values
+ .flatten()
+ .single()
+ .deliveryStatus
+ assertTrue(status is DeliveryStatus.Read)
}
}
diff --git a/app/src/test/kotlin/com/bitchat/android/services/ContactDirectoryTest.kt b/app/src/test/kotlin/com/bitchat/android/services/ContactDirectoryTest.kt
new file mode 100644
index 00000000..e16996f1
--- /dev/null
+++ b/app/src/test/kotlin/com/bitchat/android/services/ContactDirectoryTest.kt
@@ -0,0 +1,59 @@
+package com.bitchat.android.services
+
+import android.content.Context
+import android.os.Build
+import com.bitchat.android.identity.SecureIdentityStateManager
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+import java.util.UUID
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE)
+class ContactDirectoryTest {
+
+ private lateinit var identityManager: SecureIdentityStateManager
+
+ @Before
+ fun setup() {
+ val context = RuntimeEnvironment.getApplication()
+ val prefs = context.getSharedPreferences(
+ "contact-directory-test-${UUID.randomUUID()}",
+ Context.MODE_PRIVATE
+ )
+ identityManager = SecureIdentityStateManager(prefs, testOnly = true)
+ ContactDirectory.initialize(context) { null }
+ ContactDirectory.identityManagerProvider = { identityManager }
+ }
+
+ @After
+ fun tearDown() {
+ ContactDirectory.identityManagerProvider = { SecureIdentityStateManager(it) }
+ }
+
+ @Test
+ fun `offline contact resolves display name from cached fingerprint nickname`() {
+ val fingerprint = "ab".repeat(32)
+ identityManager.cacheFingerprintNickname(fingerprint, "Alice")
+
+ val resolution = ContactDirectory.resolve("contact_$fingerprint")
+
+ assertEquals("Alice", resolution.displayName)
+ assertNull(resolution.meshPeerID)
+ }
+
+ @Test
+ fun `offline contact without cached nickname has no display name`() {
+ val fingerprint = "cd".repeat(32)
+
+ val resolution = ContactDirectory.resolve("contact_$fingerprint")
+
+ assertNull(resolution.displayName)
+ }
+}
diff --git a/app/src/test/kotlin/com/bitchat/android/services/ConversationAliasResolverTest.kt b/app/src/test/kotlin/com/bitchat/android/services/ConversationAliasResolverTest.kt
new file mode 100644
index 00000000..ce3eedbd
--- /dev/null
+++ b/app/src/test/kotlin/com/bitchat/android/services/ConversationAliasResolverTest.kt
@@ -0,0 +1,76 @@
+package com.bitchat.android.services
+
+import com.bitchat.android.model.BitchatMessage
+import com.bitchat.android.ui.ChatState
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import java.util.Date
+
+class ConversationAliasResolverTest {
+ private lateinit var scope: CoroutineScope
+ private lateinit var state: ChatState
+
+ @Before
+ fun setUp() {
+ AppStateStore.clear()
+ scope = CoroutineScope(SupervisorJob())
+ state = ChatState(scope)
+ }
+
+ @After
+ fun tearDown() {
+ scope.cancel()
+ AppStateStore.clear()
+ }
+
+ @Test
+ fun `explicit alias merge restores global order for interleaved arrivals`() {
+ val firstArrival = BitchatMessage(
+ id = "alias-a-first",
+ sender = "alice",
+ content = "first arrival through alias A",
+ timestamp = Date(300)
+ )
+ val secondArrival = BitchatMessage(
+ id = "alias-b-first",
+ sender = "alice",
+ content = "second arrival through alias B",
+ timestamp = Date(100)
+ )
+ val thirdArrival = BitchatMessage(
+ id = "alias-a-second",
+ sender = "alice",
+ content = "third arrival through alias A",
+ timestamp = Date(200)
+ )
+ AppStateStore.addPrivateMessage("target-peer", firstArrival)
+ AppStateStore.addPrivateMessage("source-alias", secondArrival)
+ AppStateStore.addPrivateMessage("target-peer", thirdArrival)
+ state.setPrivateChats(
+ linkedMapOf(
+ "target-peer" to listOf(firstArrival, thirdArrival),
+ "source-alias" to listOf(secondArrival)
+ )
+ )
+
+ ConversationAliasResolver.unifyChatsIntoPeer(
+ state = state,
+ targetPeerID = "target-peer",
+ keysToMerge = listOf("source-alias")
+ )
+
+ assertEquals(
+ listOf(firstArrival, secondArrival, thirdArrival),
+ state.getPrivateChatsValue()["target-peer"]
+ )
+ assertEquals(
+ listOf(firstArrival, secondArrival, thirdArrival),
+ AppStateStore.privateMessages.value["target-peer"]
+ )
+ }
+}
diff --git a/app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt b/app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt
new file mode 100644
index 00000000..48cbe2da
--- /dev/null
+++ b/app/src/test/kotlin/com/bitchat/android/services/MessageRouterTest.kt
@@ -0,0 +1,217 @@
+package com.bitchat.android.services
+
+import android.content.Context
+import android.os.Build
+import com.bitchat.android.identity.SecureIdentityStateManager
+import com.bitchat.android.mesh.MeshService
+import com.bitchat.android.mesh.PeerInfo
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.clearInvocations
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+import java.util.UUID
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE)
+class MessageRouterTest {
+
+ private val myPeerID = "1111222233334444"
+ private val peerID = "aaaabbbbccccdddd"
+ private val noiseKey = ByteArray(32) { 0x0B }
+
+ private lateinit var mesh: MeshService
+ private lateinit var router: MessageRouter
+ private var fakeTime = 1_000_000L
+ private val expired = mutableListOf()
+
+ @Before
+ fun setup() {
+ val context = RuntimeEnvironment.getApplication()
+ val prefs = context.getSharedPreferences(
+ "message-router-test-${UUID.randomUUID()}",
+ Context.MODE_PRIVATE
+ )
+ val identityManager = SecureIdentityStateManager(prefs, testOnly = true)
+ ContactDirectory.identityManagerProvider = { identityManager }
+
+ mesh = mock()
+ whenever(mesh.myPeerID).thenReturn(myPeerID)
+ whenever(mesh.getPeerNicknames()).thenReturn(mapOf(peerID to "peer"))
+
+ ContactDirectory.initialize(context) { mesh }
+
+ MessageRouter.disableSchedulerForTesting = true
+ MessageRouter.resetForTesting()
+ fakeTime = 1_000_000L
+ expired.clear()
+
+ router = MessageRouter.getInstance(context, mesh)
+ router.clock = { fakeTime }
+ router.onMessageExpired = { expired.add(it) }
+ }
+
+ @After
+ fun tearDown() {
+ MessageRouter.resetForTesting()
+ MessageRouter.disableSchedulerForTesting = false
+ ContactDirectory.identityManagerProvider = { SecureIdentityStateManager(it) }
+ }
+
+ @Test
+ fun `queued message flushes after peer returns and session establishes`() {
+ peerOffline()
+ val result = router.sendPrivate("hello", peerID, "peer", "msg-1")
+
+ assertEquals(MessageRouter.RouteResult.QUEUED, result)
+ verify(mesh, never()).sendPrivateMessage(any(), any(), any(), anyOrNull())
+ verify(mesh, never()).initiateNoiseHandshake(any())
+
+ // Peer reappears without a session: handshake kicked immediately
+ peerConnectedNoSession()
+ router.onPeersUpdated(listOf(peerID))
+ verify(mesh, times(1)).initiateNoiseHandshake(peerID)
+ verify(mesh, never()).sendPrivateMessage(any(), any(), any(), anyOrNull())
+
+ // Session established: queued message is sent
+ peerReady()
+ router.onSessionEstablished(peerID)
+ verify(mesh, times(1)).sendPrivateMessage("hello", peerID, "peer", "msg-1")
+ }
+
+ @Test
+ fun `scheduler retries handshake with capped backoff`() {
+ peerConnectedNoSession()
+ val result = router.sendPrivate("hello", peerID, "peer", "msg-1")
+ assertEquals(MessageRouter.RouteResult.QUEUED, result)
+ verify(mesh, times(1)).initiateNoiseHandshake(peerID) // immediate kick at enqueue
+ clearInvocations(mesh)
+
+ router.tickOutbox() // backoff (5s) not yet elapsed
+ verify(mesh, never()).initiateNoiseHandshake(any())
+
+ fakeTime += 6_000
+ router.tickOutbox() // attempt 2, next in 15s
+ verify(mesh, times(1)).initiateNoiseHandshake(peerID)
+
+ fakeTime += 7_000
+ router.tickOutbox() // too early
+ verify(mesh, times(1)).initiateNoiseHandshake(peerID)
+
+ fakeTime += 9_000
+ router.tickOutbox() // attempt 3, next in 30s
+ verify(mesh, times(2)).initiateNoiseHandshake(peerID)
+
+ fakeTime += 31_000
+ router.tickOutbox() // attempt 4, next in 60s
+ verify(mesh, times(3)).initiateNoiseHandshake(peerID)
+
+ fakeTime += 61_000
+ router.tickOutbox() // attempt 5, capped at 60s
+ verify(mesh, times(4)).initiateNoiseHandshake(peerID)
+ }
+
+ @Test
+ fun `expired entries are dropped and reported`() {
+ peerOffline()
+ router.sendPrivate("old message", peerID, "peer", "msg-old")
+
+ fakeTime += 86_400_001L
+ router.tickOutbox()
+
+ assertEquals(listOf("msg-old"), expired)
+
+ // Nothing left to flush even when the peer becomes reachable
+ peerReady()
+ router.tickOutbox()
+ verify(mesh, never()).sendPrivateMessage(any(), any(), any(), anyOrNull())
+ }
+
+ @Test
+ fun `outbox cap evicts oldest and preserves order`() {
+ peerOffline()
+ repeat(101) { i ->
+ router.sendPrivate("content-$i", peerID, "peer", "msg-$i")
+ }
+
+ assertEquals(listOf("msg-0"), expired)
+
+ peerReady()
+ router.onSessionEstablished(peerID)
+ verify(mesh, times(100)).sendPrivateMessage(any(), eq(peerID), any(), any())
+ verify(mesh, times(1)).sendPrivateMessage("content-1", peerID, "peer", "msg-1")
+ verify(mesh, times(1)).sendPrivateMessage("content-100", peerID, "peer", "msg-100")
+ verify(mesh, never()).sendPrivateMessage(eq("content-0"), any(), any(), anyOrNull())
+ }
+
+ @Test
+ fun `peer reappearance without pending messages does not kick handshake`() {
+ peerConnectedNoSession()
+ router.onPeersUpdated(listOf(peerID))
+ verify(mesh, never()).initiateNoiseHandshake(any())
+ }
+
+ @Test
+ fun `established session flushes directly without handshake retry state`() {
+ peerReady()
+ val result = router.sendPrivate("direct", peerID, "peer", "msg-direct")
+ assertEquals(MessageRouter.RouteResult.MESH, result)
+ verify(mesh, times(1)).sendPrivateMessage("direct", peerID, "peer", "msg-direct")
+ verify(mesh, never()).initiateNoiseHandshake(any())
+ }
+
+ @Test
+ fun `scheduler stops with the mesh service and restarts on rebind`() {
+ MessageRouter.disableSchedulerForTesting = false
+ MessageRouter.resetForTesting()
+ val context = RuntimeEnvironment.getApplication()
+ val running = MessageRouter.getInstance(context, mesh)
+ assertTrue(running.isSchedulerRunning)
+
+ running.stopOutboxScheduler()
+ assertFalse(running.isSchedulerRunning)
+
+ val rebound = MessageRouter.getInstance(context, mesh)
+ assertTrue(rebound.isSchedulerRunning)
+ }
+
+ private fun peerOffline() {
+ whenever(mesh.getPeerInfo(peerID)).thenReturn(peerInfo(isConnected = false))
+ whenever(mesh.hasEstablishedSession(peerID)).thenReturn(false)
+ }
+
+ private fun peerConnectedNoSession() {
+ whenever(mesh.getPeerInfo(peerID)).thenReturn(peerInfo(isConnected = true))
+ whenever(mesh.hasEstablishedSession(peerID)).thenReturn(false)
+ }
+
+ private fun peerReady() {
+ whenever(mesh.getPeerInfo(peerID)).thenReturn(peerInfo(isConnected = true))
+ whenever(mesh.hasEstablishedSession(peerID)).thenReturn(true)
+ }
+
+ private fun peerInfo(isConnected: Boolean) = PeerInfo(
+ id = peerID,
+ nickname = "peer",
+ isConnected = isConnected,
+ isDirectConnection = true,
+ noisePublicKey = noiseKey,
+ signingPublicKey = ByteArray(32) { 0x0A },
+ isVerifiedNickname = false,
+ lastSeen = System.currentTimeMillis()
+ )
+}
diff --git a/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt
index ebe5f744..60c57198 100644
--- a/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt
+++ b/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt
@@ -287,6 +287,43 @@ class MediaSendingManagerMigrationTest {
assertTrue(messages.none { it.type == com.bitchat.android.model.BitchatMessageType.Image })
}
+ @Test
+ fun `oversized file failure is posted to the private conversation and nothing is sent`() {
+ val bigFile = kotlin.io.path.createTempFile("oversized-private", ".jpg").toFile()
+ try {
+ bigFile.writeBytes(ByteArray(11 * 1024 * 1024) { 0x42 })
+
+ manager.sendImageNote(peerID, null, bigFile.absolutePath)
+
+ val messages = state.privateChats.value[peerID].orEmpty()
+ assertEquals(1, messages.size)
+ assertTrue(messages.single().content.contains("too large"))
+ assertTrue(messages.none { it.type == com.bitchat.android.model.BitchatMessageType.Image })
+ assertTrue(state.getMessagesValue().none { it.content.contains("too large") })
+ verify(mesh, never()).prepareFilePrivate(any(), any(), any(), any())
+ } finally {
+ bigFile.delete()
+ }
+ }
+
+ @Test
+ fun `oversized file failure is posted to the channel and nothing is sent`() {
+ val bigFile = kotlin.io.path.createTempFile("oversized-channel", ".jpg").toFile()
+ try {
+ bigFile.writeBytes(ByteArray(11 * 1024 * 1024) { 0x42 })
+
+ manager.sendImageNote(null, "#test", bigFile.absolutePath)
+
+ val channelMessages = state.getChannelMessagesValue()["#test"].orEmpty()
+ assertEquals(1, channelMessages.size)
+ assertTrue(channelMessages.single().content.contains("too large"))
+ assertTrue(state.getMessagesValue().none { it.content.contains("too large") })
+ verify(mesh, never()).prepareFilePrivate(any(), any(), any(), any())
+ } finally {
+ bigFile.delete()
+ }
+ }
+
@Test
fun `cancelled consent cannot later send or echo`() {
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
diff --git a/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt
new file mode 100644
index 00000000..162a5ef6
--- /dev/null
+++ b/app/src/test/kotlin/com/bitchat/android/ui/MeshDelegateHandlerStateContractTest.kt
@@ -0,0 +1,201 @@
+package com.bitchat.android.ui
+
+import com.bitchat.android.mesh.MeshService
+import com.bitchat.android.mesh.PeerInfo
+import com.bitchat.android.model.BitchatMessage
+import com.bitchat.android.model.DeliveryStatus
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.mockito.kotlin.any
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import java.util.Date
+import java.util.concurrent.atomic.AtomicInteger
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class MeshDelegateHandlerStateContractTest {
+ private lateinit var state: ChatState
+ private lateinit var messages: MessageManager
+ private lateinit var channels: ChannelManager
+ private lateinit var privateChats: PrivateChatManager
+ private lateinit var notifications: NotificationManager
+ private lateinit var mesh: MeshService
+ private lateinit var handler: MeshDelegateHandler
+ private lateinit var haptics: AtomicInteger
+ private lateinit var locallyReadMessageIDs: MutableList
+
+ @Before
+ fun setUp() {
+ val scope = TestScope(UnconfinedTestDispatcher())
+ state = ChatState(scope)
+ state.setNickname("Résumé")
+ messages = MessageManager(state)
+ channels = mock()
+ privateChats = mock()
+ notifications = mock()
+ mesh = mock()
+ haptics = AtomicInteger()
+ locallyReadMessageIDs = mutableListOf()
+ handler = MeshDelegateHandler(
+ state = state,
+ messageManager = messages,
+ channelManager = channels,
+ privateChatManager = privateChats,
+ notificationManager = notifications,
+ coroutineScope = scope,
+ onHapticFeedback = { haptics.incrementAndGet() },
+ getMyPeerID = { "self" },
+ getMeshService = { mesh },
+ markMessageReadLocally = locallyReadMessageIDs::add
+ )
+ }
+
+ @Test
+ fun `peer arrival deduplicates list and final departure restores disconnected state`() {
+ handler.didUpdatePeerList(listOf("peer-a", "peer-a", "peer-b"))
+
+ assertEquals(listOf("peer-a", "peer-b"), state.connectedPeers.value)
+ assertTrue(state.isConnected.value)
+ verify(notifications).showActiveUserNotification(listOf("peer-a", "peer-b"))
+ verify(channels).cleanupDisconnectedMembers(listOf("peer-a", "peer-b"), "self")
+
+ handler.didUpdatePeerList(emptyList())
+
+ assertTrue(state.connectedPeers.value.isEmpty())
+ assertFalse(state.isConnected.value)
+ verify(notifications).showActiveUserNotification(emptyList())
+ }
+
+ @Test
+ fun `delivery and read callbacks advance visible status monotonically`() {
+ val outgoing = message(
+ id = "outgoing",
+ sender = "me",
+ deliveryStatus = DeliveryStatus.Sending
+ )
+ state.setMessages(listOf(outgoing))
+
+ handler.didReceiveDeliveryAck("outgoing", "peer-a")
+ assertTrue(state.messages.value.single().deliveryStatus is DeliveryStatus.Delivered)
+
+ handler.didReceiveReadReceipt("outgoing", "peer-a")
+ assertTrue(state.messages.value.single().deliveryStatus is DeliveryStatus.Read)
+
+ handler.didReceiveDeliveryAck("outgoing", "peer-a")
+ assertTrue(state.messages.value.single().deliveryStatus is DeliveryStatus.Read)
+ }
+
+ @Test
+ fun `focused private message schedules receipt and records local read independently`() {
+ val peerID = "1122334455667788"
+ val incoming = BitchatMessage(
+ id = "focused-private-message",
+ sender = "alice",
+ content = "hello",
+ timestamp = Date(1),
+ isPrivate = true,
+ senderPeerID = peerID
+ )
+ whenever(notifications.getAppBackgroundState()).thenReturn(false)
+ whenever(notifications.getCurrentPrivateChatPeer()).thenReturn(peerID)
+ whenever(mesh.getPeerInfo(peerID)).thenReturn(
+ PeerInfo(
+ id = peerID,
+ nickname = "alice",
+ isConnected = true,
+ isDirectConnection = true,
+ noisePublicKey = ByteArray(32) { 1 },
+ signingPublicKey = null,
+ isVerifiedNickname = false,
+ lastSeen = System.currentTimeMillis()
+ )
+ )
+ whenever(mesh.hasEstablishedSession(peerID)).thenReturn(true)
+
+ handler.didReceiveMessage(incoming)
+
+ verify(mesh).sendReadReceipt(incoming.id, peerID, "Résumé")
+ assertEquals(listOf(incoming.id), locallyReadMessageIDs)
+ }
+
+ @Test
+ fun `focused private message remains locally read when transport is disconnected`() {
+ val peerID = "1122334455667788"
+ val incoming = BitchatMessage(
+ id = "focused-private-message-offline",
+ sender = "alice",
+ content = "hello",
+ timestamp = Date(1),
+ isPrivate = true,
+ senderPeerID = peerID
+ )
+ whenever(notifications.getAppBackgroundState()).thenReturn(false)
+ whenever(notifications.getCurrentPrivateChatPeer()).thenReturn(peerID)
+ whenever(mesh.getPeerInfo(peerID)).thenReturn(null)
+
+ handler.didReceiveMessage(incoming)
+
+ verify(mesh, never()).sendReadReceipt(any(), any(), any())
+ assertEquals(listOf(incoming.id), locallyReadMessageIDs)
+ }
+
+ @Test
+ fun `unicode mention notifies once and duplicate transport delivery is suppressed`() {
+ val incoming = message(
+ id = "incoming",
+ sender = "alice",
+ content = "hello @résumé",
+ senderPeerID = "peer-a"
+ )
+
+ handler.didReceiveMessage(incoming)
+ handler.didReceiveMessage(incoming)
+
+ assertEquals(1, haptics.get())
+ verify(notifications, times(1)).showMeshMentionNotification(
+ senderNickname = eq("alice"),
+ messageContent = eq("hello @résumé"),
+ senderPeerID = eq("peer-a")
+ )
+ }
+
+ @Test
+ fun `channel inbound increments unread only when conversation is not focused`() {
+ state.setJoinedChannels(setOf("#room"))
+ val incoming = message(id = "channel-1", channel = "#room")
+
+ handler.didReceiveMessage(incoming)
+ assertEquals(1, state.unreadChannelMessages.value["#room"])
+
+ state.setCurrentChannel("#room")
+ handler.didReceiveMessage(incoming.copy(id = "channel-2"))
+ assertEquals(1, state.unreadChannelMessages.value["#room"])
+ }
+
+ private fun message(
+ id: String,
+ sender: String = "alice",
+ content: String = id,
+ senderPeerID: String? = null,
+ channel: String? = null,
+ deliveryStatus: DeliveryStatus? = null
+ ) = BitchatMessage(
+ id = id,
+ sender = sender,
+ content = content,
+ timestamp = Date(1),
+ senderPeerID = senderPeerID,
+ channel = channel,
+ deliveryStatus = deliveryStatus
+ )
+}
diff --git a/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt
index 852411e8..d8371690 100644
--- a/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt
+++ b/app/src/test/kotlin/com/bitchat/android/ui/PrivateChatManagerTest.kt
@@ -10,10 +10,12 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import org.junit.After
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner
@@ -67,6 +69,34 @@ class PrivateChatManagerTest {
assertEquals(listOf(message), state.getPrivateChatsValue()[conversationID])
}
+ @Test
+ fun `headless Nostr processing stores messages without retaining UI unread work`() {
+ val headlessManager = PrivateChatManager(
+ state = state,
+ messageManager = MessageManager(state),
+ dataManager = DataManager(RuntimeEnvironment.getApplication()),
+ noiseSessionDelegate = mock(),
+ trackUnreadMessages = false
+ )
+ val message = BitchatMessage(
+ id = "background-nostr-message",
+ sender = "alice",
+ content = "background",
+ timestamp = Date(1),
+ isPrivate = true,
+ senderPeerID = "nostr_background"
+ )
+
+ headlessManager.handleIncomingPrivateMessage(
+ message = message,
+ suppressUnread = false,
+ origin = PrivateMessageOrigin.NOSTR
+ )
+
+ assertEquals(listOf(message), AppStateStore.privateMessages.value["nostr_background"])
+ assertTrue(state.getUnreadPrivateMessagesValue().isEmpty())
+ }
+
@Test
fun `canonical conversation sends read receipt through live mesh peer id`() {
val noiseKey = ByteArray(32) { 9 }
@@ -104,4 +134,101 @@ class PrivateChatManagerTest {
verify(meshService).sendReadReceipt(message.id, meshPeerID, "bob")
}
+
+ @Test
+ fun `opening canonical unread conversation clears all source aliases`() {
+ val canonicalID = "contact_alice"
+ val nostrAlias = "nostr_0123456789abcdef"
+ val meshAlias = "0123456789abcdef"
+ val unrelatedConversation = "other-contact"
+ val meshService = mock()
+ state.setUnreadPrivateMessages(
+ setOf(canonicalID, nostrAlias, meshAlias, unrelatedConversation)
+ )
+
+ manager.startPrivateChat(
+ peerID = canonicalID,
+ meshService = meshService,
+ unreadAliases = setOf(canonicalID, nostrAlias, meshAlias)
+ )
+
+ assertEquals(
+ setOf(unrelatedConversation),
+ state.getUnreadPrivateMessagesValue()
+ )
+ }
+
+ @Test
+ fun `opening chat skips messages whose receipt send already completed`() {
+ val noiseKey = ByteArray(32) { 8 }
+ val meshPeerID = ContactIdentityResolver.peerIdForNoiseKey(noiseKey)
+ val conversationID = ContactIdentityResolver.contactConversationIdForNoiseKey(noiseKey)
+ val oldMessage = BitchatMessage(
+ id = "already-read",
+ sender = "alice",
+ content = "old",
+ timestamp = Date(1),
+ isPrivate = true,
+ senderPeerID = meshPeerID
+ )
+ val unreadMessage = oldMessage.copy(
+ id = "still-unread",
+ content = "new",
+ timestamp = Date(2)
+ )
+ val meshService = mock()
+ manager = PrivateChatManager(
+ state = state,
+ messageManager = MessageManager(state),
+ dataManager = DataManager(RuntimeEnvironment.getApplication()),
+ noiseSessionDelegate = mock(),
+ hasReadReceiptBeenSent = { it == oldMessage.id }
+ )
+ state.setNickname("bob")
+ state.setPrivateChats(mapOf(conversationID to listOf(oldMessage, unreadMessage)))
+ whenever(meshService.getPeerInfo(meshPeerID)).thenReturn(
+ PeerInfo(
+ id = meshPeerID,
+ nickname = "alice",
+ isConnected = true,
+ isDirectConnection = true,
+ noisePublicKey = noiseKey,
+ signingPublicKey = null,
+ isVerifiedNickname = false,
+ lastSeen = System.currentTimeMillis()
+ )
+ )
+ whenever(meshService.hasEstablishedSession(meshPeerID)).thenReturn(true)
+
+ manager.sendReadReceiptsForPeer(conversationID, meshPeerID, meshService)
+
+ verify(meshService, never()).sendReadReceipt(oldMessage.id, meshPeerID, "bob")
+ verify(meshService).sendReadReceipt(unreadMessage.id, meshPeerID, "bob")
+ }
+
+ @Test
+ fun `canonical conversation send does not require resolved nickname`() {
+ val conversationID =
+ ContactIdentityResolver.contactConversationIdForNoiseKey(ByteArray(32) { 4 })
+ var callbackInvoked = false
+
+ manager.sendPrivateMessage(
+ content = "hello",
+ peerID = conversationID,
+ recipientNickname = null,
+ senderNickname = "bob",
+ myPeerID = "self"
+ ) { content, recipientID, nickname, _ ->
+ callbackInvoked = true
+ assertEquals("hello", content)
+ assertEquals(conversationID, recipientID)
+ assertEquals("", nickname)
+ }
+
+ assertTrue(callbackInvoked)
+ assertEquals(
+ "hello",
+ state.getPrivateChatsValue()[conversationID]?.single()?.content
+ )
+ }
}
diff --git a/app/src/test/kotlin/com/bitchat/android/ui/UnreadConversationSummaryTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/UnreadConversationSummaryTest.kt
new file mode 100644
index 00000000..2b5f4613
--- /dev/null
+++ b/app/src/test/kotlin/com/bitchat/android/ui/UnreadConversationSummaryTest.kt
@@ -0,0 +1,137 @@
+package com.bitchat.android.ui
+
+import com.bitchat.android.model.BitchatMessage
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.util.Date
+
+class UnreadConversationSummaryTest {
+ @Test
+ fun `unread conversations survive missing presence and sort by latest unread`() {
+ val older = incoming(
+ id = "older",
+ sender = "alice",
+ timestamp = 100
+ )
+ val newer = incoming(
+ id = "newer",
+ sender = "bob",
+ timestamp = 200
+ )
+
+ val rows = buildUnreadConversationSummaries(
+ unreadConversationIDs = setOf("alice-peer", "bob-peer"),
+ privateChats = mapOf(
+ "alice-peer" to listOf(older),
+ "bob-peer" to listOf(newer)
+ ),
+ currentUserIdentifiers = setOf("me"),
+ canonicalize = { it },
+ isMessageRead = { false }
+ )
+
+ assertEquals(listOf("bob-peer", "alice-peer"), rows.map { it.conversationID })
+ assertEquals(listOf("bob", "alice"), rows.map { it.displayName })
+ }
+
+ @Test
+ fun `canonical aliases produce one unread conversation row`() {
+ val message = incoming(
+ id = "message",
+ sender = "alice",
+ timestamp = 100
+ )
+
+ val rows = buildUnreadConversationSummaries(
+ unreadConversationIDs = setOf("mesh-alias", "nostr_alias"),
+ privateChats = mapOf(
+ "mesh-alias" to listOf(message),
+ "nostr_alias" to listOf(message)
+ ),
+ currentUserIdentifiers = setOf("me"),
+ canonicalize = { "contact_alice" },
+ isMessageRead = { false }
+ )
+
+ assertEquals(1, rows.size)
+ assertEquals("contact_alice", rows.single().conversationID)
+ assertEquals(DirectMessageTransport.NOSTR, rows.single().transport)
+ assertEquals(
+ setOf("mesh-alias", "nostr_alias", "contact_alice"),
+ rows.single().identityAliases
+ )
+ }
+
+ @Test
+ fun `only unseen incoming messages contribute to unread count`() {
+ val read = incoming(
+ id = "read",
+ sender = "alice",
+ timestamp = 100
+ )
+ val unread = incoming(
+ id = "unread",
+ sender = "alice",
+ timestamp = 200
+ )
+ val outgoing = incoming(
+ id = "outgoing",
+ sender = "me",
+ timestamp = 300
+ )
+
+ val row = buildUnreadConversationSummaries(
+ unreadConversationIDs = setOf("alice-peer"),
+ privateChats = mapOf("alice-peer" to listOf(read, unread, outgoing)),
+ currentUserIdentifiers = setOf("me"),
+ canonicalize = { it },
+ isMessageRead = { it.id == "read" }
+ ).single()
+
+ assertEquals(1, row.unreadCount)
+ assertEquals(200, row.latestMessageAt)
+ }
+
+ @Test
+ fun `unread key without hydrated messages still produces a row`() {
+ val row = buildUnreadConversationSummaries(
+ unreadConversationIDs = setOf("orphan-peer"),
+ privateChats = emptyMap(),
+ currentUserIdentifiers = setOf("me"),
+ canonicalize = { it },
+ isMessageRead = { false }
+ ).single()
+
+ assertEquals("orphan-peer", row.conversationID)
+ assertEquals(1, row.unreadCount)
+ assertTrue(row.displayName.isNotBlank())
+ }
+
+ @Test
+ fun `canonical unread lookup returns every matching source alias`() {
+ val aliases = matchingUnreadAliases(
+ unreadConversationIDs = setOf("mesh-alias", "nostr_alias", "other-contact"),
+ canonicalConversationID = "contact_alice",
+ canonicalize = { unreadID ->
+ if (unreadID == "other-contact") unreadID else "contact_alice"
+ }
+ )
+
+ assertEquals(
+ setOf("mesh-alias", "nostr_alias", "contact_alice"),
+ aliases
+ )
+ }
+
+ private fun incoming(
+ id: String,
+ sender: String,
+ timestamp: Long
+ ) = BitchatMessage(
+ id = id,
+ sender = sender,
+ content = "hello",
+ timestamp = Date(timestamp)
+ )
+}
diff --git a/app/src/test/kotlin/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicyTest.kt
deleted file mode 100644
index 1f483543..00000000
--- a/app/src/test/kotlin/com/bitchat/android/wifi-aware/AuthenticatedIngressLinkPolicyTest.kt
+++ /dev/null
@@ -1,93 +0,0 @@
-package com.bitchat.android.wifiaware
-
-import org.junit.Assert.assertNull
-import org.junit.Assert.assertSame
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertTrue
-import org.junit.Test
-
-class AuthenticatedIngressLinkPolicyTest {
- @Test
- fun `promotion claim must match the challenged relay and link`() {
- val claim = AuthenticatedIngressLinkPolicy.Claim("provisional", "challenged-link")
-
- assertTrue(
- AuthenticatedIngressLinkPolicy.matches(
- claim,
- authenticatedRelayAddress = "provisional",
- authenticatedLinkID = "challenged-link"
- )
- )
- assertFalse(
- AuthenticatedIngressLinkPolicy.matches(
- claim,
- authenticatedRelayAddress = "provisional",
- authenticatedLinkID = "different-link"
- )
- )
- assertFalse(
- AuthenticatedIngressLinkPolicy.matches(
- expected = null,
- authenticatedRelayAddress = "provisional",
- authenticatedLinkID = "challenged-link"
- )
- )
- }
-
- @Test
- fun `authentication promotes only the exact ingress link`() {
- val attackerSocket = Any()
- val victimSocket = Any()
- val links = mapOf(
- "attacker-link" to AuthenticatedIngressLinkPolicy.Link("provisional-attacker", attackerSocket),
- "victim-link" to AuthenticatedIngressLinkPolicy.Link("provisional-victim", victimSocket)
- )
- val current = mapOf(
- "provisional-attacker" to attackerSocket,
- "provisional-victim" to victimSocket
- )
-
- val resolved = AuthenticatedIngressLinkPolicy.resolve(
- authenticatedLinkID = "victim-link",
- authenticatedRelayAddress = "provisional-victim",
- links = links,
- currentTransportForRelay = current::get
- )
-
- assertSame(victimSocket, resolved?.transport)
- }
-
- @Test
- fun `stale replaced or mismatched ingress links cannot be promoted`() {
- val completedSocket = Any()
- val replacementSocket = Any()
- val links = mapOf(
- "completed-link" to AuthenticatedIngressLinkPolicy.Link("provisional", completedSocket)
- )
-
- assertNull(
- AuthenticatedIngressLinkPolicy.resolve(
- authenticatedLinkID = "missing-link",
- authenticatedRelayAddress = "provisional",
- links = links,
- currentTransportForRelay = { completedSocket }
- )
- )
- assertNull(
- AuthenticatedIngressLinkPolicy.resolve(
- authenticatedLinkID = "completed-link",
- authenticatedRelayAddress = "different-provisional",
- links = links,
- currentTransportForRelay = { completedSocket }
- )
- )
- assertNull(
- AuthenticatedIngressLinkPolicy.resolve(
- authenticatedLinkID = "completed-link",
- authenticatedRelayAddress = "provisional",
- links = links,
- currentTransportForRelay = { replacementSocket }
- )
- )
- }
-}
diff --git a/app/src/test/kotlin/com/bitchat/android/wifi-aware/IngressLinkPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/wifi-aware/IngressLinkPolicyTest.kt
new file mode 100644
index 00000000..cf352d7c
--- /dev/null
+++ b/app/src/test/kotlin/com/bitchat/android/wifi-aware/IngressLinkPolicyTest.kt
@@ -0,0 +1,64 @@
+package com.bitchat.android.wifiaware
+
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertSame
+import org.junit.Test
+
+class IngressLinkPolicyTest {
+ @Test
+ fun `observation resolves only the exact ingress link`() {
+ val attackerSocket = Any()
+ val victimSocket = Any()
+ val links = mapOf(
+ "attacker-link" to IngressLinkPolicy.Link("provisional-attacker", attackerSocket),
+ "victim-link" to IngressLinkPolicy.Link("provisional-victim", victimSocket)
+ )
+ val current = mapOf(
+ "provisional-attacker" to attackerSocket,
+ "provisional-victim" to victimSocket
+ )
+
+ val resolved = IngressLinkPolicy.resolve(
+ ingressLinkID = "victim-link",
+ relayAddress = "provisional-victim",
+ links = links,
+ currentTransportForRelay = current::get
+ )
+
+ assertSame(victimSocket, resolved?.transport)
+ }
+
+ @Test
+ fun `stale replaced or mismatched ingress links cannot be observed`() {
+ val completedSocket = Any()
+ val replacementSocket = Any()
+ val links = mapOf(
+ "completed-link" to IngressLinkPolicy.Link("provisional", completedSocket)
+ )
+
+ assertNull(
+ IngressLinkPolicy.resolve(
+ ingressLinkID = "missing-link",
+ relayAddress = "provisional",
+ links = links,
+ currentTransportForRelay = { completedSocket }
+ )
+ )
+ assertNull(
+ IngressLinkPolicy.resolve(
+ ingressLinkID = "completed-link",
+ relayAddress = "different-provisional",
+ links = links,
+ currentTransportForRelay = { completedSocket }
+ )
+ )
+ assertNull(
+ IngressLinkPolicy.resolve(
+ ingressLinkID = "completed-link",
+ relayAddress = "provisional",
+ links = links,
+ currentTransportForRelay = { replacementSocket }
+ )
+ )
+ }
+}
diff --git a/app/src/test/kotlin/com/bitchat/android/wifi-aware/SyncedSocketContractTest.kt b/app/src/test/kotlin/com/bitchat/android/wifi-aware/SyncedSocketContractTest.kt
new file mode 100644
index 00000000..ed5cec9f
--- /dev/null
+++ b/app/src/test/kotlin/com/bitchat/android/wifi-aware/SyncedSocketContractTest.kt
@@ -0,0 +1,151 @@
+package com.bitchat.android.wifiaware
+
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertThrows
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.verify
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
+import java.io.DataInputStream
+import java.io.DataOutputStream
+import java.io.FilterInputStream
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+import java.net.Socket
+import java.util.Collections
+
+class SyncedSocketContractTest {
+ @Test
+ fun `write emits big-endian length payload and empty keepalive frames`() {
+ val output = ByteArrayOutputStream()
+ val raw = socket(input = ByteArrayInputStream(byteArrayOf()), output = output)
+ val synced = SyncedSocket(raw, readTimeoutMs = 1_234)
+
+ synced.write(byteArrayOf(1, 2, 3))
+ synced.write(ByteArray(0))
+
+ assertArrayEquals(
+ byteArrayOf(0, 0, 0, 3, 1, 2, 3, 0, 0, 0, 0),
+ output.toByteArray()
+ )
+ verify(raw).soTimeout = 1_234
+ }
+
+ @Test
+ fun `readFully reconstructs one-byte partial reads and keepalives`() {
+ val wire = framed(byteArrayOf(1, 2, 3, 4)) + framed(ByteArray(0))
+ val partial = object : FilterInputStream(ByteArrayInputStream(wire)) {
+ override fun read(buffer: ByteArray, offset: Int, length: Int): Int =
+ super.read(buffer, offset, minOf(1, length))
+ }
+ val synced = SyncedSocket(socket(partial, ByteArrayOutputStream()))
+
+ assertArrayEquals(byteArrayOf(1, 2, 3, 4), synced.read())
+ assertArrayEquals(ByteArray(0), synced.read())
+ assertNull(synced.read())
+ }
+
+ @Test
+ fun `EOF truncated invalid and oversized frames fail closed`() {
+ val cases = listOf(
+ ByteArray(0),
+ byteArrayOf(0, 0),
+ byteArrayOf(0, 0, 0, 4, 1, 2),
+ intPrefix(-1),
+ intPrefix(65_537)
+ )
+
+ cases.forEach { wire ->
+ val synced = SyncedSocket(
+ socket(ByteArrayInputStream(wire), ByteArrayOutputStream())
+ )
+ assertNull(synced.read())
+ }
+ }
+
+ @Test
+ fun `write exceptions propagate and do not create a partial success`() {
+ val failingOutput = object : OutputStream() {
+ override fun write(value: Int) {
+ throw IOException("scripted write failure")
+ }
+ }
+ val synced = SyncedSocket(socket(ByteArrayInputStream(byteArrayOf()), failingOutput))
+
+ assertThrows(IOException::class.java) {
+ synced.write(byteArrayOf(1))
+ }
+ }
+
+ @Test
+ fun `concurrent writers produce complete non-interleaved frames`() {
+ val output = ByteArrayOutputStream()
+ val synced = SyncedSocket(socket(ByteArrayInputStream(byteArrayOf()), output))
+ val payloads = (0 until 16).map { index ->
+ ByteArray(index + 1) { index.toByte() }
+ }
+ val failures = Collections.synchronizedList(mutableListOf())
+ val threads = payloads.map { payload ->
+ Thread {
+ runCatching { synced.write(payload) }
+ .exceptionOrNull()
+ ?.let(failures::add)
+ }.also(Thread::start)
+ }
+ threads.forEach { thread ->
+ thread.join(2_000)
+ assertFalse("Writer thread did not complete", thread.isAlive)
+ }
+ assertTrue(failures.isEmpty())
+
+ val input = DataInputStream(ByteArrayInputStream(output.toByteArray()))
+ val decoded = mutableListOf()
+ while (input.available() > 0) {
+ val length = input.readInt()
+ decoded += ByteArray(length).also(input::readFully)
+ }
+ assertEquals(
+ payloads.map(ByteArray::toList).toSet(),
+ decoded.map(ByteArray::toList).toSet()
+ )
+ }
+
+ @Test
+ fun `close and raw socket status are exposed`() {
+ val raw = socket(ByteArrayInputStream(byteArrayOf()), ByteArrayOutputStream())
+ org.mockito.kotlin.whenever(raw.isClosed).thenReturn(false, true)
+ org.mockito.kotlin.whenever(raw.isConnected).thenReturn(true)
+ val synced = SyncedSocket(raw)
+
+ assertFalse(synced.isClosed())
+ assertTrue(synced.isConnected())
+ synced.close()
+ verify(raw).close()
+ assertTrue(synced.isClosed())
+ }
+
+ private fun socket(input: InputStream, output: OutputStream): Socket = mock {
+ on { getInputStream() } doReturn input
+ on { getOutputStream() } doReturn output
+ }
+
+ private fun framed(payload: ByteArray): ByteArray =
+ ByteArrayOutputStream().also { output ->
+ DataOutputStream(output).use { data ->
+ data.writeInt(payload.size)
+ data.write(payload)
+ }
+ }.toByteArray()
+
+ private fun intPrefix(value: Int): ByteArray =
+ ByteArrayOutputStream().also { output ->
+ DataOutputStream(output).use { it.writeInt(value) }
+ }.toByteArray()
+}
diff --git a/app/src/test/kotlin/com/bitchat/android/wifi-aware/WifiAwareConnectionTrackerTest.kt b/app/src/test/kotlin/com/bitchat/android/wifi-aware/WifiAwareConnectionTrackerTest.kt
index b1b12d0e..5cd2cbf7 100644
--- a/app/src/test/kotlin/com/bitchat/android/wifi-aware/WifiAwareConnectionTrackerTest.kt
+++ b/app/src/test/kotlin/com/bitchat/android/wifi-aware/WifiAwareConnectionTrackerTest.kt
@@ -17,21 +17,21 @@ import java.net.Socket
class WifiAwareConnectionTrackerTest {
@Test
- fun `compare and rebind rejects stale authenticated socket after replacement`() {
+ fun `compare and rebind rejects stale observed socket after replacement`() {
val tracker = WifiAwareConnectionTracker(
CoroutineScope(SupervisorJob() + Dispatchers.Unconfined),
mock()
)
- val authenticatedSocket = syncedSocket()
+ val observedSocket = syncedSocket()
val replacementSocket = syncedSocket()
- tracker.onClientConnected("provisional", authenticatedSocket)
+ tracker.onClientConnected("provisional", observedSocket)
tracker.onClientConnected("provisional", replacementSocket)
assertFalse(
tracker.rebindPeerIdIfCurrent(
previousPeerId = "provisional",
resolvedPeerId = "canonical",
- expectedSocket = authenticatedSocket
+ expectedSocket = observedSocket
)
)
assertSame(replacementSocket, tracker.getSocketForPeer("provisional"))
@@ -49,7 +49,7 @@ class WifiAwareConnectionTrackerTest {
}
@Test
- fun `authenticated provisional socket cannot displace existing canonical socket`() {
+ fun `observed provisional socket cannot displace existing canonical socket`() {
val tracker = WifiAwareConnectionTracker(
CoroutineScope(SupervisorJob() + Dispatchers.Unconfined),
mock()
diff --git a/build.gradle.kts b/build.gradle.kts
index 44c5199e..5aac5518 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -10,3 +10,9 @@ tasks.whenTaskAdded {
enabled = false
}
}
+
+tasks.register("clientRewriteContractTest") {
+ group = "verification"
+ description = "Runs the complete compatibility gate for a from-scratch client rewrite."
+ dependsOn(":app:testDebugUnitTest")
+}
diff --git a/docs/client-rewrite-contracts.md b/docs/client-rewrite-contracts.md
new file mode 100644
index 00000000..0337ccf9
--- /dev/null
+++ b/docs/client-rewrite-contracts.md
@@ -0,0 +1,63 @@
+# Client rewrite compatibility contracts
+
+This document defines the behavior a from-scratch BitChat client must preserve.
+The executable source of truth is the JVM test suite under
+`app/src/test/**/contracts`, together with the pre-existing protocol, security,
+mesh, and state tests.
+
+The remaining implementation work and milestone progress are tracked in
+[test-implementation-plan.md](test-implementation-plan.md).
+
+## Required contract layers
+
+| Layer | Compatibility promise | Primary tests |
+|---|---|---|
+| Outer mesh packet | v1/v2 header widths, big-endian fields, flags, section order, route placement, signature placement, padding, compression, signing bytes | `BinaryProtocolTest`, `ClientRewriteWireContractTest` |
+| Chat payload | Flag bits, millisecond timestamp, UTF-8 byte lengths, encrypted-content substitution, optional-field order | `ClientRewriteWireContractTest` |
+| Inner payloads | Noise type bytes, private-message TLVs, peer-state TLVs, file-transfer TLVs, fragment header, sync request TLVs | `ClientRewriteWireContractTest`, `AuthenticatedPeerStateTest`, `PrivateMediaTransferPreparerTest`, `FragmentManagerTest` |
+| Identity/security | Announcement extensions, capability bitfield endianness, Noise static-key binding, handshake identity binding, signatures | `IdentityAnnouncementTest`, `NoiseSessionManagerIdentityBindingTest`, `ClientRewritePrimitiveContractTest` |
+| Sync/routing | Stable packet IDs, GCS bitstream, replay collapse, TTL handling, relay choice, confirmed graph edges | `ClientRewritePrimitiveContractTest`, `GCSFilterTest`, `PacketRelayManagerTest`, `MeshGraphServiceTest`, `TransportBridgeServiceTest` |
+| Nostr | Bech32, secp256k1 key derivation, NIP-01 event IDs/signatures, NIP-44 authenticated encryption, NIP-13 PoW, authenticated NIP-17 seals | `ClientRewriteNostrContractTest`, `NostrProtocolTest` |
+| Application state | Peer unions, canonical private conversations, chronological history, delivery/read behavior, media migration policy | `AppStateStoreTest`, `PrivateChatManagerTest`, `MediaSendingManagerMigrationTest` |
+
+## Golden-vector policy
+
+Golden vectors compare literal externally visible bytes or hashes. Do not update
+them merely because an implementation changed. Update a vector only when the
+wire protocol is intentionally versioned and interoperating clients are updated
+together.
+
+Round-trip tests remain useful but are not sufficient on their own: an encoder
+and decoder can share the same defect. Each critical wire format therefore has
+at least one literal vector.
+
+## Rewrite acceptance gate
+
+From a configured Android development environment, run:
+
+```sh
+./gradlew clientRewriteContractTest
+```
+
+The task runs the new golden vectors and the complete existing unit suite. A
+rewrite is compatible only when this gate passes. Tests should be ported
+unchanged when package boundaries change; adapter façades are preferable to
+weakening assertions.
+
+## Device-only acceptance
+
+Local JVM tests cannot prove Android radio and lifecycle behavior. Before
+shipping a rewrite, run the following on at least two physical devices:
+
+1. BLE discovery, connection, disconnect, reconnect, and multi-hop relay.
+2. Runtime permission denial/retry for Bluetooth, location, notifications, and
+ microphone.
+3. Foreground-service survival with the screen off and after process recreation.
+4. Cross-client Android/iOS exchange for announce, public/private text, delivery
+ and read receipts, image/audio/file transfer, sync replay, and Nostr fallback.
+5. Corrupt, duplicated, reordered, delayed, and partially delivered fragments.
+6. Identity rotation, verification continuity, downgrade rejection, and recovery
+ after stale Noise sessions.
+
+Those scenarios belong in instrumented tests or a two-device interoperability
+harness; they must not be represented as passing JVM mocks.
diff --git a/docs/device-transport-test-matrix.md b/docs/device-transport-test-matrix.md
new file mode 100644
index 00000000..8ada4018
--- /dev/null
+++ b/docs/device-transport-test-matrix.md
@@ -0,0 +1,113 @@
+# Physical transport validation matrix
+
+This is the device-only companion to the deterministic Milestone 4 transport
+suite. It validates that the fake adapters, Robolectric behavior, and pure
+state machines match Android framework behavior. It is also consumed by the
+Milestone 10 release gate.
+
+## Required device set
+
+- Two physical Android devices from different manufacturers.
+- At least one Android 13+ device for `NEARBY_WIFI_DEVICES`.
+- At least one device that supports Wi-Fi Aware.
+- Bluetooth LE central and peripheral support on both devices.
+- A build from the exact commit under test installed on both devices.
+- Clean app data before the first run; retain a second run for restart tests.
+
+Record device models, API levels, build commit, negotiated MTUs, and timestamps
+in the release artifact. Do not record user names, device serials, Bluetooth
+addresses, IP addresses, peer IDs, message contents, or other identifying
+values.
+
+## BLE discovery and recovery
+
+- [ ] Start both clients and confirm each begins scanning and advertising.
+- [ ] Stop and restart the foreground service; confirm exactly one scanner and
+ advertiser generation remains active.
+- [ ] Toggle Bluetooth off during scanning, then on; confirm scanning,
+ advertising, announcements, and peer discovery recover without process
+ restart.
+- [ ] Disable and re-enable the BLE debug transport; confirm the same service
+ instance can recover without duplicate callbacks.
+- [ ] Rotate the observed BLE address by restarting advertising; confirm the
+ canonical peer remains singular.
+- [ ] Trigger a transient scan failure or Android Bluetooth process restart;
+ confirm bounded retry and watchdog recovery.
+- [ ] Confirm permission denial reports unavailable state without a crash,
+ prompt loop, or active radio work.
+
+## GATT setup and teardown
+
+- [ ] Connect in both directions simultaneously and confirm one canonical link
+ survives.
+- [ ] Record the negotiated MTU and repeat at 23, 247, and 517 where the device
+ or test peripheral allows it.
+- [ ] Remove the service, characteristic, or CCCD in a test peripheral and
+ confirm setup fails closed.
+- [ ] Reject notification registration and descriptor writes; confirm the peer
+ is never published ready.
+- [ ] Disconnect during MTU negotiation, service discovery, subscription,
+ client write, and server notification; confirm no stale ready callback.
+- [ ] Leave setup incomplete for more than 30 seconds; confirm timeout and
+ resource closure.
+- [ ] Connect beyond configured client, server, and total limits; confirm
+ deterministic oldest-link eviction.
+
+## Packet delivery and fragmentation
+
+- [ ] Send directed and broadcast packets over client and server roles.
+- [ ] Saturate each link faster than radio completion callbacks; confirm one
+ outstanding operation, bounded backpressure, and no reordered frames.
+- [ ] Inject a failed `onCharacteristicWrite` and `onNotificationSent`; confirm
+ queued work is discarded and the failed generation is cleaned up.
+- [ ] Transfer payloads immediately below and above the fragmentation boundary.
+- [ ] Transfer a maximum admitted private-media payload at negotiated MTU 517.
+- [ ] At MTU 247 and 23, confirm oversized frames are rejected rather than
+ partially sent. Adaptive per-link fragmentation remains tracked as
+ `TDB-023`.
+- [ ] Disconnect and reconnect halfway through a fragmented transfer; confirm
+ incomplete state expires and a fresh transfer can finish.
+- [ ] Cancel a queued transfer and stop the service during another; confirm no
+ later fragments or progress callbacks.
+
+## Wi-Fi Aware
+
+- [ ] Confirm unsupported hardware and temporarily unavailable radio states are
+ distinct.
+- [ ] Deny and grant `NEARBY_WIFI_DEVICES`; confirm publish/subscribe work only
+ after grant.
+- [ ] Start and stop publish and subscribe sessions repeatedly; confirm no
+ duplicate discovery callbacks.
+- [ ] Authenticate a provisional socket and promote it to the canonical peer.
+- [ ] Replace a socket while authentication is in flight; confirm the stale
+ socket cannot promote or deliver.
+- [ ] Toggle Wi-Fi, location, and airplane mode; confirm rediscovery and bounded
+ reconnect after availability returns.
+- [ ] Stop the service with active sockets, server sockets, and network
+ callbacks; confirm all are closed or unregistered.
+
+## Unified transport and failover
+
+- [ ] Connect the same peer over BLE and Wi-Fi Aware; confirm one peer-list row.
+- [ ] Send with both transports active; confirm the preferred transport is used.
+- [ ] Drop the preferred transport during a transfer and confirm defined
+ failover behavior without duplicate application delivery.
+- [ ] Relay between transports and confirm TTL decreases once per hop.
+- [ ] Reflect a bridged packet back over the other transport; confirm loop and
+ duplicate suppression.
+- [ ] Stop the foreground service; confirm scans, advertisements, sessions,
+ sockets, operation queues, transfer jobs, and callbacks all terminate.
+
+## Evidence template
+
+| Field | Value |
+|---|---|
+| Commit | |
+| Device/API classes | |
+| BLE central/peripheral | Pass / Fail |
+| MTU cases | Pass / Fail / Unsupported |
+| BLE recovery | Pass / Fail |
+| Wi-Fi Aware lifecycle | Pass / Fail / Unsupported |
+| Cross-transport failover | Pass / Fail |
+| Shutdown leak check | Pass / Fail |
+| Bugs filed | |
diff --git a/docs/file_transfer.md b/docs/file_transfer.md
index 333c4d5a..8422b77a 100644
--- a/docs/file_transfer.md
+++ b/docs/file_transfer.md
@@ -107,6 +107,28 @@ source-route metadata. It does not imply multi-gigabyte mesh transfer support.
transport threshold; the data portion is at most 469 bytes and becomes
smaller when recipient or source-route overhead is present.
+#### Compressed expansion rollout gate (resolved)
+
+Android's bounded decoder applies the same 10 MiB expanded-payload ceiling to every outer
+message type. It also requires a non-empty compressed body, an exact declared output size, and a
+complete deflate stream. The `FILE_TRANSFER (0x22)` byte cannot safely grant a larger ceiling: it is
+attacker-controlled before packet signature verification, and the current receive pipeline must
+inflate before it can perform that verification.
+
+New Android senders cap files just below 10 MiB (reserving envelope overhead) and refuse to encode
+any payload above the receiver ceiling; exceeding the cap surfaces a user-visible error in chat.
+Support for legacy >10 MiB compressed transfers is explicitly ended: legacy Android senders can
+still produce a highly compressible public file between 10 MiB and their 50 MiB UI limit that the
+bounded decoder rejects. Grandfathering 50 MiB is not safe after only a type check: inflation
+allocates the declared payload and
+`BitchatFilePacket.decode` currently copies the content again, creating a greater than 100 MiB peak
+for a maximum-size transfer.
+
+Before enabling that legacy range, receive processing needs an authenticated admission decision
+made before large allocation plus streaming inflation/TLV parsing into a bounded temporary file (or
+another ownership-preserving design that avoids the second full-size copy). The sender limit and a
+wire capability/version transition must then be coordinated so old and new clients fail predictably.
+
### 1.3 File Transfer TLV payload (BitchatFilePacket)
The file payload is a TLV structure with mixed length field sizes to support large contents efficiently.
diff --git a/docs/release-gate-runbook.md b/docs/release-gate-runbook.md
new file mode 100644
index 00000000..414bfbae
--- /dev/null
+++ b/docs/release-gate-runbook.md
@@ -0,0 +1,244 @@
+# Physical-device and cross-client release gate
+
+This runbook turns Milestone 10 into a repeatable release procedure. The gate
+uses a host-side CLI and USB/ADB as its control channel, so control traffic
+never shares BLE, Wi-Fi Aware, Nostr, or Tor with the system under test.
+
+The gate cannot pass without the required physical devices and counterpart
+clients. A pending or blocked result is useful diagnostic evidence, but it is
+not release approval.
+
+## Safety and privacy rules
+
+- Use only disposable lab app data, identities, nicknames, messages, and files.
+- Never use a personal Nostr account or a production relay.
+- Do not put device serials, UDIDs, Bluetooth/MAC/IP addresses, peer IDs,
+ usernames, email addresses, local home paths, or message contents in a
+ result, trace, filename, issue, commit, or release artifact.
+- Device selectors may be supplied to ADB commands as ephemeral inputs. The
+ tooling emits only logical aliases such as `android-current`.
+- Models, manufacturer classes, Android API levels, negotiated MTU classes,
+ client versions, commit hashes, aggregate counts, durations, and stable
+ failure reason codes are allowed.
+- Do not archive raw logcat. Convert observations to the structured,
+ privacy-checked trace format, and keep any raw diagnostic capture local until
+ it has been reviewed and sanitized.
+
+The validator rejects known identifying fields and values before a passing
+bundle can be created.
+
+## Required lab
+
+Prepare:
+
+- At least three physical Android devices for three-hop relay testing.
+- At least two Android API levels and two manufacturer classes.
+- BLE central and peripheral support on every Android device.
+- At least one Android 13+ device with Wi-Fi Aware.
+- One physical device running the current iOS client.
+- The last supported Android client.
+- The release-candidate APK built from one exact full Git commit.
+- A local, disposable Nostr relay/Tor fixture with production network access
+ blocked.
+
+One physical handset may be reused for the legacy-client phase after the
+current-client evidence for that slot is complete, but the matrix must keep the
+logical aliases and installed client versions unambiguous.
+
+## 1. Verify the deterministic gate
+
+From the repository root:
+
+```sh
+./gradlew clientRewriteContractTest checkChangedLineCoverage lintDebug
+python3 tools/release_gate/release_gate.py validate-manifest
+```
+
+Do not begin device work from a dirty tree or a build whose deterministic gate
+does not pass.
+
+## 2. Create the device matrix
+
+Copy `tools/release_gate/device-matrix.example.json` to an ignored working
+directory under `release-gate-results/`. Replace every template value and set
+both current-Android commit fields to the exact full commit under test.
+
+Probe Android capabilities without storing the ADB selector:
+
+```sh
+python3 tools/release_gate/android_lab.py probe \
+ --serial "$BITCHAT_ADB_SELECTOR" \
+ --alias android-current
+```
+
+Copy only the returned logical metadata into the matrix. Validate it:
+
+```sh
+python3 tools/release_gate/release_gate.py validate-matrix \
+ --matrix release-gate-results/device-matrix.json \
+ --commit "$BITCHAT_RELEASE_COMMIT"
+```
+
+The matrix validator enforces physical devices, three Android participants, two
+API levels, two manufacturer classes, Wi-Fi Aware, BLE roles, iOS, and explicit
+current/legacy client versions.
+
+## 3. Initialize disposable fixtures
+
+```sh
+python3 tools/release_gate/release_gate.py init \
+ --matrix release-gate-results/device-matrix.json \
+ --commit "$BITCHAT_RELEASE_COMMIT" \
+ --run-id rc-lab-01 \
+ --output release-gate-results/rc-lab-01
+```
+
+Initialization pins the scenario and fixture manifests, creates every scenario
+as `pending`, and generates deterministic:
+
+- zero-byte and small files;
+- a Unicode-named medium file;
+- sparse exact-maximum and oversized boundary files.
+
+The fixture manifest records size and SHA-256. The final archive contains the
+manifest, not the large fixture bodies.
+
+Clear only the disposable app data on each selected lab device:
+
+```sh
+python3 tools/release_gate/android_lab.py prepare \
+ --serial "$BITCHAT_ADB_SELECTOR" \
+ --confirm-disposable-app-data
+```
+
+This stops the app and runs package-data cleanup. The explicit confirmation is
+required because the operation is destructive to that app's local data.
+
+## 4. Execute scenarios
+
+The canonical scenario list is
+`tools/release_gate/scenarios.json`. It contains 27 mandatory scenarios:
+
+- the complete physical transport matrix;
+- Android API/manufacturer/permission/background coverage;
+- 11 Android-to-Android workflows;
+- 8 cross-client/backward-compatibility workflows;
+- 6 background and endurance workflows.
+
+For each scenario:
+
+1. Confirm the listed participants and capabilities.
+2. Perform the corresponding steps in
+ [device-transport-test-matrix.md](device-transport-test-matrix.md) and the
+ Milestone 10 checklist.
+3. Record connection, lifecycle, transport, receipt, resource, and terminal
+ state as aggregate evidence.
+4. Append at least one structured trace event.
+5. Mark the scenario `pass`, `fail`, `blocked`, or `unsupported`.
+
+Record evidence with the exact keys declared by the scenario:
+
+```sh
+python3 tools/release_gate/release_gate.py record \
+ --run release-gate-results/rc-lab-01 \
+ --scenario A2A-001 \
+ --status pass \
+ --evidence connection-transitions=4 \
+ --evidence packet-correlation-count=6 \
+ --evidence failure-reasons=none
+```
+
+Append a privacy-safe trace event:
+
+```sh
+python3 tools/release_gate/release_gate.py trace \
+ --run release-gate-results/rc-lab-01 \
+ --scenario A2A-001 \
+ --source android-current \
+ --event reconnect-terminal \
+ --outcome pass \
+ --metric reconnect-count=1 \
+ --metric duplicate-delivery-count=0
+```
+
+Capture resource snapshots during endurance work:
+
+```sh
+python3 tools/release_gate/android_lab.py snapshot \
+ --serial "$BITCHAT_ADB_SELECTOR" \
+ --alias android-current \
+ --run release-gate-results/rc-lab-01 \
+ --scenario END-003
+```
+
+Use run-local sequential correlation labels while observing packets; archive
+only aggregate correlation counts. Record failures with a stable reason code,
+file a regression issue, and preserve the incomplete artifact.
+
+## 5. Endurance requirements
+
+- `END-001` requires at least 240 minutes.
+- `END-002` requires at least 50 large-transfer/cancellation cycles.
+- Sample memory, threads, file descriptors, wake locks, connection counts, and
+ late callbacks at consistent intervals.
+- A passing result requires bounded resource behavior and a clean terminal
+ state; merely completing the time window is insufficient.
+
+The validator rejects shorter durations and cycle counts.
+
+## 6. Inspect progress and validate
+
+During a run:
+
+```sh
+python3 tools/release_gate/release_gate.py validate \
+ --run release-gate-results/rc-lab-01 \
+ --allow-incomplete
+
+python3 tools/release_gate/release_gate.py summary \
+ --run release-gate-results/rc-lab-01
+```
+
+The release validator, without `--allow-incomplete`, requires:
+
+- every scenario to be `pass`;
+- every declared evidence field;
+- at least one structured trace per scenario;
+- the pinned scenario and fixture manifests;
+- the exact client commit and complete device matrix;
+- endurance minimums;
+- a completion timestamp;
+- no detected identifying fields or values.
+
+`unsupported`, `blocked`, and `pending` never satisfy release approval.
+
+## 7. Archive release approval
+
+After the complete validator passes:
+
+```sh
+python3 tools/release_gate/release_gate.py bundle \
+ --run release-gate-results/rc-lab-01 \
+ --output release-gate-results/rc-lab-01.zip
+```
+
+The deterministic archive contains the scenario manifest, device/client matrix,
+results, structured trace, fixture manifest, Markdown summary, and
+`SHA256SUMS`. Attach it to the release approval record without renaming fields
+or adding raw diagnostics.
+
+Finally, clean the disposable app data with the same confirmed `cleanup`
+command and stop the local relay/Tor fixture.
+
+## Failure handling
+
+- `fail`: behavior violated a contract. Record a stable reason code, file a bug,
+ add a deterministic regression where possible, fix it, and rerun the affected
+ scenario plus dependent scenarios.
+- `blocked`: required lab infrastructure or counterpart client was unavailable.
+ Preserve the artifact and do not approve release.
+- `unsupported`: the selected device lacks a capability. Because the defined
+ matrix requires Wi-Fi Aware, replace the device or matrix; unsupported does
+ not waive a mandatory scenario.
+- A flaky result is a failure until its cause is understood. Never average
+ retries into a pass.
diff --git a/docs/test-implementation-plan.md b/docs/test-implementation-plan.md
new file mode 100644
index 00000000..e64b633b
--- /dev/null
+++ b/docs/test-implementation-plan.md
@@ -0,0 +1,779 @@
+# Test implementation plan
+
+## Objective
+
+Build enough deterministic, adversarial, integration, and device-level coverage
+that the Android client can be rewritten from scratch without silently changing
+its wire behavior, security properties, delivery semantics, lifecycle behavior,
+or user-visible workflows.
+
+The canonical compatibility requirements are documented in
+[client-rewrite-contracts.md](client-rewrite-contracts.md). This plan describes
+how to turn those requirements into a complete, continuously enforced test
+program.
+
+## Status legend
+
+- **Complete**: acceptance criteria are met and the tests run in the rewrite gate.
+- **In progress**: implementation has started but acceptance criteria are not met.
+- **Not started**: no implementation work has been completed.
+- **Blocked**: progress requires an external dependency, device, or decision.
+
+## Current progress
+
+| Milestone | Status | Progress | Depends on |
+|---|---|---:|---|
+| 0. Compatibility baseline | Complete | 100% | — |
+| 1. Coverage and deterministic test infrastructure | Not started | 0% | 0 |
+| 2. Adversarial protocol and parser testing | Not started | 0% | 1 |
+| 3. Noise, cryptography, and identity testing | Not started | 0% | 1 |
+| 4. BLE, Wi-Fi Aware, and transport lifecycle testing | Not started | 0% | 1, 3 |
+| 5. Sync, routing, and store-and-forward testing | Not started | 0% | 1, 4 |
+| 6. Nostr and Tor integration testing | Not started | 0% | 1, 3 |
+| 7. Android lifecycle and permission testing | Not started | 0% | 1, 4 |
+| 8. Persistence, migration, and recovery testing | Not started | 0% | 1, 3 |
+| 9. UI, media, and accessibility testing | Not started | 0% | 1, 7, 8 |
+| 10. Physical-device and cross-client release gate | Not started | 0% | 2–9 |
+
+Milestone completion is currently **1 of 11 milestones (9%)**. This is
+milestone-based progress, not line or branch coverage. Milestone 1 will establish
+measured coverage baselines and trends.
+
+## Test levels and execution policy
+
+| Level | Purpose | Expected execution |
+|---|---|---|
+| Pure JVM unit tests | Protocols, state machines, crypto vectors, parsing, routing, and deterministic utilities | Every pull request |
+| Property and fuzz tests | Malformed inputs, boundary exploration, invariants, and crash resistance | Bounded set on every pull request; extended corpus nightly |
+| Robolectric tests | Android services, lifecycle, broadcasts, permissions, persistence, and process recreation | Every pull request where stable |
+| Instrumented emulator tests | Compose semantics, navigation, database/filesystem integration, and permission flows | Main branch and release candidates |
+| Physical-device tests | BLE, Wi-Fi Aware, radios, background execution, and manufacturer-specific behavior | Nightly where devices are available; mandatory release gate |
+| Cross-client interoperability | Android/iOS and old/new client wire compatibility | Mandatory release gate |
+
+## Global rules
+
+- [x] Keep literal golden vectors for externally visible bytes and hashes.
+- [x] Run all compatibility and regression tests through
+ `./gradlew clientRewriteContractTest`.
+- [ ] Prefer public behavior and stable adapter interfaces over implementation
+ details.
+- [ ] Require deterministic clocks, randomness, dispatchers, storage, and
+ transports in tests.
+- [ ] Never use production relay or internet availability as a test dependency.
+- [ ] Every fixed protocol or security defect must receive a regression test.
+- [ ] Every decoder must have positive, boundary, malformed, and fuzz coverage.
+- [ ] Every asynchronous test must have bounded completion and must not use
+ arbitrary sleeps.
+- [ ] Test failures must preserve seeds, inputs, and traces needed to reproduce
+ the failure.
+- [ ] Golden vectors may change only with an intentional protocol version change
+ and coordinated interoperability review.
+
+---
+
+## Milestone 0: Compatibility baseline
+
+**Status:** Complete
+**Progress:** 100%
+
+### Scope
+
+Establish executable rewrite contracts for the most important deterministic
+wire formats and reuse the existing regression suite as a single acceptance
+gate.
+
+### Completed checklist
+
+- [x] Create an isolated workspace and
+ `codex/client-rewrite-contract-tests` branch.
+- [x] Add literal v1 and v2 outer packet vectors.
+- [x] Add public, private, optional-field, and encrypted message vectors.
+- [x] Add private-message, Noise envelope, fragment, sync, identity, and file
+ transfer vectors.
+- [x] Add padding, binary encoding, geohash, gossip, packet ID, GCS, and Noise
+ peer-ID contracts.
+- [x] Add Bech32, secp256k1, NIP-01, NIP-44, and NIP-13 contracts.
+- [x] Add required-prefix and representative truncated-input rejection tests.
+- [x] Add explicit validation for malformed fragment IDs.
+- [x] Add `clientRewriteContractTest` as the complete rewrite acceptance task.
+- [x] Verify 32 new tests pass without skips.
+- [x] Verify the full gate discovers 254 tests with zero failures or errors.
+- [x] Document the remaining device-only acceptance requirements.
+
+### Acceptance criteria
+
+- [x] The original `main` workspace remains unchanged.
+- [x] All new golden-vector tests pass.
+- [x] The complete unit suite passes through one documented command.
+
+---
+
+## Milestone 1: Coverage and deterministic test infrastructure
+
+**Status:** Not started
+**Progress:** 0%
+
+### Goal
+
+Make coverage measurable and provide reusable deterministic seams so later
+milestones test behavior without real time, radios, network access, or flaky
+scheduling.
+
+### TODO checklist
+
+#### Coverage reporting
+
+- [ ] Add JaCoCo or Kover for JVM unit-test line and branch coverage.
+- [ ] Generate XML and HTML reports from the rewrite acceptance task.
+- [ ] Record the initial project-wide line and branch coverage baseline.
+- [ ] Record package-level baselines for `mesh`, `noise`, `nostr`, `service`,
+ `services`, `sync`, `model`, `protocol`, `identity`, and `ui`.
+- [ ] Publish coverage artifacts in CI.
+- [ ] Add a changed-lines coverage check for new production code.
+- [ ] Add non-regression thresholds without forcing low-value tests for trivial
+ generated or platform glue.
+- [ ] Exclude generated code, Compose compiler output, Android resource classes,
+ and vendored cryptographic code from first-party coverage metrics.
+
+#### Deterministic seams
+
+- [ ] Introduce an injectable monotonic clock and wall clock.
+- [ ] Introduce injectable secure and non-secure random-byte sources where
+ deterministic vectors are required.
+- [ ] Introduce injectable coroutine dispatchers and test scopes.
+- [ ] Introduce an in-memory key/value storage adapter for preferences.
+- [ ] Introduce an in-memory file store with controllable I/O failures.
+- [ ] Define a fake mesh transport that can connect, disconnect, delay, drop,
+ duplicate, corrupt, reorder, and fragment packets.
+- [ ] Define fake BLE scanner, advertiser, GATT client, and GATT server adapters.
+- [ ] Define a fake Wi-Fi Aware session/socket adapter.
+- [ ] Define a fake Nostr relay transport or MockWebServer fixture.
+- [ ] Provide reusable packet, identity, peer, graph, and message fixture
+ builders.
+- [ ] Provide seed capture and reproduction helpers for randomized tests.
+- [ ] Add test naming and directory conventions for unit, property, Robolectric,
+ instrumented, and interoperability suites.
+
+### Acceptance criteria
+
+- [ ] One command generates a repeatable coverage report.
+- [ ] Two consecutive clean runs produce identical deterministic test results.
+- [ ] Fake time and transport behavior require no wall-clock sleeps.
+- [ ] CI publishes coverage and test-result artifacts.
+- [ ] The plan's progress table is updated with measured baseline numbers.
+
+---
+
+## Milestone 2: Adversarial protocol and parser testing
+
+**Status:** Not started
+**Progress:** 0%
+
+### Goal
+
+Prove that all wire decoders preserve canonical behavior, reject unsafe input,
+and never crash or allocate unreasonable memory for attacker-controlled data.
+
+### TODO checklist
+
+#### Outer packet protocol
+
+- [ ] Test every valid flag combination for v1 and v2.
+- [ ] Test exact minimum and maximum payload sizes.
+- [ ] Test sender and recipient IDs at 0, 1, 7, 8, 9, and oversized lengths.
+- [ ] Test signatures at 0, 1, 63, 64, 65, and oversized lengths.
+- [ ] Test route counts at 0, 1, 254, 255, and truncated route entries.
+- [ ] Test unknown message type values remain safely representable or are
+ rejected according to the protocol contract.
+- [ ] Test invalid versions, reserved flags, integer overflow, and unsigned
+ length conversion.
+- [ ] Test trailing bytes and concatenated frames explicitly.
+- [ ] Test padding boundaries around 256, 512, 1024, and 2048 bytes.
+- [ ] Test malformed PKCS#7 tails and ambiguous unpadded frames.
+- [ ] Test raw DEFLATE and zlib-header compatibility vectors.
+- [ ] Test forged original-size fields, compression bombs, and truncated
+ compressed streams.
+- [ ] Add encode/decode property tests for all valid packet shapes.
+- [ ] Add a mutation corpus derived from every golden packet.
+
+#### Inner payloads and TLVs
+
+- [ ] Fuzz `BitchatMessage.fromBinaryPayload`.
+- [ ] Fuzz `IdentityAnnouncement.decode`.
+- [ ] Fuzz `AuthenticatedPeerState.decode`.
+- [ ] Fuzz `PrivateMessagePacket.decode`.
+- [ ] Fuzz `NoisePayload.decode`.
+- [ ] Fuzz `BitchatFilePacket.decode`.
+- [ ] Fuzz `FragmentPayload.decode`.
+- [ ] Fuzz `RequestSyncPacket.decode`.
+- [ ] Test missing, duplicated, reordered, unknown, and zero-length TLVs.
+- [ ] Test truncated headers and values at every byte offset.
+- [ ] Test UTF-8 ASCII, multi-byte, combining-mark, emoji, invalid-byte, and
+ maximum-byte-length cases.
+- [ ] Test 255-byte one-byte-length boundaries.
+- [ ] Test 65,535-byte two-byte-length boundaries.
+- [ ] Test four-byte file content lengths and impossible content declarations.
+- [ ] Test fragmented file content using one and multiple content TLVs.
+- [ ] Define and test whether non-canonical but tolerated inputs re-encode
+ canonically.
+
+#### Fuzzing operations
+
+- [ ] Select a JVM-compatible property/fuzz framework.
+- [ ] Add bounded pull-request fuzz runs with fixed seeds.
+- [ ] Add extended randomized nightly runs.
+- [ ] Store minimized failing inputs as regression fixtures.
+- [ ] Assert no decoder throws for arbitrary byte arrays.
+- [ ] Assert decoder runtime and allocations stay within configured bounds.
+
+### Acceptance criteria
+
+- [ ] Every externally reachable decoder has boundary and malformed-input tests.
+- [ ] Every decoder has a bounded arbitrary-byte no-crash property.
+- [ ] All discovered crashes or ambiguous contracts have regression fixtures.
+- [ ] Extended fuzzing completes nightly and preserves reproduction seeds.
+
+---
+
+## Milestone 3: Noise, cryptography, and identity testing
+
+**Status:** Not started
+**Progress:** 0%
+
+### Goal
+
+Prove confidentiality, authenticity, identity binding, replay behavior, session
+replacement, rekeying, and recovery across the complete secure-channel
+lifecycle.
+
+### TODO checklist
+
+#### Known vectors and primitives
+
+- [ ] Add known Curve25519 key agreement vectors.
+- [ ] Add known Ed25519 signing and verification vectors.
+- [ ] Add known BIP-340 verification vectors.
+- [ ] Add known HKDF and channel-key derivation vectors.
+- [ ] Add external Noise XX handshake transcript vectors where compatible.
+- [ ] Add deterministic channel encryption vectors with injected nonces.
+- [ ] Test constant-time verification APIs where the underlying library exposes
+ an appropriate contract.
+
+#### Noise session lifecycle
+
+- [ ] Test initiator and responder handshakes without manager wrappers.
+- [ ] Test all valid handshake state transitions.
+- [ ] Test every invalid message for every handshake state.
+- [ ] Test tampered handshake messages and remote static-key substitution.
+- [ ] Test post-handshake encryption in both directions.
+- [ ] Test empty, small, maximum, and fragmented plaintext.
+- [ ] Test tampered ciphertext, nonce, tag, and associated data.
+- [ ] Test replayed ciphertext.
+- [ ] Test skipped, duplicated, and out-of-order transport messages.
+- [ ] Test send and receive nonce progression.
+- [ ] Test nonce exhaustion and counter-overflow behavior.
+- [ ] Test rekey thresholds, successful rekey, failed rekey, and simultaneous
+ rekey.
+- [ ] Test session reset and destruction zeroize or discard sensitive state as
+ designed.
+- [ ] Test handshake timeouts and stale generation leases with fake time.
+- [ ] Test simultaneous initiator tie-breaking across a larger peer matrix.
+- [ ] Test process restart with and without persisted identity.
+
+#### Identity and downgrade protection
+
+- [ ] Test peer-ID derivation for valid and malformed static keys.
+- [ ] Test signing-key rotation with authorized and unauthorized announcements.
+- [ ] Test private-media capability pinning across restart.
+- [ ] Test downgrade attempts after a capability has been pinned.
+- [ ] Test corrupted, missing, partially written, and legacy identity storage.
+- [ ] Test atomic clearing of identity, capability, and peer mappings.
+- [ ] Test verification fingerprints remain stable for unchanged identities.
+- [ ] Test identity replacement does not expose an established session before
+ authentication completes.
+
+### Acceptance criteria
+
+- [ ] Known vectors pass independently of Android storage and services.
+- [ ] Replay, tampering, downgrade, and identity-substitution tests all fail
+ closed.
+- [ ] All timeouts and rekey tests use fake time.
+- [ ] No sensitive test fixtures contain production keys or user data.
+
+---
+
+## Milestone 4: BLE, Wi-Fi Aware, and transport lifecycle testing
+
+**Status:** Not started
+**Progress:** 0%
+
+### Goal
+
+Verify connection state machines and packet delivery across unreliable Android
+transports without requiring real radios for the majority of cases.
+
+### TODO checklist
+
+#### BLE discovery and connection
+
+- [ ] Test scan start, stop, restart, and failure callbacks.
+- [ ] Test advertising start, stop, restart, and failure callbacks.
+- [ ] Test Bluetooth-off and Bluetooth-on recovery.
+- [ ] Test duplicate scan results and rapidly changing peer addresses.
+- [ ] Test connection success, rejection, timeout, and cancellation.
+- [ ] Test simultaneous inbound and outbound connection races.
+- [ ] Test canonical connection selection and duplicate-link teardown.
+- [ ] Test service discovery failure and missing characteristics.
+- [ ] Test GATT disconnect during discovery, negotiation, read, and write.
+- [ ] Test reconnect backoff with fake time.
+- [ ] Test maximum-connection enforcement and eviction policy.
+- [ ] Test RSSI thresholds and power-mode transitions.
+
+#### Packet transfer
+
+- [ ] Test MTU negotiation at minimum, normal, and maximum values.
+- [ ] Test partial writes and write callbacks delivered out of order.
+- [ ] Test notification subscription and notification failure.
+- [ ] Test queue backpressure and bounded memory use.
+- [ ] Test fragmentation and reassembly across disconnect/reconnect.
+- [ ] Test duplicate, missing, reordered, and corrupted fragments.
+- [ ] Test cancellation cleans pending queues and transfer state.
+- [ ] Test large file/media transfers under constrained MTU.
+- [ ] Test broadcast and directed packet delivery.
+- [ ] Test packet relay while one link disconnects.
+
+#### Wi-Fi Aware
+
+- [ ] Test feature unavailable and permission-denied behavior.
+- [ ] Test publish/subscribe session creation and teardown.
+- [ ] Test provisional link authentication and canonical promotion.
+- [ ] Test socket replacement and stale-socket rejection.
+- [ ] Test partial reads, writes, EOF, exceptions, and cancellation.
+- [ ] Test reconnect and rediscovery.
+- [ ] Test coexistence with BLE for the same peer.
+
+#### Unified transport behavior
+
+- [ ] Test peer-list union and removal across transports.
+- [ ] Test preferred-transport selection.
+- [ ] Test transparent failover between BLE and Wi-Fi Aware.
+- [ ] Test duplicate packet suppression across transports.
+- [ ] Test transport bridge TTL decrement and loop prevention.
+- [ ] Test shutdown cancels all jobs, scans, advertisements, sockets, and queues.
+
+### Acceptance criteria
+
+- [ ] Transport state-machine tests run deterministically on the JVM or
+ Robolectric.
+- [ ] Disconnect and cancellation tests leave no queued work or active jobs.
+- [ ] Cross-transport duplicate delivery and loops are prevented.
+- [ ] A smaller physical-device suite confirms the fake adapters match Android
+ behavior.
+
+---
+
+## Milestone 5: Sync, routing, and store-and-forward testing
+
+**Status:** Not started
+**Progress:** 0%
+
+### Goal
+
+Verify eventual delivery, bounded resource usage, correct routing, and duplicate
+suppression during partitions, topology changes, and reconnects.
+
+### TODO checklist
+
+#### Packet identity and sync filters
+
+- [ ] Add more packet-ID vectors for every message type.
+- [ ] Prove TTL, route, recipient, and signature mutations do not change sync
+ identity.
+- [ ] Prove payload, sender, timestamp, and type mutations do change identity.
+- [ ] Property-test GCS encode/decode membership.
+- [ ] Test empty, singleton, maximum-capacity, duplicate, and collision-heavy
+ filters.
+- [ ] Test false-positive behavior statistically against configured tolerances.
+- [ ] Test maximum accepted filter bytes and malicious bitstreams.
+- [ ] Test sync requests with unknown TLVs and future capability extensions.
+
+#### Store and forward
+
+- [ ] Test caching decisions for public, private, favorite, and offline peers.
+- [ ] Test cache capacity and deterministic eviction.
+- [ ] Test cache expiry with fake time.
+- [ ] Test delivery acknowledgement removal.
+- [ ] Test retransmission after reconnect.
+- [ ] Test duplicate acknowledgements and late acknowledgements.
+- [ ] Test process restart persistence policy.
+- [ ] Test shutdown and cleanup under active delivery.
+- [ ] Test memory bounds under repeated undeliverable messages.
+
+#### Routing and topology
+
+- [ ] Test shortest paths for disconnected, cyclic, diamond, and changing graphs.
+- [ ] Test deterministic tie-breaking for equal-length routes.
+- [ ] Test only confirmed edges are used.
+- [ ] Test edge expiry and peer disappearance with fake time.
+- [ ] Test a route invalidated between planning and send.
+- [ ] Test relay TTL exhaustion at every hop.
+- [ ] Test source-route loop rejection.
+- [ ] Test broadcast storm suppression.
+- [ ] Test delivery across mixed BLE and Wi-Fi Aware paths.
+- [ ] Test graph updates while sync and relay operations run concurrently.
+
+### Acceptance criteria
+
+- [ ] Partition/reconnect scenarios eventually deliver exactly once at the
+ application layer.
+- [ ] Cache, graph, and filter resource bounds are enforced.
+- [ ] No topology or bridge scenario produces an infinite relay loop.
+- [ ] All expiry behavior uses fake time.
+
+---
+
+## Milestone 6: Nostr and Tor integration testing
+
+**Status:** Not started
+**Progress:** 0%
+
+### Goal
+
+Verify relay communication, subscriptions, event validation, NIP-17 delivery,
+and Tor-mode behavior under realistic network failures.
+
+### TODO checklist
+
+#### Relay protocol
+
+- [ ] Add a scripted local WebSocket relay fixture.
+- [ ] Test initial connection and clean disconnect.
+- [ ] Test DNS, TCP, TLS, WebSocket, and protocol failures.
+- [ ] Test reconnect backoff and cancellation with fake time.
+- [ ] Test relay notices, acknowledgements, end-of-stored-events, and malformed
+ messages.
+- [ ] Test subscription creation, replacement, unsubscribe, and reconnect
+ restoration.
+- [ ] Test duplicate, delayed, reordered, and conflicting events.
+- [ ] Test multi-relay publish success, partial success, and total failure.
+- [ ] Test event deduplication across relays.
+- [ ] Test relay-list selection and invalid relay URLs.
+
+#### Event security and messaging
+
+- [ ] Add external NIP-01, NIP-13, NIP-17, and NIP-44 vectors.
+- [ ] Test invalid event IDs and signatures are rejected before dispatch.
+- [ ] Test future timestamps, stale timestamps, and integer boundaries.
+- [ ] Test NIP-17 gift-wrap signer/rumor identity mismatches.
+- [ ] Test malformed seals, wrong recipients, and tampered ciphertext.
+- [ ] Test private-message and acknowledgement embedding/extraction.
+- [ ] Test geohash note, presence, and ephemeral-event filters.
+- [ ] Test nickname and teleport tags.
+- [ ] Test proof-of-work policy at exact difficulty boundaries.
+- [ ] Test cancellation and bounded mining iterations.
+
+#### Tor behavior
+
+- [ ] Add a fake Tor-state provider and proxy-selection tests.
+- [ ] Test direct, Tor-only, and fallback modes.
+- [ ] Test bootstrap delay, bootstrap failure, proxy failure, and shutdown.
+- [ ] Verify Tor-only mode never silently uses a direct connection.
+- [ ] Verify mode changes rebuild clients and close old connections.
+
+### Acceptance criteria
+
+- [ ] Nostr integration tests require no public relay or internet connection.
+- [ ] Invalid or unauthenticated events never reach application state.
+- [ ] Reconnect restores intended subscriptions without duplicate delivery.
+- [ ] Tor-only policy fails closed.
+
+---
+
+## Milestone 7: Android lifecycle and permission testing
+
+**Status:** Not started
+**Progress:** 0%
+
+### Goal
+
+Verify the app behaves correctly under Android process, service, permission,
+Bluetooth, battery, and background-execution rules.
+
+### TODO checklist
+
+#### Foreground service
+
+- [ ] Add Robolectric tests for service create, start, bind, unbind, and destroy.
+- [ ] Test repeated start commands are idempotent.
+- [ ] Test foreground notification creation and channel configuration.
+- [ ] Test explicit shutdown clears transport and application state correctly.
+- [ ] Test unexpected process/service recreation restores required state.
+- [ ] Test task removal behavior.
+- [ ] Test boot-completed handling.
+- [ ] Test service start restrictions and failure reporting.
+- [ ] Test all coroutines and resources are cancelled on destroy.
+
+#### Permissions and system state
+
+- [ ] Test first-run permission explanations.
+- [ ] Test denial, permanent denial, and later grant.
+- [ ] Test partial Bluetooth permission grants by Android version.
+- [ ] Test location-disabled and Bluetooth-disabled states.
+- [ ] Test notification permission denial.
+- [ ] Test microphone permission denial during voice recording.
+- [ ] Test background-location preferences where applicable.
+- [ ] Test battery-optimization accepted, declined, and unavailable paths.
+- [ ] Test configuration changes during onboarding.
+- [ ] Test onboarding restoration after process recreation.
+
+#### Android-version matrix
+
+- [ ] Define minimum, target, and newest-supported API test matrix.
+- [ ] Add emulator coverage for behavior changes in permissions and foreground
+ services.
+- [ ] Add at least one low-memory/process-death scenario.
+- [ ] Add manufacturer-device coverage for known BLE/background differences.
+
+### Acceptance criteria
+
+- [ ] Critical service and permission flows have Robolectric or instrumented
+ coverage.
+- [ ] No permission denial crashes or leaves onboarding irrecoverable.
+- [ ] Service recreation does not duplicate transports or lose required state.
+- [ ] Required API-level matrix passes before release.
+
+---
+
+## Milestone 8: Persistence, migration, and recovery testing
+
+**Status:** Not started
+**Progress:** 0%
+
+### Goal
+
+Ensure identities, settings, aliases, favorites, bookmarks, messages, and
+capability pins survive upgrades and fail safely when storage is incomplete or
+corrupt.
+
+### TODO checklist
+
+- [ ] Inventory every persisted key, file, schema, and version marker.
+- [ ] Create legacy fixtures for every supported application version.
+- [ ] Test clean first launch with no persisted state.
+- [ ] Test upgrade from each retained legacy fixture.
+- [ ] Test unknown future fields are preserved or ignored safely.
+- [ ] Test truncated, malformed, empty, and type-mismatched preference values.
+- [ ] Test partial multi-key identity writes.
+- [ ] Test storage write failure and rollback.
+- [ ] Test concurrent readers and writers.
+- [ ] Test alias merging and canonical conversation migration.
+- [ ] Test chronological ordering after migration.
+- [ ] Test favorite and bookmark preservation.
+- [ ] Test message-retention expiry with fake time.
+- [ ] Test secure identity clearing removes all linked mappings and pins.
+- [ ] Test signing-key and capability rotation is atomic.
+- [ ] Test backup/restore policy does not duplicate or expose sensitive identity
+ material.
+- [ ] Test migration idempotence by running each migration twice.
+- [ ] Test downgrade behavior when a newer schema has already been written.
+
+### Acceptance criteria
+
+- [ ] Every supported legacy fixture migrates deterministically.
+- [ ] Failed migrations leave either the old valid state or the new valid state,
+ never a partial mixture.
+- [ ] Security-sensitive corruption fails closed with a recoverable user path.
+- [ ] Migration and retention behavior uses deterministic storage and time.
+
+---
+
+## Milestone 9: UI, media, and accessibility testing
+
+**Status:** Not started
+**Progress:** 0%
+
+### Goal
+
+Protect user-visible behavior and media workflows while keeping most assertions
+at ViewModel/state boundaries and reserving Compose instrumentation for genuine
+interaction and rendering contracts.
+
+### TODO checklist
+
+#### ViewModels and application state
+
+- [ ] Restore or replace the currently skipped command-processor tests.
+- [ ] Restore or replace the currently skipped notification tests.
+- [ ] Test public/private/channel conversation switching.
+- [ ] Test optimistic send, success, failure, retry, and cancellation.
+- [ ] Test delivery and read-receipt transitions.
+- [ ] Test peer arrival, departure, alias change, and identity rotation.
+- [ ] Test state restoration after configuration change and process recreation.
+- [ ] Test concurrent inbound messages while changing conversations.
+- [ ] Test error messages for permission, transport, storage, and crypto failures.
+
+#### Compose UI
+
+- [ ] Add semantics tests for critical chat actions.
+- [ ] Test onboarding navigation and recoverability.
+- [ ] Test empty, loading, connected, disconnected, and error states.
+- [ ] Test long nicknames, messages, channels, and localized text.
+- [ ] Test dynamic font sizes and display scaling.
+- [ ] Test light, dark, and supported theme variants.
+- [ ] Add screenshot tests only for stable high-value layouts.
+- [ ] Test keyboard, focus, back navigation, and bottom-sheet behavior.
+- [ ] Test screen-reader labels, traversal order, and minimum touch targets.
+- [ ] Test reduced-motion behavior where animations are nonessential.
+
+#### Files, images, and voice
+
+- [ ] Test zero-byte, small, maximum-size, and oversized files.
+- [ ] Test unsupported and misleading MIME types.
+- [ ] Test missing filenames and Unicode filenames.
+- [ ] Test file read/write failures and insufficient storage.
+- [ ] Test image decode failures, orientation metadata, and large-image memory
+ limits.
+- [ ] Test voice-recording start, pause/stop, cancellation, and microphone loss.
+- [ ] Test corrupt and unsupported audio playback.
+- [ ] Test waveform generation boundaries.
+- [ ] Test interrupted private-media preparation and commit rollback.
+- [ ] Test cleanup of temporary files after success, failure, and cancellation.
+
+### Acceptance criteria
+
+- [ ] Critical user journeys pass through state-level tests.
+- [ ] A focused Compose suite protects navigation, semantics, and accessibility.
+- [ ] Media failures are visible, recoverable, and leak no temporary resources.
+- [ ] Previously skipped UI-related tests are either active or replaced with
+ equivalent coverage.
+
+---
+
+## Milestone 10: Physical-device and cross-client release gate
+
+**Status:** Not started
+**Progress:** 0%
+
+### Goal
+
+Validate the behavior that JVM, Robolectric, and emulator tests cannot prove:
+real radios, background limits, device interoperability, and compatibility with
+released clients.
+
+### TODO checklist
+
+#### Device matrix and harness
+
+- [ ] Define a minimum physical-device matrix covering at least two Android API
+ levels and two manufacturers.
+- [ ] Include devices supporting BLE only and BLE plus Wi-Fi Aware where
+ available.
+- [ ] Build a test control channel that does not interfere with mesh transport.
+- [ ] Capture structured traces, packet IDs, connection transitions, and failure
+ reasons.
+- [ ] Make test accounts, identities, and files disposable and non-personal.
+- [ ] Provide deterministic scenario setup and cleanup.
+
+#### Android-to-Android scenarios
+
+- [ ] Discover, connect, exchange announcements, disconnect, and reconnect.
+- [ ] Send public and private messages in both directions.
+- [ ] Verify delivery and read receipts.
+- [ ] Transfer image, audio, and generic files at multiple sizes.
+- [ ] Relay across at least three devices.
+- [ ] Partition the mesh and verify store-and-forward delivery after reconnect.
+- [ ] Disable and re-enable Bluetooth during active transfers.
+- [ ] Lock screens and background both apps during active mesh operation.
+- [ ] Kill and recreate one process.
+- [ ] Exercise simultaneous connections and duplicate-link resolution.
+- [ ] Exercise Wi-Fi Aware failover where supported.
+
+#### Cross-client and backward compatibility
+
+- [ ] Test current Android against the current iOS client.
+- [ ] Test the rewrite against the last supported Android release.
+- [ ] Test legacy announcements without capabilities.
+- [ ] Test current capability announcements with an older client.
+- [ ] Test canonical private-media type and decode-only prerelease alias.
+- [ ] Compare packet, message, identity, fragment, sync, and file golden vectors
+ across implementations.
+- [ ] Verify malformed and unauthenticated inputs are rejected consistently.
+- [ ] Verify Nostr fallback messages and receipts across clients.
+
+#### Background and endurance
+
+- [ ] Run a multi-hour discovery/connect/disconnect soak test.
+- [ ] Run repeated large-transfer and cancellation cycles.
+- [ ] Monitor memory, threads, file descriptors, wake locks, and battery impact.
+- [ ] Test foreground-service survival with screens off.
+- [ ] Test network and Tor availability changes during Nostr operation.
+- [ ] Confirm shutdown releases radios, sockets, jobs, and wake locks.
+
+### Acceptance criteria
+
+- [ ] All mandatory scenarios pass on the defined device matrix.
+- [ ] Android/iOS and old/new clients exchange every supported critical payload.
+- [ ] No endurance run shows unbounded growth or leaked resources.
+- [ ] Failures produce sufficient traces for deterministic reproduction where
+ possible.
+- [ ] Release approval records the client versions, device matrix, and results.
+
+---
+
+## CI rollout
+
+### Pull-request gate
+
+- [ ] Run formatting and static analysis.
+- [ ] Run deterministic JVM unit tests.
+- [ ] Run bounded property/fuzz tests.
+- [ ] Run stable Robolectric tests.
+- [ ] Run `clientRewriteContractTest`.
+- [ ] Upload JUnit and coverage reports.
+- [ ] Reject new failures, errors, or unexpected skips.
+- [ ] Reject golden-vector changes without the protocol-change review label.
+
+### Main and nightly gate
+
+- [ ] Run the extended fuzz corpus.
+- [ ] Run emulator instrumented tests.
+- [ ] Run local relay/Tor integration tests.
+- [ ] Run physical-device smoke tests when the lab is available.
+- [ ] Track runtime, flakes, coverage, and quarantined tests.
+
+### Release-candidate gate
+
+- [ ] Run the complete device matrix.
+- [ ] Run Android/iOS and old/new interoperability.
+- [ ] Run endurance and background scenarios.
+- [ ] Review all skipped or quarantined tests.
+- [ ] Archive coverage, test, trace, and version metadata with the release.
+
+## Progress update procedure
+
+When work lands:
+
+1. Check completed TODOs in the relevant milestone.
+2. Update the milestone percentage based on completed checklist items.
+3. Change status to **In progress** when its first TODO is complete.
+4. Change status to **Complete** only when all acceptance criteria are met.
+5. Update the top-level progress table and milestone completion count.
+6. Link the implementing pull request or commit next to material completed work
+ without including personal information.
+7. Record intentionally deferred items and their justification; do not mark them
+ complete.
+
+## Final definition of done
+
+The test program is complete when:
+
+- [ ] Milestones 0–10 meet every acceptance criterion.
+- [ ] Project and package-level line/branch coverage no longer regress.
+- [ ] All critical parsers have adversarial and fuzz coverage.
+- [ ] All security-sensitive state transitions fail closed under tampering,
+ replay, downgrade, and corruption.
+- [ ] Transport, sync, and lifecycle tests cover disconnection, cancellation,
+ timeout, and restart.
+- [ ] Physical-device and cross-client scenarios pass for every release.
+- [ ] The full rewrite can replace the existing implementation while preserving
+ the unchanged compatibility and acceptance tests.
diff --git a/docs/testing-conventions.md b/docs/testing-conventions.md
new file mode 100644
index 00000000..b2da4e7b
--- /dev/null
+++ b/docs/testing-conventions.md
@@ -0,0 +1,90 @@
+# Testing conventions
+
+## Purpose
+
+These conventions keep the client-rewrite suite deterministic, reproducible,
+and portable across implementations.
+
+## Test locations
+
+| Test type | Location | Naming |
+|---|---|---|
+| JVM unit and contract tests | `app/src/test/` | `*Test.kt` |
+| Shared deterministic fakes and fixtures | `app/src/test/**/testsupport/` | Descriptive fixture name |
+| Robolectric tests | `app/src/test/` | `*RobolectricTest.kt` |
+| Android instrumented tests | `app/src/androidTest/` | `*InstrumentedTest.kt` |
+| Coverage-tool tests | `tools/coverage/` | `test_*.py` |
+| Interoperability fixtures | `app/src/test/resources/contracts/` | Protocol and version in filename |
+
+## Required behavior
+
+- Tests must not use arbitrary sleeps. Advance a fake clock or coroutine test
+ scheduler instead.
+- Tests must not require public relays, internet access, Bluetooth hardware, or a
+ user's persisted data.
+- Time, randomness, dispatchers, storage, and transports must be injectable in
+ code exercised by state-machine tests.
+- Randomized failures must print a reproduction seed. Use `TEST_SEED` for a
+ specific replay.
+- Mutable byte arrays returned by fixtures and fakes must be defensively copied.
+- Negative security tests must assert fail-closed behavior.
+- Protocol round trips must be paired with literal golden vectors for critical
+ externally visible formats.
+- Asynchronous tests must have a deterministic completion condition and a
+ bounded timeout.
+- A fixed bug must retain its smallest reproducing input as a regression test.
+
+## Naming
+
+Test names should describe observable behavior:
+
+```kotlin
+@Test
+fun `replayed ciphertext is rejected without advancing receive state`() {
+ // ...
+}
+```
+
+Avoid names tied to private methods or temporary implementation structure.
+
+## Fixtures and seeds
+
+Reusable Kotlin fixtures live under
+`com.bitchat.android.testsupport`. `ReproducibleTestSeed` resolves
+`TEST_SEED` and provides a reproduction hint:
+
+```sh
+TEST_SEED=12345 ./gradlew clientRewriteContractTest
+```
+
+Never use production keys, contact information, messages, or other user data in
+fixtures.
+
+## Coverage
+
+Run the full report and non-regression floor:
+
+```sh
+./gradlew clientRewriteContractTest
+```
+
+Reports are written to:
+
+- `app/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml`
+- `app/build/reports/jacoco/jacocoTestReport/html/`
+
+Check executable production lines changed from the base branch:
+
+```sh
+COVERAGE_BASE_REF=origin/main ./gradlew checkChangedLineCoverage
+```
+
+Generated resource classes, Compose-generated singleton classes, platform
+bridges, and vendored Noise code are excluded from first-party coverage metrics.
+
+## Quarantine and skips
+
+- A flaky test must be fixed, not silently retried.
+- A temporary quarantine must include an issue and removal condition.
+- Unexpected skips fail review. Existing skips must be restored or replaced by
+ equivalent coverage.
diff --git a/tools/__init__.py b/tools/__init__.py
new file mode 100644
index 00000000..b48e1209
--- /dev/null
+++ b/tools/__init__.py
@@ -0,0 +1 @@
+"""Repository-local verification tooling."""
diff --git a/tools/coverage/__init__.py b/tools/coverage/__init__.py
new file mode 100644
index 00000000..8aec01cc
--- /dev/null
+++ b/tools/coverage/__init__.py
@@ -0,0 +1 @@
+"""Coverage verification helpers."""
diff --git a/tools/coverage/check_changed_coverage.py b/tools/coverage/check_changed_coverage.py
new file mode 100644
index 00000000..fde7b1bc
--- /dev/null
+++ b/tools/coverage/check_changed_coverage.py
@@ -0,0 +1,154 @@
+#!/usr/bin/env python3
+"""Enforce JaCoCo coverage for executable Kotlin/Java lines changed from a Git base."""
+
+from __future__ import annotations
+
+import argparse
+import pathlib
+import re
+import subprocess
+import sys
+import xml.etree.ElementTree as ET
+from dataclasses import dataclass
+
+
+SOURCE_ROOTS = (
+ "app/src/main/java/",
+ "app/src/main/kotlin/",
+)
+
+
+@dataclass(frozen=True)
+class CoverageLine:
+ missed_instructions: int
+ covered_instructions: int
+
+ @property
+ def covered(self) -> bool:
+ return self.covered_instructions > 0
+
+
+def parse_jacoco(xml_path: pathlib.Path) -> dict[tuple[str, int], CoverageLine]:
+ root = ET.parse(xml_path).getroot()
+ result: dict[tuple[str, int], CoverageLine] = {}
+ for package in root.findall("package"):
+ package_name = package.attrib["name"]
+ for source_file in package.findall("sourcefile"):
+ relative_path = f"{package_name}/{source_file.attrib['name']}"
+ for line in source_file.findall("line"):
+ result[(relative_path, int(line.attrib["nr"]))] = CoverageLine(
+ missed_instructions=int(line.attrib.get("mi", "0")),
+ covered_instructions=int(line.attrib.get("ci", "0")),
+ )
+ return result
+
+
+def parse_changed_lines(diff_text: str) -> dict[str, set[int]]:
+ changed: dict[str, set[int]] = {}
+ current_path: str | None = None
+ for raw_line in diff_text.splitlines():
+ if raw_line.startswith("+++ b/"):
+ current_path = raw_line[6:]
+ continue
+ if raw_line.startswith("+++ /dev/null"):
+ current_path = None
+ continue
+ if not raw_line.startswith("@@") or current_path is None:
+ continue
+ match = re.search(r"\+(\d+)(?:,(\d+))?", raw_line)
+ if match is None:
+ continue
+ start = int(match.group(1))
+ count = int(match.group(2) or "1")
+ if count > 0:
+ changed.setdefault(current_path, set()).update(range(start, start + count))
+ return changed
+
+
+def jacoco_relative_path(repository_path: str) -> str | None:
+ for root in SOURCE_ROOTS:
+ if repository_path.startswith(root):
+ return repository_path[len(root) :]
+ return None
+
+
+def git_diff_command(base: str) -> list[str]:
+ return [
+ "git",
+ "diff",
+ # Reformatting an executable line without changing its tokens must not turn an otherwise
+ # covered change set into a coverage failure.
+ "--ignore-all-space",
+ "--unified=0",
+ "--diff-filter=AM",
+ base,
+ "--",
+ *SOURCE_ROOTS,
+ ]
+
+
+def git_diff(base: str) -> str:
+ command = git_diff_command(base)
+ result = subprocess.run(command, check=True, capture_output=True, text=True)
+ return result.stdout
+
+
+def evaluate(
+ changed: dict[str, set[int]],
+ coverage: dict[tuple[str, int], CoverageLine],
+) -> tuple[int, int, list[str]]:
+ executable = 0
+ covered = 0
+ missed: list[str] = []
+ for repository_path, line_numbers in sorted(changed.items()):
+ source_path = jacoco_relative_path(repository_path)
+ if source_path is None:
+ continue
+ for line_number in sorted(line_numbers):
+ line = coverage.get((source_path, line_number))
+ if line is None:
+ continue
+ executable += 1
+ if line.covered:
+ covered += 1
+ else:
+ missed.append(f"{repository_path}:{line_number}")
+ return covered, executable, missed
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--xml", type=pathlib.Path, required=True)
+ parser.add_argument("--base", required=True)
+ parser.add_argument("--threshold", type=float, default=0.80)
+ return parser.parse_args()
+
+
+def main() -> int:
+ args = parse_args()
+ if not 0.0 <= args.threshold <= 1.0:
+ raise SystemExit("--threshold must be between 0 and 1")
+ if not args.xml.is_file():
+ raise SystemExit(f"JaCoCo XML report not found: {args.xml}")
+
+ coverage = parse_jacoco(args.xml)
+ changed = parse_changed_lines(git_diff(args.base))
+ covered, executable, missed = evaluate(changed, coverage)
+ ratio = 1.0 if executable == 0 else covered / executable
+
+ print(
+ f"Changed executable line coverage: {covered}/{executable} "
+ f"({ratio:.1%}), required {args.threshold:.1%}"
+ )
+ if ratio >= args.threshold:
+ return 0
+
+ for location in missed[:50]:
+ print(f"UNCOVERED {location}")
+ if len(missed) > 50:
+ print(f"... and {len(missed) - 50} more uncovered executable lines")
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/coverage/test_check_changed_coverage.py b/tools/coverage/test_check_changed_coverage.py
new file mode 100644
index 00000000..7c452449
--- /dev/null
+++ b/tools/coverage/test_check_changed_coverage.py
@@ -0,0 +1,85 @@
+import pathlib
+import tempfile
+import unittest
+
+from tools.coverage.check_changed_coverage import (
+ CoverageLine,
+ evaluate,
+ git_diff_command,
+ jacoco_relative_path,
+ parse_changed_lines,
+ parse_jacoco,
+)
+
+
+class ChangedCoverageToolTest(unittest.TestCase):
+ def test_parses_added_and_modified_hunks(self) -> None:
+ diff = """\
+diff --git a/app/src/main/java/example/Thing.kt b/app/src/main/java/example/Thing.kt
+--- a/app/src/main/java/example/Thing.kt
++++ b/app/src/main/java/example/Thing.kt
+@@ -1,0 +2,3 @@
++a
++b
++c
+@@ -9 +12 @@
+-old
++new
+"""
+ self.assertEqual(
+ {"app/src/main/java/example/Thing.kt": {2, 3, 4, 12}},
+ parse_changed_lines(diff),
+ )
+
+ def test_parses_jacoco_source_lines(self) -> None:
+ xml = """\
+
+
+
+
+
+
+
+
+"""
+ with tempfile.TemporaryDirectory() as directory:
+ path = pathlib.Path(directory) / "report.xml"
+ path.write_text(xml, encoding="utf-8")
+ parsed = parse_jacoco(path)
+
+ self.assertTrue(parsed[("example/Thing.kt", 2)].covered)
+ self.assertFalse(parsed[("example/Thing.kt", 3)].covered)
+
+ def test_evaluates_only_executable_changed_lines(self) -> None:
+ changed = {"app/src/main/java/example/Thing.kt": {1, 2, 3}}
+ coverage = {
+ ("example/Thing.kt", 2): CoverageLine(0, 1),
+ ("example/Thing.kt", 3): CoverageLine(1, 0),
+ }
+
+ self.assertEqual(
+ (1, 2, ["app/src/main/java/example/Thing.kt:3"]),
+ evaluate(changed, coverage),
+ )
+
+ def test_maps_both_supported_source_roots(self) -> None:
+ self.assertEqual(
+ "example/Thing.kt",
+ jacoco_relative_path("app/src/main/java/example/Thing.kt"),
+ )
+ self.assertEqual(
+ "example/Thing.kt",
+ jacoco_relative_path("app/src/main/kotlin/example/Thing.kt"),
+ )
+ self.assertIsNone(jacoco_relative_path("app/src/test/example/Thing.kt"))
+
+ def test_changed_line_diff_ignores_formatting_only_edits(self) -> None:
+ command = git_diff_command("origin/main")
+
+ self.assertIn("--ignore-all-space", command)
+ self.assertIn("--unified=0", command)
+ self.assertEqual("origin/main", command[command.index("--diff-filter=AM") + 1])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tools/release_gate/__init__.py b/tools/release_gate/__init__.py
new file mode 100644
index 00000000..e9ab0855
--- /dev/null
+++ b/tools/release_gate/__init__.py
@@ -0,0 +1 @@
+"""Physical-device and cross-client release-gate tooling."""
diff --git a/tools/release_gate/android_lab.py b/tools/release_gate/android_lab.py
new file mode 100644
index 00000000..165bb469
--- /dev/null
+++ b/tools/release_gate/android_lab.py
@@ -0,0 +1,209 @@
+#!/usr/bin/env python3
+"""USB/ADB control helpers for the physical release gate.
+
+Device selectors are accepted only as ephemeral command inputs. They are never
+printed or written to artifacts; all output uses the operator-assigned alias.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+from typing import Callable
+
+REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
+if str(REPOSITORY_ROOT) not in sys.path:
+ sys.path.insert(0, str(REPOSITORY_ROOT))
+
+from tools.release_gate.release_gate import (
+ GateError,
+ SAFE_ID_RE,
+ append_trace_event,
+)
+
+
+APPLICATION_ID = "com.bitchat.droid"
+
+
+def find_adb() -> str:
+ direct = shutil.which("adb")
+ if direct:
+ return direct
+ android_home = os.environ.get("ANDROID_HOME")
+ if android_home:
+ candidate = Path(android_home) / "platform-tools" / "adb"
+ if candidate.is_file():
+ return str(candidate)
+ raise GateError("adb was not found; set ANDROID_HOME or add adb to PATH")
+
+
+def run_adb(
+ serial: str,
+ arguments: list[str],
+ *,
+ runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
+) -> str:
+ result = runner(
+ [find_adb(), "-s", serial, *arguments],
+ check=False,
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ if result.returncode != 0:
+ raise GateError("ADB command failed for the selected logical device")
+ return result.stdout.strip()
+
+
+def count_connected_devices(
+ *,
+ runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
+) -> int:
+ result = runner(
+ [find_adb(), "devices"],
+ check=False,
+ capture_output=True,
+ text=True,
+ timeout=30,
+ )
+ if result.returncode != 0:
+ raise GateError("could not enumerate ADB devices")
+ return sum(
+ 1
+ for line in result.stdout.splitlines()[1:]
+ if line.strip().endswith("\tdevice")
+ )
+
+
+def probe_device(serial: str, alias: str) -> dict[str, object]:
+ if not SAFE_ID_RE.fullmatch(alias):
+ raise GateError("device alias must be a lowercase logical identifier")
+ api_text = run_adb(serial, ["shell", "getprop", "ro.build.version.sdk"])
+ if not api_text.isdigit():
+ raise GateError("selected device returned an invalid API level")
+ features = run_adb(serial, ["shell", "pm", "list", "features"])
+ manufacturer = run_adb(
+ serial, ["shell", "getprop", "ro.product.manufacturer"]
+ ).strip().lower()
+ model = run_adb(serial, ["shell", "getprop", "ro.product.model"]).strip()
+ capabilities = ["ble-central", "ble-peripheral"]
+ if "android.hardware.wifi.aware" in features:
+ capabilities.append("wifi-aware")
+ return {
+ "alias": alias,
+ "platform": "android",
+ "model": model,
+ "manufacturer_class": re.sub(r"[^a-z0-9._-]", "-", manufacturer)[:64],
+ "api_level": int(api_text),
+ "physical": True,
+ "capabilities": capabilities,
+ }
+
+
+def prepare_disposable_device(serial: str, confirmed: bool) -> None:
+ if not confirmed:
+ raise GateError("prepare requires --confirm-disposable-app-data")
+ run_adb(serial, ["shell", "am", "force-stop", APPLICATION_ID])
+ output = run_adb(serial, ["shell", "pm", "clear", APPLICATION_ID])
+ if "Success" not in output:
+ raise GateError("could not clear disposable app data")
+
+
+def collect_resource_snapshot(serial: str) -> dict[str, int | bool]:
+ pid_text = run_adb(serial, ["shell", "pidof", APPLICATION_ID])
+ pid = pid_text.split()[0] if pid_text else ""
+ metrics: dict[str, int | bool] = {"process-running": bool(pid)}
+ if not pid.isdigit():
+ return metrics
+ meminfo = run_adb(serial, ["shell", "dumpsys", "meminfo", APPLICATION_ID])
+ total_match = re.search(r"TOTAL\s+(\d+)", meminfo)
+ metrics["total-pss-kb"] = int(total_match.group(1)) if total_match else -1
+ thread_text = run_adb(
+ serial,
+ ["shell", "sh", "-c", f"find /proc/{pid}/task -mindepth 1 -maxdepth 1 | wc -l"],
+ )
+ fd_text = run_adb(
+ serial,
+ ["shell", "sh", "-c", f"find /proc/{pid}/fd -mindepth 1 -maxdepth 1 | wc -l"],
+ )
+ metrics["thread-count"] = int(thread_text) if thread_text.isdigit() else -1
+ metrics["fd-count"] = int(fd_text) if fd_text.isdigit() else -1
+ power = run_adb(serial, ["shell", "dumpsys", "power"])
+ metrics["app-wakelock-count"] = sum(
+ 1
+ for line in power.splitlines()
+ if APPLICATION_ID in line and "WakeLock" in line
+ )
+ battery = run_adb(serial, ["shell", "dumpsys", "battery"])
+ battery_level = re.search(r"^\s*level:\s*(\d+)", battery, re.MULTILINE)
+ metrics["battery-level-percent"] = (
+ int(battery_level.group(1)) if battery_level else -1
+ )
+ return metrics
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description=__doc__)
+ commands = parser.add_subparsers(dest="command", required=True)
+
+ commands.add_parser("count")
+
+ probe = commands.add_parser("probe")
+ probe.add_argument("--serial", required=True, help=argparse.SUPPRESS)
+ probe.add_argument("--alias", required=True)
+
+ prepare = commands.add_parser("prepare")
+ prepare.add_argument("--serial", required=True, help=argparse.SUPPRESS)
+ prepare.add_argument("--confirm-disposable-app-data", action="store_true")
+
+ cleanup = commands.add_parser("cleanup")
+ cleanup.add_argument("--serial", required=True, help=argparse.SUPPRESS)
+ cleanup.add_argument("--confirm-disposable-app-data", action="store_true")
+
+ snapshot = commands.add_parser("snapshot")
+ snapshot.add_argument("--serial", required=True, help=argparse.SUPPRESS)
+ snapshot.add_argument("--alias", required=True)
+ snapshot.add_argument("--run", type=Path, required=True)
+ snapshot.add_argument("--scenario", required=True)
+ snapshot.add_argument("--event", default="resource-snapshot")
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ args = build_parser().parse_args(argv)
+ try:
+ if args.command == "count":
+ print(json.dumps({"authorized-device-count": count_connected_devices()}))
+ elif args.command == "probe":
+ print(json.dumps(probe_device(args.serial, args.alias), sort_keys=True))
+ elif args.command in {"prepare", "cleanup"}:
+ prepare_disposable_device(
+ args.serial, args.confirm_disposable_app_data
+ )
+ print(json.dumps({"status": "clean", "application": APPLICATION_ID}))
+ elif args.command == "snapshot":
+ metrics = collect_resource_snapshot(args.serial)
+ append_trace_event(
+ args.run,
+ args.scenario,
+ args.alias,
+ args.event,
+ "observed",
+ None,
+ metrics,
+ )
+ print(json.dumps({"source_alias": args.alias, "metrics": metrics}, sort_keys=True))
+ return 0
+ except (GateError, OSError, subprocess.SubprocessError) as error:
+ print(f"android lab error: {error}", file=sys.stderr)
+ return 2
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/release_gate/device-matrix.example.json b/tools/release_gate/device-matrix.example.json
new file mode 100644
index 00000000..01b8f629
--- /dev/null
+++ b/tools/release_gate/device-matrix.example.json
@@ -0,0 +1,72 @@
+{
+ "schema_version": 1,
+ "commit": "0000000000000000000000000000000000000000",
+ "clients": {
+ "android-current": {
+ "version": "replace-with-release-candidate",
+ "commit": "0000000000000000000000000000000000000000"
+ },
+ "android-legacy": {
+ "version": "replace-with-last-supported-release"
+ },
+ "ios-current": {
+ "version": "replace-with-current-ios-release"
+ }
+ },
+ "lab_capabilities": ["local-relay", "tor"],
+ "devices": [
+ {
+ "alias": "android-low",
+ "platform": "android",
+ "model": "replace-with-model",
+ "manufacturer_class": "manufacturer-a",
+ "api_level": 28,
+ "physical": true,
+ "capabilities": ["ble-central", "ble-peripheral"]
+ },
+ {
+ "alias": "android-current",
+ "platform": "android",
+ "model": "replace-with-model",
+ "manufacturer_class": "manufacturer-b",
+ "api_level": 35,
+ "physical": true,
+ "capabilities": ["ble-central", "ble-peripheral", "wifi-aware"]
+ },
+ {
+ "alias": "android-relay",
+ "platform": "android",
+ "model": "replace-with-model",
+ "manufacturer_class": "manufacturer-c",
+ "api_level": 33,
+ "physical": true,
+ "capabilities": ["ble-central", "ble-peripheral"]
+ },
+ {
+ "alias": "android-aware",
+ "platform": "android",
+ "model": "replace-with-model",
+ "manufacturer_class": "manufacturer-b",
+ "api_level": 35,
+ "physical": true,
+ "capabilities": ["ble-central", "ble-peripheral", "wifi-aware"]
+ },
+ {
+ "alias": "android-legacy",
+ "platform": "android",
+ "model": "replace-with-model",
+ "manufacturer_class": "manufacturer-a",
+ "api_level": 28,
+ "physical": true,
+ "capabilities": ["ble-central", "ble-peripheral"]
+ },
+ {
+ "alias": "ios-current",
+ "platform": "ios",
+ "model": "replace-with-model",
+ "manufacturer_class": "apple",
+ "physical": true,
+ "capabilities": ["ble-central", "ble-peripheral"]
+ }
+ ]
+}
diff --git a/tools/release_gate/release_gate.py b/tools/release_gate/release_gate.py
new file mode 100644
index 00000000..fbec7e32
--- /dev/null
+++ b/tools/release_gate/release_gate.py
@@ -0,0 +1,744 @@
+#!/usr/bin/env python3
+"""Create, record, validate, and archive the physical release gate.
+
+The host-side CLI is the control channel. It coordinates operators over USB or
+local files and never sends control traffic through the mesh under test.
+Artifacts intentionally contain logical device aliases and aggregate evidence,
+not device identifiers, addresses, peer IDs, or message contents.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import math
+import re
+import sys
+import zipfile
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any, Iterable
+
+
+SCHEMA_VERSION = 1
+RESULT_STATUSES = {"pending", "pass", "fail", "blocked", "unsupported"}
+COMMIT_RE = re.compile(r"^[0-9a-f]{40}$")
+SAFE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
+DISALLOWED_KEYS = {
+ "serial",
+ "serial_number",
+ "udid",
+ "imei",
+ "bluetooth_address",
+ "mac_address",
+ "ip_address",
+ "peer_id",
+ "username",
+ "user_name",
+ "email",
+ "account",
+ "device_name",
+}
+HASH_VALUE_KEYS = {
+ "commit",
+ "sha256",
+ "digest",
+ "scenario_manifest_sha256",
+ "fixture_sha256",
+ "vector_manifest_digest",
+ "corpus_digest",
+}
+SENSITIVE_PATTERNS = (
+ re.compile(r"(?:^|[\s/])Users/[^/\s]+"),
+ re.compile(r"(?:^|[\s/])home/[^/\s]+"),
+ re.compile(r"\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b"),
+ re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b"),
+ re.compile(r"\b(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}\b"),
+ re.compile(r"\b[0-9A-Fa-f]{16,}\b"),
+)
+MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024
+
+
+class GateError(ValueError):
+ """A release-gate artifact violated an executable contract."""
+
+
+def utc_now() -> str:
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
+
+
+def load_json(path: Path) -> dict[str, Any]:
+ try:
+ value = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as error:
+ raise GateError(f"could not read JSON {path.name}: {error}") from error
+ if not isinstance(value, dict):
+ raise GateError(f"{path.name} must contain a JSON object")
+ return value
+
+
+def write_json(path: Path, value: dict[str, Any]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temporary = path.with_suffix(path.suffix + ".tmp")
+ temporary.write_text(
+ json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
+ encoding="utf-8",
+ )
+ temporary.replace(path)
+
+
+def sha256_bytes(value: bytes) -> str:
+ return hashlib.sha256(value).hexdigest()
+
+
+def sha256_file(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as stream:
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def canonical_json_digest(value: dict[str, Any]) -> str:
+ encoded = json.dumps(
+ value, sort_keys=True, separators=(",", ":"), ensure_ascii=False
+ ).encode("utf-8")
+ return sha256_bytes(encoded)
+
+
+def validate_privacy(value: Any, path: tuple[str, ...] = ()) -> None:
+ if isinstance(value, dict):
+ for key, child in value.items():
+ normalized = str(key).lower()
+ if normalized in DISALLOWED_KEYS:
+ raise GateError(f"disallowed identifying field: {'.'.join(path + (str(key),))}")
+ validate_privacy(child, path + (str(key),))
+ return
+ if isinstance(value, list):
+ for index, child in enumerate(value):
+ validate_privacy(child, path + (str(index),))
+ return
+ if not isinstance(value, str):
+ return
+ final_key = path[-1].lower().replace("-", "_") if path else ""
+ for index, pattern in enumerate(SENSITIVE_PATTERNS):
+ if index == len(SENSITIVE_PATTERNS) - 1 and (
+ final_key in HASH_VALUE_KEYS
+ or final_key.endswith("_digest")
+ or final_key.endswith("_sha256")
+ ):
+ continue
+ if pattern.search(value):
+ raise GateError(f"potential identifying value at {'.'.join(path)}")
+
+
+def validate_manifest(manifest: dict[str, Any]) -> dict[str, dict[str, Any]]:
+ if manifest.get("schema_version") != SCHEMA_VERSION:
+ raise GateError("unsupported scenario schema_version")
+ scenarios = manifest.get("scenarios")
+ if not isinstance(scenarios, list) or not scenarios:
+ raise GateError("scenario manifest must contain scenarios")
+ by_id: dict[str, dict[str, Any]] = {}
+ for scenario in scenarios:
+ if not isinstance(scenario, dict):
+ raise GateError("each scenario must be an object")
+ scenario_id = scenario.get("id")
+ if not isinstance(scenario_id, str) or not re.fullmatch(
+ r"[A-Z0-9]{3}-\d{3}", scenario_id
+ ):
+ raise GateError(f"invalid scenario id: {scenario_id!r}")
+ if scenario_id in by_id:
+ raise GateError(f"duplicate scenario id: {scenario_id}")
+ if scenario.get("category") not in {
+ "transport",
+ "android-platform",
+ "android-to-android",
+ "cross-client",
+ "endurance",
+ }:
+ raise GateError(f"invalid category for {scenario_id}")
+ if scenario.get("required") is not True:
+ raise GateError(f"release scenario {scenario_id} must be required")
+ if not scenario.get("participants") or not scenario.get("evidence"):
+ raise GateError(f"scenario {scenario_id} lacks participants or evidence")
+ by_id[scenario_id] = scenario
+ validate_privacy(manifest)
+ return by_id
+
+
+def validate_matrix(
+ matrix: dict[str, Any],
+ expected_commit: str | None = None,
+ *,
+ allow_placeholders: bool = False,
+) -> dict[str, dict[str, Any]]:
+ if matrix.get("schema_version") != SCHEMA_VERSION:
+ raise GateError("unsupported matrix schema_version")
+ commit = matrix.get("commit")
+ if not isinstance(commit, str) or not COMMIT_RE.fullmatch(commit):
+ raise GateError("matrix commit must be a lowercase full Git commit")
+ if expected_commit is not None and commit != expected_commit:
+ raise GateError("matrix commit does not match the release candidate")
+ clients = matrix.get("clients")
+ required_clients = {"android-current", "android-legacy", "ios-current"}
+ if not isinstance(clients, dict) or not required_clients.issubset(clients):
+ raise GateError("matrix must version current Android, legacy Android, and current iOS")
+ for client_name in required_clients:
+ client = clients.get(client_name)
+ if not isinstance(client, dict) or not isinstance(client.get("version"), str):
+ raise GateError(f"{client_name} must declare a version")
+ if not allow_placeholders and client["version"].startswith("replace-with-"):
+ raise GateError(f"{client_name} still contains a template version")
+ current_commit = clients["android-current"].get("commit")
+ if current_commit != commit and not (allow_placeholders and commit == "0" * 40):
+ raise GateError("current Android client commit must match the matrix commit")
+ lab_capabilities = matrix.get("lab_capabilities")
+ if not isinstance(lab_capabilities, list) or any(
+ not isinstance(capability, str) or not SAFE_ID_RE.fullmatch(capability)
+ for capability in lab_capabilities
+ ):
+ raise GateError("matrix must declare logical lab_capabilities")
+ devices = matrix.get("devices")
+ if not isinstance(devices, list):
+ raise GateError("matrix devices must be a list")
+ by_alias: dict[str, dict[str, Any]] = {}
+ for device in devices:
+ if not isinstance(device, dict):
+ raise GateError("each device must be an object")
+ alias = device.get("alias")
+ if not isinstance(alias, str) or not SAFE_ID_RE.fullmatch(alias):
+ raise GateError(f"invalid logical device alias: {alias!r}")
+ if alias in by_alias:
+ raise GateError(f"duplicate device alias: {alias}")
+ if device.get("physical") is not True:
+ raise GateError(f"{alias} is not a physical device")
+ if device.get("platform") not in {"android", "ios"}:
+ raise GateError(f"{alias} has an unsupported platform")
+ if not isinstance(device.get("capabilities"), list):
+ raise GateError(f"{alias} must declare capabilities")
+ if not allow_placeholders and (
+ not isinstance(device.get("model"), str)
+ or device["model"].startswith("replace-with-")
+ ):
+ raise GateError(f"{alias} still contains template values")
+ by_alias[alias] = device
+ android = [device for device in devices if device.get("platform") == "android"]
+ if len(android) < 3:
+ raise GateError("three physical Android devices are required for relay scenarios")
+ api_levels = {device.get("api_level") for device in android}
+ manufacturers = {device.get("manufacturer_class") for device in android}
+ if len(api_levels) < 2 or not all(isinstance(level, int) for level in api_levels):
+ raise GateError("Android matrix must cover at least two API levels")
+ if len(manufacturers) < 2 or None in manufacturers:
+ raise GateError("Android matrix must cover at least two manufacturer classes")
+ for device in android:
+ capabilities = set(device["capabilities"])
+ if not {"ble-central", "ble-peripheral"}.issubset(capabilities):
+ raise GateError(f"{device['alias']} lacks required BLE roles")
+ if not any("wifi-aware" in device["capabilities"] for device in android):
+ raise GateError("at least one Android device must support Wi-Fi Aware")
+ if not any(device.get("platform") == "ios" for device in devices):
+ raise GateError("a physical iOS device is required")
+ validate_privacy(matrix)
+ return by_alias
+
+
+def _fixture_bytes(seed: bytes, size: int) -> bytes:
+ output = bytearray()
+ counter = 0
+ while len(output) < size:
+ output.extend(hashlib.sha256(seed + counter.to_bytes(4, "big")).digest())
+ counter += 1
+ return bytes(output[:size])
+
+
+def create_fixtures(directory: Path, run_id: str) -> dict[str, Any]:
+ fixture_directory = directory / "fixtures"
+ fixture_directory.mkdir()
+ definitions = (
+ ("empty.bin", 0, False),
+ ("small.bin", 4 * 1024, False),
+ ("lab-résumé-秘密.bin", 256 * 1024, False),
+ ("maximum.bin", MAX_FILE_SIZE_BYTES, True),
+ ("oversized.bin", MAX_FILE_SIZE_BYTES + 1, True),
+ )
+ fixtures: list[dict[str, Any]] = []
+ for name, size, sparse in definitions:
+ path = fixture_directory / name
+ if sparse:
+ with path.open("wb") as stream:
+ stream.truncate(size)
+ else:
+ path.write_bytes(_fixture_bytes(run_id.encode("utf-8"), size))
+ fixtures.append(
+ {
+ "name": name,
+ "size_bytes": size,
+ "sparse": sparse,
+ "fixture_sha256": sha256_file(path),
+ }
+ )
+ manifest = {"schema_version": SCHEMA_VERSION, "fixtures": fixtures}
+ write_json(fixture_directory / "manifest.json", manifest)
+ return manifest
+
+
+def initialize_run(
+ manifest: dict[str, Any],
+ matrix: dict[str, Any],
+ output: Path,
+ commit: str,
+ run_id: str,
+ *,
+ started_at: str | None = None,
+) -> dict[str, Any]:
+ scenarios = validate_manifest(manifest)
+ devices = validate_matrix(matrix, commit)
+ if not SAFE_ID_RE.fullmatch(run_id):
+ raise GateError("run id must be a non-identifying lowercase logical id")
+ missing_aliases = {
+ participant
+ for scenario in scenarios.values()
+ for participant in scenario["participants"]
+ if participant not in devices
+ }
+ if missing_aliases:
+ raise GateError(f"matrix lacks scenario aliases: {sorted(missing_aliases)}")
+ for scenario_id, scenario in scenarios.items():
+ available = set(matrix["lab_capabilities"])
+ for participant in scenario["participants"]:
+ available.update(devices[participant]["capabilities"])
+ missing_capabilities = set(scenario["capabilities"]) - available
+ if missing_capabilities:
+ raise GateError(
+ f"{scenario_id} lacks capabilities: {sorted(missing_capabilities)}"
+ )
+ output.mkdir(parents=True, exist_ok=False)
+ write_json(output / "manifest.json", manifest)
+ write_json(output / "matrix.json", matrix)
+ fixtures = create_fixtures(output, run_id)
+ results = {
+ "schema_version": SCHEMA_VERSION,
+ "run_id": run_id,
+ "commit": commit,
+ "started_at": started_at or utc_now(),
+ "completed_at": None,
+ "scenario_manifest_sha256": canonical_json_digest(manifest),
+ "fixture_manifest_sha256": canonical_json_digest(fixtures),
+ "scenario_results": {
+ scenario_id: {
+ "status": "pending",
+ "participants": scenario["participants"],
+ "evidence": {},
+ "reason_code": None,
+ "updated_at": None,
+ "history": [],
+ }
+ for scenario_id, scenario in scenarios.items()
+ },
+ }
+ write_json(output / "results.json", results)
+ (output / "trace.jsonl").write_text("", encoding="utf-8")
+ return results
+
+
+def _parse_scalar(value: str) -> Any:
+ lowered = value.lower()
+ if lowered in {"true", "false"}:
+ return lowered == "true"
+ try:
+ return int(value)
+ except ValueError:
+ try:
+ return float(value)
+ except ValueError:
+ return value
+
+
+def parse_evidence(values: Iterable[str]) -> dict[str, Any]:
+ evidence: dict[str, Any] = {}
+ for value in values:
+ if "=" not in value:
+ raise GateError("evidence must use key=value")
+ key, raw = value.split("=", 1)
+ if not SAFE_ID_RE.fullmatch(key):
+ raise GateError(f"invalid evidence key: {key!r}")
+ evidence[key] = _parse_scalar(raw)
+ validate_privacy(evidence)
+ return evidence
+
+
+def record_result(
+ run_directory: Path,
+ scenario_id: str,
+ status: str,
+ evidence: dict[str, Any],
+ reason_code: str | None,
+ *,
+ updated_at: str | None = None,
+) -> dict[str, Any]:
+ manifest = load_json(run_directory / "manifest.json")
+ scenarios = validate_manifest(manifest)
+ if scenario_id not in scenarios:
+ raise GateError(f"unknown scenario: {scenario_id}")
+ if status not in RESULT_STATUSES - {"pending"}:
+ raise GateError(f"invalid terminal status: {status}")
+ if reason_code is not None and not SAFE_ID_RE.fullmatch(reason_code):
+ raise GateError("reason code must be a non-identifying stable code")
+ if status == "pass" and not evidence:
+ raise GateError("passing a scenario requires structured evidence")
+ if status == "pass" and reason_code is not None:
+ raise GateError("passing a scenario cannot have a failure reason code")
+ if status in {"fail", "blocked", "unsupported"} and reason_code is None:
+ raise GateError(f"{status} requires a stable reason code")
+ if any(not SAFE_ID_RE.fullmatch(str(key)) for key in evidence):
+ raise GateError("evidence keys must be stable logical identifiers")
+ validate_privacy(evidence)
+ results = load_json(run_directory / "results.json")
+ result = results["scenario_results"][scenario_id]
+ terminal_update = {
+ "status": status,
+ "evidence": evidence,
+ "reason_code": reason_code,
+ "updated_at": updated_at or utc_now(),
+ }
+ result.setdefault("history", []).append(terminal_update.copy())
+ result.update(
+ terminal_update
+ )
+ results["completed_at"] = (
+ result["updated_at"]
+ if all(
+ item.get("status") == "pass"
+ for item in results["scenario_results"].values()
+ )
+ else None
+ )
+ write_json(run_directory / "results.json", results)
+ return results
+
+
+def append_trace_event(
+ run_directory: Path,
+ scenario_id: str,
+ source_alias: str,
+ event: str,
+ outcome: str,
+ reason_code: str | None,
+ metrics: dict[str, Any],
+ *,
+ timestamp: str | None = None,
+) -> dict[str, Any]:
+ manifest = load_json(run_directory / "manifest.json")
+ matrix = load_json(run_directory / "matrix.json")
+ scenarios = validate_manifest(manifest)
+ devices = validate_matrix(matrix, allow_placeholders=False)
+ if scenario_id not in scenarios:
+ raise GateError(f"unknown scenario: {scenario_id}")
+ if source_alias not in devices:
+ raise GateError(f"unknown source alias: {source_alias}")
+ for label, value in (("event", event), ("outcome", outcome)):
+ if not SAFE_ID_RE.fullmatch(value):
+ raise GateError(f"invalid {label}")
+ if reason_code is not None and not SAFE_ID_RE.fullmatch(reason_code):
+ raise GateError("invalid reason code")
+ if any(not SAFE_ID_RE.fullmatch(str(key)) for key in metrics):
+ raise GateError("trace metric keys must be stable logical identifiers")
+ if any(
+ not isinstance(value, (int, float, bool))
+ or isinstance(value, float) and not math.isfinite(value)
+ for value in metrics.values()
+ ):
+ raise GateError("trace metrics must be numeric or boolean aggregates")
+ trace = {
+ "timestamp": timestamp or utc_now(),
+ "scenario_id": scenario_id,
+ "source_alias": source_alias,
+ "event": event,
+ "outcome": outcome,
+ "reason_code": reason_code,
+ "metrics": metrics,
+ }
+ validate_privacy(trace)
+ with (run_directory / "trace.jsonl").open("a", encoding="utf-8") as stream:
+ stream.write(json.dumps(trace, sort_keys=True, ensure_ascii=False) + "\n")
+ return trace
+
+
+def _validate_trace(
+ run_directory: Path,
+ scenarios: dict[str, dict[str, Any]],
+ devices: dict[str, dict[str, Any]],
+) -> tuple[int, set[str]]:
+ path = run_directory / "trace.jsonl"
+ if not path.exists():
+ raise GateError("trace.jsonl is missing")
+ count = 0
+ traced_scenarios: set[str] = set()
+ for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
+ try:
+ event = json.loads(line)
+ except json.JSONDecodeError as error:
+ raise GateError(f"invalid trace line {line_number}") from error
+ if not isinstance(event, dict):
+ raise GateError(f"trace line {line_number} must be an object")
+ if event.get("scenario_id") not in scenarios:
+ raise GateError(f"trace line {line_number} has an unknown scenario")
+ if event.get("source_alias") not in devices:
+ raise GateError(f"trace line {line_number} has an unknown source")
+ if set(event) != {
+ "timestamp",
+ "scenario_id",
+ "source_alias",
+ "event",
+ "outcome",
+ "reason_code",
+ "metrics",
+ }:
+ raise GateError(f"trace line {line_number} has unexpected fields")
+ if not isinstance(event.get("metrics"), dict) or any(
+ not isinstance(value, (int, float, bool))
+ for value in event["metrics"].values()
+ ):
+ raise GateError(f"trace line {line_number} has invalid metrics")
+ validate_privacy(event)
+ count += 1
+ traced_scenarios.add(event["scenario_id"])
+ return count, traced_scenarios
+
+
+def validate_run(run_directory: Path, *, allow_incomplete: bool = False) -> dict[str, Any]:
+ manifest = load_json(run_directory / "manifest.json")
+ matrix = load_json(run_directory / "matrix.json")
+ results = load_json(run_directory / "results.json")
+ scenarios = validate_manifest(manifest)
+ devices = validate_matrix(matrix, results.get("commit"))
+ if results.get("schema_version") != SCHEMA_VERSION:
+ raise GateError("unsupported results schema_version")
+ if results.get("scenario_manifest_sha256") != canonical_json_digest(manifest):
+ raise GateError("scenario manifest changed after run initialization")
+ fixture_manifest = load_json(run_directory / "fixtures" / "manifest.json")
+ if results.get("fixture_manifest_sha256") != canonical_json_digest(fixture_manifest):
+ raise GateError("fixture manifest changed after run initialization")
+ actual = results.get("scenario_results")
+ if not isinstance(actual, dict) or set(actual) != set(scenarios):
+ raise GateError("results must contain exactly every declared scenario")
+ validate_privacy(results)
+ for scenario_id, scenario in scenarios.items():
+ result = actual[scenario_id]
+ if result.get("participants") != scenario["participants"]:
+ raise GateError(f"{scenario_id} participants changed")
+ if any(alias not in devices for alias in result["participants"]):
+ raise GateError(f"{scenario_id} references an unknown device")
+ available = set(matrix["lab_capabilities"])
+ for participant in result["participants"]:
+ available.update(devices[participant]["capabilities"])
+ missing_capabilities = set(scenario["capabilities"]) - available
+ if missing_capabilities:
+ raise GateError(
+ f"{scenario_id} lacks capabilities: {sorted(missing_capabilities)}"
+ )
+ status = result.get("status")
+ if status not in RESULT_STATUSES:
+ raise GateError(f"{scenario_id} has invalid status")
+ if not allow_incomplete and status != "pass":
+ raise GateError(f"{scenario_id} is not passing: {status}")
+ if status == "pass":
+ evidence = result.get("evidence")
+ missing = set(scenario["evidence"]) - set(evidence or {})
+ if missing:
+ raise GateError(f"{scenario_id} lacks evidence: {sorted(missing)}")
+ if scenario.get("minimum_duration_minutes") is not None and (
+ evidence.get("duration-minutes", 0)
+ < scenario["minimum_duration_minutes"]
+ ):
+ raise GateError(f"{scenario_id} did not meet minimum duration")
+ if scenario.get("minimum_cycles") is not None and (
+ evidence.get("cycle-count", 0) < scenario["minimum_cycles"]
+ ):
+ raise GateError(f"{scenario_id} did not meet minimum cycles")
+ trace_events, traced_scenarios = _validate_trace(run_directory, scenarios, devices)
+ if not allow_incomplete:
+ missing_traces = set(scenarios) - traced_scenarios
+ if missing_traces:
+ raise GateError(
+ f"passing scenarios lack structured traces: {sorted(missing_traces)}"
+ )
+ if not results.get("completed_at"):
+ raise GateError("complete results must record completed_at")
+ summary = {
+ status: sum(
+ 1 for result in actual.values() if result.get("status") == status
+ )
+ for status in sorted(RESULT_STATUSES)
+ }
+ summary["trace_events"] = trace_events
+ summary["complete"] = all(
+ result.get("status") == "pass" for result in actual.values()
+ )
+ return summary
+
+
+def render_summary(run_directory: Path) -> str:
+ results = load_json(run_directory / "results.json")
+ summary = validate_run(run_directory, allow_incomplete=True)
+ rows = [
+ "# Physical release-gate result",
+ "",
+ f"- Run: `{results['run_id']}`",
+ f"- Commit: `{results['commit']}`",
+ f"- Complete: `{str(summary['complete']).lower()}`",
+ f"- Trace events: {summary['trace_events']}",
+ "",
+ "| Status | Count |",
+ "|---|---:|",
+ ]
+ rows.extend(
+ f"| {status} | {summary[status]} |" for status in sorted(RESULT_STATUSES)
+ )
+ return "\n".join(rows) + "\n"
+
+
+def create_bundle(run_directory: Path, output: Path) -> None:
+ validate_run(run_directory)
+ if output.exists():
+ raise GateError("refusing to overwrite an existing release-gate bundle")
+ members = [
+ Path("manifest.json"),
+ Path("matrix.json"),
+ Path("results.json"),
+ Path("trace.jsonl"),
+ Path("fixtures/manifest.json"),
+ ]
+ generated = {"summary.md": render_summary(run_directory).encode("utf-8")}
+ checksums: list[str] = []
+ payloads: dict[str, bytes] = {}
+ for member in members:
+ payload = (run_directory / member).read_bytes()
+ payloads[member.as_posix()] = payload
+ payloads.update(generated)
+ for name in sorted(payloads):
+ checksums.append(f"{sha256_bytes(payloads[name])} {name}")
+ payloads["SHA256SUMS"] = ("\n".join(checksums) + "\n").encode("utf-8")
+ output.parent.mkdir(parents=True, exist_ok=True)
+ with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive:
+ for name in sorted(payloads):
+ info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
+ info.compress_type = zipfile.ZIP_DEFLATED
+ info.external_attr = 0o100644 << 16
+ archive.writestr(info, payloads[name])
+
+
+def _default_manifest() -> Path:
+ return Path(__file__).with_name("scenarios.json")
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description=__doc__)
+ subparsers = parser.add_subparsers(dest="command", required=True)
+
+ manifest = subparsers.add_parser("validate-manifest")
+ manifest.add_argument("--manifest", type=Path, default=_default_manifest())
+
+ matrix = subparsers.add_parser("validate-matrix")
+ matrix.add_argument("--matrix", type=Path, required=True)
+ matrix.add_argument("--commit")
+ matrix.add_argument("--allow-template", action="store_true")
+
+ initialize = subparsers.add_parser("init")
+ initialize.add_argument("--manifest", type=Path, default=_default_manifest())
+ initialize.add_argument("--matrix", type=Path, required=True)
+ initialize.add_argument("--output", type=Path, required=True)
+ initialize.add_argument("--commit", required=True)
+ initialize.add_argument("--run-id", required=True)
+
+ record = subparsers.add_parser("record")
+ record.add_argument("--run", type=Path, required=True)
+ record.add_argument("--scenario", required=True)
+ record.add_argument("--status", choices=sorted(RESULT_STATUSES - {"pending"}), required=True)
+ record.add_argument("--evidence", action="append", default=[])
+ record.add_argument("--reason-code")
+
+ trace = subparsers.add_parser("trace")
+ trace.add_argument("--run", type=Path, required=True)
+ trace.add_argument("--scenario", required=True)
+ trace.add_argument("--source", required=True)
+ trace.add_argument("--event", required=True)
+ trace.add_argument("--outcome", required=True)
+ trace.add_argument("--reason-code")
+ trace.add_argument("--metric", action="append", default=[])
+
+ validate = subparsers.add_parser("validate")
+ validate.add_argument("--run", type=Path, required=True)
+ validate.add_argument("--allow-incomplete", action="store_true")
+
+ summary = subparsers.add_parser("summary")
+ summary.add_argument("--run", type=Path, required=True)
+
+ bundle = subparsers.add_parser("bundle")
+ bundle.add_argument("--run", type=Path, required=True)
+ bundle.add_argument("--output", type=Path, required=True)
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ args = build_parser().parse_args(argv)
+ try:
+ if args.command == "validate-manifest":
+ scenarios = validate_manifest(load_json(args.manifest))
+ print(f"valid scenarios: {len(scenarios)}")
+ elif args.command == "validate-matrix":
+ devices = validate_matrix(
+ load_json(args.matrix),
+ args.commit,
+ allow_placeholders=args.allow_template,
+ )
+ print(f"valid devices: {len(devices)}")
+ elif args.command == "init":
+ initialize_run(
+ load_json(args.manifest),
+ load_json(args.matrix),
+ args.output,
+ args.commit,
+ args.run_id,
+ )
+ print(args.output)
+ elif args.command == "record":
+ record_result(
+ args.run,
+ args.scenario,
+ args.status,
+ parse_evidence(args.evidence),
+ args.reason_code,
+ )
+ elif args.command == "trace":
+ append_trace_event(
+ args.run,
+ args.scenario,
+ args.source,
+ args.event,
+ args.outcome,
+ args.reason_code,
+ parse_evidence(args.metric),
+ )
+ elif args.command == "validate":
+ print(
+ json.dumps(
+ validate_run(args.run, allow_incomplete=args.allow_incomplete),
+ sort_keys=True,
+ )
+ )
+ elif args.command == "summary":
+ print(render_summary(args.run), end="")
+ elif args.command == "bundle":
+ create_bundle(args.run, args.output)
+ print(args.output)
+ return 0
+ except GateError as error:
+ print(f"release gate error: {error}", file=sys.stderr)
+ return 2
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/release_gate/scenarios.json b/tools/release_gate/scenarios.json
new file mode 100644
index 00000000..d9b50907
--- /dev/null
+++ b/tools/release_gate/scenarios.json
@@ -0,0 +1,251 @@
+{
+ "schema_version": 1,
+ "scenario_version": "1.0",
+ "scenarios": [
+ {
+ "id": "TRN-001",
+ "category": "transport",
+ "title": "Complete the physical BLE, GATT, MTU, Wi-Fi Aware, and failover matrix",
+ "participants": ["android-low", "android-current", "android-aware"],
+ "required": true,
+ "capabilities": ["ble-central", "ble-peripheral", "wifi-aware"],
+ "evidence": ["matrix-check-count", "mtu-case-count", "failure-injection-count", "shutdown-check-count"]
+ },
+ {
+ "id": "AND-001",
+ "category": "android-platform",
+ "title": "Complete API-level, manufacturer, permission-revocation, and background gates",
+ "participants": ["android-low", "android-current", "android-aware"],
+ "required": true,
+ "capabilities": ["ble-central", "ble-peripheral", "wifi-aware"],
+ "evidence": ["api-level-count", "manufacturer-count", "permission-revocation-count", "background-case-count"]
+ },
+ {
+ "id": "A2A-001",
+ "category": "android-to-android",
+ "title": "Discover, connect, announce, disconnect, and reconnect",
+ "participants": ["android-low", "android-current"],
+ "required": true,
+ "capabilities": ["ble-central", "ble-peripheral"],
+ "evidence": ["connection-transitions", "packet-correlation-count", "failure-reasons"]
+ },
+ {
+ "id": "A2A-002",
+ "category": "android-to-android",
+ "title": "Exchange public and private messages in both directions",
+ "participants": ["android-low", "android-current"],
+ "required": true,
+ "capabilities": ["ble-central", "ble-peripheral"],
+ "evidence": ["message-counts", "packet-correlation-count"]
+ },
+ {
+ "id": "A2A-003",
+ "category": "android-to-android",
+ "title": "Advance delivery and read receipts",
+ "participants": ["android-low", "android-current"],
+ "required": true,
+ "capabilities": ["ble-central", "ble-peripheral"],
+ "evidence": ["receipt-transitions"]
+ },
+ {
+ "id": "A2A-004",
+ "category": "android-to-android",
+ "title": "Transfer image, audio, and generic files at multiple sizes",
+ "participants": ["android-low", "android-current"],
+ "required": true,
+ "capabilities": ["ble-central", "ble-peripheral"],
+ "evidence": ["fixture-digests", "transfer-progress", "delivery-digests"]
+ },
+ {
+ "id": "A2A-005",
+ "category": "android-to-android",
+ "title": "Relay across three Android devices",
+ "participants": ["android-low", "android-current", "android-relay"],
+ "required": true,
+ "capabilities": ["ble-central", "ble-peripheral"],
+ "evidence": ["route-transitions", "ttl-values", "packet-correlation-count"]
+ },
+ {
+ "id": "A2A-006",
+ "category": "android-to-android",
+ "title": "Partition and recover store-and-forward delivery",
+ "participants": ["android-low", "android-current", "android-relay"],
+ "required": true,
+ "capabilities": ["ble-central", "ble-peripheral"],
+ "evidence": ["partition-window", "queue-counts", "delivery-counts"]
+ },
+ {
+ "id": "A2A-007",
+ "category": "android-to-android",
+ "title": "Toggle Bluetooth during active transfers",
+ "participants": ["android-low", "android-current"],
+ "required": true,
+ "capabilities": ["ble-central", "ble-peripheral"],
+ "evidence": ["radio-transitions", "transfer-terminal-state"]
+ },
+ {
+ "id": "A2A-008",
+ "category": "android-to-android",
+ "title": "Lock screens and background both apps during mesh operation",
+ "participants": ["android-low", "android-current"],
+ "required": true,
+ "capabilities": ["ble-central", "ble-peripheral"],
+ "evidence": ["lifecycle-transitions", "service-state", "delivery-counts"]
+ },
+ {
+ "id": "A2A-009",
+ "category": "android-to-android",
+ "title": "Kill and recreate one process",
+ "participants": ["android-low", "android-current"],
+ "required": true,
+ "capabilities": ["ble-central", "ble-peripheral"],
+ "evidence": ["process-generation", "restored-state", "reconnect-count"]
+ },
+ {
+ "id": "A2A-010",
+ "category": "android-to-android",
+ "title": "Resolve simultaneous and duplicate links",
+ "participants": ["android-low", "android-current"],
+ "required": true,
+ "capabilities": ["ble-central", "ble-peripheral"],
+ "evidence": ["candidate-links", "canonical-link-count"]
+ },
+ {
+ "id": "A2A-011",
+ "category": "android-to-android",
+ "title": "Fail over through Wi-Fi Aware",
+ "participants": ["android-current", "android-aware"],
+ "required": true,
+ "capabilities": ["wifi-aware"],
+ "evidence": ["transport-selection", "failover-window", "delivery-counts"]
+ },
+ {
+ "id": "XCL-001",
+ "category": "cross-client",
+ "title": "Current Android interoperates with current iOS",
+ "participants": ["android-current", "ios-current"],
+ "required": true,
+ "capabilities": ["ble-central", "ble-peripheral"],
+ "evidence": ["client-versions", "payload-counts"]
+ },
+ {
+ "id": "XCL-002",
+ "category": "cross-client",
+ "title": "Current rewrite contracts interoperate with the last supported Android",
+ "participants": ["android-current", "android-legacy"],
+ "required": true,
+ "capabilities": ["ble-central", "ble-peripheral"],
+ "evidence": ["client-versions", "payload-counts"]
+ },
+ {
+ "id": "XCL-003",
+ "category": "cross-client",
+ "title": "Legacy announcements without capabilities remain compatible",
+ "participants": ["android-current", "android-legacy"],
+ "required": true,
+ "capabilities": ["ble-central", "ble-peripheral"],
+ "evidence": ["announcement-version", "peer-state"]
+ },
+ {
+ "id": "XCL-004",
+ "category": "cross-client",
+ "title": "Older clients safely ignore current capability announcements",
+ "participants": ["android-current", "android-legacy"],
+ "required": true,
+ "capabilities": ["ble-central", "ble-peripheral"],
+ "evidence": ["announcement-version", "peer-state"]
+ },
+ {
+ "id": "XCL-005",
+ "category": "cross-client",
+ "title": "Canonical private media and prerelease decode-only alias interoperate",
+ "participants": ["android-current", "ios-current"],
+ "required": true,
+ "capabilities": ["ble-central", "ble-peripheral"],
+ "evidence": ["wire-type", "delivery-digests", "downgrade-decision"]
+ },
+ {
+ "id": "XCL-006",
+ "category": "cross-client",
+ "title": "Golden vectors match across implementations",
+ "participants": ["android-current", "ios-current", "android-legacy"],
+ "required": true,
+ "capabilities": [],
+ "evidence": ["vector-manifest-digest", "comparison-counts"]
+ },
+ {
+ "id": "XCL-007",
+ "category": "cross-client",
+ "title": "Malformed and unauthenticated inputs are rejected consistently",
+ "participants": ["android-current", "ios-current", "android-legacy"],
+ "required": true,
+ "capabilities": [],
+ "evidence": ["corpus-digest", "rejection-counts", "crash-count"]
+ },
+ {
+ "id": "XCL-008",
+ "category": "cross-client",
+ "title": "Nostr fallback messages and receipts interoperate",
+ "participants": ["android-current", "ios-current"],
+ "required": true,
+ "capabilities": ["local-relay", "tor"],
+ "evidence": ["relay-fixture", "event-id-count", "receipt-counts"]
+ },
+ {
+ "id": "END-001",
+ "category": "endurance",
+ "title": "Multi-hour discovery, connect, and disconnect soak",
+ "participants": ["android-low", "android-current"],
+ "required": true,
+ "capabilities": ["ble-central", "ble-peripheral"],
+ "minimum_duration_minutes": 240,
+ "evidence": ["duration-minutes", "connection-counts", "failure-counts"]
+ },
+ {
+ "id": "END-002",
+ "category": "endurance",
+ "title": "Repeated large-transfer and cancellation cycles",
+ "participants": ["android-low", "android-current"],
+ "required": true,
+ "capabilities": ["ble-central", "ble-peripheral"],
+ "minimum_cycles": 50,
+ "evidence": ["cycle-count", "delivery-counts", "cancellation-counts"]
+ },
+ {
+ "id": "END-003",
+ "category": "endurance",
+ "title": "Resource growth remains bounded",
+ "participants": ["android-low", "android-current"],
+ "required": true,
+ "capabilities": ["ble-central", "ble-peripheral"],
+ "evidence": ["memory-samples", "thread-samples", "fd-samples", "wakelock-samples", "battery-samples"]
+ },
+ {
+ "id": "END-004",
+ "category": "endurance",
+ "title": "Foreground service survives with screens off",
+ "participants": ["android-low", "android-current"],
+ "required": true,
+ "capabilities": ["ble-central", "ble-peripheral"],
+ "evidence": ["service-state", "screen-state", "delivery-counts"]
+ },
+ {
+ "id": "END-005",
+ "category": "endurance",
+ "title": "Nostr survives network and Tor availability changes",
+ "participants": ["android-current"],
+ "required": true,
+ "capabilities": ["local-relay", "tor"],
+ "evidence": ["network-transitions", "tor-transitions", "receipt-counts"]
+ },
+ {
+ "id": "END-006",
+ "category": "endurance",
+ "title": "Shutdown releases radios, sockets, jobs, and wake locks",
+ "participants": ["android-low", "android-current"],
+ "required": true,
+ "capabilities": ["ble-central", "ble-peripheral"],
+ "evidence": ["resource-terminal-state", "late-callback-count"]
+ }
+ ]
+}
diff --git a/tools/release_gate/test_release_gate.py b/tools/release_gate/test_release_gate.py
new file mode 100644
index 00000000..3ca39ac8
--- /dev/null
+++ b/tools/release_gate/test_release_gate.py
@@ -0,0 +1,367 @@
+import json
+import subprocess
+import tempfile
+import unittest
+import zipfile
+from pathlib import Path
+from unittest import mock
+
+from tools.release_gate import android_lab
+from tools.release_gate.release_gate import (
+ GateError,
+ append_trace_event,
+ canonical_json_digest,
+ create_bundle,
+ initialize_run,
+ load_json,
+ parse_evidence,
+ record_result,
+ validate_manifest,
+ validate_matrix,
+ validate_privacy,
+ validate_run,
+)
+
+
+COMMIT = "1" * 40
+TOOL_DIRECTORY = Path(__file__).parent
+
+
+def valid_matrix():
+ return {
+ "schema_version": 1,
+ "commit": COMMIT,
+ "clients": {
+ "android-current": {"version": "2.0.0-rc1", "commit": COMMIT},
+ "android-legacy": {"version": "1.9.0"},
+ "ios-current": {"version": "2.0.0"},
+ },
+ "lab_capabilities": ["local-relay", "tor"],
+ "devices": [
+ {
+ "alias": "android-low",
+ "platform": "android",
+ "model": "model-low",
+ "manufacturer_class": "vendor-a",
+ "api_level": 28,
+ "physical": True,
+ "capabilities": ["ble-central", "ble-peripheral"],
+ },
+ {
+ "alias": "android-current",
+ "platform": "android",
+ "model": "model-current",
+ "manufacturer_class": "vendor-b",
+ "api_level": 35,
+ "physical": True,
+ "capabilities": ["ble-central", "ble-peripheral", "wifi-aware"],
+ },
+ {
+ "alias": "android-relay",
+ "platform": "android",
+ "model": "model-relay",
+ "manufacturer_class": "vendor-c",
+ "api_level": 33,
+ "physical": True,
+ "capabilities": ["ble-central", "ble-peripheral"],
+ },
+ {
+ "alias": "android-aware",
+ "platform": "android",
+ "model": "model-aware",
+ "manufacturer_class": "vendor-b",
+ "api_level": 35,
+ "physical": True,
+ "capabilities": ["ble-central", "ble-peripheral", "wifi-aware"],
+ },
+ {
+ "alias": "android-legacy",
+ "platform": "android",
+ "model": "model-legacy",
+ "manufacturer_class": "vendor-a",
+ "api_level": 28,
+ "physical": True,
+ "capabilities": ["ble-central", "ble-peripheral"],
+ },
+ {
+ "alias": "ios-current",
+ "platform": "ios",
+ "model": "ios-model",
+ "manufacturer_class": "apple",
+ "physical": True,
+ "capabilities": ["ble-central", "ble-peripheral"],
+ },
+ ],
+ }
+
+
+class ReleaseGateTest(unittest.TestCase):
+ def setUp(self):
+ self.manifest = load_json(TOOL_DIRECTORY / "scenarios.json")
+
+ def test_manifest_covers_every_release_category(self):
+ scenarios = validate_manifest(self.manifest)
+ self.assertEqual(27, len(scenarios))
+ self.assertEqual(
+ {
+ "transport",
+ "android-platform",
+ "android-to-android",
+ "cross-client",
+ "endurance",
+ },
+ {scenario["category"] for scenario in scenarios.values()},
+ )
+
+ def test_documented_matrix_template_is_schema_valid_but_not_runnable(self):
+ template = load_json(TOOL_DIRECTORY / "device-matrix.example.json")
+ self.assertEqual(6, len(validate_matrix(template, allow_placeholders=True)))
+ with self.assertRaises(GateError):
+ validate_matrix(template)
+
+ def test_matrix_enforces_distinct_api_manufacturer_and_counterpart_clients(self):
+ matrix = valid_matrix()
+ self.assertEqual(6, len(validate_matrix(matrix, COMMIT)))
+
+ for device in matrix["devices"]:
+ if device["platform"] == "android":
+ device["manufacturer_class"] = "one-vendor"
+ with self.assertRaisesRegex(GateError, "manufacturer"):
+ validate_matrix(matrix, COMMIT)
+
+ def test_privacy_policy_rejects_identifiers_paths_addresses_and_long_ids(self):
+ rejected = (
+ {"serial": "device-selector"},
+ {"note": "/" + "home/operator/result"},
+ {"note": "operator@example.test"},
+ {"note": "192.0.2.1"},
+ {"note": "aa:bb:cc:dd:ee:ff"},
+ {"note": "0123456789abcdef"},
+ )
+ for value in rejected:
+ with self.subTest(value=value), self.assertRaises(GateError):
+ validate_privacy(value)
+ validate_privacy({"commit": COMMIT, "packet-correlation-count": 3})
+
+ def test_initialize_creates_disposable_fixtures_and_pending_results(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ run = Path(temporary) / "rc-run"
+ results = initialize_run(
+ self.manifest,
+ valid_matrix(),
+ run,
+ COMMIT,
+ "rc-run",
+ started_at="2026-01-01T00:00:00+00:00",
+ )
+ self.assertTrue(all(
+ result["status"] == "pending"
+ for result in results["scenario_results"].values()
+ ))
+ fixtures = load_json(run / "fixtures" / "manifest.json")["fixtures"]
+ sizes = {fixture["name"]: fixture["size_bytes"] for fixture in fixtures}
+ self.assertEqual(0, sizes["empty.bin"])
+ self.assertEqual(50 * 1024 * 1024, sizes["maximum.bin"])
+ self.assertEqual(50 * 1024 * 1024 + 1, sizes["oversized.bin"])
+ self.assertEqual(
+ results["scenario_manifest_sha256"],
+ canonical_json_digest(self.manifest),
+ )
+ summary = validate_run(run, allow_incomplete=True)
+ self.assertEqual(27, summary["pending"])
+ self.assertFalse(summary["complete"])
+
+ def test_record_requires_structured_evidence_and_rejects_pii(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ run = Path(temporary) / "rc-run"
+ initialize_run(self.manifest, valid_matrix(), run, COMMIT, "rc-run")
+ with self.assertRaises(GateError):
+ record_result(run, "A2A-001", "pass", {}, None)
+ with self.assertRaises(GateError):
+ record_result(
+ run,
+ "A2A-001",
+ "pass",
+ {"connection-transitions": "operator@example.test"},
+ None,
+ )
+ with self.assertRaisesRegex(GateError, "reason code"):
+ record_result(run, "A2A-001", "fail", {}, None)
+ recorded = record_result(
+ run,
+ "A2A-001",
+ "blocked",
+ {},
+ "counterpart-unavailable",
+ updated_at="2026-01-01T00:00:00+00:00",
+ )
+ self.assertEqual(
+ ["blocked"],
+ [
+ item["status"]
+ for item in recorded["scenario_results"]["A2A-001"]["history"]
+ ],
+ )
+
+ def test_complete_run_requires_all_evidence_traces_and_endurance_bounds(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ run = Path(temporary) / "rc-run"
+ initialize_run(self.manifest, valid_matrix(), run, COMMIT, "rc-run")
+ scenarios = validate_manifest(self.manifest)
+ for scenario_id, scenario in scenarios.items():
+ evidence = {key: 1 for key in scenario["evidence"]}
+ if "duration-minutes" in evidence:
+ evidence["duration-minutes"] = 240
+ if "cycle-count" in evidence:
+ evidence["cycle-count"] = 50
+ record_result(
+ run,
+ scenario_id,
+ "pass",
+ evidence,
+ None,
+ updated_at="2026-01-01T04:00:00+00:00",
+ )
+ append_trace_event(
+ run,
+ scenario_id,
+ scenario["participants"][0],
+ "scenario-terminal",
+ "pass",
+ None,
+ {"assertion-count": len(evidence)},
+ timestamp="2026-01-01T04:00:00+00:00",
+ )
+ summary = validate_run(run)
+ self.assertTrue(summary["complete"])
+ self.assertEqual(27, summary["pass"])
+ self.assertEqual(27, summary["trace_events"])
+
+ bundle = Path(temporary) / "release-gate.zip"
+ create_bundle(run, bundle)
+ with zipfile.ZipFile(bundle) as archive:
+ self.assertEqual(
+ {
+ "SHA256SUMS",
+ "fixtures/manifest.json",
+ "manifest.json",
+ "matrix.json",
+ "results.json",
+ "summary.md",
+ "trace.jsonl",
+ },
+ set(archive.namelist()),
+ )
+ with self.assertRaisesRegex(GateError, "overwrite"):
+ create_bundle(run, bundle)
+
+ def test_manifest_tampering_after_initialization_is_detected(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ run = Path(temporary) / "rc-run"
+ initialize_run(self.manifest, valid_matrix(), run, COMMIT, "rc-run")
+ manifest = load_json(run / "manifest.json")
+ manifest["scenario_version"] = "tampered"
+ (run / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
+ with self.assertRaisesRegex(GateError, "changed"):
+ validate_run(run, allow_incomplete=True)
+
+ def test_trace_accepts_only_aggregate_metrics(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ run = Path(temporary) / "rc-run"
+ initialize_run(self.manifest, valid_matrix(), run, COMMIT, "rc-run")
+ with self.assertRaisesRegex(GateError, "numeric"):
+ append_trace_event(
+ run,
+ "A2A-001",
+ "android-current",
+ "packet",
+ "observed",
+ None,
+ {"raw-packet": "payload"},
+ )
+
+ def test_evidence_parser_is_typed_and_privacy_checked(self):
+ self.assertEqual(
+ {"count": 3, "ratio": 0.5, "clean": True},
+ parse_evidence(["count=3", "ratio=0.5", "clean=true"]),
+ )
+ with self.assertRaises(GateError):
+ parse_evidence(["note=10.0.0.1"])
+
+ @mock.patch("tools.release_gate.android_lab.find_adb", return_value="adb")
+ def test_adb_device_count_never_returns_selectors(self, _find_adb):
+ completed = subprocess.CompletedProcess(
+ ["adb", "devices"],
+ 0,
+ "List of devices attached\nselector-one\tdevice\nselector-two\toffline\n",
+ "",
+ )
+ count = android_lab.count_connected_devices(runner=lambda *args, **kwargs: completed)
+ self.assertEqual(1, count)
+
+ @mock.patch("tools.release_gate.android_lab.run_adb")
+ def test_adb_probe_emits_only_logical_device_metadata(self, run_adb):
+ run_adb.side_effect = [
+ "35",
+ "feature:android.hardware.wifi.aware",
+ "Vendor",
+ "Model",
+ ]
+ probe = android_lab.probe_device("ephemeral-selector", "android-current")
+ self.assertEqual("android-current", probe["alias"])
+ self.assertNotIn("serial", probe)
+ self.assertIn("wifi-aware", probe["capabilities"])
+
+ @mock.patch("tools.release_gate.android_lab.run_adb")
+ def test_disposable_cleanup_targets_the_real_application_id(self, run_adb):
+ run_adb.side_effect = ["", "Success"]
+
+ android_lab.prepare_disposable_device("ephemeral-selector", confirmed=True)
+
+ self.assertEqual(
+ [
+ mock.call(
+ "ephemeral-selector",
+ ["shell", "am", "force-stop", "com.bitchat.droid"],
+ ),
+ mock.call(
+ "ephemeral-selector",
+ ["shell", "pm", "clear", "com.bitchat.droid"],
+ ),
+ ],
+ run_adb.call_args_list,
+ )
+
+ @mock.patch("tools.release_gate.android_lab.run_adb")
+ def test_resource_snapshot_returns_only_aggregate_metrics(self, run_adb):
+ run_adb.side_effect = [
+ "123",
+ "TOTAL 2048",
+ "7",
+ "11",
+ "WakeLock com.bitchat.droid\nWakeLock another.package",
+ "level: 73",
+ ]
+ metrics = android_lab.collect_resource_snapshot("ephemeral-selector")
+ self.assertEqual(
+ {
+ "process-running": True,
+ "total-pss-kb": 2048,
+ "thread-count": 7,
+ "fd-count": 11,
+ "app-wakelock-count": 1,
+ "battery-level-percent": 73,
+ },
+ metrics,
+ )
+ self.assertEqual(
+ mock.call(
+ "ephemeral-selector",
+ ["shell", "pidof", "com.bitchat.droid"],
+ ),
+ run_adb.call_args_list[0],
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()