Merge branch 'main' into fix/android-ios-fragmentation-compatibility
@@ -9,11 +9,11 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: "Checklist"
|
label: "Checklist"
|
||||||
options:
|
options:
|
||||||
- label: "I made sure that there are *no existing issues or discussions* - [open](https://github.com/callebtc/bitchat-android/issues) or [closed](https://github.com/callebtc/bitchat-android/issues?q=is%3Aissue+is%3Aclosed) - which I could contribute my information to."
|
- label: "I made sure that there are *no existing issues or discussions* - [open](https://github.com/permissionlesstech/bitchat-android/issues) or [closed](https://github.com/permissionlesstech/bitchat-android/issues?q=is%3Aissue+is%3Aclosed) - which I could contribute my information to."
|
||||||
required: true
|
required: true
|
||||||
- label: "I have taken the time to fill in all the required details. I understand that the question will be dismissed otherwise."
|
- label: "I have taken the time to fill in all the required details. I understand that the question will be dismissed otherwise."
|
||||||
required: true
|
required: true
|
||||||
- label: "I have read and understood the [technical architecture](https://github.com/callebtc/bitchat-android/blob/main/README.md#technical-architecture)."
|
- label: "I have read and understood the [technical architecture](https://github.com/permissionlesstech/bitchat-android/blob/main/README.md#technical-architecture)."
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
|||||||
@@ -14,15 +14,15 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: "Checklist"
|
label: "Checklist"
|
||||||
options:
|
options:
|
||||||
- label: "I am able to reproduce the bug with the latest version given here: [CLICK THIS LINK](https://github.com/callebtc/bitchat-android/releases/latest)."
|
- label: "I am able to reproduce the bug with the latest version given here: [CLICK THIS LINK](https://github.com/permissionlesstech/bitchat-android/releases/latest)."
|
||||||
required: true
|
required: true
|
||||||
- label: "I made sure that there are *no existing issues* - [open](https://github.com/callebtc/bitchat-android/issues) or [closed](https://github.com/callebtc/bitchat-android/issues?q=is%3Aissue+is%3Aclosed) - which I could contribute my information to."
|
- label: "I made sure that there are *no existing issues* - [open](https://github.com/permissionlesstech/bitchat-android/issues) or [closed](https://github.com/permissionlesstech/bitchat-android/issues?q=is%3Aissue+is%3Aclosed) - which I could contribute my information to."
|
||||||
required: true
|
required: true
|
||||||
- label: "I have taken the time to fill in all the required details. I understand that the bug report will be dismissed otherwise."
|
- label: "I have taken the time to fill in all the required details. I understand that the bug report will be dismissed otherwise."
|
||||||
required: true
|
required: true
|
||||||
- label: "This issue contains only one bug."
|
- label: "This issue contains only one bug."
|
||||||
required: true
|
required: true
|
||||||
- label: "I have read and understood the [contribution guidelines](https://github.com/callebtc/bitchat-android/blob/main/README.md#contributing)."
|
- label: "I have read and understood the [contribution guidelines](https://github.com/permissionlesstech/bitchat-android/blob/main/README.md#contributing)."
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: input
|
- type: input
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: Checklist
|
label: Checklist
|
||||||
options:
|
options:
|
||||||
- label: I have used the search function for [**OPEN**](https://github.com/callebtc/bitchat-android/issues) issues to see if someone else has already submitted the same feature request.
|
- label: I have used the search function for [**OPEN**](https://github.com/permissionlesstech/bitchat-android/issues) issues to see if someone else has already submitted the same feature request.
|
||||||
required: true
|
required: true
|
||||||
- label: I have **also** used the search function for [**CLOSED**](https://github.com/callebtc/bitchat-android/issues?q=is%3Aissue+is%3Aclosed) issues to see if the feature was already implemented and is just waiting to be released, or if the feature was rejected.
|
- label: I have **also** used the search function for [**CLOSED**](https://github.com/permissionlesstech/bitchat-android/issues?q=is%3Aissue+is%3Aclosed) issues to see if the feature was already implemented and is just waiting to be released, or if the feature was rejected.
|
||||||
required: true
|
required: true
|
||||||
- label: I will describe the problem with as much detail as possible.
|
- label: I will describe the problem with as much detail as possible.
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
please make sure that you have done all of the following.
|
please make sure that you have done all of the following.
|
||||||
You can tick the boxes below by placing an x inside the brackets like this: [x]
|
You can tick the boxes below by placing an x inside the brackets like this: [x]
|
||||||
-->
|
-->
|
||||||
- [ ] I have read the contribution guidelines: <https://github.com/callebtc/bitchat-android?tab=readme-ov-file#contributing>
|
- [ ] I have read the contribution guidelines: <https://github.com/permissionlesstech/bitchat-android?tab=readme-ov-file#contributing>
|
||||||
- [ ] I have performed a self-review of my code
|
- [ ] I have performed a self-review of my code
|
||||||
<!-- - [ ] I have run the automated code checks using `./gradlew checkstyle spotbugsPlayDebug spotbugsDebug :app:lintPlayDebug` -->
|
<!-- - [ ] I have run the automated code checks using `./gradlew checkstyle spotbugsPlayDebug spotbugsDebug :app:lintPlayDebug` -->
|
||||||
- [ ] I have mentioned the corresponding issue and the relevant keyword (e.g., "Closes: #xy") in the description (see <https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue>)
|
- [ ] I have mentioned the corresponding issue and the relevant keyword (e.g., "Closes: #xy") in the description (see <https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue>)
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
name: Android Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- '**'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- '**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up JDK 17
|
||||||
|
uses: actions/setup-java@v3
|
||||||
|
with:
|
||||||
|
java-version: '17'
|
||||||
|
distribution: 'temurin'
|
||||||
|
|
||||||
|
- name: Setup Gradle
|
||||||
|
uses: gradle/gradle-build-action@v2
|
||||||
|
|
||||||
|
- name: Build with Gradle
|
||||||
|
run: ./gradlew build
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# 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/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Permission onboarding screen UX: removed "Exit App" button and fixed "Grant Permissions" button positioning to always be visible
|
||||||
|
|
||||||
|
## [0.6]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Channel password management with `/pass` command for channel owners
|
||||||
|
- Monochrome/themed launcher icon for Android 12+ dynamic theming support
|
||||||
|
- Unit tests package with initial testing infrastructure
|
||||||
|
- Production build optimization with code minification and shrinking
|
||||||
|
- Native back gesture/button handling for all app views
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Favorite peer functionality completely restored and improved
|
||||||
|
- Enhanced favorite system with fallback mechanism for peers without key exchange
|
||||||
|
- Fixed UI state updates for favorite stars in both header and sidebar
|
||||||
|
- Improved favorite persistence across app sessions
|
||||||
|
- `/w` command now displays user nicknames instead of peer IDs
|
||||||
|
- Button styling and layout improvements across the app
|
||||||
|
- Enhanced back button positioning and styling
|
||||||
|
- Improved private chat and channel header button layouts
|
||||||
|
- Fixed button padding and alignment issues
|
||||||
|
- Color scheme consistency updates
|
||||||
|
- Updated orange color throughout the app to match iOS version
|
||||||
|
- Consistent color usage for private messages and UI elements
|
||||||
|
- App startup reliability improvements
|
||||||
|
- Better initialization sequence handling
|
||||||
|
- Fixed null pointer exceptions during startup
|
||||||
|
- Enhanced error handling and logging
|
||||||
|
- Input field styling and behavior improvements
|
||||||
|
- Sidebar user interaction enhancements
|
||||||
|
- Permission explanation screen layout fixes with proper vertical padding
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Updated GitHub organization references in project files
|
||||||
|
- Improved README documentation with updated clone URLs
|
||||||
|
- Enhanced logging throughout the application for better debugging
|
||||||
|
|
||||||
|
## [0.5.1] - 2025-07-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Bluetooth startup check with user prompt to enable Bluetooth if disabled
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Improved Bluetooth initialization reliability on first app launch
|
||||||
|
|
||||||
|
## [0.5] - 2025-07-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- New user onboarding screen with permission explanations
|
||||||
|
- Educational content explaining why each permission is required
|
||||||
|
- Privacy assurance messaging (no tracking, no servers, local-only data)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Comprehensive permission validation - ensures all required permissions are granted
|
||||||
|
- Proper Bluetooth stack initialization on first app load
|
||||||
|
- Eliminated need for manual app restart after installation
|
||||||
|
- Enhanced permission request coordination and error handling
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Improved first-time user experience with guided setup flow
|
||||||
|
|
||||||
|
## [0.4] - 2025-07-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Push notifications for direct messages
|
||||||
|
- Enhanced notification system with proper click handling and grouping
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
- Direct message (DM) view with better user interface
|
||||||
|
- Enhanced private messaging experience
|
||||||
|
|
||||||
|
### Known Issues
|
||||||
|
- Favorite peer functionality currently broken
|
||||||
|
|
||||||
|
## [0.3] - 2025-07-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Battery-aware scanning policies for improved power management
|
||||||
|
- Dynamic scan behavior based on device battery state
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Android-to-Android Bluetooth Low Energy connections
|
||||||
|
- Peer discovery reliability between Android devices
|
||||||
|
- Connection stability improvements
|
||||||
|
|
||||||
|
## [0.2] - 2025-07-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial Android implementation of bitchat protocol
|
||||||
|
- Bluetooth Low Energy mesh networking
|
||||||
|
- End-to-end encryption for private messages
|
||||||
|
- Channel-based messaging with password protection
|
||||||
|
- Store-and-forward message delivery
|
||||||
|
- IRC-style commands (/msg, /join, /clear, etc.)
|
||||||
|
- RSSI-based signal quality indicators
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Various Bluetooth handling improvements
|
||||||
|
- User interface refinements
|
||||||
|
- Connection reliability enhancements
|
||||||
|
|
||||||
|
## [0.1] - 2025-07-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial release of bitchat Android client
|
||||||
|
- Basic mesh networking functionality
|
||||||
|
- Core messaging features
|
||||||
|
- Protocol compatibility with iOS bitchat client
|
||||||
|
|
||||||
|
[Unreleased]: https://github.com/permissionlesstech/bitchat-android/compare/0.5.1...HEAD
|
||||||
|
[0.5.1]: https://github.com/permissionlesstech/bitchat-android/compare/0.5...0.5.1
|
||||||
|
[0.5]: https://github.com/permissionlesstech/bitchat-android/compare/0.4...0.5
|
||||||
|
[0.4]: https://github.com/permissionlesstech/bitchat-android/compare/0.3...0.4
|
||||||
|
[0.3]: https://github.com/permissionlesstech/bitchat-android/compare/0.2...0.3
|
||||||
|
[0.2]: https://github.com/permissionlesstech/bitchat-android/compare/0.1...0.2
|
||||||
|
[0.1]: https://github.com/permissionlesstech/bitchat-android/releases/tag/0.1
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# ChatScreen.kt Refactoring Plan
|
|
||||||
|
|
||||||
## Current State
|
|
||||||
- Single file: `ChatScreen.kt` (~1,100+ lines)
|
|
||||||
- Multiple UI responsibilities mixed together
|
|
||||||
- Hard to maintain and test individual components
|
|
||||||
|
|
||||||
## Proposed Component Structure
|
|
||||||
|
|
||||||
### 1. Main Screen (ChatScreen.kt)
|
|
||||||
**Responsibilities:**
|
|
||||||
- Main layout orchestration
|
|
||||||
- State management delegation
|
|
||||||
- Window insets handling
|
|
||||||
- Component coordination
|
|
||||||
|
|
||||||
### 2. Header Components (ChatHeader.kt)
|
|
||||||
**Responsibilities:**
|
|
||||||
- TopAppBar with different states (main, private, channel)
|
|
||||||
- Nickname editor
|
|
||||||
- Peer counter with status indicators
|
|
||||||
- Navigation controls
|
|
||||||
|
|
||||||
### 3. Message Components (MessageComponents.kt)
|
|
||||||
**Responsibilities:**
|
|
||||||
- MessagesList composable
|
|
||||||
- MessageItem with formatting
|
|
||||||
- Message text parsing and styling
|
|
||||||
- Delivery status indicators
|
|
||||||
- RSSI-based coloring
|
|
||||||
|
|
||||||
### 4. Input Components (InputComponents.kt)
|
|
||||||
**Responsibilities:**
|
|
||||||
- MessageInput with different modes
|
|
||||||
- Command suggestions box
|
|
||||||
- Command suggestion items
|
|
||||||
- Input validation and handling
|
|
||||||
|
|
||||||
### 5. Sidebar Components (SidebarComponents.kt)
|
|
||||||
**Responsibilities:**
|
|
||||||
- SidebarOverlay with navigation
|
|
||||||
- ChannelsSection for channel management
|
|
||||||
- PeopleSection for peer list
|
|
||||||
- Sidebar state management
|
|
||||||
|
|
||||||
### 6. Dialog Components (DialogComponents.kt)
|
|
||||||
**Responsibilities:**
|
|
||||||
- Password prompt dialog
|
|
||||||
- App info dialog
|
|
||||||
- Other modal dialogs
|
|
||||||
|
|
||||||
### 7. UI Utils (ChatUIUtils.kt)
|
|
||||||
**Responsibilities:**
|
|
||||||
- RSSI color mapping
|
|
||||||
- Text formatting utilities
|
|
||||||
- Common styling constants
|
|
||||||
- Helper functions
|
|
||||||
|
|
||||||
## Benefits
|
|
||||||
- Each file has a single, clear responsibility
|
|
||||||
- Components are easier to test in isolation
|
|
||||||
- Better code organization and navigation
|
|
||||||
- Simplified debugging
|
|
||||||
- Easier to add new UI features
|
|
||||||
@@ -13,7 +13,7 @@ This is the **Android port** of the original [bitchat iOS app](https://github.co
|
|||||||
|
|
||||||
## Install bitchat
|
## Install bitchat
|
||||||
|
|
||||||
You can download the latest version of bitchat for Android from the [GitHub Releases page](https://github.com/callebtc/bitchat-android/releases).
|
You can download the latest version of bitchat for Android from the [GitHub Releases page](https://github.com/permissionlesstech/bitchat-android/releases).
|
||||||
|
|
||||||
**Instructions:**
|
**Instructions:**
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ This project is released into the public domain. See the [LICENSE](LICENSE.md) f
|
|||||||
|
|
||||||
1. **Clone the repository:**
|
1. **Clone the repository:**
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/your-username/bitchat-android.git
|
git clone https://github.com/permissionlesstech/bitchat-android.git
|
||||||
cd bitchat-android
|
cd bitchat-android
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
# BluetoothMeshService Refactoring - Progress Report
|
|
||||||
|
|
||||||
## ✅ COMPLETED: Extracted Components (All compile successfully)
|
|
||||||
|
|
||||||
### 1. PeerManager.kt (161 lines)
|
|
||||||
**Responsibilities:**
|
|
||||||
- Active peer tracking and lifecycle management
|
|
||||||
- Peer nickname management and stale peer cleanup
|
|
||||||
- RSSI tracking and peer list updates
|
|
||||||
- **Interface:** `PeerManagerDelegate`
|
|
||||||
|
|
||||||
### 2. FragmentManager.kt (194 lines)
|
|
||||||
**Responsibilities:**
|
|
||||||
- Message fragmentation for large messages (>500 bytes)
|
|
||||||
- Fragment reassembly and cleanup
|
|
||||||
- Fragment timeout management (30 seconds)
|
|
||||||
- **Interface:** `FragmentManagerDelegate`
|
|
||||||
|
|
||||||
### 3. SecurityManager.kt (236 lines)
|
|
||||||
**Responsibilities:**
|
|
||||||
- Duplicate detection and replay attack protection
|
|
||||||
- Key exchange handling and validation
|
|
||||||
- Message encryption/decryption operations
|
|
||||||
- Packet signature verification
|
|
||||||
- **Interface:** `SecurityManagerDelegate`
|
|
||||||
|
|
||||||
### 4. StoreForwardManager.kt (295 lines)
|
|
||||||
**Responsibilities:**
|
|
||||||
- Message caching for offline peers (12 hours regular, unlimited favorites)
|
|
||||||
- Store-and-forward delivery when peers come online
|
|
||||||
- Cache cleanup and management
|
|
||||||
- **Interface:** `StoreForwardManagerDelegate`
|
|
||||||
|
|
||||||
### 5. MessageHandler.kt (284 lines)
|
|
||||||
**Responsibilities:**
|
|
||||||
- Processing different message types (ANNOUNCE, MESSAGE, LEAVE, etc.)
|
|
||||||
- Broadcast vs private message handling
|
|
||||||
- Message relay logic with adaptive probability
|
|
||||||
- Delivery acknowledgment sending
|
|
||||||
- **Interface:** `MessageHandlerDelegate`
|
|
||||||
|
|
||||||
### 6. BluetoothConnectionManager.kt (611 lines)
|
|
||||||
**Responsibilities:**
|
|
||||||
- BLE advertising and scanning
|
|
||||||
- GATT server/client setup and management
|
|
||||||
- Device connection tracking (both server and client modes)
|
|
||||||
- Packet broadcasting to all connected devices
|
|
||||||
- **Interface:** `BluetoothConnectionManagerDelegate`
|
|
||||||
|
|
||||||
## 📊 Size Reduction Analysis
|
|
||||||
|
|
||||||
| Component | Lines | Responsibility |
|
|
||||||
|-----------|--------|----------------|
|
|
||||||
| **PeerManager** | 161 | Peer lifecycle & tracking |
|
|
||||||
| **FragmentManager** | 194 | Message fragmentation |
|
|
||||||
| **SecurityManager** | 236 | Security & encryption |
|
|
||||||
| **StoreForwardManager** | 295 | Offline message caching |
|
|
||||||
| **MessageHandler** | 284 | Message type processing |
|
|
||||||
| **BluetoothConnectionManager** | 611 | BLE connection management |
|
|
||||||
| **Original File** | ~1000+ | All responsibilities mixed |
|
|
||||||
|
|
||||||
**Total Extracted:** ~1781 lines (distributed across 6 focused files)
|
|
||||||
**Reduction Factor:** Original single file → 6 smaller, focused components
|
|
||||||
|
|
||||||
## 🏗️ Next Steps for Integration
|
|
||||||
|
|
||||||
### Phase 1: Refactor Existing BluetoothMeshService
|
|
||||||
1. **Update BluetoothMeshService.kt** to use the new components
|
|
||||||
2. **Wire up all delegate interfaces**
|
|
||||||
3. **Maintain exact same public API** so ChatViewModel doesn't change
|
|
||||||
4. **Test compilation and functionality**
|
|
||||||
|
|
||||||
### Phase 2: Integration Testing
|
|
||||||
1. **Verify all existing functionality works**
|
|
||||||
2. **Test key scenarios:**
|
|
||||||
- Peer discovery and connection
|
|
||||||
- Message sending/receiving
|
|
||||||
- Fragment handling
|
|
||||||
- Store-and-forward delivery
|
|
||||||
- Security validation
|
|
||||||
|
|
||||||
### Phase 3: Benefits Validation
|
|
||||||
1. **Easier unit testing** (each component can be tested independently)
|
|
||||||
2. **Better code maintainability** (clear separation of concerns)
|
|
||||||
3. **Improved debugging** (isolated component logs)
|
|
||||||
4. **Future extensibility** (easier to add new features)
|
|
||||||
|
|
||||||
## 🔧 Current Build Status
|
|
||||||
✅ **All 6 extracted components compile successfully**
|
|
||||||
✅ **No breaking changes to existing interfaces**
|
|
||||||
✅ **Original BluetoothMeshService.kt still intact**
|
|
||||||
✅ **Ready for integration phase**
|
|
||||||
|
|
||||||
## 💡 Key Design Decisions Made
|
|
||||||
|
|
||||||
1. **Delegate Pattern:** Each component uses a delegate interface for clean separation
|
|
||||||
2. **Coroutine Scope per Component:** Isolated lifecycle management
|
|
||||||
3. **Thread-Safe Collections:** Maintained from original implementation
|
|
||||||
4. **Same UUIDs and Constants:** No protocol changes
|
|
||||||
5. **Preserved iOS Compatibility:** All timing and logic matches iOS exactly
|
|
||||||
6. **Error Handling:** Maintained original defensive programming patterns
|
|
||||||
|
|
||||||
The refactoring successfully breaks down a monolithic 1000+ line service into 6 focused, maintainable components while preserving 100% compatibility with the iOS implementation.
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
# RSSI Color Change Fix - Implementation Summary
|
|
||||||
|
|
||||||
## Problem Identified
|
|
||||||
The username colors in the chat were not changing based on RSSI signal strength even though RSSI values were being logged as changing. Investigation revealed that:
|
|
||||||
|
|
||||||
1. **RSSI values were only captured once** during the initial BLE scan discovery
|
|
||||||
2. **No mechanism existed** to continuously update RSSI values from connected devices
|
|
||||||
3. **UI was not being notified** of RSSI changes to trigger recomposition
|
|
||||||
4. **Chat rendering was using stale RSSI values** that never changed after initial connection
|
|
||||||
|
|
||||||
## Root Cause
|
|
||||||
The `BluetoothMeshService` was only recording RSSI during the scan phase (`handleScanResult`) but never updating it during the connection lifetime. Bluetooth GATT provides `readRemoteRssi()` for connected devices, but this wasn't being used.
|
|
||||||
|
|
||||||
## Solution Implemented
|
|
||||||
|
|
||||||
### 1. Device-to-Peer ID Mapping
|
|
||||||
- Added `deviceToPeerIDMapping` to link Bluetooth devices to peer IDs
|
|
||||||
- This allows RSSI updates to be associated with the correct peer ID
|
|
||||||
|
|
||||||
### 2. GATT RSSI Reading Callback
|
|
||||||
- Added `onReadRemoteRssi()` callback in the GATT client callback
|
|
||||||
- Updates `peerRSSI` map when new RSSI values are read
|
|
||||||
- Maps device addresses to peer IDs for proper tracking
|
|
||||||
|
|
||||||
### 3. Periodic RSSI Monitoring
|
|
||||||
- Added background coroutine that runs every 5 seconds
|
|
||||||
- Calls `readRemoteRssi()` on all active GATT connections
|
|
||||||
- Provides continuous RSSI updates during connection lifetime
|
|
||||||
|
|
||||||
### 4. UI Notification System
|
|
||||||
- Added `didUpdateRSSI()` delegate method to notify UI of RSSI changes
|
|
||||||
- When RSSI changes, the delegate is called to potentially trigger UI updates
|
|
||||||
- Chat screen automatically recomposes when RSSI values change
|
|
||||||
|
|
||||||
### 5. Key Exchange Enhancement
|
|
||||||
- Modified `handleKeyExchange()` to map devices to peer IDs
|
|
||||||
- Transfers any existing peripheral RSSI data to peer-based tracking
|
|
||||||
- Ensures proper RSSI association after peer identification
|
|
||||||
|
|
||||||
## Code Changes Made
|
|
||||||
|
|
||||||
### BluetoothMeshService.kt
|
|
||||||
1. **Added device mapping**: `deviceToPeerIDMapping` concurrent hash map
|
|
||||||
2. **RSSI callback**: `onReadRemoteRssi()` implementation in GATT callback
|
|
||||||
3. **Periodic monitoring**: `monitorRSSI()` function called every 5 seconds
|
|
||||||
4. **Key exchange update**: Links devices to peer IDs for RSSI tracking
|
|
||||||
5. **Delegate method**: `didUpdateRSSI()` interface method for UI notifications
|
|
||||||
|
|
||||||
### ChatViewModel.kt
|
|
||||||
1. **Delegate implementation**: Added `didUpdateRSSI()` method
|
|
||||||
2. **Logging**: Debug output when RSSI values change
|
|
||||||
|
|
||||||
### ChatScreen.kt
|
|
||||||
- **No changes needed** - existing `getRSSIColor(rssi)` function already works
|
|
||||||
- UI automatically recomposes when `meshService.getPeerRSSI()` returns updated values
|
|
||||||
|
|
||||||
## How It Works Now
|
|
||||||
|
|
||||||
1. **Initial Discovery**: RSSI captured during BLE scan (as before)
|
|
||||||
2. **Connection**: Device mapped to peer ID during key exchange
|
|
||||||
3. **Monitoring**: Every 5 seconds, `readRemoteRssi()` called on connected devices
|
|
||||||
4. **Update**: RSSI callback updates `peerRSSI` map with new values
|
|
||||||
5. **Notification**: Delegate notified of RSSI change
|
|
||||||
6. **Rendering**: Chat screen uses updated RSSI values for color calculation
|
|
||||||
7. **Recomposition**: UI automatically updates with new colors
|
|
||||||
|
|
||||||
## Expected Behavior
|
|
||||||
- Username colors now change dynamically as users move closer/farther away
|
|
||||||
- Colors reflect real-time signal strength: green (strong) → yellow (medium) → red (weak)
|
|
||||||
- Updates occur every 5 seconds while devices are connected
|
|
||||||
- Your own username remains green regardless of signal strength
|
|
||||||
- Works for both client and server GATT connections
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
- Build successful with `./gradlew assembleDebug`
|
|
||||||
- No compilation errors
|
|
||||||
- All existing functionality preserved
|
|
||||||
- Ready for testing with actual devices
|
|
||||||
|
|
||||||
The fix addresses the core issue where RSSI values were static after connection. Now they continuously update, providing the dynamic color feedback based on signal strength that was originally intended.
|
|
||||||
@@ -2,6 +2,7 @@ plugins {
|
|||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
alias(libs.plugins.kotlin.parcelize)
|
alias(libs.plugins.kotlin.parcelize)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -12,8 +13,8 @@ android {
|
|||||||
applicationId = "com.bitchat.android"
|
applicationId = "com.bitchat.android"
|
||||||
minSdk = libs.versions.minSdk.get().toInt()
|
minSdk = libs.versions.minSdk.get().toInt()
|
||||||
targetSdk = libs.versions.targetSdk.get().toInt()
|
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||||
versionCode = 1
|
versionCode = 2
|
||||||
versionName = "1.0"
|
versionName = "0.6"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
@@ -23,7 +24,8 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
@@ -40,9 +42,6 @@ android {
|
|||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
}
|
}
|
||||||
composeOptions {
|
|
||||||
kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
|
|
||||||
}
|
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.activity.OnBackPressedCallback
|
||||||
|
import androidx.activity.addCallback
|
||||||
|
import com.bitchat.android.mesh.BluetoothMeshService
|
||||||
import com.bitchat.android.onboarding.*
|
import com.bitchat.android.onboarding.*
|
||||||
import com.bitchat.android.ui.ChatScreen
|
import com.bitchat.android.ui.ChatScreen
|
||||||
import com.bitchat.android.ui.ChatViewModel
|
import com.bitchat.android.ui.ChatViewModel
|
||||||
@@ -29,17 +33,31 @@ class MainActivity : ComponentActivity() {
|
|||||||
private lateinit var permissionManager: PermissionManager
|
private lateinit var permissionManager: PermissionManager
|
||||||
private lateinit var onboardingCoordinator: OnboardingCoordinator
|
private lateinit var onboardingCoordinator: OnboardingCoordinator
|
||||||
private lateinit var bluetoothStatusManager: BluetoothStatusManager
|
private lateinit var bluetoothStatusManager: BluetoothStatusManager
|
||||||
private val chatViewModel: ChatViewModel by viewModels()
|
private lateinit var locationStatusManager: LocationStatusManager
|
||||||
|
|
||||||
|
// Core mesh service - managed at app level
|
||||||
|
private lateinit var meshService: BluetoothMeshService
|
||||||
|
private val chatViewModel: ChatViewModel by viewModels {
|
||||||
|
object : ViewModelProvider.Factory {
|
||||||
|
override fun <T : androidx.lifecycle.ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
return ChatViewModel(application, meshService) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// UI state for onboarding flow
|
// UI state for onboarding flow
|
||||||
private var onboardingState by mutableStateOf(OnboardingState.CHECKING)
|
private var onboardingState by mutableStateOf(OnboardingState.CHECKING)
|
||||||
private var bluetoothStatus by mutableStateOf(BluetoothStatus.ENABLED)
|
private var bluetoothStatus by mutableStateOf(BluetoothStatus.ENABLED)
|
||||||
|
private var locationStatus by mutableStateOf(LocationStatus.ENABLED)
|
||||||
private var errorMessage by mutableStateOf("")
|
private var errorMessage by mutableStateOf("")
|
||||||
private var isBluetoothLoading by mutableStateOf(false)
|
private var isBluetoothLoading by mutableStateOf(false)
|
||||||
|
private var isLocationLoading by mutableStateOf(false)
|
||||||
|
|
||||||
enum class OnboardingState {
|
enum class OnboardingState {
|
||||||
CHECKING,
|
CHECKING,
|
||||||
BLUETOOTH_CHECK,
|
BLUETOOTH_CHECK,
|
||||||
|
LOCATION_CHECK,
|
||||||
PERMISSION_EXPLANATION,
|
PERMISSION_EXPLANATION,
|
||||||
PERMISSION_REQUESTING,
|
PERMISSION_REQUESTING,
|
||||||
INITIALIZING,
|
INITIALIZING,
|
||||||
@@ -50,6 +68,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// Initialize core mesh service first
|
||||||
|
meshService = BluetoothMeshService(this)
|
||||||
|
|
||||||
// Initialize permission management
|
// Initialize permission management
|
||||||
permissionManager = PermissionManager(this)
|
permissionManager = PermissionManager(this)
|
||||||
bluetoothStatusManager = BluetoothStatusManager(
|
bluetoothStatusManager = BluetoothStatusManager(
|
||||||
@@ -58,6 +79,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
onBluetoothEnabled = ::handleBluetoothEnabled,
|
onBluetoothEnabled = ::handleBluetoothEnabled,
|
||||||
onBluetoothDisabled = ::handleBluetoothDisabled
|
onBluetoothDisabled = ::handleBluetoothDisabled
|
||||||
)
|
)
|
||||||
|
locationStatusManager = LocationStatusManager(
|
||||||
|
activity = this,
|
||||||
|
context = this,
|
||||||
|
onLocationEnabled = ::handleLocationEnabled,
|
||||||
|
onLocationDisabled = ::handleLocationDisabled
|
||||||
|
)
|
||||||
onboardingCoordinator = OnboardingCoordinator(
|
onboardingCoordinator = OnboardingCoordinator(
|
||||||
activity = this,
|
activity = this,
|
||||||
permissionManager = permissionManager,
|
permissionManager = permissionManager,
|
||||||
@@ -101,15 +128,26 @@ class MainActivity : ComponentActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OnboardingState.LOCATION_CHECK -> {
|
||||||
|
LocationCheckScreen(
|
||||||
|
status = locationStatus,
|
||||||
|
onEnableLocation = {
|
||||||
|
isLocationLoading = true
|
||||||
|
locationStatusManager.requestEnableLocation()
|
||||||
|
},
|
||||||
|
onRetry = {
|
||||||
|
checkLocationAndProceed()
|
||||||
|
},
|
||||||
|
isLoading = isLocationLoading
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
OnboardingState.PERMISSION_EXPLANATION -> {
|
OnboardingState.PERMISSION_EXPLANATION -> {
|
||||||
PermissionExplanationScreen(
|
PermissionExplanationScreen(
|
||||||
permissionCategories = permissionManager.getCategorizedPermissions(),
|
permissionCategories = permissionManager.getCategorizedPermissions(),
|
||||||
onContinue = {
|
onContinue = {
|
||||||
onboardingState = OnboardingState.PERMISSION_REQUESTING
|
onboardingState = OnboardingState.PERMISSION_REQUESTING
|
||||||
onboardingCoordinator.requestPermissions()
|
onboardingCoordinator.requestPermissions()
|
||||||
},
|
|
||||||
onCancel = {
|
|
||||||
finish()
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -123,6 +161,24 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
OnboardingState.COMPLETE -> {
|
OnboardingState.COMPLETE -> {
|
||||||
|
// Set up back navigation handling for the chat screen
|
||||||
|
val backCallback = object : OnBackPressedCallback(true) {
|
||||||
|
override fun handleOnBackPressed() {
|
||||||
|
// Let ChatViewModel handle navigation state
|
||||||
|
val handled = chatViewModel.handleBackPressed()
|
||||||
|
if (!handled) {
|
||||||
|
// If ChatViewModel doesn't handle it, disable this callback
|
||||||
|
// and let the system handle it (which will exit the app)
|
||||||
|
this.isEnabled = false
|
||||||
|
onBackPressedDispatcher.onBackPressed()
|
||||||
|
this.isEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the callback - this will be automatically removed when the activity is destroyed
|
||||||
|
onBackPressedDispatcher.addCallback(this, backCallback)
|
||||||
|
|
||||||
ChatScreen(viewModel = chatViewModel)
|
ChatScreen(viewModel = chatViewModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +213,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
* Check Bluetooth status and proceed with onboarding flow
|
* Check Bluetooth status and proceed with onboarding flow
|
||||||
*/
|
*/
|
||||||
private fun checkBluetoothAndProceed() {
|
private fun checkBluetoothAndProceed() {
|
||||||
android.util.Log.d("MainActivity", "Checking Bluetooth status")
|
// android.util.Log.d("MainActivity", "Checking Bluetooth status")
|
||||||
|
|
||||||
// For first-time users, skip Bluetooth check and go straight to permissions
|
// For first-time users, skip Bluetooth check and go straight to permissions
|
||||||
// We'll check Bluetooth after permissions are granted
|
// We'll check Bluetooth after permissions are granted
|
||||||
@@ -173,8 +229,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
when (bluetoothStatus) {
|
when (bluetoothStatus) {
|
||||||
BluetoothStatus.ENABLED -> {
|
BluetoothStatus.ENABLED -> {
|
||||||
// Bluetooth is enabled, proceed with permission/onboarding check
|
// Bluetooth is enabled, check location services next
|
||||||
proceedWithPermissionCheck()
|
checkLocationAndProceed()
|
||||||
}
|
}
|
||||||
BluetoothStatus.DISABLED -> {
|
BluetoothStatus.DISABLED -> {
|
||||||
// Show Bluetooth enable screen (should have permissions as existing user)
|
// Show Bluetooth enable screen (should have permissions as existing user)
|
||||||
@@ -221,9 +277,78 @@ class MainActivity : ComponentActivity() {
|
|||||||
android.util.Log.d("MainActivity", "Bluetooth enabled by user")
|
android.util.Log.d("MainActivity", "Bluetooth enabled by user")
|
||||||
isBluetoothLoading = false
|
isBluetoothLoading = false
|
||||||
bluetoothStatus = BluetoothStatus.ENABLED
|
bluetoothStatus = BluetoothStatus.ENABLED
|
||||||
|
checkLocationAndProceed()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check Location services status and proceed with onboarding flow
|
||||||
|
*/
|
||||||
|
private fun checkLocationAndProceed() {
|
||||||
|
android.util.Log.d("MainActivity", "Checking location services status")
|
||||||
|
|
||||||
|
// For first-time users, skip location check and go straight to permissions
|
||||||
|
// We'll check location after permissions are granted
|
||||||
|
if (permissionManager.isFirstTimeLaunch()) {
|
||||||
|
android.util.Log.d("MainActivity", "First-time launch, skipping location check - will check after permissions")
|
||||||
|
proceedWithPermissionCheck()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// For existing users, check location status
|
||||||
|
locationStatusManager.logLocationStatus()
|
||||||
|
locationStatus = locationStatusManager.checkLocationStatus()
|
||||||
|
|
||||||
|
when (locationStatus) {
|
||||||
|
LocationStatus.ENABLED -> {
|
||||||
|
// Location services enabled, proceed with permission/onboarding check
|
||||||
|
proceedWithPermissionCheck()
|
||||||
|
}
|
||||||
|
LocationStatus.DISABLED -> {
|
||||||
|
// Show location enable screen (should have permissions as existing user)
|
||||||
|
android.util.Log.d("MainActivity", "Location services disabled, showing enable screen")
|
||||||
|
onboardingState = OnboardingState.LOCATION_CHECK
|
||||||
|
isLocationLoading = false
|
||||||
|
}
|
||||||
|
LocationStatus.NOT_AVAILABLE -> {
|
||||||
|
// Device doesn't support location services (very unusual)
|
||||||
|
android.util.Log.e("MainActivity", "Location services not available")
|
||||||
|
onboardingState = OnboardingState.LOCATION_CHECK
|
||||||
|
isLocationLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Location enabled callback
|
||||||
|
*/
|
||||||
|
private fun handleLocationEnabled() {
|
||||||
|
android.util.Log.d("MainActivity", "Location services enabled by user")
|
||||||
|
isLocationLoading = false
|
||||||
|
locationStatus = LocationStatus.ENABLED
|
||||||
proceedWithPermissionCheck()
|
proceedWithPermissionCheck()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Location disabled callback
|
||||||
|
*/
|
||||||
|
private fun handleLocationDisabled(message: String) {
|
||||||
|
android.util.Log.w("MainActivity", "Location services disabled or failed: $message")
|
||||||
|
isLocationLoading = false
|
||||||
|
locationStatus = locationStatusManager.checkLocationStatus()
|
||||||
|
|
||||||
|
when {
|
||||||
|
locationStatus == LocationStatus.NOT_AVAILABLE -> {
|
||||||
|
// Show permanent error for devices without location services
|
||||||
|
errorMessage = message
|
||||||
|
onboardingState = OnboardingState.ERROR
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Stay on location check screen for retry
|
||||||
|
onboardingState = OnboardingState.LOCATION_CHECK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle Bluetooth disabled callback
|
* Handle Bluetooth disabled callback
|
||||||
*/
|
*/
|
||||||
@@ -257,20 +382,33 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun handleOnboardingComplete() {
|
private fun handleOnboardingComplete() {
|
||||||
android.util.Log.d("MainActivity", "Onboarding completed, checking Bluetooth again before initializing app")
|
android.util.Log.d("MainActivity", "Onboarding completed, checking Bluetooth and Location before initializing app")
|
||||||
|
|
||||||
// After permissions are granted, re-check Bluetooth status
|
// After permissions are granted, re-check both Bluetooth and Location status
|
||||||
val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus()
|
val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus()
|
||||||
if (currentBluetoothStatus == BluetoothStatus.ENABLED) {
|
val currentLocationStatus = locationStatusManager.checkLocationStatus()
|
||||||
// Bluetooth is enabled, proceed to app initialization
|
|
||||||
onboardingState = OnboardingState.INITIALIZING
|
when {
|
||||||
initializeApp()
|
currentBluetoothStatus != BluetoothStatus.ENABLED -> {
|
||||||
} else {
|
// Bluetooth still disabled, but now we have permissions to enable it
|
||||||
// Bluetooth still disabled, but now we have permissions to enable it
|
android.util.Log.d("MainActivity", "Permissions granted, but Bluetooth still disabled. Showing Bluetooth enable screen.")
|
||||||
android.util.Log.d("MainActivity", "Permissions granted, but Bluetooth still disabled. Showing Bluetooth enable screen.")
|
bluetoothStatus = currentBluetoothStatus
|
||||||
bluetoothStatus = currentBluetoothStatus
|
onboardingState = OnboardingState.BLUETOOTH_CHECK
|
||||||
onboardingState = OnboardingState.BLUETOOTH_CHECK
|
isBluetoothLoading = false
|
||||||
isBluetoothLoading = false
|
}
|
||||||
|
currentLocationStatus != LocationStatus.ENABLED -> {
|
||||||
|
// Location services still disabled, but now we have permissions to enable it
|
||||||
|
android.util.Log.d("MainActivity", "Permissions granted, but Location services still disabled. Showing Location enable screen.")
|
||||||
|
locationStatus = currentLocationStatus
|
||||||
|
onboardingState = OnboardingState.LOCATION_CHECK
|
||||||
|
isLocationLoading = false
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Both are enabled, proceed to app initialization
|
||||||
|
android.util.Log.d("MainActivity", "Both Bluetooth and Location services are enabled, proceeding to initialization")
|
||||||
|
onboardingState = OnboardingState.INITIALIZING
|
||||||
|
initializeApp()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,7 +427,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
// This solves the issue where app needs restart to work on first install
|
// This solves the issue where app needs restart to work on first install
|
||||||
delay(1000) // Give the system time to process permission grants
|
delay(1000) // Give the system time to process permission grants
|
||||||
|
|
||||||
android.util.Log.d("MainActivity", "Permissions verified, starting mesh service")
|
android.util.Log.d("MainActivity", "Permissions verified, initializing chat system")
|
||||||
|
|
||||||
// Ensure all permissions are still granted (user might have revoked in settings)
|
// Ensure all permissions are still granted (user might have revoked in settings)
|
||||||
if (!permissionManager.areAllPermissionsGranted()) {
|
if (!permissionManager.areAllPermissionsGranted()) {
|
||||||
@@ -299,8 +437,11 @@ class MainActivity : ComponentActivity() {
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize chat view model - this will start the mesh service
|
// Set up mesh service delegate and start services
|
||||||
chatViewModel.meshService.startServices()
|
meshService.delegate = chatViewModel
|
||||||
|
meshService.startServices()
|
||||||
|
|
||||||
|
android.util.Log.d("MainActivity", "Mesh service started successfully")
|
||||||
|
|
||||||
// Handle any notification intent
|
// Handle any notification intent
|
||||||
handleNotificationIntent(intent)
|
handleNotificationIntent(intent)
|
||||||
@@ -318,18 +459,20 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent?) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
// Handle notification intents when app is already running
|
// Handle notification intents when app is already running
|
||||||
if (onboardingState == OnboardingState.COMPLETE) {
|
if (onboardingState == OnboardingState.COMPLETE) {
|
||||||
intent?.let { handleNotificationIntent(it) }
|
handleNotificationIntent(intent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
// Check Bluetooth status on resume and handle accordingly
|
// Check Bluetooth and Location status on resume and handle accordingly
|
||||||
if (onboardingState == OnboardingState.COMPLETE) {
|
if (onboardingState == OnboardingState.COMPLETE) {
|
||||||
|
// Set app foreground state
|
||||||
|
meshService.connectionManager.setAppBackgroundState(false)
|
||||||
chatViewModel.setAppBackgroundState(false)
|
chatViewModel.setAppBackgroundState(false)
|
||||||
|
|
||||||
// Check if Bluetooth was disabled while app was backgrounded
|
// Check if Bluetooth was disabled while app was backgrounded
|
||||||
@@ -339,6 +482,16 @@ class MainActivity : ComponentActivity() {
|
|||||||
bluetoothStatus = currentBluetoothStatus
|
bluetoothStatus = currentBluetoothStatus
|
||||||
onboardingState = OnboardingState.BLUETOOTH_CHECK
|
onboardingState = OnboardingState.BLUETOOTH_CHECK
|
||||||
isBluetoothLoading = false
|
isBluetoothLoading = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if location services were disabled while app was backgrounded
|
||||||
|
val currentLocationStatus = locationStatusManager.checkLocationStatus()
|
||||||
|
if (currentLocationStatus != LocationStatus.ENABLED) {
|
||||||
|
android.util.Log.w("MainActivity", "Location services disabled while app was backgrounded")
|
||||||
|
locationStatus = currentLocationStatus
|
||||||
|
onboardingState = OnboardingState.LOCATION_CHECK
|
||||||
|
isLocationLoading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,6 +500,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
super.onPause()
|
super.onPause()
|
||||||
// Only set background state if app is fully initialized
|
// Only set background state if app is fully initialized
|
||||||
if (onboardingState == OnboardingState.COMPLETE) {
|
if (onboardingState == OnboardingState.COMPLETE) {
|
||||||
|
// Set app background state
|
||||||
|
meshService.connectionManager.setAppBackgroundState(true)
|
||||||
chatViewModel.setAppBackgroundState(true)
|
chatViewModel.setAppBackgroundState(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -376,12 +531,41 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restart mesh services (for debugging/troubleshooting)
|
||||||
|
*/
|
||||||
|
fun restartMeshServices() {
|
||||||
|
if (onboardingState == OnboardingState.COMPLETE) {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
android.util.Log.d("MainActivity", "Restarting mesh services")
|
||||||
|
meshService.stopServices()
|
||||||
|
delay(1000)
|
||||||
|
meshService.startServices()
|
||||||
|
android.util.Log.d("MainActivity", "Mesh services restarted successfully")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("MainActivity", "Error restarting mesh services: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
// Only stop mesh services if they were started
|
|
||||||
|
// Cleanup location status manager
|
||||||
|
try {
|
||||||
|
locationStatusManager.cleanup()
|
||||||
|
android.util.Log.d("MainActivity", "Location status manager cleaned up successfully")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.w("MainActivity", "Error cleaning up location status manager: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop mesh services if app was fully initialized
|
||||||
if (onboardingState == OnboardingState.COMPLETE) {
|
if (onboardingState == OnboardingState.COMPLETE) {
|
||||||
try {
|
try {
|
||||||
chatViewModel.meshService.stopServices()
|
meshService.stopServices()
|
||||||
|
android.util.Log.d("MainActivity", "Mesh services stopped successfully")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.w("MainActivity", "Error stopping mesh services in onDestroy: ${e.message}")
|
android.util.Log.w("MainActivity", "Error stopping mesh services in onDestroy: ${e.message}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import android.os.ParcelUuid
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
import com.bitchat.android.protocol.BitchatPacket
|
import com.bitchat.android.protocol.BitchatPacket
|
||||||
|
import com.bitchat.android.protocol.SpecialRecipients
|
||||||
|
import com.bitchat.android.model.RoutedPacket
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
@@ -54,6 +56,7 @@ class BluetoothConnectionManager(
|
|||||||
// Simplified connection tracking - reduced memory footprint
|
// Simplified connection tracking - reduced memory footprint
|
||||||
private val connectedDevices = ConcurrentHashMap<String, DeviceConnection>()
|
private val connectedDevices = ConcurrentHashMap<String, DeviceConnection>()
|
||||||
private val subscribedDevices = CopyOnWriteArrayList<BluetoothDevice>()
|
private val subscribedDevices = CopyOnWriteArrayList<BluetoothDevice>()
|
||||||
|
public val addressPeerMap = ConcurrentHashMap<String, String>()
|
||||||
|
|
||||||
// Connection attempt tracking with automatic cleanup
|
// Connection attempt tracking with automatic cleanup
|
||||||
private val pendingConnections = ConcurrentHashMap<String, ConnectionAttempt>()
|
private val pendingConnections = ConcurrentHashMap<String, ConnectionAttempt>()
|
||||||
@@ -129,15 +132,17 @@ class BluetoothConnectionManager(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
isActive = true
|
isActive = true
|
||||||
|
|
||||||
|
// Setup GATT server first
|
||||||
setupGattServer()
|
setupGattServer()
|
||||||
|
|
||||||
// Start power manager and services
|
// Start power manager and services
|
||||||
connectionScope.launch {
|
connectionScope.launch {
|
||||||
powerManager.start()
|
powerManager.start()
|
||||||
delay(500) // Ensure GATT server is ready
|
delay(300) // Brief delay to ensure GATT server is ready
|
||||||
|
|
||||||
startAdvertising()
|
startAdvertising()
|
||||||
delay(200)
|
delay(100)
|
||||||
|
|
||||||
if (powerManager.shouldUseDutyCycle()) {
|
if (powerManager.shouldUseDutyCycle()) {
|
||||||
Log.i(TAG, "Using power-aware duty cycling")
|
Log.i(TAG, "Using power-aware duty cycling")
|
||||||
@@ -147,13 +152,14 @@ class BluetoothConnectionManager(
|
|||||||
|
|
||||||
startPeriodicCleanup()
|
startPeriodicCleanup()
|
||||||
|
|
||||||
Log.i(TAG, "Power-optimized Bluetooth services started successfully (CLIENT ONLY)")
|
Log.i(TAG, "Bluetooth services started successfully")
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to start Bluetooth services: ${e.message}")
|
Log.e(TAG, "Failed to start Bluetooth services: ${e.message}")
|
||||||
|
isActive = false
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,11 +203,50 @@ class BluetoothConnectionManager(
|
|||||||
powerManager.setAppBackgroundState(inBackground)
|
powerManager.setAppBackgroundState(inBackground)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Function to send data to a single device (server side)
|
||||||
|
private fun notifyDevice(device: BluetoothDevice, data: ByteArray): Boolean {
|
||||||
|
return try {
|
||||||
|
characteristic?.let { char ->
|
||||||
|
char.value = data
|
||||||
|
val result = gattServer?.notifyCharacteristicChanged(device, char, false) ?: false
|
||||||
|
result
|
||||||
|
} ?: false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Error sending to server connection ${device.address}: ${e.message}")
|
||||||
|
connectionScope.launch {
|
||||||
|
delay(CLEANUP_DELAY)
|
||||||
|
subscribedDevices.remove(device)
|
||||||
|
addressPeerMap.remove(device.address)
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to send data to a single device (client side)
|
||||||
|
private fun writeToDeviceConn(deviceConn: DeviceConnection, data: ByteArray): Boolean {
|
||||||
|
return try {
|
||||||
|
deviceConn.characteristic?.let { char ->
|
||||||
|
char.value = data
|
||||||
|
val result = deviceConn.gatt?.writeCharacteristic(char) ?: false
|
||||||
|
result
|
||||||
|
} ?: false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Error sending to client connection ${deviceConn.device.address}: ${e.message}")
|
||||||
|
connectionScope.launch {
|
||||||
|
delay(CLEANUP_DELAY)
|
||||||
|
cleanupDeviceConnection(deviceConn.device.address)
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Broadcast packet to connected devices with connection limit enforcement
|
* Broadcast packet to connected devices with connection limit enforcement
|
||||||
* Automatically fragments large packets to fit within BLE MTU limits
|
* Automatically fragments large packets to fit within BLE MTU limits
|
||||||
*/
|
*/
|
||||||
fun broadcastPacket(packet: BitchatPacket) {
|
fun broadcastPacket(routed: RoutedPacket) {
|
||||||
|
val packet = routed.packet
|
||||||
|
|
||||||
if (!isActive) return
|
if (!isActive) return
|
||||||
|
|
||||||
// Check if we need to fragment
|
// Check if we need to fragment
|
||||||
@@ -229,40 +274,61 @@ class BluetoothConnectionManager(
|
|||||||
*/
|
*/
|
||||||
private fun sendSinglePacket(packet: BitchatPacket) {
|
private fun sendSinglePacket(packet: BitchatPacket) {
|
||||||
val data = packet.toBinaryData() ?: return
|
val data = packet.toBinaryData() ?: return
|
||||||
|
|
||||||
Log.d(TAG, "Sending packet type ${packet.type} (${data.size} bytes) to ${subscribedDevices.size} server + ${connectedDevices.size} client connections")
|
Log.d(TAG, "Sending packet type ${packet.type} (${data.size} bytes) to ${subscribedDevices.size} server + ${connectedDevices.size} client connections")
|
||||||
|
|
||||||
|
if (packet.recipientID != SpecialRecipients.BROADCAST) {
|
||||||
|
val recipientID = packet.recipientID?.let {
|
||||||
|
String(it).replace("\u0000", "").trim()
|
||||||
|
} ?: ""
|
||||||
|
|
||||||
|
// Try to find the recipient in server connections (subscribedDevices)
|
||||||
|
val targetDevice = subscribedDevices.firstOrNull { addressPeerMap[it.address] == recipientID }
|
||||||
|
// If found, send directly
|
||||||
|
if (targetDevice != null) {
|
||||||
|
Log.d(TAG, "Send packet type ${packet.type} directly to target device for recipient $recipientID: ${targetDevice.address}")
|
||||||
|
if (notifyDevice(targetDevice, data))
|
||||||
|
return // Sent, no need to continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find the recipient in client connections (connectedDevices)
|
||||||
|
val targetDeviceConn = connectedDevices.values.firstOrNull { addressPeerMap[it.device.address] == recipientID }
|
||||||
|
// If found, send directly
|
||||||
|
if (targetDeviceConn != null) {
|
||||||
|
Log.d(TAG, "Send packet type ${packet.type} directly to target client connection for recipient $recipientID: ${targetDeviceConn.device.address}")
|
||||||
|
if (writeToDeviceConn(targetDeviceConn, data))
|
||||||
|
return // Sent, no need to continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Else, continue with broadcasting to all devices
|
||||||
|
Log.d(TAG, "Broadcasting packet type ${packet.type} to ${subscribedDevices.size} server + ${connectedDevices.size} client connections")
|
||||||
|
|
||||||
|
val senderID = String(packet.senderID).replace("\u0000", "")
|
||||||
// Send to server connections (devices connected to our GATT server)
|
// Send to server connections (devices connected to our GATT server)
|
||||||
subscribedDevices.forEach { device ->
|
subscribedDevices.forEach { device ->
|
||||||
try {
|
if (device.address == routed.relayAddress) {
|
||||||
characteristic?.let { char ->
|
Log.d(TAG, "Skipping broadcast back to relayer: ${device.address}")
|
||||||
char.value = data
|
return@forEach
|
||||||
gattServer?.notifyCharacteristicChanged(device, char, false)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Error sending to server connection ${device.address}: ${e.message}")
|
|
||||||
// Clean up failed connection
|
|
||||||
connectionScope.launch {
|
|
||||||
delay(CLEANUP_DELAY)
|
|
||||||
subscribedDevices.remove(device)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (addressPeerMap[device.address] == senderID) {
|
||||||
|
Log.d(TAG, "Skipping broadcast back to sender: ${device.address}")
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
notifyDevice(device, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send to client connections
|
// Send to client connections
|
||||||
connectedDevices.values.forEach { deviceConn ->
|
connectedDevices.values.forEach { deviceConn ->
|
||||||
if (deviceConn.isClient && deviceConn.gatt != null && deviceConn.characteristic != null) {
|
if (deviceConn.isClient && deviceConn.gatt != null && deviceConn.characteristic != null) {
|
||||||
try {
|
if (deviceConn.device.address == routed.relayAddress) {
|
||||||
deviceConn.characteristic.value = data
|
Log.d(TAG, "Skipping broadcast back to relayer: ${deviceConn.device.address}")
|
||||||
deviceConn.gatt.writeCharacteristic(deviceConn.characteristic)
|
return@forEach
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Error sending to client connection ${deviceConn.device.address}: ${e.message}")
|
|
||||||
// Clean up failed connection
|
|
||||||
connectionScope.launch {
|
|
||||||
delay(CLEANUP_DELAY)
|
|
||||||
cleanupDeviceConnection(deviceConn.device.address)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (addressPeerMap[deviceConn.device.address] == senderID) {
|
||||||
|
Log.d(TAG, "Skipping broadcast back to sender: ${deviceConn.device.address}")
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
writeToDeviceConn(deviceConn, data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,7 +343,8 @@ class BluetoothConnectionManager(
|
|||||||
*/
|
*/
|
||||||
fun getDebugInfo(): String {
|
fun getDebugInfo(): String {
|
||||||
return buildString {
|
return buildString {
|
||||||
appendLine("=== Power-Optimized Bluetooth Connection Manager ===")
|
appendLine("=== Bluetooth Connection Manager ===")
|
||||||
|
appendLine("Bluetooth MAC Address: ${bluetoothAdapter?.address}")
|
||||||
appendLine("Active: $isActive")
|
appendLine("Active: $isActive")
|
||||||
appendLine("Bluetooth Enabled: ${bluetoothAdapter?.isEnabled}")
|
appendLine("Bluetooth Enabled: ${bluetoothAdapter?.isEnabled}")
|
||||||
appendLine("Has Permissions: ${hasBluetoothPermissions()}")
|
appendLine("Has Permissions: ${hasBluetoothPermissions()}")
|
||||||
@@ -381,6 +448,12 @@ class BluetoothConnectionManager(
|
|||||||
|
|
||||||
val serverCallback = object : BluetoothGattServerCallback() {
|
val serverCallback = object : BluetoothGattServerCallback() {
|
||||||
override fun onConnectionStateChange(device: BluetoothDevice, status: Int, newState: Int) {
|
override fun onConnectionStateChange(device: BluetoothDevice, status: Int, newState: Int) {
|
||||||
|
// Guard against callbacks after service shutdown
|
||||||
|
if (!isActive) {
|
||||||
|
Log.d(TAG, "Server: Ignoring connection state change after shutdown")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
when (newState) {
|
when (newState) {
|
||||||
BluetoothProfile.STATE_CONNECTED -> {
|
BluetoothProfile.STATE_CONNECTED -> {
|
||||||
Log.d(TAG, "Server: Device connected ${device.address}")
|
Log.d(TAG, "Server: Device connected ${device.address}")
|
||||||
@@ -397,6 +470,20 @@ class BluetoothConnectionManager(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onServiceAdded(status: Int, service: BluetoothGattService) {
|
||||||
|
// Guard against callbacks after service shutdown
|
||||||
|
if (!isActive) {
|
||||||
|
Log.d(TAG, "Server: Ignoring service added callback after shutdown")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||||
|
Log.d(TAG, "Server: Service added successfully: ${service.uuid}")
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Server: Failed to add service: ${service.uuid}, status: $status")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCharacteristicWriteRequest(
|
override fun onCharacteristicWriteRequest(
|
||||||
device: BluetoothDevice,
|
device: BluetoothDevice,
|
||||||
requestId: Int,
|
requestId: Int,
|
||||||
@@ -406,11 +493,22 @@ class BluetoothConnectionManager(
|
|||||||
offset: Int,
|
offset: Int,
|
||||||
value: ByteArray
|
value: ByteArray
|
||||||
) {
|
) {
|
||||||
|
// Guard against callbacks after service shutdown
|
||||||
|
if (!isActive) {
|
||||||
|
Log.d(TAG, "Server: Ignoring characteristic write after shutdown")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (characteristic.uuid == CHARACTERISTIC_UUID) {
|
if (characteristic.uuid == CHARACTERISTIC_UUID) {
|
||||||
|
Log.d(TAG, "Server: Received packet from ${device.address}, size: ${value.size} bytes")
|
||||||
val packet = BitchatPacket.fromBinaryData(value)
|
val packet = BitchatPacket.fromBinaryData(value)
|
||||||
if (packet != null) {
|
if (packet != null) {
|
||||||
val peerID = String(packet.senderID).replace("\u0000", "")
|
val peerID = String(packet.senderID).replace("\u0000", "")
|
||||||
|
Log.d(TAG, "Server: Parsed packet type ${packet.type} from $peerID")
|
||||||
delegate?.onPacketReceived(packet, peerID, device)
|
delegate?.onPacketReceived(packet, peerID, device)
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Server: Failed to parse packet from ${device.address}, size: ${value.size} bytes")
|
||||||
|
Log.w(TAG, "Server: Packet data: ${value.joinToString(" ") { "%02x".format(it) }}")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (responseNeeded) {
|
if (responseNeeded) {
|
||||||
@@ -428,13 +526,21 @@ class BluetoothConnectionManager(
|
|||||||
offset: Int,
|
offset: Int,
|
||||||
value: ByteArray
|
value: ByteArray
|
||||||
) {
|
) {
|
||||||
|
// Guard against callbacks after service shutdown
|
||||||
|
if (!isActive) {
|
||||||
|
Log.d(TAG, "Server: Ignoring descriptor write after shutdown")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE.contentEquals(value)) {
|
if (BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE.contentEquals(value)) {
|
||||||
Log.d(TAG, "Device ${device.address} subscribed to notifications")
|
Log.d(TAG, "Device ${device.address} subscribed to notifications")
|
||||||
subscribedDevices.add(device)
|
subscribedDevices.add(device)
|
||||||
|
|
||||||
connectionScope.launch {
|
connectionScope.launch {
|
||||||
delay(100)
|
delay(100)
|
||||||
delegate?.onDeviceConnected(device)
|
if (isActive) { // Check if still active
|
||||||
|
delegate?.onDeviceConnected(device)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,9 +550,25 @@ class BluetoothConnectionManager(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up existing server
|
// Proper cleanup sequencing to prevent race conditions
|
||||||
gattServer?.close()
|
gattServer?.let { server ->
|
||||||
|
Log.d(TAG, "Cleaning up existing GATT server")
|
||||||
|
try {
|
||||||
|
server.close()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Error closing existing GATT server: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small delay to ensure cleanup is complete
|
||||||
|
Thread.sleep(100)
|
||||||
|
|
||||||
|
if (!isActive) {
|
||||||
|
Log.d(TAG, "Service inactive, skipping GATT server creation")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new server
|
||||||
gattServer = bluetoothManager.openGattServer(context, serverCallback)
|
gattServer = bluetoothManager.openGattServer(context, serverCallback)
|
||||||
|
|
||||||
// Create characteristic with notification support
|
// Create characteristic with notification support
|
||||||
@@ -554,8 +676,7 @@ class BluetoothConnectionManager(
|
|||||||
// DEBUG: Log ALL scan results first
|
// DEBUG: Log ALL scan results first
|
||||||
val device = result.device
|
val device = result.device
|
||||||
val rssi = result.rssi
|
val rssi = result.rssi
|
||||||
val scanRecord = result.scanRecord
|
// Log.d(TAG, "Scan result: device: ${device.address}, Name: '${device.name}', RSSI: $rssi")
|
||||||
Log.d(TAG, "Scan result: device: ${device.address}, Name: '${device.name}', RSSI: $rssi")
|
|
||||||
handleScanResult(result)
|
handleScanResult(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -643,8 +764,6 @@ class BluetoothConnectionManager(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d(TAG, "Processing bitchat device: $deviceAddress, name: '$deviceName', peerID: $extractedPeerID, RSSI: $rssi")
|
|
||||||
|
|
||||||
// Power-aware RSSI filtering
|
// Power-aware RSSI filtering
|
||||||
if (rssi < powerManager.getRSSIThreshold()) {
|
if (rssi < powerManager.getRSSIThreshold()) {
|
||||||
Log.d(TAG, "Skipping device $deviceAddress due to weak signal: $rssi < ${powerManager.getRSSIThreshold()}")
|
Log.d(TAG, "Skipping device $deviceAddress due to weak signal: $rssi < ${powerManager.getRSSIThreshold()}")
|
||||||
@@ -654,7 +773,7 @@ class BluetoothConnectionManager(
|
|||||||
// CRITICAL FIX: Prevent multiple simultaneous connections to same device
|
// CRITICAL FIX: Prevent multiple simultaneous connections to same device
|
||||||
// Check if already connected OR already attempting to connect
|
// Check if already connected OR already attempting to connect
|
||||||
if (connectedDevices.containsKey(deviceAddress)) {
|
if (connectedDevices.containsKey(deviceAddress)) {
|
||||||
Log.d(TAG, "Device $deviceAddress already connected, skipping")
|
// Log.d(TAG, "Device $deviceAddress already connected, skipping")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -685,12 +804,6 @@ class BluetoothConnectionManager(
|
|||||||
val attempts = (currentAttempt?.attempts ?: 0) + 1
|
val attempts = (currentAttempt?.attempts ?: 0) + 1
|
||||||
pendingConnections[deviceAddress] = ConnectionAttempt(attempts)
|
pendingConnections[deviceAddress] = ConnectionAttempt(attempts)
|
||||||
|
|
||||||
if (extractedPeerID != null) {
|
|
||||||
Log.i(TAG, "Initiating connection to peer $extractedPeerID at $deviceAddress (RSSI: $rssi, attempt: $attempts)")
|
|
||||||
} else {
|
|
||||||
Log.i(TAG, "Initiating connection to device with bitchat service at $deviceAddress (RSSI: $rssi, attempt: $attempts)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start connection immediately while holding lock
|
// Start connection immediately while holding lock
|
||||||
connectToDevice(device, rssi)
|
connectToDevice(device, rssi)
|
||||||
}
|
}
|
||||||
@@ -701,6 +814,7 @@ class BluetoothConnectionManager(
|
|||||||
if (!hasBluetoothPermissions()) return
|
if (!hasBluetoothPermissions()) return
|
||||||
|
|
||||||
val deviceAddress = device.address
|
val deviceAddress = device.address
|
||||||
|
Log.d(TAG, "Connecting to bitchat device: $deviceAddress")
|
||||||
|
|
||||||
val gattCallback = object : BluetoothGattCallback() {
|
val gattCallback = object : BluetoothGattCallback() {
|
||||||
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
|
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
|
||||||
@@ -764,8 +878,6 @@ class BluetoothConnectionManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
|
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
|
||||||
Log.d(TAG, "Client: Service discovery completed for $deviceAddress with status: $status")
|
|
||||||
|
|
||||||
if (status == BluetoothGatt.GATT_SUCCESS) {
|
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||||
val service = gatt.getService(SERVICE_UUID)
|
val service = gatt.getService(SERVICE_UUID)
|
||||||
if (service != null) {
|
if (service != null) {
|
||||||
@@ -808,25 +920,30 @@ class BluetoothConnectionManager(
|
|||||||
|
|
||||||
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
|
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
|
||||||
val value = characteristic.value
|
val value = characteristic.value
|
||||||
|
Log.d(TAG, "Client: Received packet from ${gatt.device.address}, size: ${value.size} bytes")
|
||||||
val packet = BitchatPacket.fromBinaryData(value)
|
val packet = BitchatPacket.fromBinaryData(value)
|
||||||
if (packet != null) {
|
if (packet != null) {
|
||||||
val peerID = String(packet.senderID).replace("\u0000", "")
|
val peerID = String(packet.senderID).replace("\u0000", "")
|
||||||
|
Log.d(TAG, "Client: Parsed packet type ${packet.type} from $peerID")
|
||||||
delegate?.onPacketReceived(packet, peerID, gatt.device)
|
delegate?.onPacketReceived(packet, peerID, gatt.device)
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Client: Failed to parse packet from ${gatt.device.address}, size: ${value.size} bytes")
|
||||||
|
Log.w(TAG, "Client: Packet data: ${value.joinToString(" ") { "%02x".format(it) }}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Log.d(TAG, "Attempting GATT connection to $deviceAddress with autoConnect=false")
|
Log.d(TAG, "Client: Attempting GATT connection to $deviceAddress with autoConnect=false")
|
||||||
val gatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
|
val gatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
|
||||||
if (gatt == null) {
|
if (gatt == null) {
|
||||||
Log.e(TAG, "connectGatt returned null for $deviceAddress")
|
Log.e(TAG, "connectGatt returned null for $deviceAddress")
|
||||||
pendingConnections.remove(deviceAddress)
|
pendingConnections.remove(deviceAddress)
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "GATT connection initiated successfully for $deviceAddress")
|
Log.d(TAG, "Client: GATT connection initiated successfully for $deviceAddress")
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Exception connecting to $deviceAddress: ${e.message}")
|
Log.e(TAG, "Client: Exception connecting to $deviceAddress: ${e.message}")
|
||||||
pendingConnections.remove(deviceAddress)
|
pendingConnections.remove(deviceAddress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -879,6 +996,7 @@ class BluetoothConnectionManager(
|
|||||||
private fun cleanupDeviceConnection(deviceAddress: String) {
|
private fun cleanupDeviceConnection(deviceAddress: String) {
|
||||||
connectedDevices.remove(deviceAddress)?.let { deviceConn ->
|
connectedDevices.remove(deviceAddress)?.let { deviceConn ->
|
||||||
subscribedDevices.removeAll { it.address == deviceAddress }
|
subscribedDevices.removeAll { it.address == deviceAddress }
|
||||||
|
addressPeerMap.remove(deviceAddress)
|
||||||
}
|
}
|
||||||
// CRITICAL FIX: Always remove from pending connections when cleaning up
|
// CRITICAL FIX: Always remove from pending connections when cleaning up
|
||||||
// This prevents failed connections from blocking future attempts
|
// This prevents failed connections from blocking future attempts
|
||||||
@@ -907,6 +1025,7 @@ class BluetoothConnectionManager(
|
|||||||
private fun clearAllConnections() {
|
private fun clearAllConnections() {
|
||||||
connectedDevices.clear()
|
connectedDevices.clear()
|
||||||
subscribedDevices.clear()
|
subscribedDevices.clear()
|
||||||
|
addressPeerMap.clear()
|
||||||
pendingConnections.clear()
|
pendingConnections.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.util.Log
|
|||||||
import com.bitchat.android.crypto.EncryptionService
|
import com.bitchat.android.crypto.EncryptionService
|
||||||
import com.bitchat.android.crypto.MessagePadding
|
import com.bitchat.android.crypto.MessagePadding
|
||||||
import com.bitchat.android.model.BitchatMessage
|
import com.bitchat.android.model.BitchatMessage
|
||||||
|
import com.bitchat.android.model.RoutedPacket
|
||||||
import com.bitchat.android.model.DeliveryAck
|
import com.bitchat.android.model.DeliveryAck
|
||||||
import com.bitchat.android.model.ReadReceipt
|
import com.bitchat.android.model.ReadReceipt
|
||||||
import com.bitchat.android.protocol.BitchatPacket
|
import com.bitchat.android.protocol.BitchatPacket
|
||||||
@@ -47,6 +48,9 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access
|
internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access
|
||||||
private val packetProcessor = PacketProcessor(myPeerID)
|
private val packetProcessor = PacketProcessor(myPeerID)
|
||||||
|
|
||||||
|
// Service state management
|
||||||
|
private var isActive = false
|
||||||
|
|
||||||
// Delegate for message callbacks (maintains same interface)
|
// Delegate for message callbacks (maintains same interface)
|
||||||
var delegate: BluetoothMeshDelegate? = null
|
var delegate: BluetoothMeshDelegate? = null
|
||||||
|
|
||||||
@@ -66,10 +70,10 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
while (isActive) {
|
while (isActive) {
|
||||||
try {
|
try {
|
||||||
delay(10000) // 10 seconds
|
delay(10000) // 10 seconds
|
||||||
val debugInfo = getDebugStatus()
|
if (isActive) { // Double-check before logging
|
||||||
Log.d(TAG, "=== PERIODIC DEBUG STATUS ===")
|
val debugInfo = getDebugStatus()
|
||||||
Log.d(TAG, debugInfo)
|
Log.d(TAG, "=== PERIODIC DEBUG STATUS ===\n$debugInfo\n=== END DEBUG STATUS ===")
|
||||||
Log.d(TAG, "=== END DEBUG STATUS ===")
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error in periodic debug logging: ${e.message}")
|
Log.e(TAG, "Error in periodic debug logging: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -98,7 +102,14 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
|
|
||||||
// SecurityManager delegate for key exchange notifications
|
// SecurityManager delegate for key exchange notifications
|
||||||
securityManager.delegate = object : SecurityManagerDelegate {
|
securityManager.delegate = object : SecurityManagerDelegate {
|
||||||
override fun onKeyExchangeCompleted(peerID: String) {
|
override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray, receivedAddress: String?) {
|
||||||
|
// Notify delegate about key exchange completion so it can register peer fingerprint
|
||||||
|
delegate?.registerPeerPublicKey(peerID, peerPublicKeyData)
|
||||||
|
|
||||||
|
receivedAddress?.let { address ->
|
||||||
|
connectionManager.addressPeerMap[address] = peerID
|
||||||
|
}
|
||||||
|
|
||||||
// Send announcement and cached messages after key exchange
|
// Send announcement and cached messages after key exchange
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
delay(100)
|
delay(100)
|
||||||
@@ -121,7 +132,7 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun sendPacket(packet: BitchatPacket) {
|
override fun sendPacket(packet: BitchatPacket) {
|
||||||
connectionManager.broadcastPacket(packet)
|
connectionManager.broadcastPacket(RoutedPacket(packet))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,11 +165,11 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
|
|
||||||
// Packet operations
|
// Packet operations
|
||||||
override fun sendPacket(packet: BitchatPacket) {
|
override fun sendPacket(packet: BitchatPacket) {
|
||||||
connectionManager.broadcastPacket(packet)
|
connectionManager.broadcastPacket(RoutedPacket(packet))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun relayPacket(packet: BitchatPacket) {
|
override fun relayPacket(routed: RoutedPacket) {
|
||||||
connectionManager.broadcastPacket(packet)
|
connectionManager.broadcastPacket(routed)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getBroadcastRecipient(): ByteArray {
|
override fun getBroadcastRecipient(): ByteArray {
|
||||||
@@ -215,32 +226,32 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
peerManager.updatePeerLastSeen(peerID)
|
peerManager.updatePeerLastSeen(peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun handleKeyExchange(packet: BitchatPacket, peerID: String): Boolean {
|
override fun handleKeyExchange(routed: RoutedPacket): Boolean {
|
||||||
return runBlocking { securityManager.handleKeyExchange(packet, peerID) }
|
return runBlocking { securityManager.handleKeyExchange(routed) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun handleAnnounce(packet: BitchatPacket, peerID: String) {
|
override fun handleAnnounce(routed: RoutedPacket) {
|
||||||
serviceScope.launch { messageHandler.handleAnnounce(packet, peerID) }
|
serviceScope.launch { messageHandler.handleAnnounce(routed) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun handleMessage(packet: BitchatPacket, peerID: String) {
|
override fun handleMessage(routed: RoutedPacket) {
|
||||||
serviceScope.launch { messageHandler.handleMessage(packet, peerID) }
|
serviceScope.launch { messageHandler.handleMessage(routed) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun handleLeave(packet: BitchatPacket, peerID: String) {
|
override fun handleLeave(routed: RoutedPacket) {
|
||||||
serviceScope.launch { messageHandler.handleLeave(packet, peerID) }
|
serviceScope.launch { messageHandler.handleLeave(routed) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun handleFragment(packet: BitchatPacket): BitchatPacket? {
|
override fun handleFragment(packet: BitchatPacket): BitchatPacket? {
|
||||||
return fragmentManager.handleFragment(packet)
|
return fragmentManager.handleFragment(packet)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun handleDeliveryAck(packet: BitchatPacket, peerID: String) {
|
override fun handleDeliveryAck(routed: RoutedPacket) {
|
||||||
serviceScope.launch { messageHandler.handleDeliveryAck(packet, peerID) }
|
serviceScope.launch { messageHandler.handleDeliveryAck(routed) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun handleReadReceipt(packet: BitchatPacket, peerID: String) {
|
override fun handleReadReceipt(routed: RoutedPacket) {
|
||||||
serviceScope.launch { messageHandler.handleReadReceipt(packet, peerID) }
|
serviceScope.launch { messageHandler.handleReadReceipt(routed) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun sendAnnouncementToPeer(peerID: String) {
|
override fun sendAnnouncementToPeer(peerID: String) {
|
||||||
@@ -251,15 +262,15 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
storeForwardManager.sendCachedMessages(peerID)
|
storeForwardManager.sendCachedMessages(peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun relayPacket(packet: BitchatPacket) {
|
override fun relayPacket(routed: RoutedPacket) {
|
||||||
connectionManager.broadcastPacket(packet)
|
connectionManager.broadcastPacket(routed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// BluetoothConnectionManager delegates
|
// BluetoothConnectionManager delegates
|
||||||
connectionManager.delegate = object : BluetoothConnectionManagerDelegate {
|
connectionManager.delegate = object : BluetoothConnectionManagerDelegate {
|
||||||
override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: android.bluetooth.BluetoothDevice?) {
|
override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: android.bluetooth.BluetoothDevice?) {
|
||||||
packetProcessor.processPacket(packet, peerID)
|
packetProcessor.processPacket(RoutedPacket(packet, peerID, device?.address))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDeviceConnected(device: android.bluetooth.BluetoothDevice) {
|
override fun onDeviceConnected(device: android.bluetooth.BluetoothDevice) {
|
||||||
@@ -276,11 +287,16 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
* Start the mesh service
|
* Start the mesh service
|
||||||
*/
|
*/
|
||||||
fun startServices() {
|
fun startServices() {
|
||||||
|
// Prevent double starts (defensive programming)
|
||||||
|
if (isActive) {
|
||||||
|
Log.w(TAG, "Mesh service already active, ignoring duplicate start request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
Log.i(TAG, "Starting Bluetooth mesh service with peer ID: $myPeerID")
|
Log.i(TAG, "Starting Bluetooth mesh service with peer ID: $myPeerID")
|
||||||
|
|
||||||
if (connectionManager.startServices()) {
|
if (connectionManager.startServices()) {
|
||||||
Log.i(TAG, "Bluetooth services started successfully")
|
isActive = true
|
||||||
|
|
||||||
// Send initial announcements after services are ready
|
// Send initial announcements after services are ready
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
delay(1000)
|
delay(1000)
|
||||||
@@ -295,7 +311,13 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
* Stop all mesh services
|
* Stop all mesh services
|
||||||
*/
|
*/
|
||||||
fun stopServices() {
|
fun stopServices() {
|
||||||
|
if (!isActive) {
|
||||||
|
Log.w(TAG, "Mesh service not active, ignoring stop request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
Log.i(TAG, "Stopping Bluetooth mesh service")
|
Log.i(TAG, "Stopping Bluetooth mesh service")
|
||||||
|
isActive = false
|
||||||
|
|
||||||
// Send leave announcement
|
// Send leave announcement
|
||||||
sendLeaveAnnouncement()
|
sendLeaveAnnouncement()
|
||||||
@@ -351,7 +373,7 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
|
|
||||||
// Send with random delay and retry for reliability
|
// Send with random delay and retry for reliability
|
||||||
// delay(Random.nextLong(50, 500))
|
// delay(Random.nextLong(50, 500))
|
||||||
connectionManager.broadcastPacket(packet)
|
connectionManager.broadcastPacket(RoutedPacket(packet))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -404,7 +426,7 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
|
|
||||||
// Send with delay
|
// Send with delay
|
||||||
delay(Random.nextLong(50, 500))
|
delay(Random.nextLong(50, 500))
|
||||||
connectionManager.broadcastPacket(packet)
|
connectionManager.broadcastPacket(RoutedPacket(packet))
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -430,13 +452,13 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
|
|
||||||
// Send multiple times for reliability
|
// Send multiple times for reliability
|
||||||
delay(Random.nextLong(0, 500))
|
delay(Random.nextLong(0, 500))
|
||||||
connectionManager.broadcastPacket(announcePacket)
|
connectionManager.broadcastPacket(RoutedPacket(announcePacket))
|
||||||
|
|
||||||
delay(500 + Random.nextLong(0, 500))
|
delay(500 + Random.nextLong(0, 500))
|
||||||
connectionManager.broadcastPacket(announcePacket)
|
connectionManager.broadcastPacket(RoutedPacket(announcePacket))
|
||||||
|
|
||||||
delay(1000 + Random.nextLong(0, 500))
|
delay(1000 + Random.nextLong(0, 500))
|
||||||
connectionManager.broadcastPacket(announcePacket)
|
connectionManager.broadcastPacket(RoutedPacket(announcePacket))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,7 +476,7 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
payload = nickname.toByteArray()
|
payload = nickname.toByteArray()
|
||||||
)
|
)
|
||||||
|
|
||||||
connectionManager.broadcastPacket(packet)
|
connectionManager.broadcastPacket(RoutedPacket(packet))
|
||||||
peerManager.markPeerAsAnnouncedTo(peerID)
|
peerManager.markPeerAsAnnouncedTo(peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,7 +492,7 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
payload = publicKeyData
|
payload = publicKeyData
|
||||||
)
|
)
|
||||||
|
|
||||||
connectionManager.broadcastPacket(packet)
|
connectionManager.broadcastPacket(RoutedPacket(packet))
|
||||||
Log.d(TAG, "Sent key exchange")
|
Log.d(TAG, "Sent key exchange")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,7 +508,7 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
payload = nickname.toByteArray()
|
payload = nickname.toByteArray()
|
||||||
)
|
)
|
||||||
|
|
||||||
connectionManager.broadcastPacket(packet)
|
connectionManager.broadcastPacket(RoutedPacket(packet))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -547,4 +569,5 @@ interface BluetoothMeshDelegate {
|
|||||||
fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?
|
fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?
|
||||||
fun getNickname(): String?
|
fun getNickname(): String?
|
||||||
fun isFavorite(peerID: String): Boolean
|
fun isFavorite(peerID: String): Boolean
|
||||||
|
fun registerPeerPublicKey(peerID: String, publicKeyData: ByteArray)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.bitchat.android.crypto.MessagePadding
|
|||||||
import com.bitchat.android.model.BitchatMessage
|
import com.bitchat.android.model.BitchatMessage
|
||||||
import com.bitchat.android.model.DeliveryAck
|
import com.bitchat.android.model.DeliveryAck
|
||||||
import com.bitchat.android.model.ReadReceipt
|
import com.bitchat.android.model.ReadReceipt
|
||||||
|
import com.bitchat.android.model.RoutedPacket
|
||||||
import com.bitchat.android.protocol.BitchatPacket
|
import com.bitchat.android.protocol.BitchatPacket
|
||||||
import com.bitchat.android.protocol.MessageType
|
import com.bitchat.android.protocol.MessageType
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
@@ -30,7 +31,10 @@ class MessageHandler(private val myPeerID: String) {
|
|||||||
/**
|
/**
|
||||||
* Handle announce message
|
* Handle announce message
|
||||||
*/
|
*/
|
||||||
suspend fun handleAnnounce(packet: BitchatPacket, peerID: String): Boolean {
|
suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
|
||||||
|
val packet = routed.packet
|
||||||
|
val peerID = routed.peerID ?: "unknown"
|
||||||
|
|
||||||
if (peerID == myPeerID) return false
|
if (peerID == myPeerID) return false
|
||||||
|
|
||||||
val nickname = String(packet.payload, Charsets.UTF_8)
|
val nickname = String(packet.payload, Charsets.UTF_8)
|
||||||
@@ -43,7 +47,7 @@ class MessageHandler(private val myPeerID: String) {
|
|||||||
if (packet.ttl > 1u) {
|
if (packet.ttl > 1u) {
|
||||||
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
|
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
|
||||||
delay(Random.nextLong(100, 300))
|
delay(Random.nextLong(100, 300))
|
||||||
delegate?.relayPacket(relayPacket)
|
delegate?.relayPacket(RoutedPacket(relayPacket, peerID, routed.relayAddress))
|
||||||
}
|
}
|
||||||
|
|
||||||
return isFirstAnnounce
|
return isFirstAnnounce
|
||||||
@@ -52,27 +56,31 @@ class MessageHandler(private val myPeerID: String) {
|
|||||||
/**
|
/**
|
||||||
* Handle broadcast or private message
|
* Handle broadcast or private message
|
||||||
*/
|
*/
|
||||||
suspend fun handleMessage(packet: BitchatPacket, peerID: String) {
|
suspend fun handleMessage(routed: RoutedPacket) {
|
||||||
|
val packet = routed.packet
|
||||||
|
val peerID = routed.peerID ?: "unknown"
|
||||||
if (peerID == myPeerID) return
|
if (peerID == myPeerID) return
|
||||||
|
|
||||||
val recipientID = packet.recipientID?.takeIf { !it.contentEquals(delegate?.getBroadcastRecipient()) }
|
val recipientID = packet.recipientID?.takeIf { !it.contentEquals(delegate?.getBroadcastRecipient()) }
|
||||||
|
|
||||||
if (recipientID == null) {
|
if (recipientID == null) {
|
||||||
// BROADCAST MESSAGE
|
// BROADCAST MESSAGE
|
||||||
handleBroadcastMessage(packet, peerID)
|
handleBroadcastMessage(routed)
|
||||||
} else if (String(recipientID).replace("\u0000", "") == myPeerID) {
|
} else if (String(recipientID).replace("\u0000", "") == myPeerID) {
|
||||||
// PRIVATE MESSAGE FOR US
|
// PRIVATE MESSAGE FOR US
|
||||||
handlePrivateMessage(packet, peerID)
|
handlePrivateMessage(packet, peerID)
|
||||||
} else if (packet.ttl > 0u) {
|
} else if (packet.ttl > 0u) {
|
||||||
// RELAY MESSAGE
|
// RELAY MESSAGE
|
||||||
relayMessage(packet)
|
relayMessage(routed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle broadcast message
|
* Handle broadcast message
|
||||||
*/
|
*/
|
||||||
private suspend fun handleBroadcastMessage(packet: BitchatPacket, peerID: String) {
|
private suspend fun handleBroadcastMessage(routed: RoutedPacket) {
|
||||||
|
val packet = routed.packet
|
||||||
|
val peerID = routed.peerID ?: "unknown"
|
||||||
try {
|
try {
|
||||||
// Parse message
|
// Parse message
|
||||||
val message = BitchatMessage.fromBinaryPayload(packet.payload)
|
val message = BitchatMessage.fromBinaryPayload(packet.payload)
|
||||||
@@ -104,7 +112,7 @@ class MessageHandler(private val myPeerID: String) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Relay broadcast messages
|
// Relay broadcast messages
|
||||||
relayMessage(packet)
|
relayMessage(routed)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to process broadcast message: ${e.message}")
|
Log.e(TAG, "Failed to process broadcast message: ${e.message}")
|
||||||
@@ -162,7 +170,9 @@ class MessageHandler(private val myPeerID: String) {
|
|||||||
/**
|
/**
|
||||||
* Handle leave message
|
* Handle leave message
|
||||||
*/
|
*/
|
||||||
suspend fun handleLeave(packet: BitchatPacket, peerID: String) {
|
suspend fun handleLeave(routed: RoutedPacket) {
|
||||||
|
val packet = routed.packet
|
||||||
|
val peerID = routed.peerID ?: "unknown"
|
||||||
val content = String(packet.payload, Charsets.UTF_8)
|
val content = String(packet.payload, Charsets.UTF_8)
|
||||||
|
|
||||||
if (content.startsWith("#")) {
|
if (content.startsWith("#")) {
|
||||||
@@ -180,14 +190,16 @@ class MessageHandler(private val myPeerID: String) {
|
|||||||
// Relay if TTL > 0
|
// Relay if TTL > 0
|
||||||
if (packet.ttl > 1u) {
|
if (packet.ttl > 1u) {
|
||||||
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
|
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
|
||||||
delegate?.relayPacket(relayPacket)
|
delegate?.relayPacket(routed.copy(packet = relayPacket))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle delivery acknowledgment
|
* Handle delivery acknowledgment
|
||||||
*/
|
*/
|
||||||
suspend fun handleDeliveryAck(packet: BitchatPacket, peerID: String) {
|
suspend fun handleDeliveryAck(routed: RoutedPacket) {
|
||||||
|
val packet = routed.packet
|
||||||
|
val peerID = routed.peerID ?: "unknown"
|
||||||
if (packet.recipientID != null && String(packet.recipientID).replace("\u0000", "") == myPeerID) {
|
if (packet.recipientID != null && String(packet.recipientID).replace("\u0000", "") == myPeerID) {
|
||||||
try {
|
try {
|
||||||
val decryptedData = delegate?.decryptFromPeer(packet.payload, peerID)
|
val decryptedData = delegate?.decryptFromPeer(packet.payload, peerID)
|
||||||
@@ -200,17 +212,19 @@ class MessageHandler(private val myPeerID: String) {
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to decrypt delivery ACK: ${e.message}")
|
Log.e(TAG, "Failed to decrypt delivery ACK: ${e.message}")
|
||||||
}
|
}
|
||||||
} else if (packet.ttl > 0u) {
|
} else if (packet.ttl > 0u && String(packet.senderID).replace("\u0000", "") != myPeerID) {
|
||||||
// Relay
|
// Relay
|
||||||
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
|
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
|
||||||
delegate?.relayPacket(relayPacket)
|
delegate?.relayPacket(routed.copy(packet = relayPacket))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle read receipt
|
* Handle read receipt
|
||||||
*/
|
*/
|
||||||
suspend fun handleReadReceipt(packet: BitchatPacket, peerID: String) {
|
suspend fun handleReadReceipt(routed: RoutedPacket) {
|
||||||
|
val packet = routed.packet
|
||||||
|
val peerID = routed.peerID ?: "unknown"
|
||||||
if (packet.recipientID != null && String(packet.recipientID).replace("\u0000", "") == myPeerID) {
|
if (packet.recipientID != null && String(packet.recipientID).replace("\u0000", "") == myPeerID) {
|
||||||
try {
|
try {
|
||||||
val decryptedData = delegate?.decryptFromPeer(packet.payload, peerID)
|
val decryptedData = delegate?.decryptFromPeer(packet.payload, peerID)
|
||||||
@@ -223,17 +237,18 @@ class MessageHandler(private val myPeerID: String) {
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to decrypt read receipt: ${e.message}")
|
Log.e(TAG, "Failed to decrypt read receipt: ${e.message}")
|
||||||
}
|
}
|
||||||
} else if (packet.ttl > 0u) {
|
} else if (packet.ttl > 0u && String(packet.senderID).replace("\u0000", "") != myPeerID) {
|
||||||
// Relay
|
// Relay
|
||||||
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
|
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
|
||||||
delegate?.relayPacket(relayPacket)
|
delegate?.relayPacket(routed.copy(packet = relayPacket))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Relay message with adaptive probability (same as iOS)
|
* Relay message with adaptive probability (same as iOS)
|
||||||
*/
|
*/
|
||||||
private suspend fun relayMessage(packet: BitchatPacket) {
|
private suspend fun relayMessage(routed: RoutedPacket) {
|
||||||
|
val packet = routed.packet
|
||||||
if (packet.ttl == 0u.toUByte()) return
|
if (packet.ttl == 0u.toUByte()) return
|
||||||
|
|
||||||
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
|
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
|
||||||
@@ -253,7 +268,7 @@ class MessageHandler(private val myPeerID: String) {
|
|||||||
if (shouldRelay) {
|
if (shouldRelay) {
|
||||||
val delay = Random.nextLong(50, 500) // Random delay like iOS
|
val delay = Random.nextLong(50, 500) // Random delay like iOS
|
||||||
delay(delay)
|
delay(delay)
|
||||||
delegate?.relayPacket(relayPacket)
|
delegate?.relayPacket(routed.copy(packet = relayPacket))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,7 +341,7 @@ interface MessageHandlerDelegate {
|
|||||||
|
|
||||||
// Packet operations
|
// Packet operations
|
||||||
fun sendPacket(packet: BitchatPacket)
|
fun sendPacket(packet: BitchatPacket)
|
||||||
fun relayPacket(packet: BitchatPacket)
|
fun relayPacket(routed: RoutedPacket)
|
||||||
fun getBroadcastRecipient(): ByteArray
|
fun getBroadcastRecipient(): ByteArray
|
||||||
|
|
||||||
// Cryptographic operations
|
// Cryptographic operations
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.bitchat.android.mesh
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.bitchat.android.protocol.BitchatPacket
|
import com.bitchat.android.protocol.BitchatPacket
|
||||||
import com.bitchat.android.protocol.MessageType
|
import com.bitchat.android.protocol.MessageType
|
||||||
|
import com.bitchat.android.model.RoutedPacket
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,16 +25,19 @@ class PacketProcessor(private val myPeerID: String) {
|
|||||||
/**
|
/**
|
||||||
* Process received packet - main entry point for all incoming packets
|
* Process received packet - main entry point for all incoming packets
|
||||||
*/
|
*/
|
||||||
fun processPacket(packet: BitchatPacket, peerID: String) {
|
fun processPacket(routed: RoutedPacket) {
|
||||||
processorScope.launch {
|
processorScope.launch {
|
||||||
handleReceivedPacket(packet, peerID)
|
handleReceivedPacket(routed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle received packet - core protocol logic (exact same as iOS)
|
* Handle received packet - core protocol logic (exact same as iOS)
|
||||||
*/
|
*/
|
||||||
private suspend fun handleReceivedPacket(packet: BitchatPacket, peerID: String) {
|
private suspend fun handleReceivedPacket(routed: RoutedPacket) {
|
||||||
|
val packet = routed.packet
|
||||||
|
val peerID = routed.peerID ?: "unknown"
|
||||||
|
|
||||||
// Basic validation and security checks
|
// Basic validation and security checks
|
||||||
if (!delegate?.validatePacketSecurity(packet, peerID)!!) {
|
if (!delegate?.validatePacketSecurity(packet, peerID)!!) {
|
||||||
Log.d(TAG, "Packet failed security validation from $peerID")
|
Log.d(TAG, "Packet failed security validation from $peerID")
|
||||||
@@ -47,15 +51,15 @@ class PacketProcessor(private val myPeerID: String) {
|
|||||||
|
|
||||||
// Process based on message type (exact same logic as iOS)
|
// Process based on message type (exact same logic as iOS)
|
||||||
when (MessageType.fromValue(packet.type)) {
|
when (MessageType.fromValue(packet.type)) {
|
||||||
MessageType.KEY_EXCHANGE -> handleKeyExchange(packet, peerID)
|
MessageType.KEY_EXCHANGE -> handleKeyExchange(routed)
|
||||||
MessageType.ANNOUNCE -> handleAnnounce(packet, peerID)
|
MessageType.ANNOUNCE -> handleAnnounce(routed)
|
||||||
MessageType.MESSAGE -> handleMessage(packet, peerID)
|
MessageType.MESSAGE -> handleMessage(routed)
|
||||||
MessageType.LEAVE -> handleLeave(packet, peerID)
|
MessageType.LEAVE -> handleLeave(routed)
|
||||||
MessageType.FRAGMENT_START,
|
MessageType.FRAGMENT_START,
|
||||||
MessageType.FRAGMENT_CONTINUE,
|
MessageType.FRAGMENT_CONTINUE,
|
||||||
MessageType.FRAGMENT_END -> handleFragment(packet, peerID)
|
MessageType.FRAGMENT_END -> handleFragment(routed)
|
||||||
MessageType.DELIVERY_ACK -> handleDeliveryAck(packet, peerID)
|
MessageType.DELIVERY_ACK -> handleDeliveryAck(routed)
|
||||||
MessageType.READ_RECEIPT -> handleReadReceipt(packet, peerID)
|
MessageType.READ_RECEIPT -> handleReadReceipt(routed)
|
||||||
else -> {
|
else -> {
|
||||||
Log.w(TAG, "Unknown message type: ${packet.type}")
|
Log.w(TAG, "Unknown message type: ${packet.type}")
|
||||||
}
|
}
|
||||||
@@ -65,10 +69,11 @@ class PacketProcessor(private val myPeerID: String) {
|
|||||||
/**
|
/**
|
||||||
* Handle key exchange message
|
* Handle key exchange message
|
||||||
*/
|
*/
|
||||||
private suspend fun handleKeyExchange(packet: BitchatPacket, peerID: String) {
|
private suspend fun handleKeyExchange(routed: RoutedPacket) {
|
||||||
|
val peerID = routed.peerID ?: "unknown"
|
||||||
Log.d(TAG, "Processing key exchange from $peerID")
|
Log.d(TAG, "Processing key exchange from $peerID")
|
||||||
|
|
||||||
val success = delegate?.handleKeyExchange(packet, peerID) ?: false
|
val success = delegate?.handleKeyExchange(routed) ?: false
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
// Key exchange successful, send announce and cached messages
|
// Key exchange successful, send announce and cached messages
|
||||||
@@ -83,60 +88,60 @@ class PacketProcessor(private val myPeerID: String) {
|
|||||||
/**
|
/**
|
||||||
* Handle announce message
|
* Handle announce message
|
||||||
*/
|
*/
|
||||||
private suspend fun handleAnnounce(packet: BitchatPacket, peerID: String) {
|
private suspend fun handleAnnounce(routed: RoutedPacket) {
|
||||||
Log.d(TAG, "Processing announce from $peerID")
|
Log.d(TAG, "Processing announce from ${routed.peerID}")
|
||||||
delegate?.handleAnnounce(packet, peerID)
|
delegate?.handleAnnounce(routed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle regular message
|
* Handle regular message
|
||||||
*/
|
*/
|
||||||
private suspend fun handleMessage(packet: BitchatPacket, peerID: String) {
|
private suspend fun handleMessage(routed: RoutedPacket) {
|
||||||
Log.d(TAG, "Processing message from $peerID")
|
Log.d(TAG, "Processing message from ${routed.peerID}")
|
||||||
delegate?.handleMessage(packet, peerID)
|
delegate?.handleMessage(routed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle leave message
|
* Handle leave message
|
||||||
*/
|
*/
|
||||||
private suspend fun handleLeave(packet: BitchatPacket, peerID: String) {
|
private suspend fun handleLeave(routed: RoutedPacket) {
|
||||||
Log.d(TAG, "Processing leave from $peerID")
|
Log.d(TAG, "Processing leave from ${routed.peerID}")
|
||||||
delegate?.handleLeave(packet, peerID)
|
delegate?.handleLeave(routed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle message fragments
|
* Handle message fragments
|
||||||
*/
|
*/
|
||||||
private suspend fun handleFragment(packet: BitchatPacket, peerID: String) {
|
private suspend fun handleFragment(routed: RoutedPacket) {
|
||||||
Log.d(TAG, "Processing fragment from $peerID")
|
Log.d(TAG, "Processing fragment from ${routed.peerID}")
|
||||||
|
|
||||||
val reassembledPacket = delegate?.handleFragment(packet)
|
val reassembledPacket = delegate?.handleFragment(routed.packet)
|
||||||
if (reassembledPacket != null) {
|
if (reassembledPacket != null) {
|
||||||
Log.d(TAG, "Fragment reassembled, processing complete message")
|
Log.d(TAG, "Fragment reassembled, processing complete message")
|
||||||
handleReceivedPacket(reassembledPacket, peerID)
|
handleReceivedPacket(RoutedPacket(reassembledPacket, routed.peerID, routed.relayAddress))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Relay fragment regardless of reassembly
|
// Relay fragment regardless of reassembly
|
||||||
if (packet.ttl > 0u) {
|
if (routed.packet.ttl > 0u) {
|
||||||
val relayPacket = packet.copy(ttl = (packet.ttl - 1u).toUByte())
|
val relayPacket = routed.packet.copy(ttl = (routed.packet.ttl - 1u).toUByte())
|
||||||
delegate?.relayPacket(relayPacket)
|
delegate?.relayPacket(RoutedPacket(relayPacket, routed.peerID, routed.relayAddress))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle delivery acknowledgment
|
* Handle delivery acknowledgment
|
||||||
*/
|
*/
|
||||||
private suspend fun handleDeliveryAck(packet: BitchatPacket, peerID: String) {
|
private suspend fun handleDeliveryAck(routed: RoutedPacket) {
|
||||||
Log.d(TAG, "Processing delivery ACK from $peerID")
|
Log.d(TAG, "Processing delivery ACK from ${routed.peerID}")
|
||||||
delegate?.handleDeliveryAck(packet, peerID)
|
delegate?.handleDeliveryAck(routed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle read receipt
|
* Handle read receipt
|
||||||
*/
|
*/
|
||||||
private suspend fun handleReadReceipt(packet: BitchatPacket, peerID: String) {
|
private suspend fun handleReadReceipt(routed: RoutedPacket) {
|
||||||
Log.d(TAG, "Processing read receipt from $peerID")
|
Log.d(TAG, "Processing read receipt from ${routed.peerID}")
|
||||||
delegate?.handleReadReceipt(packet, peerID)
|
delegate?.handleReadReceipt(routed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -169,16 +174,16 @@ interface PacketProcessorDelegate {
|
|||||||
fun updatePeerLastSeen(peerID: String)
|
fun updatePeerLastSeen(peerID: String)
|
||||||
|
|
||||||
// Message type handlers
|
// Message type handlers
|
||||||
fun handleKeyExchange(packet: BitchatPacket, peerID: String): Boolean
|
fun handleKeyExchange(routed: RoutedPacket): Boolean
|
||||||
fun handleAnnounce(packet: BitchatPacket, peerID: String)
|
fun handleAnnounce(routed: RoutedPacket)
|
||||||
fun handleMessage(packet: BitchatPacket, peerID: String)
|
fun handleMessage(routed: RoutedPacket)
|
||||||
fun handleLeave(packet: BitchatPacket, peerID: String)
|
fun handleLeave(routed: RoutedPacket)
|
||||||
fun handleFragment(packet: BitchatPacket): BitchatPacket?
|
fun handleFragment(packet: BitchatPacket): BitchatPacket?
|
||||||
fun handleDeliveryAck(packet: BitchatPacket, peerID: String)
|
fun handleDeliveryAck(routed: RoutedPacket)
|
||||||
fun handleReadReceipt(packet: BitchatPacket, peerID: String)
|
fun handleReadReceipt(routed: RoutedPacket)
|
||||||
|
|
||||||
// Communication
|
// Communication
|
||||||
fun sendAnnouncementToPeer(peerID: String)
|
fun sendAnnouncementToPeer(peerID: String)
|
||||||
fun sendCachedMessages(peerID: String)
|
fun sendCachedMessages(peerID: String)
|
||||||
fun relayPacket(packet: BitchatPacket)
|
fun relayPacket(routed: RoutedPacket)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,12 +26,12 @@ class PowerManager(private val context: Context) {
|
|||||||
private const val MEDIUM_BATTERY = 50
|
private const val MEDIUM_BATTERY = 50
|
||||||
|
|
||||||
// Scan duty cycle periods (ms)
|
// Scan duty cycle periods (ms)
|
||||||
private const val SCAN_ON_DURATION_NORMAL = 2000L // 2 seconds on
|
private const val SCAN_ON_DURATION_NORMAL = 8000L // 8 seconds on
|
||||||
private const val SCAN_OFF_DURATION_NORMAL = 8000L // 8 seconds off
|
private const val SCAN_OFF_DURATION_NORMAL = 2000L // 2 seconds off
|
||||||
private const val SCAN_ON_DURATION_POWER_SAVE = 1000L // 1 second on
|
private const val SCAN_ON_DURATION_POWER_SAVE = 2000L // 2 seconds on
|
||||||
private const val SCAN_OFF_DURATION_POWER_SAVE = 15000L // 15 seconds off
|
private const val SCAN_OFF_DURATION_POWER_SAVE = 8000L // 8 seconds off
|
||||||
private const val SCAN_ON_DURATION_ULTRA_LOW = 500L // 0.5 seconds on
|
private const val SCAN_ON_DURATION_ULTRA_LOW = 1000L // 1 second on
|
||||||
private const val SCAN_OFF_DURATION_ULTRA_LOW = 30000L // 30 seconds off
|
private const val SCAN_OFF_DURATION_ULTRA_LOW = 10000L // 10 seconds off
|
||||||
|
|
||||||
// Connection limits
|
// Connection limits
|
||||||
private const val MAX_CONNECTIONS_NORMAL = 8
|
private const val MAX_CONNECTIONS_NORMAL = 8
|
||||||
@@ -112,39 +112,39 @@ class PowerManager(private val context: Context) {
|
|||||||
/**
|
/**
|
||||||
* Get scan settings optimized for current power mode
|
* Get scan settings optimized for current power mode
|
||||||
*/
|
*/
|
||||||
fun getScanSettings(): ScanSettings {
|
fun getScanSettings(): ScanSettings {
|
||||||
// CRITICAL FIX: Set reportDelay to 0 for all modes.
|
// CRITICAL FIX: Set reportDelay to 0 for all modes.
|
||||||
// When using a custom duty cycle, we want scan results delivered immediately,
|
// When using a custom duty cycle, we want scan results delivered immediately,
|
||||||
// not batched. A non-zero report delay can conflict with the scan window,
|
// not batched. A non-zero report delay can conflict with the scan window,
|
||||||
// causing missed results if the scan stops before the delay is met.
|
// causing missed results if the scan stops before the delay is met.
|
||||||
val builder = ScanSettings.Builder()
|
val builder = ScanSettings.Builder()
|
||||||
.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
|
.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
|
||||||
|
|
||||||
when (currentMode) {
|
when (currentMode) {
|
||||||
PowerMode.PERFORMANCE -> builder
|
PowerMode.PERFORMANCE -> builder
|
||||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||||||
.setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
|
.setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
|
||||||
.setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT)
|
.setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT)
|
||||||
|
|
||||||
PowerMode.BALANCED -> builder
|
PowerMode.BALANCED -> builder
|
||||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
.setScanMode(ScanSettings.SCAN_MODE_BALANCED)
|
||||||
.setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
|
.setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
|
||||||
.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
|
.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
|
||||||
|
|
||||||
PowerMode.POWER_SAVER -> builder
|
PowerMode.POWER_SAVER -> builder
|
||||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
|
.setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
|
||||||
.setMatchMode(ScanSettings.MATCH_MODE_STICKY)
|
.setMatchMode(ScanSettings.MATCH_MODE_STICKY)
|
||||||
.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
|
.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
|
||||||
|
|
||||||
PowerMode.ULTRA_LOW_POWER -> builder
|
PowerMode.ULTRA_LOW_POWER -> builder
|
||||||
.setScanMode(ScanSettings.SCAN_MODE_OPPORTUNISTIC)
|
.setScanMode(ScanSettings.SCAN_MODE_OPPORTUNISTIC)
|
||||||
.setMatchMode(ScanSettings.MATCH_MODE_STICKY)
|
.setMatchMode(ScanSettings.MATCH_MODE_STICKY)
|
||||||
.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
|
.setNumOfMatches(ScanSettings.MATCH_NUM_ONE_ADVERTISEMENT)
|
||||||
}
|
|
||||||
|
|
||||||
return builder.setReportDelay(0).build()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return builder.setReportDelay(0).build()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get advertising settings optimized for current power mode
|
* Get advertising settings optimized for current power mode
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.util.Log
|
|||||||
import com.bitchat.android.crypto.EncryptionService
|
import com.bitchat.android.crypto.EncryptionService
|
||||||
import com.bitchat.android.protocol.BitchatPacket
|
import com.bitchat.android.protocol.BitchatPacket
|
||||||
import com.bitchat.android.protocol.MessageType
|
import com.bitchat.android.protocol.MessageType
|
||||||
|
import com.bitchat.android.model.RoutedPacket
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.collections.mutableSetOf
|
import kotlin.collections.mutableSetOf
|
||||||
@@ -88,7 +89,10 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
|||||||
/**
|
/**
|
||||||
* Handle key exchange packet
|
* Handle key exchange packet
|
||||||
*/
|
*/
|
||||||
suspend fun handleKeyExchange(packet: BitchatPacket, peerID: String): Boolean {
|
suspend fun handleKeyExchange(routed: RoutedPacket): Boolean {
|
||||||
|
val packet = routed.packet
|
||||||
|
val peerID = routed.peerID ?: "unknown"
|
||||||
|
|
||||||
if (peerID == myPeerID) return false
|
if (peerID == myPeerID) return false
|
||||||
|
|
||||||
if (packet.payload.isEmpty()) {
|
if (packet.payload.isEmpty()) {
|
||||||
@@ -113,7 +117,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
|||||||
Log.d(TAG, "Successfully processed key exchange from $peerID")
|
Log.d(TAG, "Successfully processed key exchange from $peerID")
|
||||||
|
|
||||||
// Notify delegate
|
// Notify delegate
|
||||||
delegate?.onKeyExchangeCompleted(peerID)
|
delegate?.onKeyExchangeCompleted(peerID, packet.payload, routed.relayAddress)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
||||||
@@ -315,5 +319,5 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
|||||||
* Delegate interface for security manager callbacks
|
* Delegate interface for security manager callbacks
|
||||||
*/
|
*/
|
||||||
interface SecurityManagerDelegate {
|
interface SecurityManagerDelegate {
|
||||||
fun onKeyExchangeCompleted(peerID: String)
|
fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray, receivedAddress: String?)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.bitchat.android.model
|
||||||
|
|
||||||
|
import com.bitchat.android.protocol.BitchatPacket
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a routed packet with additional metadata
|
||||||
|
* Used for processing and routing packets in the mesh network
|
||||||
|
*/
|
||||||
|
data class RoutedPacket(
|
||||||
|
val packet: BitchatPacket,
|
||||||
|
val peerID: String? = null, // Who sent it (parsed from packet.senderID)
|
||||||
|
val relayAddress: String? = null // Address it came from (for avoiding loopback)
|
||||||
|
)
|
||||||
@@ -91,7 +91,7 @@ class BluetoothStatusManager(
|
|||||||
* This should be called on every app startup
|
* This should be called on every app startup
|
||||||
*/
|
*/
|
||||||
fun checkBluetoothStatus(): BluetoothStatus {
|
fun checkBluetoothStatus(): BluetoothStatus {
|
||||||
Log.d(TAG, "Checking Bluetooth status")
|
// Log.d(TAG, "Checking Bluetooth status")
|
||||||
|
|
||||||
return when {
|
return when {
|
||||||
bluetoothAdapter == null -> {
|
bluetoothAdapter == null -> {
|
||||||
@@ -103,7 +103,7 @@ class BluetoothStatusManager(
|
|||||||
BluetoothStatus.DISABLED
|
BluetoothStatus.DISABLED
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
Log.d(TAG, "Bluetooth is enabled and ready")
|
// Log.d(TAG, "Bluetooth is enabled and ready")
|
||||||
BluetoothStatus.ENABLED
|
BluetoothStatus.ENABLED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
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.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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Screen shown when checking location services status or requesting location services enable
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LocationCheckScreen(
|
||||||
|
status: LocationStatus,
|
||||||
|
onEnableLocation: () -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
isLoading: Boolean = false
|
||||||
|
) {
|
||||||
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(32.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
when (status) {
|
||||||
|
LocationStatus.DISABLED -> {
|
||||||
|
LocationDisabledContent(
|
||||||
|
onEnableLocation = onEnableLocation,
|
||||||
|
onRetry = onRetry,
|
||||||
|
colorScheme = colorScheme,
|
||||||
|
isLoading = isLoading
|
||||||
|
)
|
||||||
|
}
|
||||||
|
LocationStatus.NOT_AVAILABLE -> {
|
||||||
|
LocationNotAvailableContent(
|
||||||
|
colorScheme = colorScheme
|
||||||
|
)
|
||||||
|
}
|
||||||
|
LocationStatus.ENABLED -> {
|
||||||
|
LocationCheckingContent(
|
||||||
|
colorScheme = colorScheme
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LocationDisabledContent(
|
||||||
|
onEnableLocation: () -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
colorScheme: ColorScheme,
|
||||||
|
isLoading: Boolean
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
// Location icon - using LocationOn outlined icon in app's green color
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.LocationOn,
|
||||||
|
contentDescription = "Location Services",
|
||||||
|
modifier = Modifier.size(64.dp),
|
||||||
|
tint = Color(0xFF00C851) // App's main green color
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Location Services Required",
|
||||||
|
style = MaterialTheme.typography.headlineSmall.copy(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = colorScheme.primary
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||||
|
),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
// Privacy assurance section
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Security,
|
||||||
|
contentDescription = "Privacy",
|
||||||
|
tint = Color(0xFF4CAF50),
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Privacy First",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = colorScheme.onSurface
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "bitchat does NOT track your location or use GPS.\n\nLocation services are required by Android for Bluetooth scanning to work properly. This is an Android system requirement.",
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = colorScheme.onSurface.copy(alpha = 0.8f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "bitchat needs location services for:",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = colorScheme.onSurface
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "• Bluetooth device scanning (Android requirement)\n" +
|
||||||
|
"• Discovering nearby users on mesh network\n" +
|
||||||
|
"• Creating connections without internet\n" +
|
||||||
|
"• No GPS tracking or location collection",
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = colorScheme.onSurface.copy(alpha = 0.8f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
LocationLoadingIndicator()
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = onEnableLocation,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color(0xFF00C851) // App's main green color
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Open Location Settings",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onRetry,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Check Again",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontFamily = FontFamily.Monospace
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LocationNotAvailableContent(
|
||||||
|
colorScheme: ColorScheme
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
// Error icon
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.ErrorOutline,
|
||||||
|
contentDescription = "Error",
|
||||||
|
modifier = Modifier.size(64.dp),
|
||||||
|
tint = colorScheme.error
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Location Services Unavailable",
|
||||||
|
style = MaterialTheme.typography.headlineSmall.copy(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = colorScheme.error
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = colorScheme.errorContainer.copy(alpha = 0.1f)
|
||||||
|
),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Location services are not available on this device. This is unusual as location services are standard on Android devices.\n\nbitchat needs location services for Bluetooth scanning to work properly (Android requirement). Without this, the app cannot discover nearby users.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = colorScheme.onSurface
|
||||||
|
),
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LocationCheckingContent(
|
||||||
|
colorScheme: ColorScheme
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "bitchat*",
|
||||||
|
style = MaterialTheme.typography.headlineLarge.copy(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = colorScheme.primary
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
LocationLoadingIndicator()
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Checking location services...",
|
||||||
|
style = MaterialTheme.typography.bodyLarge.copy(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = colorScheme.onSurface.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LocationLoadingIndicator() {
|
||||||
|
// Animated rotation for the loading indicator
|
||||||
|
val infiniteTransition = rememberInfiniteTransition(label = "location_loading")
|
||||||
|
val rotationAngle by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 0f,
|
||||||
|
targetValue = 360f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(durationMillis = 2000, easing = LinearEasing),
|
||||||
|
repeatMode = RepeatMode.Restart
|
||||||
|
),
|
||||||
|
label = "rotation"
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(60.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.rotate(rotationAngle),
|
||||||
|
color = Color(0xFF4CAF50), // Location green
|
||||||
|
strokeWidth = 3.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package com.bitchat.android.onboarding
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.location.LocationManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages Location Services enable/disable state and user prompts
|
||||||
|
* Checks location services status on every app startup
|
||||||
|
* Note: This is for system location services, not location permissions
|
||||||
|
*/
|
||||||
|
class LocationStatusManager(
|
||||||
|
private val activity: ComponentActivity,
|
||||||
|
private val context: Context,
|
||||||
|
private val onLocationEnabled: () -> Unit,
|
||||||
|
private val onLocationDisabled: (String) -> Unit
|
||||||
|
) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "LocationStatusManager"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var locationSettingsLauncher: ActivityResultLauncher<Intent>? = null
|
||||||
|
private var locationManager: LocationManager? = null
|
||||||
|
private var locationStateReceiver: BroadcastReceiver? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
setupLocationManager()
|
||||||
|
setupLocationSettingsLauncher()
|
||||||
|
setupLocationStateReceiver()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup LocationManager reference
|
||||||
|
*/
|
||||||
|
private fun setupLocationManager() {
|
||||||
|
try {
|
||||||
|
locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||||
|
Log.d(TAG, "LocationManager initialized: ${locationManager != null}")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to initialize LocationManager", e)
|
||||||
|
locationManager = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup launcher for location settings request
|
||||||
|
*/
|
||||||
|
private fun setupLocationSettingsLauncher() {
|
||||||
|
locationSettingsLauncher = activity.registerForActivityResult(
|
||||||
|
ActivityResultContracts.StartActivityForResult()
|
||||||
|
) { result ->
|
||||||
|
val isEnabled = isLocationEnabled()
|
||||||
|
Log.d(TAG, "Location settings request result: $isEnabled (result code: ${result.resultCode})")
|
||||||
|
if (isEnabled) {
|
||||||
|
onLocationEnabled()
|
||||||
|
} else {
|
||||||
|
onLocationDisabled("Location services are required for Bluetooth scanning on Android. Please enable location services to continue.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup broadcast receiver to listen for location settings changes
|
||||||
|
*/
|
||||||
|
private fun setupLocationStateReceiver() {
|
||||||
|
locationStateReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (intent.action == LocationManager.MODE_CHANGED_ACTION ||
|
||||||
|
intent.action == LocationManager.PROVIDERS_CHANGED_ACTION) {
|
||||||
|
Log.d(TAG, "Location settings changed, checking status")
|
||||||
|
val isEnabled = isLocationEnabled()
|
||||||
|
if (isEnabled) {
|
||||||
|
onLocationEnabled()
|
||||||
|
} else {
|
||||||
|
onLocationDisabled("Location services have been disabled.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register receiver for location changes
|
||||||
|
val filter = IntentFilter().apply {
|
||||||
|
addAction(LocationManager.MODE_CHANGED_ACTION)
|
||||||
|
addAction(LocationManager.PROVIDERS_CHANGED_ACTION)
|
||||||
|
}
|
||||||
|
context.registerReceiver(locationStateReceiver, filter)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if location services are enabled (system-wide setting)
|
||||||
|
* Uses proper API depending on Android version
|
||||||
|
*/
|
||||||
|
fun isLocationEnabled(): Boolean {
|
||||||
|
return try {
|
||||||
|
locationManager?.let { lm ->
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
// API 28+ (Android 9) - Modern approach
|
||||||
|
lm.isLocationEnabled
|
||||||
|
} else {
|
||||||
|
// Older devices - Check individual providers
|
||||||
|
lm.isProviderEnabled(LocationManager.GPS_PROVIDER) ||
|
||||||
|
lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
|
||||||
|
}
|
||||||
|
} ?: false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Error checking location enabled state: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check location services status
|
||||||
|
* This should be called on every app startup
|
||||||
|
*/
|
||||||
|
fun checkLocationStatus(): LocationStatus {
|
||||||
|
Log.d(TAG, "Checking location services status")
|
||||||
|
|
||||||
|
return when {
|
||||||
|
locationManager == null -> {
|
||||||
|
Log.e(TAG, "LocationManager not available on this device")
|
||||||
|
LocationStatus.NOT_AVAILABLE
|
||||||
|
}
|
||||||
|
!isLocationEnabled() -> {
|
||||||
|
Log.w(TAG, "Location services are disabled")
|
||||||
|
LocationStatus.DISABLED
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Log.d(TAG, "Location services are enabled and ready")
|
||||||
|
LocationStatus.ENABLED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request user to enable location services
|
||||||
|
* Opens system location settings screen
|
||||||
|
*/
|
||||||
|
fun requestEnableLocation() {
|
||||||
|
Log.d(TAG, "Requesting user to enable location services")
|
||||||
|
|
||||||
|
try {
|
||||||
|
val enableLocationIntent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)
|
||||||
|
locationSettingsLauncher?.launch(enableLocationIntent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to request location enable", e)
|
||||||
|
onLocationDisabled("Failed to open location settings: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle location status check result
|
||||||
|
*/
|
||||||
|
fun handleLocationStatus(status: LocationStatus) {
|
||||||
|
when (status) {
|
||||||
|
LocationStatus.ENABLED -> {
|
||||||
|
Log.d(TAG, "Location services enabled, proceeding")
|
||||||
|
onLocationEnabled()
|
||||||
|
}
|
||||||
|
LocationStatus.DISABLED -> {
|
||||||
|
Log.d(TAG, "Location services disabled, requesting enable")
|
||||||
|
requestEnableLocation()
|
||||||
|
}
|
||||||
|
LocationStatus.NOT_AVAILABLE -> {
|
||||||
|
Log.e(TAG, "Location services not available")
|
||||||
|
onLocationDisabled("Location services are not available on this device.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user-friendly status message
|
||||||
|
*/
|
||||||
|
fun getStatusMessage(status: LocationStatus): String {
|
||||||
|
return when (status) {
|
||||||
|
LocationStatus.ENABLED -> "Location services are enabled and ready"
|
||||||
|
LocationStatus.DISABLED -> "Location services are disabled. Please enable location services for Bluetooth scanning."
|
||||||
|
LocationStatus.NOT_AVAILABLE -> "Location services are not available on this device."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get detailed diagnostics
|
||||||
|
*/
|
||||||
|
fun getDiagnostics(): String {
|
||||||
|
return buildString {
|
||||||
|
appendLine("Location Services Status Diagnostics:")
|
||||||
|
appendLine("LocationManager available: ${locationManager != null}")
|
||||||
|
appendLine("Location services enabled: ${isLocationEnabled()}")
|
||||||
|
appendLine("Current status: ${checkLocationStatus()}")
|
||||||
|
appendLine("Android version: ${Build.VERSION.SDK_INT}")
|
||||||
|
|
||||||
|
locationManager?.let { lm ->
|
||||||
|
try {
|
||||||
|
appendLine("GPS provider enabled: ${lm.isProviderEnabled(LocationManager.GPS_PROVIDER)}")
|
||||||
|
appendLine("Network provider enabled: ${lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER)}")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
appendLine("Provider details: [Error: ${e.message}]")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
appendLine("Using modern isLocationEnabled() API")
|
||||||
|
} else {
|
||||||
|
appendLine("Using legacy provider check API")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log current location status for debugging
|
||||||
|
*/
|
||||||
|
fun logLocationStatus() {
|
||||||
|
Log.d(TAG, getDiagnostics())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup resources - call this when activity is destroyed
|
||||||
|
*/
|
||||||
|
fun cleanup() {
|
||||||
|
locationStateReceiver?.let { receiver ->
|
||||||
|
try {
|
||||||
|
context.unregisterReceiver(receiver)
|
||||||
|
Log.d(TAG, "Location state receiver unregistered")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Error unregistering location state receiver: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Location services status enum
|
||||||
|
*/
|
||||||
|
enum class LocationStatus {
|
||||||
|
ENABLED,
|
||||||
|
DISABLED,
|
||||||
|
NOT_AVAILABLE
|
||||||
|
}
|
||||||
@@ -21,119 +21,130 @@ import androidx.compose.ui.unit.sp
|
|||||||
@Composable
|
@Composable
|
||||||
fun PermissionExplanationScreen(
|
fun PermissionExplanationScreen(
|
||||||
permissionCategories: List<PermissionCategory>,
|
permissionCategories: List<PermissionCategory>,
|
||||||
onContinue: () -> Unit,
|
onContinue: () -> Unit
|
||||||
onCancel: () -> Unit
|
|
||||||
) {
|
) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
Column(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize()
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp)
|
|
||||||
.verticalScroll(scrollState),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
) {
|
||||||
// Header
|
// Scrollable content
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.padding(bottom = 88.dp) // Leave space for the fixed button
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
text = "Welcome to bitchat*",
|
// Header
|
||||||
style = MaterialTheme.typography.headlineMedium.copy(
|
Column(
|
||||||
fontFamily = FontFamily.Monospace,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
fontWeight = FontWeight.Bold,
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
color = colorScheme.primary
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Welcome to bitchat*",
|
||||||
|
style = MaterialTheme.typography.headlineMedium.copy(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = colorScheme.primary
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Decentralized mesh messaging over Bluetooth",
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = colorScheme.onSurface.copy(alpha = 0.7f)
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Privacy assurance section
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||||
),
|
),
|
||||||
textAlign = TextAlign.Center
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||||
)
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "🔒",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Your Privacy is Protected",
|
||||||
|
style = MaterialTheme.typography.titleSmall.copy(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = colorScheme.onSurface
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "• bitchat doesn't track you or collect personal data\n" +
|
||||||
|
"• No servers, no internet required, no data logging\n" +
|
||||||
|
"• Location permission is only used by Android for Bluetooth scanning\n" +
|
||||||
|
"• Your messages stay on your device and peer devices only",
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = colorScheme.onSurface.copy(alpha = 0.8f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Decentralized mesh messaging over Bluetooth",
|
text = "To work properly, bitchat needs these permissions:",
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
fontFamily = FontFamily.Monospace,
|
fontWeight = FontWeight.Medium,
|
||||||
color = colorScheme.onSurface.copy(alpha = 0.7f)
|
color = colorScheme.onSurface
|
||||||
),
|
)
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
// Permission categories
|
||||||
|
permissionCategories.forEach { category ->
|
||||||
// Privacy assurance section
|
PermissionCategoryCard(
|
||||||
Card(
|
category = category,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
colorScheme = colorScheme
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
|
||||||
),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "🔒",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Your Privacy is Protected",
|
|
||||||
style = MaterialTheme.typography.titleSmall.copy(
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = colorScheme.onSurface
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "• bitchat doesn't track you or collect personal data\n" +
|
|
||||||
"• No servers, no internet required, no data logging\n" +
|
|
||||||
"• Location permission is only used by Android for Bluetooth scanning\n" +
|
|
||||||
"• Your messages stay on your device and peer devices only",
|
|
||||||
style = MaterialTheme.typography.bodySmall.copy(
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
color = colorScheme.onSurface.copy(alpha = 0.8f)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
// Fixed button at bottom
|
||||||
|
Surface(
|
||||||
Text(
|
modifier = Modifier
|
||||||
text = "To work properly, bitchat needs these permissions:",
|
.align(Alignment.BottomCenter)
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
.fillMaxWidth(),
|
||||||
fontWeight = FontWeight.Medium,
|
color = colorScheme.surface,
|
||||||
color = colorScheme.onSurface
|
shadowElevation = 8.dp
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Permission categories
|
|
||||||
permissionCategories.forEach { category ->
|
|
||||||
PermissionCategoryCard(
|
|
||||||
category = category,
|
|
||||||
colorScheme = colorScheme
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// Action buttons
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
) {
|
||||||
Button(
|
Button(
|
||||||
onClick = onContinue,
|
onClick = onContinue,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = colorScheme.primary
|
containerColor = colorScheme.primary
|
||||||
)
|
)
|
||||||
@@ -147,22 +158,6 @@ fun PermissionExplanationScreen(
|
|||||||
modifier = Modifier.padding(vertical = 4.dp)
|
modifier = Modifier.padding(vertical = 4.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = onCancel,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors = ButtonDefaults.outlinedButtonColors(
|
|
||||||
contentColor = colorScheme.onSurface.copy(alpha = 0.7f)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Exit App",
|
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
|
||||||
fontFamily = FontFamily.Monospace
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(vertical = 4.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,14 +183,14 @@ private fun PermissionCategoryCard(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = getPermissionEmoji(category.name),
|
text = getPermissionEmoji(category.type),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = getPermissionIconColor(category.name),
|
color = getPermissionIconColor(category.type),
|
||||||
modifier = Modifier.size(24.dp)
|
modifier = Modifier.size(24.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = category.name,
|
text = category.type.nameValue,
|
||||||
style = MaterialTheme.typography.titleSmall.copy(
|
style = MaterialTheme.typography.titleSmall.copy(
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = colorScheme.onSurface
|
color = colorScheme.onSurface
|
||||||
@@ -212,7 +207,7 @@ private fun PermissionCategoryCard(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
if (category.name == "Precise Location") {
|
if (category.type == PermissionType.PRECISE_LOCATION) {
|
||||||
// Extra emphasis for location permission
|
// Extra emphasis for location permission
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -237,20 +232,20 @@ private fun PermissionCategoryCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getPermissionEmoji(categoryName: String): String {
|
private fun getPermissionEmoji(permissionType: PermissionType): String {
|
||||||
return when (categoryName) {
|
return when (permissionType) {
|
||||||
"Nearby Devices" -> "📱"
|
PermissionType.NEARBY_DEVICES -> "📱"
|
||||||
"Precise Location" -> "📍"
|
PermissionType.PRECISE_LOCATION -> "📍"
|
||||||
"Notifications" -> "🔔"
|
PermissionType.NOTIFICATIONS -> "🔔"
|
||||||
else -> "🔧"
|
PermissionType.OTHER -> "🔧"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getPermissionIconColor(categoryName: String): Color {
|
private fun getPermissionIconColor(permissionType: PermissionType): Color {
|
||||||
return when (categoryName) {
|
return when (permissionType) {
|
||||||
"Nearby Devices" -> Color(0xFF2196F3) // Blue
|
PermissionType.NEARBY_DEVICES -> Color(0xFF2196F3) // Blue
|
||||||
"Precise Location" -> Color(0xFFFF9800) // Orange
|
PermissionType.PRECISE_LOCATION -> Color(0xFFFF9800) // Orange
|
||||||
"Notifications" -> Color(0xFF4CAF50) // Green
|
PermissionType.NOTIFICATIONS -> Color(0xFF4CAF50) // Green
|
||||||
else -> Color(0xFF9C27B0) // Purple
|
PermissionType.OTHER -> Color(0xFF9C27B0) // Purple
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,8 +115,8 @@ class PermissionManager(private val context: Context) {
|
|||||||
|
|
||||||
categories.add(
|
categories.add(
|
||||||
PermissionCategory(
|
PermissionCategory(
|
||||||
name = "Nearby Devices",
|
type = PermissionType.NEARBY_DEVICES,
|
||||||
description = "Required to discover and connect to other bitchat users via Bluetooth",
|
description = "Required to discover bitchat users via Bluetooth",
|
||||||
permissions = bluetoothPermissions,
|
permissions = bluetoothPermissions,
|
||||||
isGranted = bluetoothPermissions.all { isPermissionGranted(it) },
|
isGranted = bluetoothPermissions.all { isPermissionGranted(it) },
|
||||||
systemDescription = "Allow bitchat to connect to nearby devices"
|
systemDescription = "Allow bitchat to connect to nearby devices"
|
||||||
@@ -131,11 +131,11 @@ class PermissionManager(private val context: Context) {
|
|||||||
|
|
||||||
categories.add(
|
categories.add(
|
||||||
PermissionCategory(
|
PermissionCategory(
|
||||||
name = "Precise Location",
|
type = PermissionType.PRECISE_LOCATION,
|
||||||
description = "Required by Android for Bluetooth scanning.",
|
description = "Required by Android to discover nearby bitchat users via Bluetooth",
|
||||||
permissions = locationPermissions,
|
permissions = locationPermissions,
|
||||||
isGranted = locationPermissions.all { isPermissionGranted(it) },
|
isGranted = locationPermissions.all { isPermissionGranted(it) },
|
||||||
systemDescription = "Allow bitchat to access this device's location"
|
systemDescription = "bitchat needs this to scan for nearby devices"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -143,8 +143,8 @@ class PermissionManager(private val context: Context) {
|
|||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
categories.add(
|
categories.add(
|
||||||
PermissionCategory(
|
PermissionCategory(
|
||||||
name = "Notifications",
|
type = PermissionType.NOTIFICATIONS,
|
||||||
description = "Show notifications when you receive private messages while the app is in background",
|
description = "Receive notifications when you receive private messages",
|
||||||
permissions = listOf(Manifest.permission.POST_NOTIFICATIONS),
|
permissions = listOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||||
isGranted = isPermissionGranted(Manifest.permission.POST_NOTIFICATIONS),
|
isGranted = isPermissionGranted(Manifest.permission.POST_NOTIFICATIONS),
|
||||||
systemDescription = "Allow bitchat to send you notifications"
|
systemDescription = "Allow bitchat to send you notifications"
|
||||||
@@ -167,7 +167,7 @@ class PermissionManager(private val context: Context) {
|
|||||||
appendLine()
|
appendLine()
|
||||||
|
|
||||||
getCategorizedPermissions().forEach { category ->
|
getCategorizedPermissions().forEach { category ->
|
||||||
appendLine("${category.name}: ${if (category.isGranted) "✅ GRANTED" else "❌ MISSING"}")
|
appendLine("${category.type.nameValue}: ${if (category.isGranted) "✅ GRANTED" else "❌ MISSING"}")
|
||||||
category.permissions.forEach { permission ->
|
category.permissions.forEach { permission ->
|
||||||
val granted = isPermissionGranted(permission)
|
val granted = isPermissionGranted(permission)
|
||||||
appendLine(" - ${permission.substringAfterLast(".")}: ${if (granted) "✅" else "❌"}")
|
appendLine(" - ${permission.substringAfterLast(".")}: ${if (granted) "✅" else "❌"}")
|
||||||
@@ -197,9 +197,16 @@ class PermissionManager(private val context: Context) {
|
|||||||
* Data class representing a category of related permissions
|
* Data class representing a category of related permissions
|
||||||
*/
|
*/
|
||||||
data class PermissionCategory(
|
data class PermissionCategory(
|
||||||
val name: String,
|
val type: PermissionType,
|
||||||
val description: String,
|
val description: String,
|
||||||
val permissions: List<String>,
|
val permissions: List<String>,
|
||||||
val isGranted: Boolean,
|
val isGranted: Boolean,
|
||||||
val systemDescription: String
|
val systemDescription: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
enum class PermissionType(val nameValue: String) {
|
||||||
|
NEARBY_DEVICES("Nearby Devices"),
|
||||||
|
PRECISE_LOCATION("Precise Location"),
|
||||||
|
NOTIFICATIONS("Notifications"),
|
||||||
|
OTHER("Other")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package com.bitchat.android.services
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.util.Log
|
||||||
|
import com.bitchat.android.model.BitchatMessage
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.io.ObjectInputStream
|
||||||
|
import java.io.ObjectOutputStream
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message retention service for saving channel messages locally
|
||||||
|
* Matches iOS MessageRetentionService functionality
|
||||||
|
*/
|
||||||
|
class MessageRetentionService private constructor(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "MessageRetentionService"
|
||||||
|
private const val PREF_NAME = "message_retention"
|
||||||
|
private const val KEY_FAVORITE_CHANNELS = "favorite_channels"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var INSTANCE: MessageRetentionService? = null
|
||||||
|
|
||||||
|
fun getInstance(context: Context): MessageRetentionService {
|
||||||
|
return INSTANCE ?: synchronized(this) {
|
||||||
|
INSTANCE ?: MessageRetentionService(context.applicationContext).also { INSTANCE = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val prefs: SharedPreferences = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
|
||||||
|
private val retentionDir = File(context.filesDir, "retained_messages")
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (!retentionDir.exists()) {
|
||||||
|
retentionDir.mkdirs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Channel Bookmarking (Favorites)
|
||||||
|
|
||||||
|
fun getFavoriteChannels(): Set<String> {
|
||||||
|
return prefs.getStringSet(KEY_FAVORITE_CHANNELS, emptySet()) ?: emptySet()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleFavoriteChannel(channel: String): Boolean {
|
||||||
|
val currentFavorites = getFavoriteChannels().toMutableSet()
|
||||||
|
val wasAdded = if (currentFavorites.contains(channel)) {
|
||||||
|
currentFavorites.remove(channel)
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
currentFavorites.add(channel)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
prefs.edit().putStringSet(KEY_FAVORITE_CHANNELS, currentFavorites).apply()
|
||||||
|
|
||||||
|
if (!wasAdded) {
|
||||||
|
// Channel removed from favorites - delete saved messages in background
|
||||||
|
Thread {
|
||||||
|
try {
|
||||||
|
val channelFile = getChannelFile(channel)
|
||||||
|
if (channelFile.exists()) {
|
||||||
|
channelFile.delete()
|
||||||
|
Log.d(TAG, "Deleted saved messages for channel $channel")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to delete messages for channel $channel", e)
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Channel $channel ${if (wasAdded) "bookmarked" else "unbookmarked"}")
|
||||||
|
return wasAdded
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isChannelBookmarked(channel: String): Boolean {
|
||||||
|
return getFavoriteChannels().contains(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Message Storage
|
||||||
|
|
||||||
|
suspend fun saveMessage(message: BitchatMessage, forChannel: String) = withContext(Dispatchers.IO) {
|
||||||
|
if (!isChannelBookmarked(forChannel)) {
|
||||||
|
Log.w(TAG, "Attempted to save message for non-bookmarked channel: $forChannel")
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val channelFile = getChannelFile(forChannel)
|
||||||
|
val existingMessages = loadMessagesFromFile(channelFile).toMutableList()
|
||||||
|
|
||||||
|
// Check if message already exists (by ID)
|
||||||
|
if (existingMessages.any { it.id == message.id }) {
|
||||||
|
Log.d(TAG, "Message ${message.id} already saved for channel $forChannel")
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new message
|
||||||
|
existingMessages.add(message)
|
||||||
|
|
||||||
|
// Sort by timestamp
|
||||||
|
existingMessages.sortBy { it.timestamp }
|
||||||
|
|
||||||
|
// Save back to file
|
||||||
|
saveMessagesToFile(channelFile, existingMessages)
|
||||||
|
|
||||||
|
Log.d(TAG, "Saved message ${message.id} for channel $forChannel")
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to save message for channel $forChannel", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun loadMessagesForChannel(channel: String): List<BitchatMessage> = withContext(Dispatchers.IO) {
|
||||||
|
if (!isChannelBookmarked(channel)) {
|
||||||
|
Log.d(TAG, "Channel $channel not bookmarked, returning empty list")
|
||||||
|
return@withContext emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val channelFile = getChannelFile(channel)
|
||||||
|
val messages = loadMessagesFromFile(channelFile)
|
||||||
|
Log.d(TAG, "Loaded ${messages.size} messages for channel $channel")
|
||||||
|
return@withContext messages
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to load messages for channel $channel", e)
|
||||||
|
return@withContext emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteMessagesForChannel(channel: String): Unit = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val channelFile = getChannelFile(channel)
|
||||||
|
if (channelFile.exists()) {
|
||||||
|
channelFile.delete()
|
||||||
|
Log.d(TAG, "Deleted saved messages for channel $channel")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to delete messages for channel $channel", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteAllStoredMessages(): Unit = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
if (retentionDir.exists()) {
|
||||||
|
retentionDir.listFiles()?.forEach { file ->
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
Log.d(TAG, "Deleted all stored messages")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to delete all stored messages", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - File Operations
|
||||||
|
|
||||||
|
private fun getChannelFile(channel: String): File {
|
||||||
|
// Sanitize channel name for filename
|
||||||
|
val sanitizedChannel = channel.replace("[^a-zA-Z0-9_-]".toRegex(), "_")
|
||||||
|
return File(retentionDir, "channel_${sanitizedChannel}.dat")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadMessagesFromFile(file: File): List<BitchatMessage> {
|
||||||
|
if (!file.exists()) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
FileInputStream(file).use { fis ->
|
||||||
|
ObjectInputStream(fis).use { ois ->
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
ois.readObject() as List<BitchatMessage>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to load messages from ${file.name}, returning empty list", e)
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveMessagesToFile(file: File, messages: List<BitchatMessage>) {
|
||||||
|
FileOutputStream(file).use { fos ->
|
||||||
|
ObjectOutputStream(fos).use { oos ->
|
||||||
|
oos.writeObject(messages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Statistics
|
||||||
|
|
||||||
|
fun getBookmarkedChannelsCount(): Int {
|
||||||
|
return getFavoriteChannels().size
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getTotalStoredMessagesCount(): Int = withContext(Dispatchers.IO) {
|
||||||
|
var totalCount = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
retentionDir.listFiles()?.forEach { file ->
|
||||||
|
if (file.name.startsWith("channel_") && file.name.endsWith(".dat")) {
|
||||||
|
val messages = loadMessagesFromFile(file)
|
||||||
|
totalCount += messages.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to count stored messages", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalCount
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -285,8 +285,20 @@ class ChannelManager(
|
|||||||
state.setPasswordPromptChannel(null)
|
state.setPasswordPromptChannel(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setChannelPassword(channel: String, password: String): Boolean {
|
fun setChannelPassword(channel: String, password: String) {
|
||||||
return verifyChannelPassword(channel, password)
|
|
||||||
|
channelPasswords[channel] = password
|
||||||
|
|
||||||
|
channelKeys[channel] = deriveChannelKey(password, channel)
|
||||||
|
|
||||||
|
state.setPasswordProtectedChannels(
|
||||||
|
state.getPasswordProtectedChannelsValue().toMutableSet().apply { add(channel) }
|
||||||
|
)
|
||||||
|
|
||||||
|
dataManager.saveChannelData(
|
||||||
|
state.getJoinedChannelsValue(),
|
||||||
|
state.getPasswordProtectedChannelsValue()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Emergency Clear
|
// MARK: - Emergency Clear
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.bitchat.android.ui
|
package com.bitchat.android.ui
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
@@ -103,7 +104,7 @@ fun PeerCounter(
|
|||||||
imageVector = Icons.Filled.Email,
|
imageVector = Icons.Filled.Email,
|
||||||
contentDescription = "Unread private messages",
|
contentDescription = "Unread private messages",
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
tint = Color(0xFFFF8C00) // Orange to match private message theme
|
tint = Color(0xFFFF9500) // Orange to match private message theme
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
}
|
}
|
||||||
@@ -151,11 +152,17 @@ fun ChatHeaderContent(
|
|||||||
|
|
||||||
when {
|
when {
|
||||||
selectedPrivatePeer != null -> {
|
selectedPrivatePeer != null -> {
|
||||||
// Private chat header
|
// Private chat header - ensure state synchronization
|
||||||
|
val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet())
|
||||||
|
val fingerprint = viewModel.privateChatManager.getPeerFingerprint(selectedPrivatePeer)
|
||||||
|
val isFavorite = favoritePeers.contains(fingerprint)
|
||||||
|
|
||||||
|
Log.d("ChatHeader", "Header recomposing: peer=$selectedPrivatePeer, fingerprint=$fingerprint, isFav=$isFavorite")
|
||||||
|
|
||||||
PrivateChatHeader(
|
PrivateChatHeader(
|
||||||
peerID = selectedPrivatePeer,
|
peerID = selectedPrivatePeer,
|
||||||
peerNicknames = viewModel.meshService.getPeerNicknames(),
|
peerNicknames = viewModel.meshService.getPeerNicknames(),
|
||||||
isFavorite = viewModel.isFavorite(selectedPrivatePeer),
|
isFavorite = isFavorite,
|
||||||
onBackClick = onBackClick,
|
onBackClick = onBackClick,
|
||||||
onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) }
|
onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) }
|
||||||
)
|
)
|
||||||
@@ -201,19 +208,18 @@ private fun PrivateChatHeader(
|
|||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
val peerNickname = peerNicknames[peerID] ?: peerID
|
val peerNickname = peerNicknames[peerID] ?: peerID
|
||||||
|
|
||||||
Row(
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
// Back button - positioned all the way to the left with minimal margin
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
// Fixed: Make back button wider to prevent text cropping
|
|
||||||
Button(
|
Button(
|
||||||
onClick = onBackClick,
|
onClick = onBackClick,
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
contentColor = colorScheme.primary
|
contentColor = colorScheme.primary
|
||||||
),
|
),
|
||||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
|
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(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
@@ -233,27 +239,33 @@ private fun PrivateChatHeader(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
// Title - perfectly centered regardless of other elements
|
||||||
|
Row(
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.align(Alignment.Center)
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.Lock,
|
imageVector = Icons.Filled.Lock,
|
||||||
contentDescription = "Private chat",
|
contentDescription = "Private chat",
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
tint = Color(0xFFFF8C00) // Orange to match private message theme
|
tint = Color(0xFFFF9500) // Orange to match private message theme
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = peerNickname,
|
text = peerNickname,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = Color(0xFFFF8C00) // Orange
|
color = Color(0xFFFF9500) // Orange
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
// Favorite button - positioned on the right
|
||||||
|
IconButton(
|
||||||
// Favorite button with proper filled/outlined star
|
onClick = {
|
||||||
IconButton(onClick = onToggleFavorite) {
|
Log.d("ChatHeader", "Header toggle favorite: peerID=$peerID, currentFavorite=$isFavorite")
|
||||||
|
onToggleFavorite()
|
||||||
|
},
|
||||||
|
modifier = Modifier.align(Alignment.CenterEnd)
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star,
|
imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star,
|
||||||
contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites",
|
contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites",
|
||||||
@@ -273,12 +285,19 @@ private fun ChannelHeader(
|
|||||||
) {
|
) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
|
||||||
Row(
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
// Back button - positioned all the way to the left with minimal margin
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
Button(
|
||||||
verticalAlignment = Alignment.CenterVertically
|
onClick = onBackClick,
|
||||||
) {
|
colors = ButtonDefaults.buttonColors(
|
||||||
IconButton(onClick = onBackClick) {
|
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(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
@@ -297,18 +316,21 @@ private fun ChannelHeader(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
// Title - perfectly centered regardless of other elements
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "channel: $channel",
|
text = "channel: $channel",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = Color(0xFF0080FF), // Blue
|
color = Color(0xFFFF9500), // Orange to match input field
|
||||||
modifier = Modifier.clickable { onSidebarClick() }
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.clickable { onSidebarClick() }
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
// Leave button - positioned on the right
|
||||||
|
TextButton(
|
||||||
TextButton(onClick = onLeaveChannel) {
|
onClick = onLeaveChannel,
|
||||||
|
modifier = Modifier.align(Alignment.CenterEnd)
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "leave",
|
text = "leave",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
|||||||
@@ -60,15 +60,15 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
|||||||
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
|
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
|
||||||
val privateChats by viewModel.privateChats.observeAsState(emptyMap())
|
val privateChats by viewModel.privateChats.observeAsState(emptyMap())
|
||||||
val channelMessages by viewModel.channelMessages.observeAsState(emptyMap())
|
val channelMessages by viewModel.channelMessages.observeAsState(emptyMap())
|
||||||
var showSidebar by remember { mutableStateOf(false) }
|
val showSidebar by viewModel.showSidebar.observeAsState(false)
|
||||||
val showCommandSuggestions by viewModel.showCommandSuggestions.observeAsState(false)
|
val showCommandSuggestions by viewModel.showCommandSuggestions.observeAsState(false)
|
||||||
val commandSuggestions by viewModel.commandSuggestions.observeAsState(emptyList())
|
val commandSuggestions by viewModel.commandSuggestions.observeAsState(emptyList())
|
||||||
|
val showAppInfo by viewModel.showAppInfo.observeAsState(false)
|
||||||
|
|
||||||
var messageText by remember { mutableStateOf("") }
|
var messageText by remember { mutableStateOf("") }
|
||||||
var showPasswordPrompt by remember { mutableStateOf(false) }
|
var showPasswordPrompt by remember { mutableStateOf(false) }
|
||||||
var showPasswordDialog by remember { mutableStateOf(false) }
|
var showPasswordDialog by remember { mutableStateOf(false) }
|
||||||
var passwordInput by remember { mutableStateOf("") }
|
var passwordInput by remember { mutableStateOf("") }
|
||||||
var showAppInfo by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
// Show password dialog when needed
|
// Show password dialog when needed
|
||||||
LaunchedEffect(showPasswordPrompt) {
|
LaunchedEffect(showPasswordPrompt) {
|
||||||
@@ -142,8 +142,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
|||||||
nickname = nickname,
|
nickname = nickname,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
onSidebarToggle = { showSidebar = true },
|
onSidebarToggle = { viewModel.showSidebar() },
|
||||||
onShowAppInfo = { showAppInfo = true },
|
onShowAppInfo = { viewModel.showAppInfo() },
|
||||||
onPanicClear = { viewModel.panicClearAllData() }
|
onPanicClear = { viewModel.panicClearAllData() }
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -162,7 +162,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
|||||||
) {
|
) {
|
||||||
SidebarOverlay(
|
SidebarOverlay(
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onDismiss = { showSidebar = false },
|
onDismiss = { viewModel.hideSidebar() },
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -188,7 +188,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
|||||||
passwordInput = ""
|
passwordInput = ""
|
||||||
},
|
},
|
||||||
showAppInfo = showAppInfo,
|
showAppInfo = showAppInfo,
|
||||||
onAppInfoDismiss = { showAppInfo = false }
|
onAppInfoDismiss = { viewModel.hideAppInfo() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.bitchat.android.ui
|
package com.bitchat.android.ui
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.MediatorLiveData
|
import androidx.lifecycle.MediatorLiveData
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
@@ -84,6 +85,10 @@ class ChatState {
|
|||||||
|
|
||||||
val peerIDToPublicKeyFingerprint = mutableMapOf<String, String>()
|
val peerIDToPublicKeyFingerprint = mutableMapOf<String, String>()
|
||||||
|
|
||||||
|
// Navigation state
|
||||||
|
private val _showAppInfo = MutableLiveData<Boolean>(false)
|
||||||
|
val showAppInfo: LiveData<Boolean> = _showAppInfo
|
||||||
|
|
||||||
// Unread state computed properties
|
// Unread state computed properties
|
||||||
val hasUnreadChannels: MediatorLiveData<Boolean> = MediatorLiveData<Boolean>()
|
val hasUnreadChannels: MediatorLiveData<Boolean> = MediatorLiveData<Boolean>()
|
||||||
val hasUnreadPrivateMessages: MediatorLiveData<Boolean> = MediatorLiveData<Boolean>()
|
val hasUnreadPrivateMessages: MediatorLiveData<Boolean> = MediatorLiveData<Boolean>()
|
||||||
@@ -117,6 +122,7 @@ class ChatState {
|
|||||||
fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value ?: false
|
fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value ?: false
|
||||||
fun getCommandSuggestionsValue() = _commandSuggestions.value ?: emptyList()
|
fun getCommandSuggestionsValue() = _commandSuggestions.value ?: emptyList()
|
||||||
fun getFavoritePeersValue() = _favoritePeers.value ?: emptySet()
|
fun getFavoritePeersValue() = _favoritePeers.value ?: emptySet()
|
||||||
|
fun getShowAppInfoValue() = _showAppInfo.value ?: false
|
||||||
|
|
||||||
// Setters for state updates
|
// Setters for state updates
|
||||||
fun setMessages(messages: List<BitchatMessage>) {
|
fun setMessages(messages: List<BitchatMessage>) {
|
||||||
@@ -188,7 +194,21 @@ class ChatState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setFavoritePeers(favorites: Set<String>) {
|
fun setFavoritePeers(favorites: Set<String>) {
|
||||||
|
val currentValue = _favoritePeers.value ?: emptySet()
|
||||||
|
Log.d("ChatState", "setFavoritePeers called with ${favorites.size} favorites: $favorites")
|
||||||
|
Log.d("ChatState", "Current value: $currentValue")
|
||||||
|
Log.d("ChatState", "Values equal: ${currentValue == favorites}")
|
||||||
|
Log.d("ChatState", "Setting on thread: ${Thread.currentThread().name}")
|
||||||
|
|
||||||
|
// Always set the value - even if equal, this ensures observers are triggered
|
||||||
_favoritePeers.value = favorites
|
_favoritePeers.value = favorites
|
||||||
|
|
||||||
|
Log.d("ChatState", "LiveData value after set: ${_favoritePeers.value}")
|
||||||
|
Log.d("ChatState", "LiveData has active observers: ${_favoritePeers.hasActiveObservers()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setShowAppInfo(show: Boolean) {
|
||||||
|
_showAppInfo.value = show
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ private fun appendFormattedContent(
|
|||||||
}
|
}
|
||||||
"mention" -> {
|
"mention" -> {
|
||||||
builder.pushStyle(SpanStyle(
|
builder.pushStyle(SpanStyle(
|
||||||
color = Color(0xFFFF8C00), // Orange
|
color = Color(0xFFFF9500), // Orange
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.bitchat.android.ui
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
@@ -19,13 +20,13 @@ import kotlin.random.Random
|
|||||||
* Refactored ChatViewModel - Main coordinator for bitchat functionality
|
* Refactored ChatViewModel - Main coordinator for bitchat functionality
|
||||||
* Delegates specific responsibilities to specialized managers while maintaining 100% iOS compatibility
|
* Delegates specific responsibilities to specialized managers while maintaining 100% iOS compatibility
|
||||||
*/
|
*/
|
||||||
class ChatViewModel(application: Application) : AndroidViewModel(application), BluetoothMeshDelegate {
|
class ChatViewModel(
|
||||||
|
application: Application,
|
||||||
|
val meshService: BluetoothMeshService
|
||||||
|
) : AndroidViewModel(application), BluetoothMeshDelegate {
|
||||||
|
|
||||||
private val context: Context = application.applicationContext
|
private val context: Context = application.applicationContext
|
||||||
|
|
||||||
// Core services
|
|
||||||
val meshService = BluetoothMeshService(context)
|
|
||||||
|
|
||||||
// State management
|
// State management
|
||||||
private val state = ChatState()
|
private val state = ChatState()
|
||||||
|
|
||||||
@@ -70,9 +71,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), B
|
|||||||
val showCommandSuggestions: LiveData<Boolean> = state.showCommandSuggestions
|
val showCommandSuggestions: LiveData<Boolean> = state.showCommandSuggestions
|
||||||
val commandSuggestions: LiveData<List<CommandSuggestion>> = state.commandSuggestions
|
val commandSuggestions: LiveData<List<CommandSuggestion>> = state.commandSuggestions
|
||||||
val favoritePeers: LiveData<Set<String>> = state.favoritePeers
|
val favoritePeers: LiveData<Set<String>> = state.favoritePeers
|
||||||
|
val showAppInfo: LiveData<Boolean> = state.showAppInfo
|
||||||
|
|
||||||
init {
|
init {
|
||||||
meshService.delegate = this
|
// Note: Mesh service delegate is now set by MainActivity
|
||||||
loadAndInitialize()
|
loadAndInitialize()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,8 +102,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), B
|
|||||||
state.setFavoritePeers(dataManager.favoritePeers)
|
state.setFavoritePeers(dataManager.favoritePeers)
|
||||||
dataManager.loadBlockedUsers()
|
dataManager.loadBlockedUsers()
|
||||||
|
|
||||||
// Start mesh service
|
// Log all favorites at startup
|
||||||
meshService.startServices()
|
dataManager.logAllFavorites()
|
||||||
|
logCurrentFavoriteState()
|
||||||
|
|
||||||
|
// Note: Mesh service is now started by MainActivity
|
||||||
|
|
||||||
// Show welcome message if no peers after delay
|
// Show welcome message if no peers after delay
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -120,7 +125,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), B
|
|||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
meshService.stopServices()
|
// Note: Mesh service lifecycle is now managed by MainActivity
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Nickname Management
|
// MARK: - Nickname Management
|
||||||
@@ -250,11 +255,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), B
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun toggleFavorite(peerID: String) {
|
fun toggleFavorite(peerID: String) {
|
||||||
|
Log.d("ChatViewModel", "toggleFavorite called for peerID: $peerID")
|
||||||
privateChatManager.toggleFavorite(peerID)
|
privateChatManager.toggleFavorite(peerID)
|
||||||
|
|
||||||
|
// Log current state after toggle
|
||||||
|
logCurrentFavoriteState()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun registerPeerPublicKey(peerID: String, publicKeyData: ByteArray) {
|
private fun logCurrentFavoriteState() {
|
||||||
privateChatManager.registerPeerPublicKey(peerID, publicKeyData)
|
Log.i("ChatViewModel", "=== CURRENT FAVORITE STATE ===")
|
||||||
|
Log.i("ChatViewModel", "LiveData favorite peers: ${favoritePeers.value}")
|
||||||
|
Log.i("ChatViewModel", "DataManager favorite peers: ${dataManager.favoritePeers}")
|
||||||
|
Log.i("ChatViewModel", "Peer fingerprints: ${privateChatManager.getAllPeerFingerprints()}")
|
||||||
|
Log.i("ChatViewModel", "==============================")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Debug and Troubleshooting
|
// MARK: - Debug and Troubleshooting
|
||||||
@@ -263,18 +276,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), B
|
|||||||
return meshService.getDebugStatus()
|
return meshService.getDebugStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun restartMeshServices() {
|
// Note: Mesh service restart is now handled by MainActivity
|
||||||
viewModelScope.launch {
|
// This function is no longer needed
|
||||||
meshService.stopServices()
|
|
||||||
delay(1000)
|
|
||||||
meshService.startServices()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setAppBackgroundState(inBackground: Boolean) {
|
fun setAppBackgroundState(inBackground: Boolean) {
|
||||||
// Forward to connection manager for power optimization
|
|
||||||
meshService.connectionManager.setAppBackgroundState(inBackground)
|
|
||||||
|
|
||||||
// Forward to notification manager for notification logic
|
// Forward to notification manager for notification logic
|
||||||
notificationManager.setAppBackgroundState(inBackground)
|
notificationManager.setAppBackgroundState(inBackground)
|
||||||
}
|
}
|
||||||
@@ -341,6 +346,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), B
|
|||||||
return meshDelegateHandler.isFavorite(peerID)
|
return meshDelegateHandler.isFavorite(peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun registerPeerPublicKey(peerID: String, publicKeyData: ByteArray) {
|
||||||
|
privateChatManager.registerPeerPublicKey(peerID, publicKeyData)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Emergency Clear
|
// MARK: - Emergency Clear
|
||||||
|
|
||||||
fun panicClearAllData() {
|
fun panicClearAllData() {
|
||||||
@@ -355,13 +364,62 @@ class ChatViewModel(application: Application) : AndroidViewModel(application), B
|
|||||||
state.setNickname(newNickname)
|
state.setNickname(newNickname)
|
||||||
dataManager.saveNickname(newNickname)
|
dataManager.saveNickname(newNickname)
|
||||||
|
|
||||||
// Disconnect from mesh
|
// Note: Mesh service restart is now handled by MainActivity
|
||||||
meshService.stopServices()
|
// This method now only clears data, not mesh service lifecycle
|
||||||
|
}
|
||||||
|
|
||||||
// Restart services with new identity
|
// MARK: - Navigation Management
|
||||||
viewModelScope.launch {
|
|
||||||
delay(500)
|
fun showAppInfo() {
|
||||||
meshService.startServices()
|
state.setShowAppInfo(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hideAppInfo() {
|
||||||
|
state.setShowAppInfo(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showSidebar() {
|
||||||
|
state.setShowSidebar(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hideSidebar() {
|
||||||
|
state.setShowSidebar(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Android back navigation
|
||||||
|
* Returns true if the back press was handled, false if it should be passed to the system
|
||||||
|
*/
|
||||||
|
fun handleBackPressed(): Boolean {
|
||||||
|
return when {
|
||||||
|
// Close app info dialog
|
||||||
|
state.getShowAppInfoValue() -> {
|
||||||
|
hideAppInfo()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
// Close sidebar
|
||||||
|
state.getShowSidebarValue() -> {
|
||||||
|
hideSidebar()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
// Close password dialog
|
||||||
|
state.getShowPasswordPromptValue() -> {
|
||||||
|
state.setShowPasswordPrompt(false)
|
||||||
|
state.setPasswordPromptChannel(null)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
// Exit private chat
|
||||||
|
state.getSelectedPrivateChatPeerValue() != null -> {
|
||||||
|
endPrivateChat()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
// Exit channel view
|
||||||
|
state.getCurrentChannelValue() != null -> {
|
||||||
|
switchToChannel(null)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
// No special navigation state - let system handle (usually exits app)
|
||||||
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,8 +37,9 @@ class CommandProcessor(
|
|||||||
when (cmd) {
|
when (cmd) {
|
||||||
"/j", "/join" -> handleJoinCommand(parts, myPeerID)
|
"/j", "/join" -> handleJoinCommand(parts, myPeerID)
|
||||||
"/m", "/msg" -> handleMessageCommand(parts, meshService)
|
"/m", "/msg" -> handleMessageCommand(parts, meshService)
|
||||||
"/w" -> handleWhoCommand()
|
"/w" -> handleWhoCommand(meshService)
|
||||||
"/clear" -> handleClearCommand()
|
"/clear" -> handleClearCommand()
|
||||||
|
"/pass" -> handlePassCommand(parts, myPeerID)
|
||||||
"/block" -> handleBlockCommand(parts, meshService)
|
"/block" -> handleBlockCommand(parts, meshService)
|
||||||
"/unblock" -> handleUnblockCommand(parts, meshService)
|
"/unblock" -> handleUnblockCommand(parts, meshService)
|
||||||
"/hug" -> handleActionCommand(parts, "gives", "a warm hug 🫂", meshService, myPeerID, onSendMessage)
|
"/hug" -> handleActionCommand(parts, "gives", "a warm hug 🫂", meshService, myPeerID, onSendMessage)
|
||||||
@@ -54,7 +55,8 @@ class CommandProcessor(
|
|||||||
if (parts.size > 1) {
|
if (parts.size > 1) {
|
||||||
val channelName = parts[1]
|
val channelName = parts[1]
|
||||||
val channel = if (channelName.startsWith("#")) channelName else "#$channelName"
|
val channel = if (channelName.startsWith("#")) channelName else "#$channelName"
|
||||||
val success = channelManager.joinChannel(channel, null, myPeerID)
|
val password = if (parts.size > 2) parts[2] else null
|
||||||
|
val success = channelManager.joinChannel(channel, password, myPeerID)
|
||||||
if (success) {
|
if (success) {
|
||||||
val systemMessage = BitchatMessage(
|
val systemMessage = BitchatMessage(
|
||||||
sender = "system",
|
sender = "system",
|
||||||
@@ -127,11 +129,11 @@ class CommandProcessor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleWhoCommand() {
|
private fun handleWhoCommand(meshService: Any) {
|
||||||
val connectedPeers = state.getConnectedPeersValue()
|
val connectedPeers = state.getConnectedPeersValue()
|
||||||
val peerList = connectedPeers.joinToString(", ") { peerID ->
|
val peerList = connectedPeers.joinToString(", ") { peerID ->
|
||||||
// This would need mesh service access for nicknames
|
// Convert peerID to nickname using the mesh service
|
||||||
peerID // For now just use peer ID
|
getPeerNickname(peerID, meshService)
|
||||||
}
|
}
|
||||||
|
|
||||||
val systemMessage = BitchatMessage(
|
val systemMessage = BitchatMessage(
|
||||||
@@ -166,6 +168,52 @@ class CommandProcessor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handlePassCommand(parts: List<String>, peerID: String) {
|
||||||
|
val currentChannel = state.getCurrentChannelValue()
|
||||||
|
|
||||||
|
if (currentChannel == null) {
|
||||||
|
val systemMessage = BitchatMessage(
|
||||||
|
sender = "system",
|
||||||
|
content = "you must be in a channel to set a password.",
|
||||||
|
timestamp = Date(),
|
||||||
|
isRelay = false
|
||||||
|
)
|
||||||
|
messageManager.addMessage(systemMessage)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.size == 2){
|
||||||
|
if(!channelManager.isChannelCreator(channel = currentChannel, peerID = peerID)){
|
||||||
|
val systemMessage = BitchatMessage(
|
||||||
|
sender = "system",
|
||||||
|
content = "you must be the channel creator to set a password.",
|
||||||
|
timestamp = Date(),
|
||||||
|
isRelay = false
|
||||||
|
)
|
||||||
|
channelManager.addChannelMessage(currentChannel,systemMessage,null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val newPassword = parts[1]
|
||||||
|
channelManager.setChannelPassword(currentChannel, newPassword)
|
||||||
|
val systemMessage = BitchatMessage(
|
||||||
|
sender = "system",
|
||||||
|
content = "password changed for channel $currentChannel",
|
||||||
|
timestamp = Date(),
|
||||||
|
isRelay = false
|
||||||
|
)
|
||||||
|
channelManager.addChannelMessage(currentChannel,systemMessage,null)
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
val systemMessage = BitchatMessage(
|
||||||
|
sender = "system",
|
||||||
|
content = "usage: /pass <password>",
|
||||||
|
timestamp = Date(),
|
||||||
|
isRelay = false
|
||||||
|
)
|
||||||
|
channelManager.addChannelMessage(currentChannel,systemMessage,null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleBlockCommand(parts: List<String>, meshService: Any) {
|
private fun handleBlockCommand(parts: List<String>, meshService: Any) {
|
||||||
if (parts.size > 1) {
|
if (parts.size > 1) {
|
||||||
val targetName = parts[1].removePrefix("@")
|
val targetName = parts[1].removePrefix("@")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.bitchat.android.ui
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
|
import android.util.Log
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
@@ -10,6 +11,10 @@ import kotlin.random.Random
|
|||||||
*/
|
*/
|
||||||
class DataManager(private val context: Context) {
|
class DataManager(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "DataManager"
|
||||||
|
}
|
||||||
|
|
||||||
private val prefs: SharedPreferences = context.getSharedPreferences("bitchat_prefs", Context.MODE_PRIVATE)
|
private val prefs: SharedPreferences = context.getSharedPreferences("bitchat_prefs", Context.MODE_PRIVATE)
|
||||||
private val gson = Gson()
|
private val gson = Gson()
|
||||||
|
|
||||||
@@ -126,24 +131,41 @@ class DataManager(private val context: Context) {
|
|||||||
fun loadFavorites() {
|
fun loadFavorites() {
|
||||||
val savedFavorites = prefs.getStringSet("favorites", emptySet()) ?: emptySet()
|
val savedFavorites = prefs.getStringSet("favorites", emptySet()) ?: emptySet()
|
||||||
_favoritePeers.addAll(savedFavorites)
|
_favoritePeers.addAll(savedFavorites)
|
||||||
|
Log.d(TAG, "Loaded ${savedFavorites.size} favorite users from storage: $savedFavorites")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveFavorites() {
|
fun saveFavorites() {
|
||||||
prefs.edit().putStringSet("favorites", _favoritePeers).apply()
|
prefs.edit().putStringSet("favorites", _favoritePeers).apply()
|
||||||
|
Log.d(TAG, "Saved ${_favoritePeers.size} favorite users to storage: $_favoritePeers")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun addFavorite(fingerprint: String) {
|
fun addFavorite(fingerprint: String) {
|
||||||
_favoritePeers.add(fingerprint)
|
val wasAdded = _favoritePeers.add(fingerprint)
|
||||||
|
Log.d(TAG, "addFavorite: fingerprint=$fingerprint, wasAdded=$wasAdded")
|
||||||
saveFavorites()
|
saveFavorites()
|
||||||
|
logAllFavorites()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeFavorite(fingerprint: String) {
|
fun removeFavorite(fingerprint: String) {
|
||||||
_favoritePeers.remove(fingerprint)
|
val wasRemoved = _favoritePeers.remove(fingerprint)
|
||||||
|
Log.d(TAG, "removeFavorite: fingerprint=$fingerprint, wasRemoved=$wasRemoved")
|
||||||
saveFavorites()
|
saveFavorites()
|
||||||
|
logAllFavorites()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isFavorite(fingerprint: String): Boolean {
|
fun isFavorite(fingerprint: String): Boolean {
|
||||||
return _favoritePeers.contains(fingerprint)
|
val result = _favoritePeers.contains(fingerprint)
|
||||||
|
Log.d(TAG, "isFavorite check: fingerprint=$fingerprint, result=$result")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logAllFavorites() {
|
||||||
|
Log.i(TAG, "=== ALL FAVORITE USERS ===")
|
||||||
|
Log.i(TAG, "Total favorites: ${_favoritePeers.size}")
|
||||||
|
_favoritePeers.forEach { fingerprint ->
|
||||||
|
Log.i(TAG, "Favorite fingerprint: $fingerprint")
|
||||||
|
}
|
||||||
|
Log.i(TAG, "========================")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Blocked Users Management
|
// MARK: - Blocked Users Management
|
||||||
|
|||||||
@@ -44,20 +44,17 @@ fun MessageInput(
|
|||||||
modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp), // Reduced padding
|
modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp), // Reduced padding
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
// Fixed: Remove arrow from private message input
|
// Remove arrow from both private and channel inputs to match DM style
|
||||||
Text(
|
Text(
|
||||||
text = when {
|
text = "<@$nickname>", // No arrow for both private and channel
|
||||||
selectedPrivatePeer != null -> "<@$nickname>" // Removed arrow for private
|
|
||||||
currentChannel != null -> "<@$nickname> →" // Keep arrow for channels
|
|
||||||
else -> "<@$nickname>"
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Medium),
|
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Medium),
|
||||||
color = when {
|
color = when {
|
||||||
selectedPrivatePeer != null -> Color(0xFFFF8C00) // Orange for private
|
selectedPrivatePeer != null -> Color(0xFFFF9500) // Orange for private
|
||||||
currentChannel != null -> Color(0xFFFF8C00) // Orange if encrypted channel
|
currentChannel != null -> Color(0xFFFF9500) // Orange for channels too
|
||||||
else -> colorScheme.primary
|
else -> colorScheme.primary
|
||||||
},
|
},
|
||||||
fontFamily = FontFamily.Monospace
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 14.sp
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
@@ -78,7 +75,7 @@ fun MessageInput(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.width(8.dp)) // Reduced spacing
|
Spacer(modifier = Modifier.width(8.dp)) // Reduced spacing
|
||||||
|
|
||||||
// Fixed: Make send button orange in private mode to match nickname color
|
// Update send button to match input field colors
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = onSend,
|
onClick = onSend,
|
||||||
modifier = Modifier.size(32.dp)
|
modifier = Modifier.size(32.dp)
|
||||||
@@ -87,9 +84,9 @@ fun MessageInput(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(30.dp)
|
.size(30.dp)
|
||||||
.background(
|
.background(
|
||||||
color = if (selectedPrivatePeer != null) {
|
color = if (selectedPrivatePeer != null || currentChannel != null) {
|
||||||
// Orange for private messages to match nickname color
|
// Orange for both private messages and channels to match nickname color
|
||||||
Color(0xFFFF8C00).copy(alpha = 0.75f)
|
Color(0xFFFF9500).copy(alpha = 0.75f)
|
||||||
} else if (colorScheme.background == Color.Black) {
|
} else if (colorScheme.background == Color.Black) {
|
||||||
Color(0xFF00FF00).copy(alpha = 0.75f) // Bright green for dark theme
|
Color(0xFF00FF00).copy(alpha = 0.75f) // Bright green for dark theme
|
||||||
} else {
|
} else {
|
||||||
@@ -100,11 +97,11 @@ fun MessageInput(
|
|||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.KeyboardArrowUp,
|
imageVector = Icons.Filled.KeyboardArrowRight,
|
||||||
contentDescription = "Send message",
|
contentDescription = "Send message",
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
tint = if (selectedPrivatePeer != null) {
|
tint = if (selectedPrivatePeer != null || currentChannel != null) {
|
||||||
// Black arrow on orange in private mode
|
// Black arrow on orange for both private and channel modes
|
||||||
Color.Black
|
Color.Black
|
||||||
} else if (colorScheme.background == Color.Black) {
|
} else if (colorScheme.background == Color.Black) {
|
||||||
Color.Black // Black arrow on bright green in dark theme
|
Color.Black // Black arrow on bright green in dark theme
|
||||||
|
|||||||
@@ -154,4 +154,8 @@ class MeshDelegateHandler(
|
|||||||
override fun isFavorite(peerID: String): Boolean {
|
override fun isFavorite(peerID: String): Boolean {
|
||||||
return privateChatManager.isFavorite(peerID)
|
return privateChatManager.isFavorite(peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun registerPeerPublicKey(peerID: String, publicKeyData: ByteArray) {
|
||||||
|
privateChatManager.registerPeerPublicKey(peerID, publicKeyData)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.bitchat.android.ui
|
|||||||
import com.bitchat.android.model.BitchatMessage
|
import com.bitchat.android.model.BitchatMessage
|
||||||
import com.bitchat.android.model.DeliveryStatus
|
import com.bitchat.android.model.DeliveryStatus
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles private chat functionality including peer management and blocking
|
* Handles private chat functionality including peer management and blocking
|
||||||
@@ -13,6 +14,10 @@ class PrivateChatManager(
|
|||||||
private val dataManager: DataManager
|
private val dataManager: DataManager
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "PrivateChatManager"
|
||||||
|
}
|
||||||
|
|
||||||
// Peer identification mapping
|
// Peer identification mapping
|
||||||
private val peerIDToPublicKeyFingerprint = mutableMapOf<String, String>()
|
private val peerIDToPublicKeyFingerprint = mutableMapOf<String, String>()
|
||||||
|
|
||||||
@@ -99,17 +104,43 @@ class PrivateChatManager(
|
|||||||
fun toggleFavorite(peerID: String) {
|
fun toggleFavorite(peerID: String) {
|
||||||
val fingerprint = peerIDToPublicKeyFingerprint[peerID] ?: return
|
val fingerprint = peerIDToPublicKeyFingerprint[peerID] ?: return
|
||||||
|
|
||||||
if (dataManager.isFavorite(fingerprint)) {
|
Log.d(TAG, "toggleFavorite called for peerID: $peerID, fingerprint: $fingerprint")
|
||||||
|
|
||||||
|
val wasFavorite = dataManager.isFavorite(fingerprint)
|
||||||
|
Log.d(TAG, "Current favorite status: $wasFavorite")
|
||||||
|
|
||||||
|
val currentFavorites = state.getFavoritePeersValue()
|
||||||
|
Log.d(TAG, "Current UI state favorites: $currentFavorites")
|
||||||
|
|
||||||
|
if (wasFavorite) {
|
||||||
dataManager.removeFavorite(fingerprint)
|
dataManager.removeFavorite(fingerprint)
|
||||||
|
Log.d(TAG, "Removed from favorites: $fingerprint")
|
||||||
} else {
|
} else {
|
||||||
dataManager.addFavorite(fingerprint)
|
dataManager.addFavorite(fingerprint)
|
||||||
|
Log.d(TAG, "Added to favorites: $fingerprint")
|
||||||
}
|
}
|
||||||
state.setFavoritePeers(dataManager.favoritePeers)
|
|
||||||
|
// Always update state to trigger UI refresh - create new set to ensure change detection
|
||||||
|
val newFavorites = dataManager.favoritePeers.toSet()
|
||||||
|
state.setFavoritePeers(newFavorites)
|
||||||
|
|
||||||
|
Log.d(TAG, "Force updated favorite peers state. New favorites: $newFavorites")
|
||||||
|
Log.d(TAG, "All peer fingerprints: $peerIDToPublicKeyFingerprint")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isFavorite(peerID: String): Boolean {
|
fun isFavorite(peerID: String): Boolean {
|
||||||
val fingerprint = peerIDToPublicKeyFingerprint[peerID] ?: return false
|
val fingerprint = peerIDToPublicKeyFingerprint[peerID] ?: return false
|
||||||
return dataManager.isFavorite(fingerprint)
|
val isFav = dataManager.isFavorite(fingerprint)
|
||||||
|
Log.d(TAG, "isFavorite check: peerID=$peerID, fingerprint=$fingerprint, result=$isFav")
|
||||||
|
return isFav
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPeerFingerprint(peerID: String): String? {
|
||||||
|
return peerIDToPublicKeyFingerprint[peerID]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPeerFingerprints(): Map<String, String> {
|
||||||
|
return peerIDToPublicKeyFingerprint.toMap()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Block/Unblock Operations
|
// MARK: - Block/Unblock Operations
|
||||||
@@ -262,10 +293,6 @@ class PrivateChatManager(
|
|||||||
|
|
||||||
// MARK: - Public Getters
|
// MARK: - Public Getters
|
||||||
|
|
||||||
fun getPeerFingerprint(peerID: String): String? {
|
|
||||||
return peerIDToPublicKeyFingerprint[peerID]
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getAllPeerFingerprints(): Map<String, String> {
|
fun getAllPeerFingerprints(): Map<String, String> {
|
||||||
return peerIDToPublicKeyFingerprint.toMap()
|
return peerIDToPublicKeyFingerprint.toMap()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.bitchat.android.ui
|
package com.bitchat.android.ui
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import androidx.compose.animation.*
|
import androidx.compose.animation.*
|
||||||
import androidx.compose.animation.core.*
|
import androidx.compose.animation.core.*
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
@@ -193,7 +194,7 @@ fun ChannelsSection(
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "#$channel",
|
text = channel, // Channel already contains the # prefix
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = if (isSelected) colorScheme.primary else colorScheme.onSurface,
|
color = if (isSelected) colorScheme.primary else colorScheme.onSurface,
|
||||||
fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal,
|
fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal,
|
||||||
@@ -263,28 +264,40 @@ fun PeopleSection(
|
|||||||
val privateChats by viewModel.privateChats.observeAsState(emptyMap())
|
val privateChats by viewModel.privateChats.observeAsState(emptyMap())
|
||||||
val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet())
|
val favoritePeers by viewModel.favoritePeers.observeAsState(emptySet())
|
||||||
|
|
||||||
|
// Pre-calculate all favorite states to ensure proper state synchronization
|
||||||
|
val peerFavoriteStates = remember(favoritePeers, connectedPeers) {
|
||||||
|
connectedPeers.associateWith { peerID ->
|
||||||
|
val fingerprint = viewModel.privateChatManager.getPeerFingerprint(peerID)
|
||||||
|
favoritePeers.contains(fingerprint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d("SidebarComponents", "Recomposing with ${favoritePeers.size} favorites, peer states: $peerFavoriteStates")
|
||||||
|
|
||||||
// Smart sorting: unread DMs first, then by most recent DM, then favorites, then alphabetical
|
// Smart sorting: unread DMs first, then by most recent DM, then favorites, then alphabetical
|
||||||
val sortedPeers = connectedPeers.sortedWith(
|
val sortedPeers = connectedPeers.sortedWith(
|
||||||
compareBy<String> { !hasUnreadPrivateMessages.contains(it) } // Unread DM senders first
|
compareBy<String> { !hasUnreadPrivateMessages.contains(it) } // Unread DM senders first
|
||||||
.thenByDescending { privateChats[it]?.maxByOrNull { msg -> msg.timestamp }?.timestamp?.time ?: 0L } // Most recent DM (convert Date to Long)
|
.thenByDescending { privateChats[it]?.maxByOrNull { msg -> msg.timestamp }?.timestamp?.time ?: 0L } // Most recent DM (convert Date to Long)
|
||||||
.thenBy {
|
.thenBy { !(peerFavoriteStates[it] ?: false) } // Favorites first
|
||||||
val fingerprint = viewModel.privateChatManager.getPeerFingerprint(it)
|
|
||||||
fingerprint == null || !favoritePeers.contains(fingerprint)
|
|
||||||
} // Favorites
|
|
||||||
.thenBy { (if (it == nickname) "You" else (peerNicknames[it] ?: it)).lowercase() } // Alphabetical
|
.thenBy { (if (it == nickname) "You" else (peerNicknames[it] ?: it)).lowercase() } // Alphabetical
|
||||||
)
|
)
|
||||||
|
|
||||||
sortedPeers.forEach { peerID ->
|
sortedPeers.forEach { peerID ->
|
||||||
|
val isFavorite = peerFavoriteStates[peerID] ?: false
|
||||||
|
|
||||||
PeerItem(
|
PeerItem(
|
||||||
peerID = peerID,
|
peerID = peerID,
|
||||||
displayName = if (peerID == nickname) "You" else (peerNicknames[peerID] ?: peerID),
|
displayName = if (peerID == nickname) "You" else (peerNicknames[peerID] ?: peerID),
|
||||||
signalStrength = peerRSSI[peerID] ?: 0,
|
signalStrength = peerRSSI[peerID] ?: 0,
|
||||||
isSelected = peerID == selectedPrivatePeer,
|
isSelected = peerID == selectedPrivatePeer,
|
||||||
isFavorite = favoritePeers.contains(viewModel.privateChatManager.getPeerFingerprint(peerID)),
|
isFavorite = isFavorite,
|
||||||
hasUnreadDM = hasUnreadPrivateMessages.contains(peerID),
|
hasUnreadDM = hasUnreadPrivateMessages.contains(peerID),
|
||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
onItemClick = { onPrivateChatStart(peerID) },
|
onItemClick = { onPrivateChatStart(peerID) },
|
||||||
onToggleFavorite = { viewModel.toggleFavorite(peerID) }
|
onToggleFavorite = {
|
||||||
|
Log.d("SidebarComponents", "Sidebar toggle favorite: peerID=$peerID, currentFavorite=$isFavorite")
|
||||||
|
viewModel.toggleFavorite(peerID)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -320,7 +333,7 @@ private fun PeerItem(
|
|||||||
imageVector = Icons.Filled.Email,
|
imageVector = Icons.Filled.Email,
|
||||||
contentDescription = "Unread messages",
|
contentDescription = "Unread messages",
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
tint = Color(0xFFFF8C00) // Orange to match private message theme
|
tint = Color(0xFFFF9500) // Orange to match private message theme
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Signal strength indicators
|
// Signal strength indicators
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:viewportWidth="135.4667"
|
||||||
|
android:viewportHeight="135.4667"
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:height="108dp"
|
android:height="108dp">
|
||||||
android:viewportWidth="108"
|
|
||||||
android:viewportHeight="108">
|
|
||||||
<path
|
<path
|
||||||
android:fillColor="#2C2C2E"
|
android:pathData="M-0.2617076 -0.1145289H135.7284V135.5812H-0.2617076V-0.1145289Z"
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
android:fillColor="#000000" />
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
@@ -1,51 +1,15 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:viewportWidth="135.4667"
|
||||||
|
android:viewportHeight="135.4667"
|
||||||
android:width="108dp"
|
android:width="108dp"
|
||||||
android:height="108dp"
|
android:height="108dp">
|
||||||
android:viewportWidth="108"
|
<group
|
||||||
android:viewportHeight="108">
|
android:scaleX="0.2645833"
|
||||||
<!-- Centered chat terminal window - safe zone is 66dp circle centered at 54,54 -->
|
android:scaleY="0.2645833"
|
||||||
<!-- Outer terminal border (28dp wide, 18dp tall, centered) -->
|
android:translateX="-1.0877"
|
||||||
<path
|
android:translateY="47.11238">
|
||||||
android:fillColor="#00FF00"
|
<path
|
||||||
android:pathData="M40,45h28c2.2,0 4,1.8 4,4v10c0,2.2 -1.8,4 -4,4H40c-2.2,0 -4,-1.8 -4,-4V49C36,46.8 37.8,45 40,45Z"
|
android:pathData="M187.45493 73.477844q9.17966 2.148433 14.38798 8.333313 5.20832 6.18488 5.20832 14.713504 0 12.499969 -9.11456 19.921819 -9.04945 7.42186 -21.80984 7.42186h-26.1067V32.006596h25.45566q13.28122 0 20.76818 5.85936 7.48696 5.794256 7.48696 16.406208 0 6.5104 -4.36197 11.588512 -4.29686 5.078112 -11.91403 7.356752zM160.69718 68.52994h12.49997q19.59631 0 19.59631 -13.99736 0 -6.380192 -4.8177 -9.7656 -4.8177 -3.450512 -14.12757 -3.450512h-13.15101zm0 46.02853h12.10935q10.61195 0 16.79683 -4.8177 6.24998 -4.81769 6.24998 -13.216109 0 -8.919248 -6.11977 -13.802048 -6.11978 -4.8828 -17.70829 -4.8828h-11.3281zm116.14554 9.30987h-41.73166v-9.30987h15.82027V41.316468h-15.82027v-9.309872h41.73166v9.309872h-15.23433v73.242002h15.23433zM370.20186 40.665428H341.88162V123.86834H331.26967V40.665428h-28.45045v-8.658832h67.38264z"
|
||||||
android:strokeWidth="0" />
|
android:fillColor="#FFFFFF" />
|
||||||
|
</group>
|
||||||
<!-- Inner terminal background -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#1C1C1E"
|
|
||||||
android:pathData="M41,47h26c1.1,0 2,0.9 2,2v8c0,1.1 -0.9,2 -2,2H41c-1.1,0 -2,-0.9 -2,-2v-8C39,47.9 39.9,47 41,47Z"
|
|
||||||
android:strokeWidth="0" />
|
|
||||||
|
|
||||||
<!-- Terminal cursor block (centered left) -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#00FF00"
|
|
||||||
android:pathData="M43,51h3v4h-3z" />
|
|
||||||
|
|
||||||
<!-- Text indicators (representing chat messages) -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#00AA00"
|
|
||||||
android:pathData="M48,52h16v1.5h-16z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00AA00"
|
|
||||||
android:pathData="M48,54.5h12v1.5h-12z" />
|
|
||||||
|
|
||||||
<!-- Connection indicator dots (mesh network) - positioned in top right -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#00CC00"
|
|
||||||
android:pathData="M62,38m-1.5,0a1.5,1.5 0,1 1,3 0a1.5,1.5 0,1 1,-3 0" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00CC00"
|
|
||||||
android:pathData="M58.5,35m-1,0a1,1 0,1 1,2 0a1,1 0,1 1,-2 0" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00CC00"
|
|
||||||
android:pathData="M65.5,35m-1,0a1,1 0,1 1,2 0a1,1 0,1 1,-2 0" />
|
|
||||||
|
|
||||||
<!-- Additional connection lines to show mesh network -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#00AA00"
|
|
||||||
android:pathData="M59.5,36l3,2l3,-2"
|
|
||||||
android:strokeColor="#00AA00"
|
|
||||||
android:strokeWidth="0.5"
|
|
||||||
android:fillType="nonZero" />
|
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:name="vector"
|
||||||
|
android:viewportWidth="135.4667"
|
||||||
|
android:viewportHeight="135.4667"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp">
|
||||||
|
<group
|
||||||
|
android:scaleX="0.2645833"
|
||||||
|
android:scaleY="0.2645833"
|
||||||
|
android:translateX="-1.0877"
|
||||||
|
android:translateY="47.11238">
|
||||||
|
<path
|
||||||
|
android:pathData="M187.45493 73.477844q9.17966 2.148433 14.38798 8.333313 5.20832 6.18488 5.20832 14.713504 0 12.499969 -9.11456 19.921819 -9.04945 7.42186 -21.80984 7.42186h-26.1067V32.006596h25.45566q13.28122 0 20.76818 5.85936 7.48696 5.794256 7.48696 16.406208 0 6.5104 -4.36197 11.588512 -4.29686 5.078112 -11.91403 7.356752zM160.69718 68.52994h12.49997q19.59631 0 19.59631 -13.99736 0 -6.380192 -4.8177 -9.7656 -4.8177 -3.450512 -14.12757 -3.450512h-13.15101zm0 46.02853h12.10935q10.61195 0 16.79683 -4.8177 6.24998 -4.81769 6.24998 -13.216109 0 -8.919248 -6.11977 -13.802048 -6.11978 -4.8828 -17.70829 -4.8828h-11.3281zm116.14554 9.30987h-41.73166v-9.30987h15.82027V41.316468h-15.82027v-9.309872h41.73166v9.309872h-15.23433v73.242002h15.23433zM370.20186 40.665428H341.88162V123.86834H331.26967V40.665428h-28.45045v-8.658832h67.38264z"
|
||||||
|
android:fillColor="#000000" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 628 B After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 628 B |
|
Before Width: | Height: | Size: 765 B After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 765 B |
|
Before Width: | Height: | Size: 848 B After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 848 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,78 @@
|
|||||||
|
package com.bitchat
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a consistent color for a username based on their peer ID or nickname
|
||||||
|
* Returns colors that work well on both light and dark backgrounds
|
||||||
|
*/
|
||||||
|
|
||||||
|
class ColorTest {
|
||||||
|
fun getUsernameColor(identifier: String): Color {
|
||||||
|
// Hash the identifier to get a consistent number
|
||||||
|
val hash = identifier.hashCode().toUInt()
|
||||||
|
|
||||||
|
// Terminal-friendly colors that work on both black and white backgrounds
|
||||||
|
val colors = listOf(
|
||||||
|
Color(0xFF00FF00), // Bright Green
|
||||||
|
Color(0xFF00FFFF), // Cyan
|
||||||
|
Color(0xFFFFFF00), // Yellow
|
||||||
|
Color(0xFFFF00FF), // Magenta
|
||||||
|
Color(0xFF0080FF), // Bright Blue
|
||||||
|
Color(0xFFFF8000), // Orange
|
||||||
|
Color(0xFF80FF00), // Lime Green
|
||||||
|
Color(0xFF8000FF), // Purple
|
||||||
|
Color(0xFFFF0080), // Pink
|
||||||
|
Color(0xFF00FF80), // Spring Green
|
||||||
|
Color(0xFF80FFFF), // Light Cyan
|
||||||
|
Color(0xFFFF8080), // Light Red
|
||||||
|
Color(0xFF8080FF), // Light Blue
|
||||||
|
Color(0xFFFFFF80), // Light Yellow
|
||||||
|
Color(0xFFFF80FF), // Light Magenta
|
||||||
|
Color(0xFF80FF80), // Light Green
|
||||||
|
)
|
||||||
|
|
||||||
|
// Use modulo to get consistent color for same identifier
|
||||||
|
return colors[(hash % colors.size.toUInt()).toInt()]
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun is_username_derived_color_consistent() {
|
||||||
|
|
||||||
|
println("Testing username color function:")
|
||||||
|
|
||||||
|
val testUsers = listOf("alice", "bob", "charlie", "diana", "eve")
|
||||||
|
|
||||||
|
testUsers.forEach { user ->
|
||||||
|
val color = getUsernameColor(user)
|
||||||
|
println("User '$user' gets color: ${color.value.toString(16).uppercase()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val `alice'sColor` = getUsernameColor(testUsers[0])
|
||||||
|
val `bob'sColor` = getUsernameColor(testUsers[1])
|
||||||
|
val `charlie'sColor` = getUsernameColor(testUsers[2])
|
||||||
|
val `diana'sColor` = getUsernameColor(testUsers[3])
|
||||||
|
val `eve'sColor` = getUsernameColor(testUsers[4])
|
||||||
|
|
||||||
|
// Test consistency - same user should always get same color
|
||||||
|
println("\nTesting consistency:")
|
||||||
|
repeat(3) {
|
||||||
|
val `alice's_color` = getUsernameColor(testUsers[0])
|
||||||
|
val `bob's_color` = getUsernameColor(testUsers[1])
|
||||||
|
val `charlie's_color` = getUsernameColor(testUsers[2])
|
||||||
|
val `diana's_color` = getUsernameColor(testUsers[3])
|
||||||
|
val `eve's_color` = getUsernameColor(testUsers[4])
|
||||||
|
|
||||||
|
|
||||||
|
assertEquals(`alice'sColor`, `alice's_color`)
|
||||||
|
assertEquals(`bob'sColor`, `bob's_color`)
|
||||||
|
assertEquals(`charlie'sColor`, `charlie's_color`)
|
||||||
|
assertEquals(`diana'sColor`, `diana's_color`)
|
||||||
|
assertEquals(`eve'sColor`, `eve's_color`)
|
||||||
|
|
||||||
|
println("Alice color (test ${it + 1}): ${`alice'sColor`.value.toString(16).uppercase()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,4 +3,5 @@ plugins {
|
|||||||
alias(libs.plugins.android.application) apply false
|
alias(libs.plugins.android.application) apply false
|
||||||
alias(libs.plugins.kotlin.android) apply false
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
alias(libs.plugins.android.library) apply false
|
alias(libs.plugins.android.library) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
# Username Color Feature Demo
|
|
||||||
|
|
||||||
The bitchat Android app already has the username color feature fully implemented! Here's how it works:
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
1. **Your username remains green** - Uses `colorScheme.primary` (bright green in dark mode, dark green in light mode)
|
|
||||||
2. **Other users get unique colors** - Based on their peer ID using the `getUsernameColor()` function
|
|
||||||
3. **Colors are consistent** - Same user always gets the same color across sessions
|
|
||||||
4. **Terminal-friendly palette** - 16 colors that work on both black and white backgrounds
|
|
||||||
|
|
||||||
## Color Palette
|
|
||||||
|
|
||||||
The system uses these 16 terminal-friendly colors for other users:
|
|
||||||
|
|
||||||
- 🟢 Bright Green (#00FF00)
|
|
||||||
- 🔵 Cyan (#00FFFF)
|
|
||||||
- 🟡 Yellow (#FFFF00)
|
|
||||||
- 🔴 Magenta (#FF00FF)
|
|
||||||
- 🟦 Bright Blue (#0080FF)
|
|
||||||
- 🟠 Orange (#FF8000)
|
|
||||||
- 🔶 Lime Green (#80FF00)
|
|
||||||
- 🟣 Purple (#8000FF)
|
|
||||||
- 🩷 Pink (#FF0080)
|
|
||||||
- 💚 Spring Green (#00FF80)
|
|
||||||
- 🟦 Light Cyan (#80FFFF)
|
|
||||||
- 🩷 Light Red (#FF8080)
|
|
||||||
- 🟦 Light Blue (#8080FF)
|
|
||||||
- 🟡 Light Yellow (#FFFF80)
|
|
||||||
- 🩷 Light Magenta (#FF80FF)
|
|
||||||
- 🟢 Light Green (#80FF80)
|
|
||||||
|
|
||||||
## Example Chat Display
|
|
||||||
|
|
||||||
```
|
|
||||||
[14:23:45] <@you> hello everyone! ← Your message (green)
|
|
||||||
[14:23:47] <@alice> hey there! ← Alice (cyan)
|
|
||||||
[14:23:50] <@bob> how's it going? ← Bob (yellow)
|
|
||||||
[14:23:52] <@charlie> great to see you all ← Charlie (magenta)
|
|
||||||
[14:23:55] <@you> having a great time ← Your message (green)
|
|
||||||
[14:23:58] <@alice> same here @you! ← Alice (cyan again)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Code Implementation
|
|
||||||
|
|
||||||
The feature is implemented in `/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt`:
|
|
||||||
|
|
||||||
- Line 342-350: Color assignment logic in `formatMessageAsAnnotatedString()`
|
|
||||||
- Line 820-855: `getUsernameColor()` function that generates consistent colors
|
|
||||||
- Uses peer ID for consistency (falls back to nickname if no peer ID available)
|
|
||||||
- Integrates perfectly with the existing IRC-style chat format
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
The feature is already working in the app. When you chat with multiple users, you'll see:
|
|
||||||
- Your messages in green
|
|
||||||
- Each other user in their own unique color
|
|
||||||
- Same user always has the same color
|
|
||||||
- Colors remain consistent across app restarts
|
|
||||||
- Works in both light and dark themes
|
|
||||||
|
|
||||||
The feature is **complete and ready to use**! 🎉
|
|
||||||
@@ -1,36 +1,35 @@
|
|||||||
[versions]
|
[versions]
|
||||||
# Android and Kotlin
|
# Android and Kotlin
|
||||||
agp = "8.2.0"
|
agp = "8.10.1"
|
||||||
kotlin = "1.9.20"
|
kotlin = "2.2.0"
|
||||||
compileSdk = "34"
|
compileSdk = "35"
|
||||||
minSdk = "26" # API 26 for proper BLE support
|
minSdk = "26" # API 26 for proper BLE support
|
||||||
targetSdk = "34"
|
targetSdk = "34"
|
||||||
|
|
||||||
# AndroidX Core
|
# AndroidX Core
|
||||||
core-ktx = "1.12.0"
|
core-ktx = "1.16.0"
|
||||||
lifecycle-runtime = "2.7.0"
|
lifecycle-runtime = "2.9.1"
|
||||||
activity-compose = "1.8.2"
|
activity-compose = "1.10.1"
|
||||||
appcompat = "1.6.1"
|
appcompat = "1.7.1"
|
||||||
|
|
||||||
# Compose
|
# Compose
|
||||||
compose-bom = "2023.10.01"
|
compose-bom = "2025.06.01"
|
||||||
compose-compiler = "1.5.5"
|
|
||||||
|
|
||||||
# Navigation
|
# Navigation
|
||||||
navigation-compose = "2.7.6"
|
navigation-compose = "2.9.1"
|
||||||
|
|
||||||
# Accompanist
|
# Accompanist
|
||||||
accompanist-permissions = "0.32.0"
|
accompanist-permissions = "0.37.3"
|
||||||
|
|
||||||
# Cryptography
|
# Cryptography
|
||||||
bouncycastle = "1.70"
|
bouncycastle = "1.70"
|
||||||
tink-android = "1.10.0"
|
tink-android = "1.10.0"
|
||||||
|
|
||||||
# JSON
|
# JSON
|
||||||
gson = "2.10.1"
|
gson = "2.13.1"
|
||||||
|
|
||||||
# Coroutines
|
# Coroutines
|
||||||
kotlinx-coroutines = "1.7.3"
|
kotlinx-coroutines = "1.10.2"
|
||||||
|
|
||||||
# Bluetooth
|
# Bluetooth
|
||||||
nordic-ble = "2.6.1"
|
nordic-ble = "2.6.1"
|
||||||
@@ -39,12 +38,12 @@ nordic-ble = "2.6.1"
|
|||||||
lz4-java = "1.8.0"
|
lz4-java = "1.8.0"
|
||||||
|
|
||||||
# Security
|
# Security
|
||||||
security-crypto = "1.1.0-alpha06"
|
security-crypto = "1.1.0-beta01"
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
androidx-test-ext = "1.1.5"
|
androidx-test-ext = "1.2.1"
|
||||||
espresso = "3.5.1"
|
espresso = "3.6.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
# AndroidX Core
|
# AndroidX Core
|
||||||
@@ -104,6 +103,7 @@ android-application = { id = "com.android.application", version.ref = "agp" }
|
|||||||
android-library = { id = "com.android.library", version.ref = "agp" }
|
android-library = { id = "com.android.library", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
kotlin-parcelize = { id = "kotlin-parcelize" }
|
kotlin-parcelize = { id = "kotlin-parcelize" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
|
||||||
[bundles]
|
[bundles]
|
||||||
compose = [
|
compose = [
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
# BluetoothMeshService Refactoring Plan
|
|
||||||
|
|
||||||
## Current State
|
|
||||||
- Single file: `BluetoothMeshService.kt` (~1000+ lines)
|
|
||||||
- Multiple responsibilities mixed together
|
|
||||||
- Hard to test and maintain
|
|
||||||
|
|
||||||
## Proposed Structure
|
|
||||||
|
|
||||||
### 1. Core Service (BluetoothMeshService.kt)
|
|
||||||
**Responsibilities:**
|
|
||||||
- Service lifecycle management
|
|
||||||
- Coordination between components
|
|
||||||
- Public API for sending messages
|
|
||||||
- Delegate management
|
|
||||||
|
|
||||||
### 2. Connection Management (BluetoothConnectionManager.kt)
|
|
||||||
**Responsibilities:**
|
|
||||||
- BLE scanning and advertising
|
|
||||||
- GATT server/client setup and management
|
|
||||||
- Device connection tracking
|
|
||||||
- Peer discovery and RSSI tracking
|
|
||||||
|
|
||||||
### 3. Packet Processing (PacketProcessor.kt)
|
|
||||||
**Responsibilities:**
|
|
||||||
- Incoming packet handling
|
|
||||||
- Message type routing
|
|
||||||
- TTL and duplicate detection
|
|
||||||
- Timestamp validation
|
|
||||||
|
|
||||||
### 4. Message Handler (MessageHandler.kt)
|
|
||||||
**Responsibilities:**
|
|
||||||
- Processing specific message types (ANNOUNCE, MESSAGE, LEAVE, etc.)
|
|
||||||
- Message parsing and validation
|
|
||||||
- Relay logic
|
|
||||||
|
|
||||||
### 5. Fragment Manager (FragmentManager.kt)
|
|
||||||
**Responsibilities:**
|
|
||||||
- Message fragmentation for large messages
|
|
||||||
- Fragment reassembly
|
|
||||||
- Fragment cleanup and timeouts
|
|
||||||
|
|
||||||
### 6. Store-and-Forward Manager (StoreForwardManager.kt)
|
|
||||||
**Responsibilities:**
|
|
||||||
- Message caching for offline peers
|
|
||||||
- Delivering cached messages when peers come online
|
|
||||||
- Cache cleanup and management
|
|
||||||
|
|
||||||
### 7. Peer Manager (PeerManager.kt)
|
|
||||||
**Responsibilities:**
|
|
||||||
- Active peer tracking
|
|
||||||
- Peer nickname management
|
|
||||||
- Stale peer cleanup
|
|
||||||
- Peer list updates
|
|
||||||
|
|
||||||
### 8. Security Manager (SecurityManager.kt)
|
|
||||||
**Responsibilities:**
|
|
||||||
- Key exchange handling
|
|
||||||
- Message signing and verification
|
|
||||||
- Duplicate detection tracking
|
|
||||||
|
|
||||||
## Refactoring Strategy
|
|
||||||
1. Extract each component while maintaining exact functionality
|
|
||||||
2. Use dependency injection for component communication
|
|
||||||
3. Ensure all existing tests pass
|
|
||||||
4. Maintain the same public API
|
|
||||||
|
|
||||||
## Benefits
|
|
||||||
- Easier to test individual components
|
|
||||||
- Better separation of concerns
|
|
||||||
- More maintainable code
|
|
||||||
- Easier to add new features
|
|
||||||
- Better code reuse
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a consistent color for a username based on their peer ID or nickname
|
|
||||||
* Returns colors that work well on both light and dark backgrounds
|
|
||||||
*/
|
|
||||||
fun getUsernameColor(identifier: String): Color {
|
|
||||||
// Hash the identifier to get a consistent number
|
|
||||||
val hash = identifier.hashCode().toUInt()
|
|
||||||
|
|
||||||
// Terminal-friendly colors that work on both black and white backgrounds
|
|
||||||
val colors = listOf(
|
|
||||||
Color(0xFF00FF00), // Bright Green
|
|
||||||
Color(0xFF00FFFF), // Cyan
|
|
||||||
Color(0xFFFFFF00), // Yellow
|
|
||||||
Color(0xFFFF00FF), // Magenta
|
|
||||||
Color(0xFF0080FF), // Bright Blue
|
|
||||||
Color(0xFFFF8000), // Orange
|
|
||||||
Color(0xFF80FF00), // Lime Green
|
|
||||||
Color(0xFF8000FF), // Purple
|
|
||||||
Color(0xFFFF0080), // Pink
|
|
||||||
Color(0xFF00FF80), // Spring Green
|
|
||||||
Color(0xFF80FFFF), // Light Cyan
|
|
||||||
Color(0xFFFF8080), // Light Red
|
|
||||||
Color(0xFF8080FF), // Light Blue
|
|
||||||
Color(0xFFFFFF80), // Light Yellow
|
|
||||||
Color(0xFFFF80FF), // Light Magenta
|
|
||||||
Color(0xFF80FF80), // Light Green
|
|
||||||
)
|
|
||||||
|
|
||||||
// Use modulo to get consistent color for same identifier
|
|
||||||
return colors[(hash % colors.size.toUInt()).toInt()]
|
|
||||||
}
|
|
||||||
|
|
||||||
fun main() {
|
|
||||||
println("Testing username color function:")
|
|
||||||
|
|
||||||
val testUsers = listOf("alice", "bob", "charlie", "diana", "eve")
|
|
||||||
|
|
||||||
testUsers.forEach { user ->
|
|
||||||
val color = getUsernameColor(user)
|
|
||||||
println("User '$user' gets color: ${color.value.toString(16).uppercase()}")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test consistency - same user should always get same color
|
|
||||||
println("\nTesting consistency:")
|
|
||||||
repeat(3) {
|
|
||||||
val aliceColor = getUsernameColor("alice")
|
|
||||||
println("Alice color (test ${it + 1}): ${aliceColor.value.toString(16).uppercase()}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||