Merge branch 'main' into fix/android-ios-fragmentation-compatibility

This commit is contained in:
callebtc
2025-07-13 21:25:11 +02:00
committed by GitHub
68 changed files with 2102 additions and 969 deletions
+2 -2
View File
@@ -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
+3 -3
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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>)
+29
View File
@@ -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
+126
View File
@@ -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
-64
View File
@@ -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
+2 -2
View File
@@ -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
``` ```
-103
View File
@@ -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.
-80
View File
@@ -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.
+5 -6
View File
@@ -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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 628 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 628 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 765 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 765 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 B

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

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()}")
}
}
}
+1
View File
@@ -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
} }
-62
View File
@@ -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**! 🎉
+16 -16
View File
@@ -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 = [
-73
View File
@@ -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
-51
View File
@@ -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()}")
}
}