mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
security: stop logging Noise key material, reduce noisy logging app-wide (#775)
C1 from security review: SymmetricState/HandshakeState logged raw X25519 shared secrets, chaining keys, and handshake hashes in hex to logcat on every handshake, in release builds. A logcat transcript of a handshake allowed full session decryption. Both classes no longer log at all. Also reduces excessive logging across the app (~50% fewer log calls in the noisiest files): - NoiseSession emits one line per completed handshake; per-message encrypt/decrypt and per-handshake-step debug logs removed - Removes all content/key logging: decrypted DM content, file names, payload hex dumps, pubkeys, event IDs, lat/lon, peer IPs, arti log forwarding - Collapses multi-line banner/emoji log sequences into single factual lifecycle lines (connect/disconnect, relay/Tor state transitions) - Keeps security-relevant warnings (signature failures, replay detection, key mismatches, panic wipe) in compact form No logic changes. Includes the full security review report in docs/security-review-jul-27.md.
This commit is contained in:
parent
4a34408db9
commit
bc49c71ea0
@ -221,7 +221,6 @@ class MainActivity : OrientationAwareActivity() {
|
||||
onDispose {
|
||||
try {
|
||||
context.unregisterReceiver(receiver)
|
||||
Log.d("BluetoothStatusUI", "BroadcastReceiver unregistered")
|
||||
} catch (e: IllegalStateException) {
|
||||
Log.w("BluetoothStatusUI", "Receiver was not registered")
|
||||
}
|
||||
@ -354,7 +353,7 @@ class MainActivity : OrientationAwareActivity() {
|
||||
when (state) {
|
||||
OnboardingState.COMPLETE -> {
|
||||
// App is fully initialized, mesh service is running
|
||||
android.util.Log.d("MainActivity", "Onboarding completed - app ready")
|
||||
android.util.Log.i("MainActivity", "Onboarding completed - app ready")
|
||||
}
|
||||
OnboardingState.ERROR -> {
|
||||
android.util.Log.e("MainActivity", "Onboarding error state reached")
|
||||
@ -364,8 +363,6 @@ class MainActivity : OrientationAwareActivity() {
|
||||
}
|
||||
|
||||
private fun checkOnboardingStatus() {
|
||||
Log.d("MainActivity", "Checking onboarding status")
|
||||
|
||||
lifecycleScope.launch {
|
||||
// Small delay to show the checking state
|
||||
delay(500)
|
||||
@ -379,19 +376,15 @@ class MainActivity : OrientationAwareActivity() {
|
||||
* Check Bluetooth status and proceed with onboarding flow
|
||||
*/
|
||||
private fun checkBluetoothAndProceed() {
|
||||
// Log.d("MainActivity", "Checking Bluetooth status")
|
||||
|
||||
// Check if user has skipped Bluetooth check for this session
|
||||
if (mainViewModel.isBluetoothCheckSkipped.value) {
|
||||
Log.d("MainActivity", "Bluetooth check skipped by user, proceeding to location check")
|
||||
checkLocationAndProceed()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// For first-time users, skip Bluetooth check and go straight to permissions
|
||||
// We'll check Bluetooth after permissions are granted
|
||||
if (permissionManager.isFirstTimeLaunch()) {
|
||||
Log.d("MainActivity", "First-time launch, skipping Bluetooth check - will check after permissions")
|
||||
proceedWithPermissionCheck()
|
||||
return
|
||||
}
|
||||
@ -413,7 +406,6 @@ class MainActivity : OrientationAwareActivity() {
|
||||
}
|
||||
BluetoothStatus.DISABLED -> {
|
||||
// Show Bluetooth enable screen (should have permissions as existing user)
|
||||
Log.d("MainActivity", "Bluetooth disabled, showing enable screen")
|
||||
mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK)
|
||||
mainViewModel.updateBluetoothLoading(false)
|
||||
}
|
||||
@ -430,16 +422,12 @@ class MainActivity : OrientationAwareActivity() {
|
||||
* Proceed with permission checking
|
||||
*/
|
||||
private fun proceedWithPermissionCheck() {
|
||||
Log.d("MainActivity", "Proceeding with permission check")
|
||||
|
||||
lifecycleScope.launch {
|
||||
delay(200) // Small delay for smooth transition
|
||||
|
||||
|
||||
if (permissionManager.isFirstTimeLaunch()) {
|
||||
Log.d("MainActivity", "First time launch, showing permission explanation")
|
||||
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
|
||||
} else if (permissionManager.areRequiredPermissionsGranted()) {
|
||||
Log.d("MainActivity", "Existing user with required permissions")
|
||||
if (permissionManager.needsBackgroundLocationPermission() &&
|
||||
!permissionManager.isBackgroundLocationGranted() &&
|
||||
!com.bitchat.android.onboarding.BackgroundLocationPreferenceManager.isSkipped(this@MainActivity)
|
||||
@ -450,7 +438,6 @@ class MainActivity : OrientationAwareActivity() {
|
||||
initializeApp()
|
||||
}
|
||||
} else {
|
||||
Log.d("MainActivity", "Existing user missing permissions, showing explanation")
|
||||
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
|
||||
}
|
||||
}
|
||||
@ -460,7 +447,6 @@ class MainActivity : OrientationAwareActivity() {
|
||||
* Handle Bluetooth enabled callback
|
||||
*/
|
||||
private fun handleBluetoothEnabled() {
|
||||
Log.d("MainActivity", "Bluetooth enabled by user")
|
||||
mainViewModel.updateBluetoothLoading(false)
|
||||
mainViewModel.updateBluetoothStatus(BluetoothStatus.ENABLED)
|
||||
checkLocationAndProceed()
|
||||
@ -470,12 +456,9 @@ class MainActivity : OrientationAwareActivity() {
|
||||
* Check Location services status and proceed with onboarding flow
|
||||
*/
|
||||
private fun checkLocationAndProceed() {
|
||||
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()) {
|
||||
Log.d("MainActivity", "First-time launch, skipping location check - will check after permissions")
|
||||
proceedWithPermissionCheck()
|
||||
return
|
||||
}
|
||||
@ -491,7 +474,6 @@ class MainActivity : OrientationAwareActivity() {
|
||||
}
|
||||
LocationStatus.DISABLED -> {
|
||||
// Show location enable screen (should have permissions as existing user)
|
||||
Log.d("MainActivity", "Location services disabled, showing enable screen")
|
||||
mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK)
|
||||
mainViewModel.updateLocationLoading(false)
|
||||
}
|
||||
@ -508,7 +490,6 @@ class MainActivity : OrientationAwareActivity() {
|
||||
* Handle Location enabled callback
|
||||
*/
|
||||
private fun handleLocationEnabled() {
|
||||
Log.d("MainActivity", "Location services enabled by user")
|
||||
mainViewModel.updateLocationLoading(false)
|
||||
mainViewModel.updateLocationStatus(LocationStatus.ENABLED)
|
||||
// Ensure Wi-Fi Aware starts now that location is enabled
|
||||
@ -554,12 +535,10 @@ class MainActivity : OrientationAwareActivity() {
|
||||
message.contains("Permission") && permissionManager.isFirstTimeLaunch() -> {
|
||||
// During first-time onboarding, if Bluetooth enable fails due to permissions,
|
||||
// proceed to permission explanation screen where user will grant permissions first
|
||||
Log.d("MainActivity", "Bluetooth enable requires permissions, proceeding to permission explanation")
|
||||
proceedWithPermissionCheck()
|
||||
}
|
||||
message.contains("Permission") -> {
|
||||
// For existing users, redirect to permission explanation to grant missing permissions
|
||||
Log.d("MainActivity", "Bluetooth enable requires permissions, showing permission explanation")
|
||||
mainViewModel.updateOnboardingState(OnboardingState.PERMISSION_EXPLANATION)
|
||||
}
|
||||
else -> {
|
||||
@ -570,8 +549,6 @@ class MainActivity : OrientationAwareActivity() {
|
||||
}
|
||||
|
||||
private fun handleOnboardingComplete() {
|
||||
Log.d("MainActivity", "Onboarding completed, checking Bluetooth and Location before initializing app")
|
||||
|
||||
// After permissions are granted, re-check Bluetooth, Location, and Battery Optimization status
|
||||
val currentBluetoothStatus = bluetoothStatusManager.checkBluetoothStatus()
|
||||
val currentLocationStatus = locationStatusManager.checkLocationStatus()
|
||||
@ -585,28 +562,24 @@ class MainActivity : OrientationAwareActivity() {
|
||||
when {
|
||||
bleRequired2 && currentBluetoothStatus != BluetoothStatus.ENABLED -> {
|
||||
// Bluetooth still disabled, but now we have permissions to enable it
|
||||
Log.d("MainActivity", "Permissions granted, but Bluetooth still disabled. Showing Bluetooth enable screen.")
|
||||
mainViewModel.updateBluetoothStatus(currentBluetoothStatus)
|
||||
mainViewModel.updateOnboardingState(OnboardingState.BLUETOOTH_CHECK)
|
||||
mainViewModel.updateBluetoothLoading(false)
|
||||
}
|
||||
currentLocationStatus != LocationStatus.ENABLED -> {
|
||||
// Location services still disabled, but now we have permissions to enable it
|
||||
Log.d("MainActivity", "Permissions granted, but Location services still disabled. Showing Location enable screen.")
|
||||
mainViewModel.updateLocationStatus(currentLocationStatus)
|
||||
mainViewModel.updateOnboardingState(OnboardingState.LOCATION_CHECK)
|
||||
mainViewModel.updateLocationLoading(false)
|
||||
}
|
||||
currentBatteryOptimizationStatus == BatteryOptimizationStatus.ENABLED -> {
|
||||
// Battery optimization still enabled, show battery optimization screen
|
||||
android.util.Log.d("MainActivity", "Permissions granted, but battery optimization still enabled. Showing battery optimization screen.")
|
||||
mainViewModel.updateBatteryOptimizationStatus(currentBatteryOptimizationStatus)
|
||||
mainViewModel.updateOnboardingState(OnboardingState.BATTERY_OPTIMIZATION_CHECK)
|
||||
mainViewModel.updateBatteryOptimizationLoading(false)
|
||||
}
|
||||
else -> {
|
||||
// Both are enabled, proceed to app initialization
|
||||
Log.d("MainActivity", "Both Bluetooth and Location services are enabled, proceeding to initialization")
|
||||
mainViewModel.updateOnboardingState(OnboardingState.INITIALIZING)
|
||||
initializeApp()
|
||||
}
|
||||
@ -639,19 +612,15 @@ class MainActivity : OrientationAwareActivity() {
|
||||
* Check Battery Optimization status and proceed with onboarding flow
|
||||
*/
|
||||
private fun checkBatteryOptimizationAndProceed() {
|
||||
android.util.Log.d("MainActivity", "Checking battery optimization status")
|
||||
|
||||
// For first-time users, skip battery optimization check and go straight to permissions
|
||||
// We'll check battery optimization after permissions are granted
|
||||
if (permissionManager.isFirstTimeLaunch()) {
|
||||
android.util.Log.d("MainActivity", "First-time launch, skipping battery optimization check - will check after permissions")
|
||||
proceedWithPermissionCheck()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Check if user has previously skipped battery optimization
|
||||
if (BatteryOptimizationPreferenceManager.isSkipped(this)) {
|
||||
android.util.Log.d("MainActivity", "User previously skipped battery optimization, proceeding to permissions")
|
||||
proceedWithPermissionCheck()
|
||||
return
|
||||
}
|
||||
@ -672,7 +641,6 @@ class MainActivity : OrientationAwareActivity() {
|
||||
}
|
||||
BatteryOptimizationStatus.ENABLED -> {
|
||||
// Show battery optimization disable screen
|
||||
android.util.Log.d("MainActivity", "Battery optimization enabled, showing disable screen")
|
||||
mainViewModel.updateOnboardingState(OnboardingState.BATTERY_OPTIMIZATION_CHECK)
|
||||
mainViewModel.updateBatteryOptimizationLoading(false)
|
||||
}
|
||||
@ -683,7 +651,6 @@ class MainActivity : OrientationAwareActivity() {
|
||||
* Handle Battery Optimization disabled callback
|
||||
*/
|
||||
private fun handleBatteryOptimizationDisabled() {
|
||||
android.util.Log.d("MainActivity", "Battery optimization disabled by user")
|
||||
mainViewModel.updateBatteryOptimizationLoading(false)
|
||||
mainViewModel.updateBatteryOptimizationStatus(BatteryOptimizationStatus.DISABLED)
|
||||
proceedWithPermissionCheck()
|
||||
@ -707,19 +674,14 @@ class MainActivity : OrientationAwareActivity() {
|
||||
}
|
||||
|
||||
private fun initializeApp() {
|
||||
Log.d("MainActivity", "Starting app initialization")
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
// Initialize the app with a proper delay to ensure Bluetooth stack is ready
|
||||
// This solves the issue where app needs restart to work on first install
|
||||
delay(1000) // Give the system time to process permission grants
|
||||
|
||||
Log.d("MainActivity", "Permissions verified, initializing chat system")
|
||||
|
||||
|
||||
// Initialize PoW preferences early in the initialization process
|
||||
PoWPreferenceManager.init(this@MainActivity)
|
||||
Log.d("MainActivity", "PoW preferences initialized")
|
||||
|
||||
// Initialize Location Notes Manager (extracted to separate file)
|
||||
com.bitchat.android.nostr.LocationNotesInitializer.initialize(this@MainActivity)
|
||||
@ -736,16 +698,14 @@ class MainActivity : OrientationAwareActivity() {
|
||||
unifiedMeshService.delegate = chatViewModel
|
||||
unifiedMeshService.startServices()
|
||||
startMeshForegroundServiceBestEffort()
|
||||
|
||||
Log.d("MainActivity", "Mesh service started successfully")
|
||||
|
||||
|
||||
// Handle any notification intent
|
||||
handleNotificationIntent(intent)
|
||||
handleVerificationIntent(intent)
|
||||
|
||||
|
||||
// Small delay to ensure mesh service is fully initialized
|
||||
delay(500)
|
||||
Log.d("MainActivity", "App initialization complete")
|
||||
Log.i("MainActivity", "App initialization complete")
|
||||
mainViewModel.updateOnboardingState(OnboardingState.COMPLETE)
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainActivity", "Failed to initialize app", e)
|
||||
@ -902,7 +862,6 @@ class MainActivity : OrientationAwareActivity() {
|
||||
// Cleanup location status manager
|
||||
try {
|
||||
locationStatusManager.cleanup()
|
||||
Log.d("MainActivity", "Location status manager cleaned up successfully")
|
||||
} catch (e: Exception) {
|
||||
Log.w("MainActivity", "Error cleaning up location status manager: ${e.message}")
|
||||
}
|
||||
|
||||
@ -145,20 +145,16 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
* UNIFIED: Only requests location if location services are enabled by user
|
||||
*/
|
||||
fun enableLocationChannels() {
|
||||
Log.d(TAG, "enableLocationChannels() called")
|
||||
|
||||
if (!_locationServicesEnabled.value || !_systemLocationEnabled.value) {
|
||||
Log.w(TAG, "Location services disabled (app or system) - not requesting location")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (getCurrentPermissionStatus() == PermissionState.AUTHORIZED) {
|
||||
Log.d(TAG, "Permission authorized - requesting location")
|
||||
_permissionState.value = PermissionState.AUTHORIZED
|
||||
requestOneShotLocation()
|
||||
} else {
|
||||
Log.d(TAG, "Permission not granted")
|
||||
_permissionState.value = PermissionState.DENIED
|
||||
}
|
||||
}
|
||||
@ -177,8 +173,6 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
* Uses requestLocationUpdates for continuous updates, plus a one-shot to prime state immediately
|
||||
*/
|
||||
fun beginLiveRefresh(interval: Long = 5000L) {
|
||||
Log.d(TAG, "Beginning live refresh (continuous updates)")
|
||||
|
||||
if (_permissionState.value != PermissionState.AUTHORIZED) {
|
||||
Log.w(TAG, "Cannot start live refresh - permission not authorized")
|
||||
return
|
||||
@ -204,7 +198,6 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
* Stop periodic refreshes when selector UI is dismissed
|
||||
*/
|
||||
fun endLiveRefresh() {
|
||||
Log.d(TAG, "Ending live refresh")
|
||||
locationProvider.removeLocationUpdates(locationUpdateCallback)
|
||||
}
|
||||
|
||||
@ -231,7 +224,6 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
)
|
||||
val isTeleportedNow = currentGeohash != channel.channel.geohash
|
||||
_teleported.value = isTeleportedNow
|
||||
Log.d(TAG, "Teleported (immediate recompute): $isTeleportedNow (current: $currentGeohash, selected: ${channel.channel.geohash})")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -241,7 +233,6 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
* Set teleported status (for manual geohash teleportation)
|
||||
*/
|
||||
fun setTeleported(teleported: Boolean) {
|
||||
Log.d(TAG, "Setting teleported status: $teleported")
|
||||
_teleported.value = teleported
|
||||
}
|
||||
|
||||
@ -249,7 +240,6 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
* Enable location services (user-controlled toggle)
|
||||
*/
|
||||
fun enableLocationServices() {
|
||||
Log.d(TAG, "enableLocationServices() called by user")
|
||||
_locationServicesEnabled.value = true
|
||||
saveLocationServicesState(true)
|
||||
|
||||
@ -263,7 +253,6 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
* Disable location services (user-controlled toggle)
|
||||
*/
|
||||
fun disableLocationServices() {
|
||||
Log.d(TAG, "disableLocationServices() called by user")
|
||||
_locationServicesEnabled.value = false
|
||||
saveLocationServicesState(false)
|
||||
|
||||
@ -298,21 +287,17 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "Requesting one-shot location")
|
||||
// Set loading state initially
|
||||
_isLoadingLocation.value = true
|
||||
|
||||
|
||||
locationProvider.getLastKnownLocation { cached ->
|
||||
// If we have a cached location and it's reasonably recent (e.g. < 5 mins), use it
|
||||
// For now, we just use it if it exists, similar to previous logic
|
||||
if (cached != null) {
|
||||
Log.d(TAG, "Using last known location: ${cached.latitude}, ${cached.longitude}")
|
||||
onLocationUpdated(cached)
|
||||
} else {
|
||||
Log.d(TAG, "No last known location available, requesting fresh...")
|
||||
locationProvider.requestFreshLocation { fresh ->
|
||||
if (fresh != null) {
|
||||
Log.d(TAG, "Fresh location received: ${fresh.latitude}, ${fresh.longitude}")
|
||||
onLocationUpdated(fresh)
|
||||
} else {
|
||||
Log.w(TAG, "Failed to get fresh location")
|
||||
@ -348,7 +333,6 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
val newState = if (hasPermission) PermissionState.AUTHORIZED else PermissionState.DENIED
|
||||
|
||||
if (_permissionState.value != newState) {
|
||||
Log.d(TAG, "Permission state updated to: $newState")
|
||||
_permissionState.value = newState
|
||||
}
|
||||
|
||||
@ -356,11 +340,9 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
}
|
||||
|
||||
private fun computeChannels(location: Location) {
|
||||
Log.d(TAG, "Computing channels for location: ${location.latitude}, ${location.longitude}")
|
||||
|
||||
val levels = GeohashChannelLevel.allCases()
|
||||
val result = mutableListOf<GeohashChannel>()
|
||||
|
||||
|
||||
for (level in levels) {
|
||||
val geohash = Geohash.encode(
|
||||
latitude = location.latitude,
|
||||
@ -368,8 +350,6 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
precision = level.precision
|
||||
)
|
||||
result.add(GeohashChannel(level = level, geohash = geohash))
|
||||
|
||||
Log.v(TAG, "Generated ${level.displayName}: $geohash")
|
||||
}
|
||||
|
||||
_availableChannels.value = result
|
||||
@ -388,7 +368,6 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
)
|
||||
val isTeleported = currentGeohash != selectedChannelValue.channel.geohash
|
||||
_teleported.value = isTeleported
|
||||
Log.d(TAG, "Teleported status: $isTeleported (current: $currentGeohash, selected: ${selectedChannelValue.channel.geohash})")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -399,8 +378,6 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
|
||||
geocodingJob = scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Log.d(TAG, "Starting reverse geocoding")
|
||||
|
||||
val addresses = geocoderProvider.getFromLocation(location.latitude, location.longitude, 1)
|
||||
|
||||
if (!isActive) return@launch
|
||||
@ -408,7 +385,6 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
if (addresses.isNotEmpty()) {
|
||||
val address = addresses[0]
|
||||
val names = namesByLevel(address)
|
||||
Log.d(TAG, "Reverse geocoding result: $names")
|
||||
_locationNames.value = names
|
||||
} else {
|
||||
Log.w(TAG, "No reverse geocoding results")
|
||||
@ -482,7 +458,6 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
)
|
||||
}
|
||||
dataManager?.saveLastGeohashChannel(channelData)
|
||||
Log.d(TAG, "Saved channel selection: ${channel.displayName}")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to save channel selection: ${e.message}")
|
||||
}
|
||||
@ -499,13 +474,10 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
val channel = persisted?.toChannel()
|
||||
if (channel != null) {
|
||||
_selectedChannel.value = channel
|
||||
Log.d(TAG, "Restored persisted channel: ${channel.displayName}")
|
||||
} else {
|
||||
Log.d(TAG, "Could not restore persisted channel, defaulting to Mesh")
|
||||
_selectedChannel.value = ChannelID.Mesh
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "No persisted channel found, defaulting to Mesh")
|
||||
_selectedChannel.value = ChannelID.Mesh
|
||||
}
|
||||
} catch (e: JsonSyntaxException) {
|
||||
@ -540,7 +512,6 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
dataManager?.clearLastGeohashChannel()
|
||||
_selectedChannel.value = ChannelID.Mesh
|
||||
_teleported.value = false
|
||||
Log.d(TAG, "Cleared persisted channel selection")
|
||||
}
|
||||
|
||||
// MARK: - Location Services State Persistence
|
||||
@ -551,7 +522,6 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
private fun saveLocationServicesState(enabled: Boolean) {
|
||||
try {
|
||||
dataManager?.saveLocationServicesEnabled(enabled)
|
||||
Log.d(TAG, "Saved location services state: $enabled")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to save location services state: ${e.message}")
|
||||
}
|
||||
@ -564,7 +534,6 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
try {
|
||||
val enabled = dataManager?.isLocationServicesEnabled() ?: false
|
||||
_locationServicesEnabled.value = enabled
|
||||
Log.d(TAG, "Loaded location services state: $enabled")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load location services state: ${e.message}")
|
||||
_locationServicesEnabled.value = false
|
||||
@ -575,7 +544,6 @@ class LocationChannelManager private constructor(private val context: Context) {
|
||||
* Cleanup resources
|
||||
*/
|
||||
fun cleanup() {
|
||||
Log.d(TAG, "Cleaning up LocationChannelManager")
|
||||
endLiveRefresh()
|
||||
locationProvider.cancel()
|
||||
|
||||
|
||||
@ -48,7 +48,6 @@ class BluetoothConnectionManager(
|
||||
device: BluetoothDevice?,
|
||||
ingressLinkID: String
|
||||
) {
|
||||
Log.d(TAG, "onPacketReceived: Packet received from ${device?.address} ($peerID)")
|
||||
device?.let { bluetoothDevice ->
|
||||
// Get current RSSI for this device and update if available
|
||||
val currentRSSI = connectionTracker.getBestRSSI(bluetoothDevice.address)
|
||||
@ -183,10 +182,8 @@ class BluetoothConnectionManager(
|
||||
|
||||
toEvict.forEach { conn ->
|
||||
if (conn.isClient) {
|
||||
Log.d(TAG, "Evicting client ${conn.device.address}")
|
||||
try { conn.gatt?.disconnect() } catch (_: Exception) { }
|
||||
} else {
|
||||
Log.d(TAG, "Evicting server ${conn.device.address}")
|
||||
serverManager.disconnectDevice(conn.device)
|
||||
}
|
||||
}
|
||||
@ -200,8 +197,6 @@ class BluetoothConnectionManager(
|
||||
* Start all Bluetooth services with power optimization
|
||||
*/
|
||||
fun startServices(): Boolean {
|
||||
Log.i(TAG, "Starting power-optimized Bluetooth services...")
|
||||
|
||||
if (!isBleTransportEnabled()) {
|
||||
Log.i(TAG, "BLE transport disabled by debug settings; not starting Bluetooth services")
|
||||
disableTransport()
|
||||
@ -220,7 +215,6 @@ class BluetoothConnectionManager(
|
||||
|
||||
try {
|
||||
isActive = true
|
||||
Log.d(TAG, "ConnectionManager activated (permissions and adapter OK)")
|
||||
|
||||
// set the adapter's name to our 8-character peerID for iOS privacy, TODO: Make this configurable
|
||||
// try {
|
||||
@ -250,7 +244,6 @@ class BluetoothConnectionManager(
|
||||
this@BluetoothConnectionManager.isActive = false
|
||||
return@launch
|
||||
}
|
||||
Log.d(TAG, "GATT Server started")
|
||||
} else {
|
||||
Log.i(TAG, "GATT Server disabled by debug settings; not starting")
|
||||
}
|
||||
@ -261,7 +254,6 @@ class BluetoothConnectionManager(
|
||||
this@BluetoothConnectionManager.isActive = false
|
||||
return@launch
|
||||
}
|
||||
Log.d(TAG, "GATT Client started")
|
||||
} else {
|
||||
Log.i(TAG, "GATT Client disabled by debug settings; not starting")
|
||||
}
|
||||
@ -295,12 +287,9 @@ class BluetoothConnectionManager(
|
||||
* Stop all Bluetooth services with proper cleanup
|
||||
*/
|
||||
fun stopServices() {
|
||||
Log.i(TAG, "Stopping power-optimized Bluetooth services")
|
||||
|
||||
isActive = false
|
||||
|
||||
|
||||
connectionScope.launch {
|
||||
Log.d(TAG, "Stopping client/server and power components...")
|
||||
// Stop component managers
|
||||
clientManager.stop()
|
||||
serverManager.stop()
|
||||
@ -323,11 +312,7 @@ class BluetoothConnectionManager(
|
||||
* Returns false if its coroutine scope has been cancelled.
|
||||
*/
|
||||
fun isReusable(): Boolean {
|
||||
val active = connectionScope.isActive
|
||||
if (!active) {
|
||||
Log.d(TAG, "BluetoothConnectionManager isReusable=false (scope cancelled)")
|
||||
}
|
||||
return active
|
||||
return connectionScope.isActive
|
||||
}
|
||||
|
||||
/**
|
||||
@ -492,15 +477,12 @@ class BluetoothConnectionManager(
|
||||
// Only restart scanning if the duty cycle behavior changed
|
||||
val nowUsingDutyCycle = powerManager.shouldUseDutyCycle()
|
||||
if (wasUsingDutyCycle != nowUsingDutyCycle) {
|
||||
Log.d(TAG, "Duty cycle behavior changed (${wasUsingDutyCycle} -> ${nowUsingDutyCycle}), restarting scan")
|
||||
val clientEnabled = isGattClientEnabled()
|
||||
if (clientEnabled) {
|
||||
clientManager.restartScanning()
|
||||
} else {
|
||||
clientManager.stop()
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Duty cycle behavior unchanged, keeping existing scan state")
|
||||
}
|
||||
|
||||
// Enforce connection limits
|
||||
|
||||
@ -63,7 +63,7 @@ class BluetoothGattClientManager(
|
||||
*/
|
||||
fun connectToAddress(deviceAddress: String): Boolean {
|
||||
if (!isClientRoleEnabled()) {
|
||||
Log.i(TAG, "connectToAddress skipped: BLE client disabled")
|
||||
Log.d(TAG, "connectToAddress skipped: BLE client disabled")
|
||||
return false
|
||||
}
|
||||
val device = bluetoothAdapter?.getRemoteDevice(deviceAddress)
|
||||
@ -111,7 +111,6 @@ class BluetoothGattClientManager(
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
Log.d(TAG, "GATT client already active; start is a no-op")
|
||||
return true
|
||||
}
|
||||
if (!permissionManager.hasBluetoothPermissions()) {
|
||||
@ -159,7 +158,6 @@ class BluetoothGattClientManager(
|
||||
// Idempotent stop
|
||||
stopScanning()
|
||||
stopRSSIMonitoring()
|
||||
Log.i(TAG, "GATT client manager stopped (already inactive)")
|
||||
return
|
||||
}
|
||||
|
||||
@ -205,10 +203,9 @@ class BluetoothGattClientManager(
|
||||
val connectedDevices = connectionTracker.getConnectedDevices()
|
||||
connectedDevices.values.filter { it.isClient && it.gatt != null }.forEach { deviceConn ->
|
||||
try {
|
||||
Log.d(TAG, "Requesting RSSI from ${deviceConn.device.address}")
|
||||
deviceConn.gatt?.readRemoteRssi()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to request RSSI from ${deviceConn.device.address}: ${e.message}")
|
||||
Log.d(TAG, "Failed to request RSSI from ${deviceConn.device.address}: ${e.message}")
|
||||
}
|
||||
}
|
||||
delay(AppConstants.Mesh.RSSI_UPDATE_INTERVAL_MS)
|
||||
@ -240,14 +237,13 @@ class BluetoothGattClientManager(
|
||||
// Rate limit scan starts to prevent "scanning too frequently" errors
|
||||
val currentTime = System.currentTimeMillis()
|
||||
if (isCurrentlyScanning) {
|
||||
Log.d(TAG, "Scan already in progress, skipping start request")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
val timeSinceLastStart = currentTime - lastScanStartTime
|
||||
if (timeSinceLastStart < scanRateLimit) {
|
||||
val remainingWait = scanRateLimit - timeSinceLastStart
|
||||
Log.w(TAG, "Scan rate limited: need to wait ${remainingWait}ms before starting scan")
|
||||
Log.d(TAG, "Scan rate limited: waiting ${remainingWait}ms before starting scan")
|
||||
|
||||
// Schedule delayed scan start
|
||||
connectionScope.launch {
|
||||
@ -263,63 +259,52 @@ class BluetoothGattClientManager(
|
||||
.setServiceUuid(ParcelUuid(AppConstants.Mesh.Gatt.SERVICE_UUID))
|
||||
.build()
|
||||
|
||||
val scanFilters = listOf(scanFilter)
|
||||
|
||||
Log.d(TAG, "Starting BLE scan with target service UUID: ${AppConstants.Mesh.Gatt.SERVICE_UUID}")
|
||||
|
||||
val scanFilters = listOf(scanFilter)
|
||||
|
||||
scanCallback = object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
// Log.d(TAG, "Scan result received: ${result.device.address}")
|
||||
handleScanResult(result)
|
||||
}
|
||||
|
||||
|
||||
override fun onBatchScanResults(results: MutableList<ScanResult>) {
|
||||
Log.d(TAG, "Batch scan results received: ${results.size} devices")
|
||||
results.forEach { result ->
|
||||
handleScanResult(result)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
Log.e(TAG, "Scan failed: $errorCode")
|
||||
isCurrentlyScanning = false
|
||||
lastScanStopTime = System.currentTimeMillis()
|
||||
|
||||
|
||||
when (errorCode) {
|
||||
1 -> {
|
||||
// Already started: the stack thinks a scan is running. Re-arm from a clean
|
||||
// state so we don't stay wedged (stop then restart with backoff).
|
||||
Log.e(TAG, "SCAN_FAILED_ALREADY_STARTED")
|
||||
stopScanning()
|
||||
scheduleScanRestart("already-started", SCAN_RETRY_BASE_MS)
|
||||
}
|
||||
2 -> {
|
||||
// App registration failed: common transient stack fault. Previously had NO
|
||||
// retry, which left discovery dead until a manual BLE toggle.
|
||||
Log.e(TAG, "SCAN_FAILED_APPLICATION_REGISTRATION_FAILED")
|
||||
scheduleScanRestart("registration-failed", SCAN_RETRY_BASE_MS)
|
||||
}
|
||||
3 -> {
|
||||
Log.e(TAG, "SCAN_FAILED_INTERNAL_ERROR")
|
||||
scheduleScanRestart("internal-error", SCAN_RETRY_BASE_MS)
|
||||
}
|
||||
4 -> Log.e(TAG, "SCAN_FAILED_FEATURE_UNSUPPORTED") // permanent: don't retry
|
||||
4 -> Unit // permanent: don't retry
|
||||
5 -> {
|
||||
// Out of hardware resources: back off longer so other scanners/connections
|
||||
// can free up before we try again.
|
||||
Log.e(TAG, "SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES")
|
||||
scheduleScanRestart("out-of-resources", SCAN_RETRY_BASE_MS * 3)
|
||||
}
|
||||
6 -> {
|
||||
Log.e(TAG, "SCAN_FAILED_SCANNING_TOO_FREQUENTLY")
|
||||
Log.w(TAG, "Scan failed due to rate limiting - will retry after delay")
|
||||
scheduleScanRestart("too-frequently", 10_000L)
|
||||
}
|
||||
else -> {
|
||||
Log.e(TAG, "Unknown scan failure code: $errorCode")
|
||||
scheduleScanRestart("unknown-$errorCode", SCAN_RETRY_BASE_MS)
|
||||
}
|
||||
}
|
||||
Log.e(TAG, "Scan failed: $errorCode")
|
||||
}
|
||||
}
|
||||
|
||||
@ -328,7 +313,7 @@ class BluetoothGattClientManager(
|
||||
isCurrentlyScanning = true
|
||||
|
||||
bleScanner.startScan(scanFilters, powerManager.getScanSettings(), scanCallback)
|
||||
Log.d(TAG, "BLE scan started successfully")
|
||||
Log.i(TAG, "BLE scan started")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Exception starting scan: ${e.message}")
|
||||
isCurrentlyScanning = false
|
||||
@ -344,9 +329,9 @@ class BluetoothGattClientManager(
|
||||
|
||||
if (isCurrentlyScanning) {
|
||||
try {
|
||||
scanCallback?.let {
|
||||
scanCallback?.let {
|
||||
bleScanner.stopScan(it)
|
||||
Log.d(TAG, "BLE scan stopped successfully")
|
||||
Log.i(TAG, "BLE scan stopped")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error stopping scan: ${e.message}")
|
||||
@ -455,15 +440,11 @@ class BluetoothGattClientManager(
|
||||
}
|
||||
|
||||
if (peerID != null) {
|
||||
// Log.v(TAG, "Found peerID $peerID in scan record for $deviceAddress")
|
||||
if (connectionTracker.isPeerConnected(peerID)) {
|
||||
Log.d(TAG, "Deduplication: Peer $peerID is already connected (ignoring $deviceAddress)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Log.d(TAG, "Received scan result from $deviceAddress - already connected: ${connectionTracker.isDeviceConnected(deviceAddress)}")
|
||||
|
||||
// Store RSSI from scan results for later use (especially for server connections)
|
||||
connectionTracker.updateScanRSSI(deviceAddress, rssi)
|
||||
|
||||
@ -481,7 +462,6 @@ class BluetoothGattClientManager(
|
||||
|
||||
// Power-aware RSSI filtering
|
||||
if (rssi < powerManager.getRSSIThreshold()) {
|
||||
Log.d(TAG, "Skipping device $deviceAddress due to weak signal: $rssi < ${powerManager.getRSSIThreshold()}")
|
||||
// Even if we skip connecting, still publish scan result to debug UI
|
||||
try {
|
||||
DebugSettingsManager.getInstance().addScanResult(
|
||||
@ -503,7 +483,6 @@ class BluetoothGattClientManager(
|
||||
|
||||
// Check if connection attempt is allowed
|
||||
if (!connectionTracker.isConnectionAttemptAllowed(deviceAddress)) {
|
||||
Log.d(TAG, "Connection to $deviceAddress not allowed due to recent attempts")
|
||||
return
|
||||
}
|
||||
|
||||
@ -513,7 +492,6 @@ class BluetoothGattClientManager(
|
||||
val maxClient = dbg?.maxClientConnections?.value ?: maxOverall
|
||||
|
||||
if (!connectionTracker.canConnectAsClient(maxOverall, maxClient)) {
|
||||
Log.d(TAG, "Client connection limit reached (overall: $maxOverall, client: $maxClient)")
|
||||
return
|
||||
}
|
||||
|
||||
@ -533,14 +511,11 @@ class BluetoothGattClientManager(
|
||||
|
||||
val deviceAddress = device.address
|
||||
val linkID = UUID.randomUUID().toString()
|
||||
Log.i(TAG, "Connecting to bitchat device: $deviceAddress (peerID: $peerID)")
|
||||
|
||||
Log.d(TAG, "Connecting to bitchat device: $deviceAddress (peerID: $peerID)")
|
||||
|
||||
val gattCallback = object : BluetoothGattCallback() {
|
||||
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
|
||||
Log.d(TAG, "Client: Connection state change - Device: $deviceAddress, Status: $status, NewState: $newState")
|
||||
|
||||
if (newState == BluetoothProfile.STATE_CONNECTED && status == BluetoothGatt.GATT_SUCCESS) {
|
||||
Log.i(TAG, "Client: Successfully connected to $deviceAddress. Requesting MTU...")
|
||||
// Request a larger MTU. Must be done before any data transfer.
|
||||
connectionScope.launch {
|
||||
delay(200) // A small delay can improve reliability of MTU request.
|
||||
@ -548,12 +523,9 @@ class BluetoothGattClientManager(
|
||||
}
|
||||
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
Log.w(TAG, "Client: Disconnected from $deviceAddress with error status $status")
|
||||
if (status == 147) {
|
||||
Log.e(TAG, "Client: Connection establishment failed (status 147) for $deviceAddress")
|
||||
}
|
||||
Log.w(TAG, "Disconnected from $deviceAddress with error status $status (client)")
|
||||
} else {
|
||||
Log.d(TAG, "Client: Cleanly disconnected from $deviceAddress")
|
||||
Log.i(TAG, "Disconnected from $deviceAddress (client)")
|
||||
}
|
||||
connectionTracker.cleanupDeviceConnectionIfCurrent(deviceAddress, linkID)
|
||||
|
||||
@ -573,11 +545,8 @@ class BluetoothGattClientManager(
|
||||
|
||||
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
|
||||
val deviceAddress = gatt.device.address
|
||||
Log.i(TAG, "Client: MTU changed for $deviceAddress to $mtu with status $status")
|
||||
|
||||
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||
Log.i(TAG, "MTU successfully negotiated for $deviceAddress. Discovering services.")
|
||||
|
||||
// Now that MTU is set, connection is fully ready.
|
||||
val deviceConn = BluetoothConnectionTracker.DeviceConnection(
|
||||
device = gatt.device,
|
||||
@ -609,7 +578,7 @@ class BluetoothGattClientManager(
|
||||
linkID
|
||||
) { it.copy(characteristic = characteristic) }
|
||||
) {
|
||||
Log.d(TAG, "Client: Updated device connection with characteristic for $deviceAddress")
|
||||
// Characteristic stored on the current device connection
|
||||
}
|
||||
|
||||
gatt.setCharacteristicNotification(characteristic, true)
|
||||
@ -620,7 +589,7 @@ class BluetoothGattClientManager(
|
||||
|
||||
connectionScope.launch {
|
||||
delay(200)
|
||||
Log.i(TAG, "Client: Connection setup complete for $deviceAddress")
|
||||
Log.i(TAG, "Connected to $deviceAddress (client)")
|
||||
delegate?.onDeviceConnected(device)
|
||||
}
|
||||
} else {
|
||||
@ -643,42 +612,34 @@ class BluetoothGattClientManager(
|
||||
|
||||
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
|
||||
val value = characteristic.value
|
||||
Log.i(TAG, "Client: Received packet from ${gatt.device.address}, size: ${value.size} bytes")
|
||||
val packet = BitchatPacket.fromBinaryData(value)
|
||||
if (packet != null) {
|
||||
val peerID = packet.senderID.take(8).toByteArray().joinToString("") { "%02x".format(it) }
|
||||
Log.d(TAG, "Client: Parsed packet type ${packet.type} from $peerID")
|
||||
delegate?.onPacketReceived(packet, peerID, gatt.device, linkID)
|
||||
} 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) }}")
|
||||
Log.d(TAG, "Failed to parse packet from ${gatt.device.address}, size: ${value.size} bytes")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) {
|
||||
val deviceAddress = gatt.device.address
|
||||
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||
Log.d(TAG, "Client: RSSI updated for $deviceAddress: $rssi dBm")
|
||||
|
||||
// Update the connection tracker with new RSSI value
|
||||
connectionTracker.updateDeviceConnectionIfCurrent(deviceAddress, linkID) {
|
||||
it.copy(rssi = rssi)
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Client: Failed to read RSSI for $deviceAddress, status: $status")
|
||||
Log.d(TAG, "Failed to read RSSI for $deviceAddress, status: $status")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Log.d(TAG, "Client: Attempting GATT connection to $deviceAddress with autoConnect=false")
|
||||
val gatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
|
||||
if (gatt == null) {
|
||||
Log.e(TAG, "connectGatt returned null for $deviceAddress")
|
||||
// keep the pending connection so we can avoid too many reconnections attempts, TODO: needs testing
|
||||
// connectionTracker.removePendingConnection(deviceAddress)
|
||||
} else {
|
||||
Log.d(TAG, "Client: GATT connection initiated successfully for $deviceAddress")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Client: Exception connecting to $deviceAddress: ${e.message}")
|
||||
|
||||
@ -87,7 +87,6 @@ class BluetoothGattServerManager(
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
Log.d(TAG, "GATT server already active; start is a no-op")
|
||||
return true
|
||||
}
|
||||
if (!permissionManager.hasBluetoothPermissions()) {
|
||||
@ -127,7 +126,6 @@ class BluetoothGattServerManager(
|
||||
gattServer?.close()
|
||||
gattServer = null
|
||||
serverLinkIDs.clear()
|
||||
Log.i(TAG, "GATT server stopped (already inactive)")
|
||||
return
|
||||
}
|
||||
|
||||
@ -175,13 +173,12 @@ class BluetoothGattServerManager(
|
||||
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) {
|
||||
BluetoothProfile.STATE_CONNECTED -> {
|
||||
Log.i(TAG, "Server: Device connected ${device.address}")
|
||||
Log.i(TAG, "Connected to ${device.address} (server)")
|
||||
val linkID = UUID.randomUUID().toString()
|
||||
serverLinkIDs[device.address] = linkID
|
||||
|
||||
@ -204,7 +201,7 @@ class BluetoothGattServerManager(
|
||||
}
|
||||
}
|
||||
BluetoothProfile.STATE_DISCONNECTED -> {
|
||||
Log.i(TAG, "Server: Device disconnected ${device.address}")
|
||||
Log.i(TAG, "Disconnected from ${device.address} (server)")
|
||||
val linkID = serverLinkIDs.remove(device.address)
|
||||
if (linkID != null) {
|
||||
connectionTracker.cleanupDeviceConnectionIfCurrent(device.address, linkID)
|
||||
@ -218,13 +215,10 @@ class BluetoothGattServerManager(
|
||||
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 {
|
||||
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
Log.e(TAG, "Server: Failed to add service: ${service.uuid}, status: $status")
|
||||
}
|
||||
}
|
||||
@ -240,15 +234,13 @@ class BluetoothGattServerManager(
|
||||
) {
|
||||
// Guard against callbacks after service shutdown
|
||||
if (!isActive) {
|
||||
Log.d(TAG, "Server: Ignoring characteristic write after shutdown")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (characteristic.uuid == AppConstants.Mesh.Gatt.CHARACTERISTIC_UUID) {
|
||||
Log.i(TAG, "Server: Received packet from ${device.address}, size: ${value.size} bytes")
|
||||
val linkID = serverLinkIDs[device.address]
|
||||
if (linkID == null) {
|
||||
Log.w(TAG, "Server: Dropping packet from stale connection ${device.address}")
|
||||
Log.d(TAG, "Server: Dropping packet from stale connection ${device.address}")
|
||||
if (responseNeeded) {
|
||||
gattServer?.sendResponse(
|
||||
device,
|
||||
@ -263,11 +255,9 @@ class BluetoothGattServerManager(
|
||||
val packet = BitchatPacket.fromBinaryData(value)
|
||||
if (packet != null) {
|
||||
val peerID = packet.senderID.take(8).toByteArray().joinToString("") { "%02x".format(it) }
|
||||
Log.d(TAG, "Server: Parsed packet type ${packet.type} from $peerID")
|
||||
delegate?.onPacketReceived(packet, peerID, device, linkID)
|
||||
} 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) }}")
|
||||
Log.d(TAG, "Server: Failed to parse packet from ${device.address}, size: ${value.size} bytes")
|
||||
}
|
||||
|
||||
if (responseNeeded) {
|
||||
@ -287,14 +277,12 @@ class BluetoothGattServerManager(
|
||||
) {
|
||||
// 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)) {
|
||||
connectionTracker.addSubscribedDevice(device)
|
||||
|
||||
Log.d(TAG, "Server: Connection setup complete for ${device.address}")
|
||||
connectionScope.launch {
|
||||
delay(100)
|
||||
if (isActive) { // Check if still active
|
||||
@ -311,19 +299,17 @@ class BluetoothGattServerManager(
|
||||
|
||||
// Proper cleanup sequencing to prevent race conditions
|
||||
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
|
||||
}
|
||||
|
||||
@ -373,11 +359,10 @@ class BluetoothGattServerManager(
|
||||
return
|
||||
}
|
||||
if (!isActive) {
|
||||
Log.d(TAG, "Not starting advertising: manager not active")
|
||||
return
|
||||
}
|
||||
if (!enabled) {
|
||||
Log.i(TAG, "Not starting advertising: GATT Server disabled via debug settings")
|
||||
Log.d(TAG, "Not starting advertising: GATT Server disabled via debug settings")
|
||||
return
|
||||
}
|
||||
if (bleAdvertiser == null) {
|
||||
@ -417,30 +402,24 @@ class BluetoothGattServerManager(
|
||||
val mode = try {
|
||||
powerManager.getPowerInfo().split("Current Mode: ")[1].split("\n")[0]
|
||||
} catch (_: Exception) { "unknown" }
|
||||
Log.i(TAG, "Advertising started (power mode: $mode) with stable ID: ${peerIDBytes.joinToString("") { "%02x".format(it) }}")
|
||||
Log.i(TAG, "Advertising started (power mode: $mode)")
|
||||
}
|
||||
|
||||
|
||||
override fun onStartFailure(errorCode: Int) {
|
||||
Log.e(TAG, "Advertising failed: $errorCode")
|
||||
// Previously this only logged, so if advertising failed this device became
|
||||
// undiscoverable until a manual BLE toggle. Retry transient failures with backoff.
|
||||
when (errorCode) {
|
||||
ADVERTISE_FAILED_ALREADY_STARTED ->
|
||||
Log.w(TAG, "ADVERTISE_FAILED_ALREADY_STARTED - already advertising, no retry")
|
||||
ADVERTISE_FAILED_DATA_TOO_LARGE ->
|
||||
Log.e(TAG, "ADVERTISE_FAILED_DATA_TOO_LARGE - config issue, not retrying")
|
||||
ADVERTISE_FAILED_FEATURE_UNSUPPORTED ->
|
||||
Log.e(TAG, "ADVERTISE_FAILED_FEATURE_UNSUPPORTED - unsupported, not retrying")
|
||||
ADVERTISE_FAILED_ALREADY_STARTED -> Unit // already advertising, no retry
|
||||
ADVERTISE_FAILED_DATA_TOO_LARGE -> Unit // config issue, not retrying
|
||||
ADVERTISE_FAILED_FEATURE_UNSUPPORTED -> Unit // unsupported, not retrying
|
||||
ADVERTISE_FAILED_TOO_MANY_ADVERTISERS -> {
|
||||
Log.w(TAG, "ADVERTISE_FAILED_TOO_MANY_ADVERTISERS - will retry after backoff")
|
||||
scheduleAdvertiseRestart("too-many-advertisers")
|
||||
}
|
||||
ADVERTISE_FAILED_INTERNAL_ERROR -> {
|
||||
Log.w(TAG, "ADVERTISE_FAILED_INTERNAL_ERROR - will retry after backoff")
|
||||
scheduleAdvertiseRestart("internal-error")
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Unknown advertise failure $errorCode - will retry after backoff")
|
||||
scheduleAdvertiseRestart("unknown-$errorCode")
|
||||
}
|
||||
}
|
||||
|
||||
@ -170,8 +170,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
peerManager.isPeerDirectlyConnected = { peerID ->
|
||||
connectionManager.addressPeerMap.containsValue(peerID)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Delegates set up; GossipSyncManager initialized")
|
||||
}
|
||||
|
||||
override fun send(packet: RoutedPacket) {
|
||||
@ -205,19 +203,17 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
*/
|
||||
private fun startPeriodicDebugLogging() {
|
||||
serviceScope.launch {
|
||||
Log.d(TAG, "Starting periodic debug logging loop")
|
||||
while (isActive) {
|
||||
try {
|
||||
delay(10000) // 10 seconds
|
||||
if (isActive) { // Double-check before logging
|
||||
val debugInfo = getDebugStatus()
|
||||
Log.d(TAG, "=== PERIODIC DEBUG STATUS ===\n$debugInfo\n=== END DEBUG STATUS ===")
|
||||
Log.d(TAG, "Periodic debug status:\n$debugInfo")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error in periodic debug logging: ${e.message}")
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "Periodic debug logging loop ended (isActive=$isActive)")
|
||||
}
|
||||
}
|
||||
|
||||
@ -227,7 +223,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
private fun sendPeriodicBroadcastAnnounce() {
|
||||
announceJob?.cancel()
|
||||
announceJob = serviceScope.launch {
|
||||
Log.d(TAG, "Starting periodic announce loop")
|
||||
while (isActive) {
|
||||
try {
|
||||
delay(30000) // 30 seconds
|
||||
@ -236,7 +231,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
Log.e(TAG, "Error in periodic broadcast announce: ${e.message}")
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "Periodic announce loop ended (isActive=$isActive)")
|
||||
}
|
||||
}
|
||||
|
||||
@ -244,7 +238,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
* Setup delegate connections between components
|
||||
*/
|
||||
private fun setupDelegates() {
|
||||
Log.d(TAG, "Setting up component delegates")
|
||||
// Provide nickname resolver to BLE broadcaster and debug manager
|
||||
try {
|
||||
val resolver: (String) -> String? = { pid -> peerManager.getPeerNickname(pid) }
|
||||
@ -269,7 +262,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
// Also drop any Noise session state for this peer when they go offline
|
||||
try {
|
||||
encryptionService.removePeer(peerID)
|
||||
Log.d(TAG, "Removed Noise session for offline peer $peerID")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to remove Noise session for $peerID: ${e.message}")
|
||||
}
|
||||
@ -308,7 +300,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
// Send announcement and cached messages after key exchange
|
||||
serviceScope.launch {
|
||||
Log.d(TAG, "Key exchange completed with $peerID; sending follow-ups")
|
||||
delay(100)
|
||||
sendAnnouncementToPeer(peerID)
|
||||
|
||||
@ -331,7 +322,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
// Sign the handshake response
|
||||
val signedPacket = signPacketBeforeBroadcast(responsePacket)
|
||||
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||
Log.d(TAG, "Sent Noise handshake response to $peerID (${response.size} bytes)")
|
||||
}
|
||||
|
||||
override fun getPeerInfo(peerID: String): PeerInfo? {
|
||||
@ -461,7 +451,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
// Sign the handshake packet before broadcasting
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||
Log.d(TAG, "Initiated Noise handshake with $peerID (${handshakeData.size} bytes)")
|
||||
} else {
|
||||
Log.w(TAG, "Failed to generate Noise handshake data for $peerID")
|
||||
}
|
||||
@ -702,7 +691,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
override fun onDeviceConnected(device: android.bluetooth.BluetoothDevice) {
|
||||
// Send initial announcements after services are ready
|
||||
serviceScope.launch {
|
||||
Log.d(TAG, "Device connected: ${device.address}; scheduling announce")
|
||||
Log.i(TAG, "Device connected: ${device.address}")
|
||||
delay(200)
|
||||
sendBroadcastAnnounce()
|
||||
}
|
||||
@ -720,7 +709,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
device: android.bluetooth.BluetoothDevice,
|
||||
linkID: String?
|
||||
) {
|
||||
Log.d(TAG, "Device disconnected: ${device.address}")
|
||||
Log.i(TAG, "Device disconnected: ${device.address}")
|
||||
val addr = device.address
|
||||
clearProvisionalBleClaimsForLink(addr, linkID)
|
||||
|
||||
@ -748,9 +737,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
provisionalBleClaims[peerID] = claim
|
||||
serviceScope.launch {
|
||||
delay(BLE_AUTHENTICATION_TIMEOUT_MS)
|
||||
if (provisionalBleClaims.remove(peerID, claim)) {
|
||||
Log.d(TAG, "Expired provisional BLE authentication claim for $peerID")
|
||||
}
|
||||
provisionalBleClaims.remove(peerID, claim)
|
||||
}
|
||||
}
|
||||
|
||||
@ -793,10 +780,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
|
||||
// Start periodic announcements for peer discovery and connectivity
|
||||
sendPeriodicBroadcastAnnounce()
|
||||
Log.d(TAG, "Started periodic broadcast announcements (every 30 seconds)")
|
||||
// Start periodic syncs
|
||||
com.bitchat.android.service.MeshServiceHolder.startSharedGossip("BLE")
|
||||
Log.d(TAG, "GossipSyncManager started")
|
||||
} else {
|
||||
Log.e(TAG, "Failed to start Bluetooth services")
|
||||
}
|
||||
@ -847,14 +832,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
sendLeaveAnnouncement()
|
||||
|
||||
serviceScope.launch {
|
||||
Log.d(TAG, "Stopping subcomponents and cancelling scope...")
|
||||
delay(200) // Give leave message time to send
|
||||
|
||||
|
||||
// Stop all components
|
||||
com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("BLE")
|
||||
Log.d(TAG, "GossipSyncManager stopped")
|
||||
connectionManager.stopServices()
|
||||
Log.d(TAG, "BluetoothConnectionManager stop requested")
|
||||
peerManager.shutdown()
|
||||
fragmentManager.shutdown()
|
||||
securityManager.shutdown()
|
||||
@ -875,9 +857,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
*/
|
||||
fun isReusable(): Boolean {
|
||||
val reusable = !terminated && serviceScope.isActive && connectionManager.isReusable()
|
||||
if (!reusable) {
|
||||
Log.d(TAG, "isReusable=false (terminated=$terminated, scopeActive=${serviceScope.isActive}, connReusable=${connectionManager.isReusable()})")
|
||||
}
|
||||
return reusable
|
||||
}
|
||||
|
||||
@ -938,13 +917,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
|
||||
fun sendFileBroadcast(file: com.bitchat.android.model.BitchatFilePacket) {
|
||||
try {
|
||||
Log.d(TAG, "📤 sendFileBroadcast: name=${file.fileName}, size=${file.fileSize}")
|
||||
val payload = file.encode()
|
||||
if (payload == null) {
|
||||
Log.e(TAG, "❌ Failed to encode file packet in sendFileBroadcast")
|
||||
Log.e(TAG, "Failed to encode file packet in sendFileBroadcast")
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "📦 Encoded payload: ${payload.size} bytes")
|
||||
serviceScope.launch {
|
||||
val packet = BitchatPacket(
|
||||
version = 2u, // FILE_TRANSFER uses v2 for 4-byte payload length to support large files
|
||||
@ -963,8 +940,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
try { gossipSyncManager.onPublicPacketSeen(signed) } catch (_: Exception) { }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ sendFileBroadcast failed: ${e.message}", e)
|
||||
Log.e(TAG, "❌ File: name=${file.fileName}, size=${file.fileSize}")
|
||||
Log.e(TAG, "sendFileBroadcast failed (size=${file.fileSize}): ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
@ -981,7 +957,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
is PrivateMediaPreparation.RequiresLegacyConsent ->
|
||||
Log.w(TAG, "Private media requires explicit one-shot legacy consent")
|
||||
PrivateMediaPreparation.NeedsHandshake -> {
|
||||
Log.i(TAG, "Private media needs a Noise handshake; initiating without sending")
|
||||
Log.d(TAG, "Private media needs a Noise handshake; initiating without sending")
|
||||
initiateNoiseHandshake(recipientPeerID)
|
||||
}
|
||||
PrivateMediaPreparation.AwaitingPeerState -> Unit
|
||||
@ -1051,9 +1027,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
|
||||
serviceScope.launch {
|
||||
val finalMessageID = messageID ?: java.util.UUID.randomUUID().toString()
|
||||
|
||||
Log.d(TAG, "📨 Sending PM to $recipientPeerID: ${content.take(30)}...")
|
||||
|
||||
|
||||
// Check if we have an established Noise session
|
||||
if (encryptionService.hasEstablishedSession(recipientPeerID)) {
|
||||
try {
|
||||
@ -1093,16 +1067,14 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
// Sign the packet before broadcasting
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||
Log.d(TAG, "📤 Sent encrypted private message to $recipientPeerID (${encrypted.size} bytes)")
|
||||
|
||||
|
||||
// The UI handles sent messages through its own sending path.
|
||||
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to encrypt private message for $recipientPeerID: ${e.message}")
|
||||
}
|
||||
} else {
|
||||
// Fire and forget - initiate handshake but don't queue exactly like iOS
|
||||
Log.d(TAG, "🤝 No session with $recipientPeerID, initiating handshake")
|
||||
messageHandler.delegate?.initiateNoiseHandshake(recipientPeerID)
|
||||
|
||||
// The UI handles sent messages through its own sending path.
|
||||
@ -1116,8 +1088,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
*/
|
||||
fun sendReadReceipt(messageID: String, recipientPeerID: String, readerNickname: String) {
|
||||
serviceScope.launch {
|
||||
Log.d(TAG, "📖 Sending read receipt for message $messageID to $recipientPeerID")
|
||||
|
||||
// Route geohash read receipts via MessageRouter instead of here
|
||||
val geo = runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance() }.getOrNull()
|
||||
val isGeoAlias = try {
|
||||
@ -1133,7 +1103,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
// Avoid duplicate read receipts: check persistent store first
|
||||
val seenStore = try { com.bitchat.android.services.SeenMessageStore.getInstance(context.applicationContext) } catch (_: Exception) { null }
|
||||
if (seenStore?.hasRead(messageID) == true) {
|
||||
Log.d(TAG, "Skipping read receipt for $messageID - already marked read")
|
||||
return@launch
|
||||
}
|
||||
|
||||
@ -1161,7 +1130,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
// Sign the packet before broadcasting
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||
Log.d(TAG, "📤 Sent read receipt to $recipientPeerID for message $messageID")
|
||||
|
||||
// Persist as read after successful send
|
||||
try { seenStore?.markRead(messageID) } catch (_: Exception) { }
|
||||
@ -1209,7 +1177,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||
Log.d(TAG, "📤 Sent $label to $recipientPeerID (${payload.data.size} bytes)")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send $label to $recipientPeerID: ${e.message}")
|
||||
}
|
||||
@ -1220,7 +1187,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
* Send broadcast announce with TLV-encoded identity announcement - exactly like iOS
|
||||
*/
|
||||
fun sendBroadcastAnnounce() {
|
||||
Log.d(TAG, "Sending broadcast announce")
|
||||
serviceScope.launch {
|
||||
val nickname = try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { myPeerID }
|
||||
|
||||
@ -1273,7 +1239,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
} ?: announcePacket
|
||||
|
||||
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||
Log.d(TAG, "Sent iOS-compatible signed TLV announce (${tlvPayload.size} bytes)")
|
||||
// Track announce for sync
|
||||
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
|
||||
}
|
||||
@ -1337,7 +1302,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
|
||||
broadcastRoutedPacket(RoutedPacket(signedPacket))
|
||||
peerManager.markPeerAsAnnouncedTo(peerID)
|
||||
Log.d(TAG, "Sent iOS-compatible signed TLV peer announce to $peerID (${tlvPayload.size} bytes)")
|
||||
|
||||
// Track announce for sync
|
||||
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
|
||||
@ -1592,7 +1556,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
// Sign the packet data using our signing key
|
||||
val signature = encryptionService.signData(packetDataForSigning)
|
||||
if (signature != null) {
|
||||
Log.d(TAG, "✅ Signed packet type ${packet.type} (signature ${signature.size} bytes)")
|
||||
withRoute.copy(signature = signature)
|
||||
} else {
|
||||
Log.w(TAG, "Failed to sign packet type ${packet.type}, sending unsigned")
|
||||
@ -1610,20 +1573,19 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
* Clear all internal mesh service data (for panic mode)
|
||||
*/
|
||||
fun clearAllInternalData() {
|
||||
Log.w(TAG, "🚨 Clearing all mesh service internal data")
|
||||
Log.w(TAG, "Clearing all mesh service internal data")
|
||||
try {
|
||||
// Stop services to cease broadcasting old ID immediately
|
||||
stopServices()
|
||||
|
||||
|
||||
// Clear all managers
|
||||
fragmentManager.clearAllFragments()
|
||||
storeForwardManager.clearAllCache()
|
||||
securityManager.clearAllData()
|
||||
peerManager.clearAllPeers()
|
||||
peerManager.clearAllFingerprints()
|
||||
Log.d(TAG, "✅ Cleared all mesh service internal data")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Error clearing mesh service internal data: ${e.message}")
|
||||
Log.e(TAG, "Error clearing mesh service internal data: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@ -1631,13 +1593,12 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
* Clear all encryption and cryptographic data (for panic mode)
|
||||
*/
|
||||
fun clearAllEncryptionData() {
|
||||
Log.w(TAG, "🚨 Clearing all encryption data")
|
||||
Log.w(TAG, "Clearing all encryption data")
|
||||
try {
|
||||
// Clear encryption service persistent identity (includes Ed25519 signing keys)
|
||||
encryptionService.clearPersistentIdentity()
|
||||
Log.d(TAG, "✅ Cleared all encryption data")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Error clearing encryption data: ${e.message}")
|
||||
Log.e(TAG, "Error clearing encryption data: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -122,13 +122,8 @@ class BluetoothPacketBroadcaster(
|
||||
private val broadcasterActor = broadcasterScope.actor<BroadcastRequest>(
|
||||
capacity = Channel.UNLIMITED
|
||||
) {
|
||||
Log.d(TAG, "🎭 Created packet broadcaster actor")
|
||||
try {
|
||||
for (request in channel) {
|
||||
broadcastSinglePacketInternal(request.routed, request.gattServer, request.characteristic)
|
||||
}
|
||||
} finally {
|
||||
Log.d(TAG, "🎭 Packet broadcaster actor terminated")
|
||||
for (request in channel) {
|
||||
broadcastSinglePacketInternal(request.routed, request.gattServer, request.characteristic)
|
||||
}
|
||||
}
|
||||
|
||||
@ -195,10 +190,6 @@ class BluetoothPacketBroadcaster(
|
||||
// iOS-compatible: Use selective padding policy for BLE
|
||||
val padForBLE = BLEPacketPaddingPolicy.shouldPadForBLE(packet.type)
|
||||
val data = packet.toBinaryData(padding = padForBLE) ?: return false
|
||||
val isFile = packet.type == MessageType.FILE_TRANSFER.value
|
||||
if (isFile) {
|
||||
Log.d(TAG, "📤 Broadcasting FILE_TRANSFER: ${packet.payload.size} bytes")
|
||||
}
|
||||
val typeName = MessageType.fromValue(packet.type)?.name ?: packet.type.toString()
|
||||
val senderPeerID = routed.peerID ?: packet.senderID.toHexString()
|
||||
val incomingAddr = routed.relayAddress
|
||||
@ -300,16 +291,14 @@ class BluetoothPacketBroadcaster(
|
||||
// If we are the sender and a source route is defined, we must send ONLY to the first hop.
|
||||
if (packet.senderID.toHexString() == myPeerID && !packet.route.isNullOrEmpty()) {
|
||||
val firstHop = packet.route!![0].toHexString()
|
||||
Log.d(TAG, "Source Routing: Packet has explicit route, attempting to send to first hop: $firstHop")
|
||||
|
||||
var sent = false
|
||||
|
||||
// Try to find first hop in server connections (subscribedDevices)
|
||||
val serverTarget = connectionTracker.getSubscribedDevices()
|
||||
.firstOrNull { connectionTracker.addressPeerMap[it.address] == firstHop }
|
||||
|
||||
|
||||
if (serverTarget != null) {
|
||||
Log.d(TAG, "Source Routing: sending directly to first hop (server conn) $firstHop: ${serverTarget.address}")
|
||||
if (notifyDevice(serverTarget, data, gattServer, characteristic)) {
|
||||
val toPeer = connectionTracker.addressPeerMap[serverTarget.address]
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, serverTarget.address, packet.ttl, packet.version, routeInfo)
|
||||
@ -323,7 +312,6 @@ class BluetoothPacketBroadcaster(
|
||||
.firstOrNull { connectionTracker.addressPeerMap[it.device.address] == firstHop }
|
||||
|
||||
if (clientTarget != null) {
|
||||
Log.d(TAG, "Source Routing: sending directly to first hop (client conn) $firstHop: ${clientTarget.device.address}")
|
||||
if (writeToDeviceConn(clientTarget, data)) {
|
||||
val toPeer = connectionTracker.addressPeerMap[clientTarget.device.address]
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, clientTarget.device.address, packet.ttl, packet.version, routeInfo)
|
||||
@ -333,8 +321,8 @@ class BluetoothPacketBroadcaster(
|
||||
}
|
||||
|
||||
if (sent) return
|
||||
|
||||
Log.w(TAG, "Source Routing: First hop $firstHop not connected. Falling back to standard broadcast logic.")
|
||||
|
||||
Log.d(TAG, "Source Routing: First hop $firstHop not connected. Falling back to standard broadcast logic.")
|
||||
}
|
||||
|
||||
if (packet.recipientID != SpecialRecipients.BROADCAST) {
|
||||
@ -346,7 +334,6 @@ class BluetoothPacketBroadcaster(
|
||||
|
||||
// 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, gattServer, characteristic)) {
|
||||
val toPeer = connectionTracker.addressPeerMap[targetDevice.address]
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDevice.address, packet.ttl, packet.version, routeInfo)
|
||||
@ -360,7 +347,6 @@ class BluetoothPacketBroadcaster(
|
||||
|
||||
// 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)) {
|
||||
val toPeer = connectionTracker.addressPeerMap[targetDeviceConn.device.address]
|
||||
logPacketRelay(typeName, senderPeerID, senderNick, incomingPeer, incomingAddr, toPeer, targetDeviceConn.device.address, packet.ttl, packet.version, routeInfo)
|
||||
@ -372,19 +358,15 @@ class BluetoothPacketBroadcaster(
|
||||
// Else, continue with broadcasting to all devices
|
||||
val subscribedDevices = connectionTracker.getSubscribedDevices()
|
||||
val connectedDevices = connectionTracker.getConnectedDevices()
|
||||
|
||||
Log.i(TAG, "Broadcasting packet v${packet.version} type ${packet.type} to ${subscribedDevices.size} server + ${connectedDevices.size} client connections")
|
||||
|
||||
val senderID = packet.senderID.toHexString()
|
||||
|
||||
|
||||
// Send to server connections (devices connected to our GATT server)
|
||||
subscribedDevices.forEach { device ->
|
||||
if (device.address == routed.relayAddress) {
|
||||
Log.d(TAG, "Skipping broadcast to client back to relayer: ${device.address}")
|
||||
return@forEach
|
||||
}
|
||||
if (connectionTracker.addressPeerMap[device.address] == senderID) {
|
||||
Log.d(TAG, "Skipping broadcast to client back to sender: ${device.address}")
|
||||
return@forEach
|
||||
}
|
||||
val sent = notifyDevice(device, data, gattServer, characteristic)
|
||||
@ -398,11 +380,9 @@ class BluetoothPacketBroadcaster(
|
||||
connectedDevices.values.forEach { deviceConn ->
|
||||
if (deviceConn.isClient && deviceConn.gatt != null && deviceConn.characteristic != null) {
|
||||
if (deviceConn.device.address == routed.relayAddress) {
|
||||
Log.d(TAG, "Skipping broadcast to server back to relayer: ${deviceConn.device.address}")
|
||||
return@forEach
|
||||
}
|
||||
if (connectionTracker.addressPeerMap[deviceConn.device.address] == senderID) {
|
||||
Log.d(TAG, "Skipping roadcast to server back to sender: ${deviceConn.device.address}")
|
||||
return@forEach
|
||||
}
|
||||
val sent = writeToDeviceConn(deviceConn, data)
|
||||
@ -479,14 +459,10 @@ class BluetoothPacketBroadcaster(
|
||||
* Shutdown the broadcaster actor gracefully
|
||||
*/
|
||||
fun shutdown() {
|
||||
Log.d(TAG, "Shutting down BluetoothPacketBroadcaster actor")
|
||||
|
||||
// Close the actor gracefully
|
||||
broadcasterActor.close()
|
||||
|
||||
|
||||
// Cancel the broadcaster scope
|
||||
broadcasterScope.cancel()
|
||||
|
||||
Log.d(TAG, "BluetoothPacketBroadcaster shutdown complete")
|
||||
}
|
||||
}
|
||||
|
||||
@ -66,22 +66,19 @@ class FragmentManager {
|
||||
Log.w(TAG, "Rejecting invalid outbound fragment limit: $maxFragments")
|
||||
return emptyList()
|
||||
}
|
||||
Log.d(TAG, "🔀 Creating fragments for packet type ${packet.type}, payload: ${packet.payload.size} bytes")
|
||||
val encoded = packet.toBinaryData()
|
||||
if (encoded == null) {
|
||||
Log.e(TAG, "❌ Failed to encode packet to binary data")
|
||||
Log.e(TAG, "Failed to encode packet to binary data")
|
||||
return emptyList()
|
||||
}
|
||||
Log.d(TAG, "📦 Encoded to ${encoded.size} bytes")
|
||||
|
||||
// Fragment the unpadded frame; each fragment will be encoded (and padded) independently - iOS fix
|
||||
val fullData = try {
|
||||
MessagePadding.unpad(encoded)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Failed to unpad data: ${e.message}", e)
|
||||
Log.e(TAG, "Failed to unpad data: ${e.message}", e)
|
||||
return emptyList()
|
||||
}
|
||||
Log.d(TAG, "📏 Unpadded to ${fullData.size} bytes")
|
||||
|
||||
// iOS logic: if data.count > 512 && packet.type != MessageType.fragment.rawValue
|
||||
if (fullData.size <= FRAGMENT_SIZE_THRESHOLD) {
|
||||
@ -111,21 +108,15 @@ class FragmentManager {
|
||||
val maxDataSize = (512 - packetOverhead).coerceAtMost(MAX_FRAGMENT_SIZE)
|
||||
|
||||
if (maxDataSize <= 0) {
|
||||
Log.e(TAG, "❌ Calculated maxDataSize is non-positive ($maxDataSize). Route too large?")
|
||||
Log.e(TAG, "Calculated maxDataSize is non-positive ($maxDataSize). Route too large?")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
Log.d(TAG, "📏 Dynamic fragment size: $maxDataSize (MAX: $MAX_FRAGMENT_SIZE, Overhead: $packetOverhead)")
|
||||
|
||||
val requiredFragments = (
|
||||
(fullData.size.toLong() + maxDataSize.toLong() - 1L) / maxDataSize.toLong()
|
||||
).toInt()
|
||||
if (requiredFragments > maxFragments) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Rejecting outbound packet requiring $requiredFragments fragments " +
|
||||
"(caller cap: $maxFragments)"
|
||||
)
|
||||
Log.w(TAG, "Rejecting outbound packet requiring $requiredFragments fragments (caller cap: $maxFragments)")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
@ -135,8 +126,6 @@ class FragmentManager {
|
||||
fullData.sliceArray(offset..<endOffset)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Creating ${fragmentChunks.size} fragments for ${fullData.size} byte packet (iOS compatible)")
|
||||
|
||||
// iOS: for (index, fragment) in fragments.enumerated()
|
||||
for (index in fragmentChunks.indices) {
|
||||
val fragmentData = fragmentChunks[index]
|
||||
@ -167,11 +156,9 @@ class FragmentManager {
|
||||
fragments.add(fragmentPacket)
|
||||
}
|
||||
|
||||
Log.d(TAG, "✅ Created ${fragments.size} fragments successfully")
|
||||
return fragments
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Fragment creation failed: ${e.message}", e)
|
||||
Log.e(TAG, "❌ Packet type: ${packet.type}, payload: ${packet.payload.size} bytes")
|
||||
Log.e(TAG, "Fragment creation failed (type=${packet.type}, payload=${packet.payload.size} bytes): ${e.message}", e)
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
@ -183,7 +170,7 @@ class FragmentManager {
|
||||
fun handleFragment(packet: BitchatPacket): BitchatPacket? {
|
||||
// iOS: guard packet.payload.count > 13 else { return }
|
||||
if (packet.payload.size < FragmentPayload.HEADER_SIZE) {
|
||||
Log.w(TAG, "Fragment packet too small: ${packet.payload.size}")
|
||||
Log.d(TAG, "Fragment packet too small: ${packet.payload.size}")
|
||||
return null
|
||||
}
|
||||
|
||||
@ -194,14 +181,12 @@ class FragmentManager {
|
||||
// Use FragmentPayload for type-safe decoding
|
||||
val fragmentPayload = FragmentPayload.decode(packet.payload)
|
||||
if (fragmentPayload == null || !fragmentPayload.isValid()) {
|
||||
Log.w(TAG, "Invalid fragment payload")
|
||||
Log.d(TAG, "Invalid fragment payload")
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
// iOS: let fragmentID = packet.payload[0..<8].map { String(format: "%02x", $0) }.joined()
|
||||
val fragmentIDString = fragmentPayload.getFragmentIDString()
|
||||
|
||||
Log.d(TAG, "Received fragment ${fragmentPayload.index}/${fragmentPayload.total} for fragmentID: $fragmentIDString, originalType: ${fragmentPayload.originalType}")
|
||||
|
||||
val maxFragments = com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID
|
||||
if (fragmentPayload.total > maxFragments) {
|
||||
@ -212,11 +197,7 @@ class FragmentManager {
|
||||
synchronized(fragmentStateLock) {
|
||||
fragmentMetadata[fragmentIDString]?.let { (expectedType, expectedTotal, _) ->
|
||||
if (expectedTotal != fragmentPayload.total || expectedType != fragmentPayload.originalType) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Rejecting fragment for $fragmentIDString: inconsistent metadata " +
|
||||
"(expected type=$expectedType total=$expectedTotal, got type=${fragmentPayload.originalType} total=${fragmentPayload.total})"
|
||||
)
|
||||
Log.w(TAG, "Rejecting fragment for $fragmentIDString: inconsistent metadata")
|
||||
removeFragmentSetLocked(fragmentIDString)
|
||||
return null
|
||||
}
|
||||
@ -265,10 +246,7 @@ class FragmentManager {
|
||||
val delta = (fragmentPayload.data.size - oldEntrySize).toLong()
|
||||
val maxGlobalBytes = com.bitchat.android.util.AppConstants.Fragmentation.MAX_GLOBAL_FRAGMENT_TOTAL_BYTES
|
||||
if (globalBufferedBytes + delta > maxGlobalBytes) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Rejecting fragment for $fragmentIDString: global buffered bytes ${(globalBufferedBytes + delta)} exceeds cap $maxGlobalBytes"
|
||||
)
|
||||
Log.w(TAG, "Rejecting fragment for $fragmentIDString: global buffered bytes exceed cap $maxGlobalBytes")
|
||||
if (isNewSet) {
|
||||
removeFragmentSetLocked(fragmentIDString)
|
||||
}
|
||||
@ -281,8 +259,6 @@ class FragmentManager {
|
||||
|
||||
val expectedTotal = fragmentMetadata[fragmentIDString]?.second ?: fragmentPayload.total
|
||||
if (fragmentMap.size == expectedTotal) {
|
||||
Log.d(TAG, "All fragments received for $fragmentIDString, reassembling...")
|
||||
|
||||
// iOS reassembly logic: for i in 0..<total { if let fragment = fragments[i] { reassembled.append(fragment) } }
|
||||
val reassembledData = mutableListOf<Byte>()
|
||||
for (i in 0 until expectedTotal) {
|
||||
@ -296,15 +272,11 @@ class FragmentManager {
|
||||
removeFragmentSetLocked(fragmentIDString)
|
||||
|
||||
val suppressedTtlPacket = originalPacket.copy(ttl = 0u.toUByte())
|
||||
Log.d(TAG, "Successfully reassembled original (${reassembledData.size} bytes); set TTL=0 to suppress relay")
|
||||
return suppressedTtlPacket
|
||||
} else {
|
||||
val metadata = fragmentMetadata[fragmentIDString]
|
||||
Log.e(TAG, "Failed to decode reassembled packet (type=${metadata?.first}, total=${metadata?.second})")
|
||||
}
|
||||
} else {
|
||||
val received = fragmentMap.size
|
||||
Log.d(TAG, "Fragment ${fragmentPayload.index} stored, have $received/$expectedTotal fragments for $fragmentIDString")
|
||||
}
|
||||
}
|
||||
|
||||
@ -353,10 +325,6 @@ class FragmentManager {
|
||||
for (fragmentID in oldFragments) {
|
||||
removeFragmentSetLocked(fragmentID)
|
||||
}
|
||||
|
||||
if (oldFragments.isNotEmpty()) {
|
||||
Log.d(TAG, "Cleaned up ${oldFragments.size} old fragment sets (iOS compatible)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -46,15 +46,12 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
val packet = routed.packet
|
||||
val peerID = routed.peerID ?: "unknown"
|
||||
|
||||
Log.d(TAG, "Processing Noise encrypted message from $peerID (${packet.payload.size} bytes)")
|
||||
|
||||
// Skip our own messages
|
||||
if (peerID == myPeerID) return
|
||||
|
||||
// Check if this message is for us
|
||||
val recipientID = packet.recipientID?.toHexString()
|
||||
if (recipientID != myPeerID) {
|
||||
Log.d(TAG, "🔐 Encrypted message not for me (for $recipientID, I am $myPeerID)")
|
||||
return
|
||||
}
|
||||
|
||||
@ -78,15 +75,11 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "🔓 Decrypted NoisePayload type ${noisePayload.type} from $peerID")
|
||||
|
||||
when (noisePayload.type) {
|
||||
com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE -> {
|
||||
// Decode TLV private message exactly like iOS
|
||||
val privateMessage = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data)
|
||||
if (privateMessage != null) {
|
||||
Log.d(TAG, "🔓 Decrypted TLV PM from $peerID: ${privateMessage.content.take(30)}...")
|
||||
|
||||
// Handle favorite/unfavorite notifications embedded as PMs
|
||||
val pmContent = privateMessage.content
|
||||
if (FavoriteControlMessage.parse(pmContent) != null) {
|
||||
@ -122,7 +115,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
// Handle encrypted file transfer; generate unique message ID
|
||||
val file = com.bitchat.android.model.BitchatFilePacket.decode(noisePayload.data)
|
||||
if (file != null) {
|
||||
Log.d(TAG, "🔓 Decrypted encrypted file from $peerID: name='${file.fileName}', size=${file.fileSize}, mime='${file.mimeType}'")
|
||||
Log.d(TAG, "Encrypted file from $peerID: ${file.fileSize} bytes")
|
||||
val uniqueMsgId = java.util.UUID.randomUUID().toString().uppercase()
|
||||
val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(appContext, file)
|
||||
val message = BitchatMessage(
|
||||
@ -137,13 +130,12 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
senderPeerID = peerID
|
||||
)
|
||||
|
||||
Log.d(TAG, "📄 Saved encrypted incoming file to $savedPath (msgId=$uniqueMsgId)")
|
||||
delegate?.onMessageReceived(message)
|
||||
|
||||
// Send delivery ACK with generated message ID
|
||||
sendDeliveryAck(uniqueMsgId, peerID)
|
||||
} else {
|
||||
Log.w(TAG, "⚠️ Failed to decode encrypted file transfer from $peerID")
|
||||
Log.w(TAG, "Failed to decode encrypted file transfer from $peerID")
|
||||
}
|
||||
}
|
||||
|
||||
@ -163,7 +155,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
com.bitchat.android.model.NoisePayloadType.DELIVERED -> {
|
||||
// Handle delivery ACK exactly like iOS
|
||||
val messageID = String(noisePayload.data, Charsets.UTF_8)
|
||||
Log.d(TAG, "📬 Delivery ACK received from $peerID for message $messageID")
|
||||
Log.d(TAG, "Delivery ACK from $peerID for $messageID")
|
||||
|
||||
// Simplified: Call delegate with messageID and peerID directly
|
||||
delegate?.onDeliveryAckReceived(messageID, peerID)
|
||||
@ -172,17 +164,15 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
com.bitchat.android.model.NoisePayloadType.READ_RECEIPT -> {
|
||||
// Handle read receipt exactly like iOS
|
||||
val messageID = String(noisePayload.data, Charsets.UTF_8)
|
||||
Log.d(TAG, "👁️ Read receipt received from $peerID for message $messageID")
|
||||
Log.d(TAG, "Read receipt from $peerID for $messageID")
|
||||
|
||||
// Simplified: Call delegate with messageID and peerID directly
|
||||
delegate?.onReadReceiptReceived(messageID, peerID)
|
||||
}
|
||||
com.bitchat.android.model.NoisePayloadType.VERIFY_CHALLENGE -> {
|
||||
Log.d(TAG, "🔐 Verify challenge received from $peerID (${noisePayload.data.size} bytes)")
|
||||
delegate?.onVerifyChallengeReceived(peerID, noisePayload.data, packet.timestamp.toLong())
|
||||
}
|
||||
com.bitchat.android.model.NoisePayloadType.VERIFY_RESPONSE -> {
|
||||
Log.d(TAG, "🔐 Verify response received from $peerID (${noisePayload.data.size} bytes)")
|
||||
delegate?.onVerifyResponseReceived(peerID, noisePayload.data, packet.timestamp.toLong())
|
||||
}
|
||||
}
|
||||
@ -223,7 +213,6 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
)
|
||||
|
||||
delegate?.sendPacket(packet)
|
||||
Log.d(TAG, "📤 Sent delivery ACK to $senderPeerID for message $messageID")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send delivery ACK to $senderPeerID: ${e.message}")
|
||||
@ -250,8 +239,6 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
if (clockSkewMs > ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS) {
|
||||
Log.w(TAG, "Ignoring ANNOUNCE from ${peerID.take(8)} with excessive clock skew (${clockSkewMs}ms > ${ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS}ms)")
|
||||
return AnnounceHandlingResult.Rejected
|
||||
} else if (clockSkewMs > com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS) {
|
||||
Log.w(TAG, "Accepting ANNOUNCE from ${peerID.take(8)} within clock skew tolerance (${clockSkewMs}ms)")
|
||||
}
|
||||
|
||||
val announcement = AnnouncementIdentityValidator.verify(packet, peerID) { signature, data, key ->
|
||||
@ -277,7 +264,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
val existingPeer = delegate?.getPeerInfo(peerID)
|
||||
|
||||
if (existingPeer != null && existingPeer.noisePublicKey != null && !existingPeer.noisePublicKey!!.contentEquals(announcement.noisePublicKey)) {
|
||||
Log.w(TAG, "⚠️ Announce key mismatch for ${peerID.take(8)}... — keeping unverified")
|
||||
Log.w(TAG, "Announce key mismatch for ${peerID.take(8)} - keeping unverified")
|
||||
verified = false
|
||||
}
|
||||
|
||||
@ -294,15 +281,9 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
|
||||
// Require verified announce; ignore otherwise (no backward compatibility)
|
||||
if (!verified) {
|
||||
Log.w(TAG, "❌ Ignoring unverified announce from ${peerID.take(8)}...")
|
||||
return AnnounceHandlingResult.Rejected
|
||||
}
|
||||
|
||||
// Successfully decoded TLV format exactly like iOS
|
||||
Log.d(TAG, "✅ Verified announce from $peerID: nickname=${announcement.nickname}, " +
|
||||
"noisePublicKey=${announcement.noisePublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., " +
|
||||
"signingPublicKey=${announcement.signingPublicKey.joinToString("") { "%02x".format(it) }.take(16)}...")
|
||||
|
||||
// Extract nickname and public keys from TLV data
|
||||
val nickname = announcement.nickname
|
||||
val noisePublicKey = announcement.noisePublicKey
|
||||
@ -325,7 +306,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
.updateFromAnnouncement(peerID, nickname, neighborsOrNull, packet.timestamp)
|
||||
} catch (_: Exception) { }
|
||||
|
||||
Log.d(TAG, "✅ Processed verified TLV announce: stored identity for $peerID")
|
||||
Log.d(TAG, "Verified announce from $peerID (${announcement.nickname})")
|
||||
return AnnounceHandlingResult.Accepted(isFirstAnnounce)
|
||||
}
|
||||
|
||||
@ -337,15 +318,12 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
val packet = routed.packet
|
||||
val peerID = routed.peerID ?: "unknown"
|
||||
|
||||
Log.d(TAG, "Processing Noise handshake from $peerID (${packet.payload.size} bytes)")
|
||||
|
||||
// Skip our own handshake messages
|
||||
if (peerID == myPeerID) return
|
||||
|
||||
// Check if handshake is addressed to us
|
||||
val recipientID = packet.recipientID?.toHexString()
|
||||
if (recipientID != myPeerID) {
|
||||
Log.d(TAG, "Handshake not for me (for $recipientID, I am $myPeerID)")
|
||||
return
|
||||
}
|
||||
|
||||
@ -354,8 +332,6 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
val response = delegate?.processNoiseHandshakeMessage(packet.payload, peerID)
|
||||
|
||||
if (response != null) {
|
||||
Log.d(TAG, "Generated handshake response for $peerID (${response.size} bytes)")
|
||||
|
||||
// Send response using same packet type (simplified iOS approach)
|
||||
val responsePacket = BitchatPacket(
|
||||
version = 1u,
|
||||
@ -369,13 +345,6 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
)
|
||||
|
||||
delegate?.sendPacket(responsePacket)
|
||||
Log.d(TAG, "📤 Sent handshake response to $peerID")
|
||||
}
|
||||
|
||||
// Check if session is now established
|
||||
val hasSession = delegate?.hasNoiseSession(peerID) ?: false
|
||||
if (hasSession) {
|
||||
Log.d(TAG, "✅ Noise session established with $peerID")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
@ -418,7 +387,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
// Enforce: only accept public messages from verified peers we know
|
||||
val peerInfo = delegate?.getPeerInfo(peerID)
|
||||
if (peerInfo == null || !peerInfo.isVerifiedNickname) {
|
||||
Log.w(TAG, "🚫 Dropping public message from unverified or unknown peer ${peerID.take(8)}...")
|
||||
Log.w(TAG, "Dropping public message from unverified peer ${peerID.take(8)}")
|
||||
return
|
||||
}
|
||||
|
||||
@ -427,9 +396,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
val isFileTransfer = com.bitchat.android.protocol.MessageType.fromValue(packet.type) == com.bitchat.android.protocol.MessageType.FILE_TRANSFER
|
||||
val file = com.bitchat.android.model.BitchatFilePacket.decode(packet.payload)
|
||||
if (file != null) {
|
||||
if (isFileTransfer) {
|
||||
Log.d(TAG, "📥 FILE_TRANSFER decode success (broadcast): name='${file.fileName}', size=${file.fileSize}, mime='${file.mimeType}', from=${peerID.take(8)}")
|
||||
}
|
||||
|
||||
val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(appContext, file)
|
||||
val message = BitchatMessage(
|
||||
id = PacketIdUtil.computeIdHex(packet).uppercase(),
|
||||
@ -439,11 +406,10 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
senderPeerID = peerID,
|
||||
timestamp = Date(packet.timestamp.toLong())
|
||||
)
|
||||
Log.d(TAG, "📄 Saved incoming file to $savedPath")
|
||||
delegate?.onMessageReceived(message)
|
||||
return
|
||||
} else if (isFileTransfer) {
|
||||
Log.w(TAG, "⚠️ FILE_TRANSFER decode failed (broadcast) from ${peerID.take(8)} payloadSize=${packet.payload.size}")
|
||||
Log.w(TAG, "FILE_TRANSFER decode failed (broadcast) from ${peerID.take(8)}")
|
||||
}
|
||||
|
||||
// Fallback: plain text
|
||||
@ -487,9 +453,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
// Try file packet first (voice, image, etc.) and log outcome for FILE_TRANSFER
|
||||
val file = com.bitchat.android.model.BitchatFilePacket.decode(packet.payload)
|
||||
if (file != null) {
|
||||
if (isFileTransfer) {
|
||||
Log.d(TAG, "📥 FILE_TRANSFER decode success (private): name='${file.fileName}', size=${file.fileSize}, mime='${file.mimeType}', from=${peerID.take(8)}")
|
||||
}
|
||||
|
||||
val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(appContext, file)
|
||||
val message = BitchatMessage(
|
||||
id = java.util.UUID.randomUUID().toString().uppercase(),
|
||||
|
||||
@ -46,15 +46,8 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
private fun getOrCreateActorForPeer(peerID: String) = processorScope.actor<RoutedPacket>(
|
||||
capacity = Channel.UNLIMITED
|
||||
) {
|
||||
Log.d(TAG, "🎭 Created packet actor for peer: ${formatPeerForLog(peerID)}")
|
||||
try {
|
||||
for (packet in channel) {
|
||||
Log.d(TAG, "📦 Processing packet type ${packet.packet.type} from ${formatPeerForLog(peerID)} (serialized)")
|
||||
handleReceivedPacket(packet)
|
||||
Log.d(TAG, "Completed packet type ${packet.packet.type} from ${formatPeerForLog(peerID)}")
|
||||
}
|
||||
} finally {
|
||||
Log.d(TAG, "🎭 Packet actor for ${formatPeerForLog(peerID)} terminated")
|
||||
for (packet in channel) {
|
||||
handleReceivedPacket(packet)
|
||||
}
|
||||
}
|
||||
|
||||
@ -71,7 +64,6 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
* SURGICAL FIX: Route to per-peer actor for serialized processing
|
||||
*/
|
||||
fun processPacket(routed: RoutedPacket) {
|
||||
Log.d(TAG, "processPacket ${routed.packet.type}")
|
||||
val peerID = routed.peerID
|
||||
|
||||
if (peerID == null) {
|
||||
@ -125,13 +117,11 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
|
||||
// Basic validation and security checks
|
||||
if (!delegate?.validatePacketSecurity(packet, peerID)!!) {
|
||||
Log.d(TAG, "Packet failed security validation from ${formatPeerForLog(peerID)}")
|
||||
return
|
||||
}
|
||||
|
||||
var validPacket = true
|
||||
val messageType = MessageType.fromValue(packet.type)
|
||||
Log.d(TAG, "Processing packet type ${messageType} from ${formatPeerForLog(peerID)}")
|
||||
// Verbose logging to debug manager (and chat via ChatViewModel observer)
|
||||
try {
|
||||
val mt = messageType?.name ?: packet.type.toString()
|
||||
@ -162,7 +152,7 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Private packet type ${messageType} not addressed to us (from: ${formatPeerForLog(peerID)} to ${packet.recipientID?.let { it.joinToString("") { b -> "%02x".format(b) } }}), skipping")
|
||||
// Not addressed to us; only relay handling below applies
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -180,8 +170,6 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
* Handle Noise handshake message - SIMPLIFIED iOS-compatible version
|
||||
*/
|
||||
private suspend fun handleNoiseHandshake(routed: RoutedPacket): Boolean {
|
||||
val peerID = routed.peerID ?: "unknown"
|
||||
Log.d(TAG, "Processing Noise handshake from ${formatPeerForLog(peerID)}")
|
||||
return delegate?.handleNoiseHandshake(routed) ?: false
|
||||
}
|
||||
|
||||
@ -189,8 +177,6 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
* Handle Noise encrypted transport message
|
||||
*/
|
||||
private suspend fun handleNoiseEncrypted(routed: RoutedPacket) {
|
||||
val peerID = routed.peerID ?: "unknown"
|
||||
Log.d(TAG, "Processing Noise encrypted message from ${formatPeerForLog(peerID)}")
|
||||
delegate?.handleNoiseEncrypted(routed)
|
||||
}
|
||||
|
||||
@ -198,8 +184,6 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
* Handle announce message
|
||||
*/
|
||||
private suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
|
||||
val peerID = routed.peerID ?: "unknown"
|
||||
Log.d(TAG, "Processing announce from ${formatPeerForLog(peerID)}")
|
||||
return delegate?.handleAnnounce(routed) ?: false
|
||||
}
|
||||
|
||||
@ -207,8 +191,6 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
* Handle regular message
|
||||
*/
|
||||
private suspend fun handleMessage(routed: RoutedPacket) {
|
||||
val peerID = routed.peerID ?: "unknown"
|
||||
Log.d(TAG, "Processing message from ${formatPeerForLog(peerID)}")
|
||||
delegate?.handleMessage(routed)
|
||||
}
|
||||
|
||||
@ -216,8 +198,6 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
* Handle leave message
|
||||
*/
|
||||
private suspend fun handleLeave(routed: RoutedPacket) {
|
||||
val peerID = routed.peerID ?: "unknown"
|
||||
Log.d(TAG, "Processing leave from ${formatPeerForLog(peerID)}")
|
||||
delegate?.handleLeave(routed)
|
||||
}
|
||||
|
||||
@ -225,12 +205,8 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
* Handle message fragments
|
||||
*/
|
||||
private suspend fun handleFragment(routed: RoutedPacket) {
|
||||
val peerID = routed.peerID ?: "unknown"
|
||||
Log.d(TAG, "Processing fragment from ${formatPeerForLog(peerID)}")
|
||||
|
||||
val reassembledPacket = delegate?.handleFragment(routed.packet)
|
||||
if (reassembledPacket != null) {
|
||||
Log.d(TAG, "Fragment reassembled, processing complete message")
|
||||
handleReceivedPacket(
|
||||
RoutedPacket(
|
||||
packet = reassembledPacket,
|
||||
@ -248,8 +224,6 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
* Handle REQUEST_SYNC packets (public, TTL=1)
|
||||
*/
|
||||
private suspend fun handleRequestSync(routed: RoutedPacket) {
|
||||
val peerID = routed.peerID ?: "unknown"
|
||||
Log.d(TAG, "Processing REQUEST_SYNC from ${formatPeerForLog(peerID)}")
|
||||
delegate?.handleRequestSync(routed)
|
||||
}
|
||||
|
||||
@ -298,8 +272,6 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
|
||||
// Cancel the main scope
|
||||
processorScope.cancel()
|
||||
|
||||
Log.d(TAG, "PacketProcessor shutdown complete")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -48,7 +48,6 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
fun validatePacket(packet: BitchatPacket, peerID: String): Boolean {
|
||||
// Skip validation for our own packets
|
||||
if (peerID == myPeerID) {
|
||||
Log.d(TAG, "Skipping validation for our own packet")
|
||||
return false
|
||||
}
|
||||
|
||||
@ -83,15 +82,12 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
packet.ttl >= com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
|
||||
|
||||
if (!isFreshAnnounce) {
|
||||
Log.d(TAG, "Dropping duplicate packet: $messageID")
|
||||
return false
|
||||
}
|
||||
Log.d(TAG, "Allowing duplicate ANNOUNCE from direct neighbor: $messageID")
|
||||
}
|
||||
|
||||
// Enforce mandatory signature verification
|
||||
if (!verifyPacketSignature(packet, peerID)) {
|
||||
Log.w(TAG, "Dropping packet from $peerID due to signature verification failure")
|
||||
return false
|
||||
}
|
||||
|
||||
@ -100,8 +96,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
// later legitimate packet with the same timestamp and payload.
|
||||
processedMessages.add(messageID)
|
||||
messageTimestamps[messageID] = currentTime
|
||||
|
||||
Log.d(TAG, "Packet validation passed for $peerID, messageID: $messageID")
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@ -115,27 +110,24 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
|
||||
// Skip handshakes not addressed to us
|
||||
if (packet.recipientID?.toHexString() != myPeerID) {
|
||||
Log.d(TAG, "Skipping handshake not addressed to us: $peerID")
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
// Skip our own handshake messages
|
||||
if (peerID == myPeerID) return false
|
||||
|
||||
if (packet.payload.isEmpty()) {
|
||||
Log.w(TAG, "Noise handshake packet has empty payload")
|
||||
Log.d(TAG, "Noise handshake packet has empty payload")
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
// Prevent duplicate handshake processing
|
||||
val exchangeKey = "$peerID-${packet.payload.sliceArray(0 until minOf(16, packet.payload.size)).contentHashCode()}"
|
||||
|
||||
|
||||
if (processedKeyExchanges.contains(exchangeKey)) {
|
||||
Log.d(TAG, "Already processed handshake: $exchangeKey")
|
||||
return false
|
||||
}
|
||||
Log.d(TAG, "Processing Noise handshake from $peerID (${packet.payload.size} bytes)")
|
||||
|
||||
|
||||
try {
|
||||
// The session manager preserves an existing transport in a separate responder-candidate
|
||||
// flow and reports whether this exact frame completed authentication. Never infer that
|
||||
@ -144,7 +136,6 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
processedKeyExchanges.add(exchangeKey)
|
||||
|
||||
if (result.response != null) {
|
||||
Log.d(TAG, "Successfully processed Noise handshake from $peerID, sending response")
|
||||
// Send handshake response through delegate
|
||||
delegate?.sendHandshakeResponse(peerID, result.response)
|
||||
}
|
||||
@ -162,7 +153,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
return false
|
||||
}
|
||||
val isDirectIngress = packet.ttl == com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
|
||||
Log.d(TAG, "✅ Noise handshake completed with $peerID")
|
||||
Log.i(TAG, "Noise handshake completed with $peerID")
|
||||
delegate?.onKeyExchangeCompleted(
|
||||
peerID = peerID,
|
||||
authenticatedRemoteStaticKey = authenticatedRemoteStaticKey,
|
||||
@ -175,7 +166,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to process Noise handshake from $peerID: ${e.message}")
|
||||
Log.w(TAG, "Failed to process Noise handshake from $peerID: ${e.message}")
|
||||
return false
|
||||
}
|
||||
}
|
||||
@ -315,7 +306,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
|
||||
// 1. Mandatory Signature Check
|
||||
if (packet.signature == null) {
|
||||
Log.w(TAG, "❌ Signature check for $peerID: NO_SIGNATURE (packet type ${packet.type})")
|
||||
Log.w(TAG, "Signature check for $peerID: NO_SIGNATURE (packet type ${packet.type})")
|
||||
return false
|
||||
}
|
||||
|
||||
@ -326,14 +317,14 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
if (signingPublicKey == null) {
|
||||
// If we don't have a key (and it's not an announce), we can't verify.
|
||||
// For security, we must reject packets from unknown peers unless it's an announce.
|
||||
Log.w(TAG, "❌ Signature check for $peerID: NO_SIGNING_KEY_AVAILABLE (packet type ${packet.type})")
|
||||
Log.w(TAG, "Signature check for $peerID: NO_SIGNING_KEY_AVAILABLE (packet type ${packet.type})")
|
||||
return false
|
||||
}
|
||||
|
||||
// 3. Get Canonical Data
|
||||
val packetDataForSigning = packet.toBinaryDataForSigning()
|
||||
if (packetDataForSigning == null) {
|
||||
Log.w(TAG, "❌ Signature check for $peerID: ENCODING_ERROR (packet type ${packet.type})")
|
||||
Log.w(TAG, "Signature check for $peerID: ENCODING_ERROR (packet type ${packet.type})")
|
||||
return false
|
||||
}
|
||||
|
||||
@ -346,15 +337,14 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
)
|
||||
|
||||
if (isSignatureValid) {
|
||||
// Log.v(TAG, "✅ Signature verified for $peerID (type ${packet.type})")
|
||||
return true
|
||||
} else {
|
||||
Log.w(TAG, "❌ Signature INVALID for $peerID (type ${packet.type})")
|
||||
Log.w(TAG, "Signature INVALID for $peerID (type ${packet.type})")
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Signature verification error for $peerID: ${e.message}")
|
||||
Log.e(TAG, "Signature verification error for $peerID: ${e.message}")
|
||||
return false
|
||||
}
|
||||
}
|
||||
@ -405,27 +395,23 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
*/
|
||||
private fun cleanupOldData() {
|
||||
val cutoffTime = System.currentTimeMillis() - MESSAGE_TIMEOUT
|
||||
var removedCount = 0
|
||||
|
||||
|
||||
// Clean up old message timestamps and corresponding processed messages
|
||||
val messagesToRemove = messageTimestamps.entries.filter { (_, timestamp) ->
|
||||
timestamp < cutoffTime
|
||||
}.map { it.key }
|
||||
|
||||
|
||||
messagesToRemove.forEach { messageId ->
|
||||
messageTimestamps.remove(messageId)
|
||||
if (processedMessages.remove(messageId)) {
|
||||
removedCount++
|
||||
}
|
||||
processedMessages.remove(messageId)
|
||||
}
|
||||
|
||||
|
||||
// Limit the size of processed messages set
|
||||
if (processedMessages.size > MAX_PROCESSED_MESSAGES) {
|
||||
val excess = processedMessages.size - MAX_PROCESSED_MESSAGES
|
||||
val toRemove = processedMessages.take(excess)
|
||||
processedMessages.removeAll(toRemove.toSet())
|
||||
removeFromMessageTimestamps(toRemove)
|
||||
removedCount += excess
|
||||
}
|
||||
|
||||
// Limit the size of processed key exchanges set
|
||||
@ -434,10 +420,6 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
val toRemove = processedKeyExchanges.take(excess)
|
||||
processedKeyExchanges.removeAll(toRemove.toSet())
|
||||
}
|
||||
|
||||
if (removedCount > 0) {
|
||||
Log.d(TAG, "Cleaned up $removedCount old processed messages")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -130,7 +130,6 @@ class ArtiTorManager private constructor() {
|
||||
val logListener = ArtiLogListener { logLine ->
|
||||
val text = logLine ?: return@ArtiLogListener
|
||||
val s = text
|
||||
Log.i(TAG, "arti: $s")
|
||||
lastLogTime.set(System.currentTimeMillis())
|
||||
_statusFlow.update { it.copy(lastLogLine = s) }
|
||||
handleArtiLogLine(s)
|
||||
@ -198,10 +197,6 @@ class ArtiTorManager private constructor() {
|
||||
if (mode == s.mode && mode != TorMode.OFF &&
|
||||
(lifecycleState == LifecycleState.STARTING || lifecycleState == LifecycleState.RUNNING)
|
||||
) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"applyMode: already in progress/running mode=$mode, state=$lifecycleState; skip"
|
||||
)
|
||||
return
|
||||
}
|
||||
when (mode) {
|
||||
@ -264,7 +259,6 @@ class ArtiTorManager private constructor() {
|
||||
private suspend fun startArti(application: Application, useDelay: Boolean = false) {
|
||||
try {
|
||||
stopArtiAndWait()
|
||||
Log.i(TAG, "Starting Arti on port $currentSocksPort…")
|
||||
if (useDelay) {
|
||||
delay(RESTART_DELAY_MS)
|
||||
}
|
||||
@ -296,10 +290,7 @@ class ArtiTorManager private constructor() {
|
||||
if (isBindError && bindRetryAttempts < MAX_RETRY_ATTEMPTS) {
|
||||
bindRetryAttempts++
|
||||
currentSocksPort++
|
||||
Log.w(
|
||||
TAG,
|
||||
"Port bind failed (attempt $bindRetryAttempts/$MAX_RETRY_ATTEMPTS), retrying with port $currentSocksPort"
|
||||
)
|
||||
Log.w(TAG, "Port bind failed (attempt $bindRetryAttempts/$MAX_RETRY_ATTEMPTS), retrying with port $currentSocksPort")
|
||||
socksAddr = InetSocketAddress("127.0.0.1", currentSocksPort)
|
||||
resetNetworkConnections()
|
||||
startArti(application, useDelay = false)
|
||||
@ -347,7 +338,6 @@ class ArtiTorManager private constructor() {
|
||||
try {
|
||||
val proxy = artiProxy
|
||||
if (proxy != null) {
|
||||
Log.i(TAG, "Stopping Arti…")
|
||||
try {
|
||||
proxy.stop()
|
||||
} catch (_: Throwable) {
|
||||
@ -397,10 +387,7 @@ class ArtiTorManager private constructor() {
|
||||
if (currentMode == TorMode.ON) {
|
||||
val bootstrapPercent = _statusFlow.value.bootstrapPercent
|
||||
if (bootstrapPercent < 100) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Inactivity detected (${timeSinceLastActivity}ms), restarting Arti"
|
||||
)
|
||||
Log.w(TAG, "Inactivity detected (${timeSinceLastActivity}ms), restarting Arti")
|
||||
currentApplication?.let { app ->
|
||||
appScope.launch {
|
||||
restartArti(app)
|
||||
@ -429,7 +416,6 @@ class ArtiTorManager private constructor() {
|
||||
delay(delayMs)
|
||||
val currentMode = _statusFlow.value.mode
|
||||
if (currentMode == TorMode.ON) {
|
||||
Log.i(TAG, "Retrying Arti start (attempt $retryAttempts)")
|
||||
restartArti(application)
|
||||
}
|
||||
}
|
||||
@ -465,7 +451,6 @@ class ArtiTorManager private constructor() {
|
||||
when {
|
||||
s.contains("AMEx: state changed to Initialized", ignoreCase = true) -> {
|
||||
if (currentLifecycle != LifecycleState.STARTING && currentLifecycle != LifecycleState.RUNNING) {
|
||||
Log.w(TAG, "Ignoring stale 'Initialized' log (lifecycle: $currentLifecycle)")
|
||||
return
|
||||
}
|
||||
_statusFlow.update { it.copy(state = TorState.STARTING) }
|
||||
@ -474,7 +459,6 @@ class ArtiTorManager private constructor() {
|
||||
|
||||
s.contains("AMEx: state changed to Starting", ignoreCase = true) -> {
|
||||
if (currentLifecycle != LifecycleState.STARTING && currentLifecycle != LifecycleState.RUNNING) {
|
||||
Log.w(TAG, "Ignoring stale 'Starting' log (lifecycle: $currentLifecycle)")
|
||||
return
|
||||
}
|
||||
_statusFlow.update { it.copy(state = TorState.STARTING) }
|
||||
@ -486,7 +470,6 @@ class ArtiTorManager private constructor() {
|
||||
ignoreCase = true
|
||||
) -> {
|
||||
if (currentLifecycle != LifecycleState.RUNNING) {
|
||||
Log.w(TAG, "Ignoring bootstrap log (lifecycle: $currentLifecycle)")
|
||||
return
|
||||
}
|
||||
_statusFlow.update {
|
||||
@ -502,7 +485,6 @@ class ArtiTorManager private constructor() {
|
||||
|
||||
s.contains("We have found that guard [scrubbed] is usable.", ignoreCase = true) -> {
|
||||
if (currentLifecycle != LifecycleState.RUNNING) {
|
||||
Log.w(TAG, "Ignoring guard discovery log (lifecycle: $currentLifecycle)")
|
||||
return
|
||||
}
|
||||
_statusFlow.update {
|
||||
@ -517,7 +499,6 @@ class ArtiTorManager private constructor() {
|
||||
|
||||
s.contains("AMEx: state changed to Stopping", ignoreCase = true) -> {
|
||||
if (currentLifecycle != LifecycleState.STOPPING) {
|
||||
Log.w(TAG, "Ignoring stale 'Stopping' log (lifecycle: $currentLifecycle)")
|
||||
return
|
||||
}
|
||||
_statusFlow.update {
|
||||
@ -530,10 +511,6 @@ class ArtiTorManager private constructor() {
|
||||
|
||||
s.contains("AMEx: state changed to Stopped", ignoreCase = true) -> {
|
||||
if (currentLifecycle != LifecycleState.STOPPING && currentLifecycle != LifecycleState.STOPPED) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Ignoring stale 'Stopped' log (lifecycle: $currentLifecycle, preventing state corruption)"
|
||||
)
|
||||
return
|
||||
}
|
||||
_statusFlow.update {
|
||||
|
||||
@ -88,7 +88,7 @@ class NoiseEncryptionService(private val context: Context) {
|
||||
if (loadedKeyPair != null) {
|
||||
staticIdentityPrivateKey = loadedKeyPair.first
|
||||
staticIdentityPublicKey = loadedKeyPair.second
|
||||
Log.d(TAG, "Loaded existing static identity key: ${calculateFingerprint(staticIdentityPublicKey)}")
|
||||
Log.d(TAG, "Identity loaded: ${calculateFingerprint(staticIdentityPublicKey).take(16)}")
|
||||
} else {
|
||||
// Generate new identity key pair
|
||||
val keyPair = generateKeyPair()
|
||||
@ -97,7 +97,6 @@ class NoiseEncryptionService(private val context: Context) {
|
||||
|
||||
// Save to secure storage
|
||||
identityStateManager.saveStaticKey(staticIdentityPrivateKey, staticIdentityPublicKey)
|
||||
Log.d(TAG, "Generated and saved new static identity key")
|
||||
}
|
||||
|
||||
// Load or create Ed25519 signing key (persistent across sessions)
|
||||
@ -105,7 +104,6 @@ class NoiseEncryptionService(private val context: Context) {
|
||||
if (loadedSigningKeyPair != null) {
|
||||
signingPrivateKey = loadedSigningKeyPair.first
|
||||
signingPublicKey = loadedSigningKeyPair.second
|
||||
Log.d(TAG, "Loaded existing Ed25519 signing key")
|
||||
} else {
|
||||
// Generate new Ed25519 signing key pair
|
||||
val signingKeyPair = generateEd25519KeyPair()
|
||||
@ -114,7 +112,6 @@ class NoiseEncryptionService(private val context: Context) {
|
||||
|
||||
// Save to secure storage
|
||||
identityStateManager.saveSigningKey(signingPrivateKey, signingPublicKey)
|
||||
Log.d(TAG, "Generated and saved new Ed25519 signing key")
|
||||
}
|
||||
}
|
||||
|
||||
@ -163,7 +160,7 @@ class NoiseEncryptionService(private val context: Context) {
|
||||
* Clear persistent identity (for panic mode)
|
||||
*/
|
||||
fun clearPersistentIdentity() {
|
||||
Log.w(TAG, "🚨 Panic Mode: Clearing persistent identity and rotating in-memory keys")
|
||||
Log.w(TAG, "Panic: clearing persistent identity and rotating in-memory keys")
|
||||
|
||||
// 1. Clear storage
|
||||
identityStateManager.clearIdentityData()
|
||||
@ -179,7 +176,6 @@ class NoiseEncryptionService(private val context: Context) {
|
||||
// 4. Re-initialize SessionManager with new keys
|
||||
initializeSessionManager()
|
||||
|
||||
Log.d(TAG, "✅ Identity cleared and keys rotated")
|
||||
}
|
||||
|
||||
// MARK: - Handshake Management
|
||||
@ -377,7 +373,7 @@ class NoiseEncryptionService(private val context: Context) {
|
||||
* Initiate rekey for a session (replaces old session with new handshake)
|
||||
*/
|
||||
fun initiateRekey(peerID: String): ByteArray? {
|
||||
Log.d(TAG, "Initiating rekey for session with $peerID")
|
||||
Log.d(TAG, "Rekeying session with $peerID")
|
||||
|
||||
// Remove old session
|
||||
sessionManager.removeSession(peerID)
|
||||
@ -437,7 +433,6 @@ class NoiseEncryptionService(private val context: Context) {
|
||||
// Calculate fingerprint for logging and callback
|
||||
val fingerprint = calculateFingerprint(remoteStaticKey)
|
||||
|
||||
Log.d(TAG, "Session established with $peerID, fingerprint: ${fingerprint.take(16)}...")
|
||||
|
||||
// Notify about authentication
|
||||
onPeerAuthenticated?.invoke(peerID, fingerprint)
|
||||
|
||||
@ -2,7 +2,6 @@ package com.bitchat.android.noise
|
||||
|
||||
import android.util.Log
|
||||
import com.bitchat.android.noise.southernstorm.protocol.*
|
||||
import com.bitchat.android.util.toHexString
|
||||
import java.security.SecureRandom
|
||||
|
||||
|
||||
@ -123,7 +122,6 @@ class NoiseSession(
|
||||
}
|
||||
// Extract ciphertext (remaining bytes)
|
||||
val ciphertext = combinedPayload.copyOfRange(NONCE_SIZE_BYTES, combinedPayload.size)
|
||||
Log.d(TAG, "Extracted nonce: $extractedNonce, ciphertext size: ${ciphertext.size}")
|
||||
return Pair(extractedNonce, ciphertext)
|
||||
|
||||
} catch (e: Exception) {
|
||||
@ -210,9 +208,7 @@ class NoiseSession(
|
||||
|
||||
init {
|
||||
try {
|
||||
// Validate static keys
|
||||
validateStaticKeys()
|
||||
Log.d(TAG, "Created ${if (isInitiator) "initiator" else "responder"} session for $peerID")
|
||||
} catch (e: Exception) {
|
||||
state = NoiseSessionState.Failed(e)
|
||||
Log.e(TAG, "Failed to initialize Noise session: ${e.message}")
|
||||
@ -237,8 +233,6 @@ class NoiseSession(
|
||||
if (localStaticPublicKey.all { it == 0.toByte() }) {
|
||||
throw IllegalArgumentException("Local static public key cannot be all zeros")
|
||||
}
|
||||
|
||||
Log.d(TAG, "Static keys validated successfully - private: ${localStaticPrivateKey.size} bytes, public: ${localStaticPublicKey.size} bytes")
|
||||
}
|
||||
|
||||
/**
|
||||
@ -247,60 +241,24 @@ class NoiseSession(
|
||||
*/
|
||||
private fun initializeNoiseHandshake(role: Int) {
|
||||
try {
|
||||
Log.d(TAG, "Creating HandshakeState with role: ${if (role == HandshakeState.INITIATOR) "INITIATOR" else "RESPONDER"}")
|
||||
|
||||
// LOGGING: Track Android handshake initialization (matching iOS)
|
||||
Log.d(TAG, "=== ANDROID NOISE SESSION - BEFORE HANDSHAKE INIT ===")
|
||||
Log.d(TAG, "Creating NoiseHandshakeState for peer: $peerID")
|
||||
Log.d(TAG, "Role: ${if (role == HandshakeState.INITIATOR) "INITIATOR" else "RESPONDER"}")
|
||||
|
||||
handshakeState = HandshakeState(PROTOCOL_NAME, role)
|
||||
Log.d(TAG, "HandshakeState created successfully")
|
||||
|
||||
Log.d(TAG, "=== ANDROID NOISE SESSION - AFTER HANDSHAKE INIT ===")
|
||||
Log.d(TAG, "NoiseHandshakeState created and mixPreMessageKeys() completed")
|
||||
|
||||
|
||||
if (handshakeState?.needsLocalKeyPair() == true) {
|
||||
Log.d(TAG, "Local static key pair is required for XX pattern")
|
||||
|
||||
val localKeyPair = handshakeState?.getLocalKeyPair()
|
||||
if (localKeyPair != null) {
|
||||
// FIXED: Use the provided persistent identity keys with our local fork
|
||||
// Our local fork properly supports setting pre-existing keys
|
||||
Log.d(TAG, "Setting persistent static identity keys...")
|
||||
|
||||
localKeyPair.setPrivateKey(localStaticPrivateKey, 0)
|
||||
|
||||
|
||||
if (!localKeyPair.hasPrivateKey() || !localKeyPair.hasPublicKey()) {
|
||||
throw IllegalStateException("Failed to set static identity keys - local fork issue")
|
||||
}
|
||||
|
||||
Log.d(TAG, "✓ Successfully set persistent static identity keys")
|
||||
Log.d(TAG, "Algorithm: ${localKeyPair.dhName}")
|
||||
Log.d(TAG, "Private key length: ${localKeyPair.privateKeyLength}")
|
||||
Log.d(TAG, "Public key length: ${localKeyPair.publicKeyLength}")
|
||||
|
||||
// Verify the keys were set correctly
|
||||
val verifyPrivate = ByteArray(32)
|
||||
val verifyPublic = ByteArray(32)
|
||||
localKeyPair.getPrivateKey(verifyPrivate, 0)
|
||||
localKeyPair.getPublicKey(verifyPublic, 0)
|
||||
|
||||
Log.d(TAG, "Persistent identity public key: ${localStaticPublicKey.joinToString("") { "%02x".format(it) }}")
|
||||
Log.d(TAG, "Set public key: ${verifyPublic.joinToString("") { "%02x".format(it) }}")
|
||||
|
||||
} else {
|
||||
throw IllegalStateException("HandshakeState returned null for local key pair")
|
||||
}
|
||||
|
||||
} else {
|
||||
Log.d(TAG, "Local static key pair not needed for this handshake pattern/role")
|
||||
}
|
||||
handshakeState?.start()
|
||||
Log.d(TAG, "Handshake state started successfully with persistent identity keys")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Exception during handshake initialization: ${e.message}", e)
|
||||
Log.e(TAG, "Handshake init failed for $peerID: ${e.message}", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
@ -315,8 +273,6 @@ class NoiseSession(
|
||||
*/
|
||||
@Synchronized
|
||||
fun startHandshake(): ByteArray {
|
||||
Log.d(TAG, "Starting noise XX handshake with $peerID as INITIATOR")
|
||||
|
||||
if (!isInitiator) {
|
||||
throw IllegalStateException("Only initiator can start handshake")
|
||||
}
|
||||
@ -343,15 +299,13 @@ class NoiseSession(
|
||||
|
||||
// Validate message size matches XX pattern expectations
|
||||
if (firstMessage.size != XX_MESSAGE_1_SIZE) {
|
||||
Log.w(TAG, "Warning: XX message 1 size ${firstMessage.size} != expected $XX_MESSAGE_1_SIZE")
|
||||
Log.w(TAG, "XX message 1 size ${firstMessage.size} != expected $XX_MESSAGE_1_SIZE")
|
||||
}
|
||||
|
||||
val ePrefix = firstMessage.take(4).toByteArray().toHexString()
|
||||
Log.d(TAG, "Sending XX handshake message 1 to $peerID (${firstMessage.size} bytes) e_prefix=$ePrefix currentPattern: $currentPattern")
|
||||
|
||||
return firstMessage
|
||||
} catch (e: Exception) {
|
||||
state = NoiseSessionState.Failed(e)
|
||||
Log.e(TAG, "Failed to start handshake: ${e.message}")
|
||||
Log.e(TAG, "Failed to start handshake with $peerID: ${e.message}")
|
||||
throw e
|
||||
}
|
||||
}
|
||||
@ -362,9 +316,6 @@ class NoiseSession(
|
||||
*/
|
||||
@Synchronized
|
||||
fun processHandshakeMessage(message: ByteArray): ByteArray? {
|
||||
val inputPrefix = message.take(4).toByteArray().toHexString()
|
||||
Log.d(TAG, "Processing handshake message from $peerID (${message.size} bytes) prefix=$inputPrefix")
|
||||
|
||||
try {
|
||||
// Initialize as responder if receiving first message
|
||||
if (state == NoiseSessionState.Uninitialized && !isInitiator) {
|
||||
@ -373,7 +324,6 @@ class NoiseSession(
|
||||
if (handshakeStartMs == null) {
|
||||
handshakeStartMs = System.currentTimeMillis()
|
||||
}
|
||||
Log.d(TAG, "Initialized as RESPONDER for XX handshake with $peerID")
|
||||
}
|
||||
|
||||
if (state != NoiseSessionState.Handshaking) {
|
||||
@ -389,13 +339,10 @@ class NoiseSession(
|
||||
// Read the incoming message - the Noise library will handle validation
|
||||
val payloadLength = handshakeStateLocal.readMessage(message, 0, message.size, payloadBuffer, 0)
|
||||
currentPattern++
|
||||
val readPrefix = message.take(4).toByteArray().toHexString()
|
||||
Log.d(TAG, "Read handshake message, payload length: $payloadLength prefix=$readPrefix currentPattern: $currentPattern")
|
||||
|
||||
|
||||
// Check what action the handshake state wants us to take next
|
||||
val action = handshakeStateLocal.getAction()
|
||||
Log.d(TAG, "Handshake action after processing message: $action")
|
||||
|
||||
|
||||
return when (action) {
|
||||
HandshakeState.WRITE_MESSAGE -> {
|
||||
// Noise library says we need to send a response
|
||||
@ -403,33 +350,22 @@ class NoiseSession(
|
||||
val responseLength = handshakeStateLocal.writeMessage(responseBuffer, 0, null, 0, 0)
|
||||
currentPattern++
|
||||
val response = responseBuffer.copyOf(responseLength)
|
||||
|
||||
Log.d(TAG, "Generated handshake response: ${response.size} bytes, action still: ${handshakeStateLocal.getAction()} currentPattern: $currentPattern")
|
||||
|
||||
completeHandshake()
|
||||
response
|
||||
}
|
||||
|
||||
|
||||
HandshakeState.SPLIT -> {
|
||||
// Handshake complete, split into transport keys
|
||||
completeHandshake()
|
||||
Log.d(TAG, "SPLIT ✅ XX handshake completed with $peerID")
|
||||
null
|
||||
}
|
||||
|
||||
|
||||
HandshakeState.FAILED -> {
|
||||
throw Exception("Handshake failed - Noise library reported FAILED state")
|
||||
}
|
||||
|
||||
HandshakeState.READ_MESSAGE -> {
|
||||
// Noise library expects us to read another message
|
||||
Log.d(TAG, "Handshake waiting for next message from $peerID")
|
||||
null
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.d(TAG, "Handshake action: $action - no immediate action needed")
|
||||
null
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
@ -448,8 +384,6 @@ class NoiseSession(
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "Completing XX handshake with $peerID")
|
||||
|
||||
try {
|
||||
val activeHandshake = handshakeState ?: throw NoiseSessionError.HandshakeFailed
|
||||
|
||||
@ -466,7 +400,6 @@ class NoiseSession(
|
||||
throw NoiseSessionError.PeerIdentityMismatch(peerID, derivedPeerID)
|
||||
}
|
||||
remoteStaticPublicKey = authenticatedRemoteKey
|
||||
Log.d(TAG, "Remote static public key is bound to $peerID")
|
||||
|
||||
// Only a bound remote identity may derive transport ciphers.
|
||||
val cipherPair = activeHandshake.split()
|
||||
@ -492,8 +425,7 @@ class NoiseSession(
|
||||
replayWindow = ByteArray(REPLAY_WINDOW_BYTES)
|
||||
|
||||
state = NoiseSessionState.Established
|
||||
Log.d(TAG, "Handshake completed with $peerID as isInitiator: $isInitiator - transport keys derived")
|
||||
Log.d(TAG, "✅ XX handshake completed with $peerID")
|
||||
Log.i(TAG, "Handshake established with $peerID (${if (isInitiator) "initiator" else "responder"})")
|
||||
} catch (e: Exception) {
|
||||
state = NoiseSessionState.Failed(e)
|
||||
Log.e(TAG, "Failed to complete handshake: ${e.message}")
|
||||
@ -556,20 +488,13 @@ class NoiseSession(
|
||||
|
||||
// Log high nonce values that might indicate issues
|
||||
if (currentNonce > HIGH_NONCE_WARNING_THRESHOLD) {
|
||||
Log.w(TAG, "High nonce value detected: $currentNonce - consider rekeying")
|
||||
Log.w(TAG, "High send nonce $currentNonce for $peerID - rekey recommended")
|
||||
}
|
||||
|
||||
Log.d(TAG, "✅ ANDROID ENCRYPT: ${data.size} → ${combinedPayload.size} bytes (nonce: $currentNonce, ciphertextLength+TAG: ${ciphertextLength}) for $peerID (msg #$messagesSent, role: ${if (isInitiator) "INITIATOR" else "RESPONDER"})")
|
||||
|
||||
return combinedPayload
|
||||
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Real encryption failed - exception: ${e.message}")
|
||||
|
||||
// ENHANCED: Log cipher state for debugging
|
||||
if (sendCipher != null) {
|
||||
Log.e(TAG, "Send cipher state: ${sendCipher!!.javaClass.simpleName}")
|
||||
}
|
||||
|
||||
Log.e(TAG, "Encryption failed for $peerID: ${e.message}")
|
||||
throw SessionError.EncryptionFailed
|
||||
}
|
||||
}
|
||||
@ -630,23 +555,13 @@ class NoiseSession(
|
||||
|
||||
// Log high nonce values that might indicate issues
|
||||
if (extractedNonce > HIGH_NONCE_WARNING_THRESHOLD) {
|
||||
Log.w(TAG, "High nonce value detected: $extractedNonce - consider rekeying")
|
||||
Log.w(TAG, "High receive nonce $extractedNonce for $peerID - rekey recommended")
|
||||
}
|
||||
|
||||
val result = plaintext.copyOf(plaintextLength)
|
||||
Log.d(TAG, "✅ ANDROID DECRYPT: ${combinedPayload.size} → ${result.size} bytes from $peerID (nonce: $extractedNonce, highest: $highestReceivedNonce, role: ${if (isInitiator) "INITIATOR" else "RESPONDER"})")
|
||||
return result
|
||||
|
||||
return plaintext.copyOf(plaintextLength)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Decryption failed - exception: ${e.message}")
|
||||
|
||||
// ENHANCED: Log cipher state and session details for debugging
|
||||
if (receiveCipher != null) {
|
||||
Log.e(TAG, "Receive cipher state: ${receiveCipher!!.javaClass.simpleName}")
|
||||
}
|
||||
Log.e(TAG, "Session state: $state, highest received nonce: $highestReceivedNonce")
|
||||
Log.e(TAG, "Input data size: ${combinedPayload.size} bytes")
|
||||
|
||||
Log.w(TAG, "Decrypt failed for $peerID: ${e.message} (state=$state, highestNonce=$highestReceivedNonce)")
|
||||
throw SessionError.DecryptionFailed
|
||||
}
|
||||
}
|
||||
@ -747,9 +662,7 @@ class NoiseSession(
|
||||
if (state !is NoiseSessionState.Failed) {
|
||||
state = NoiseSessionState.Failed(Exception("Session destroyed"))
|
||||
}
|
||||
|
||||
Log.d(TAG, "Session destroyed for $peerID")
|
||||
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error during session cleanup: ${e.message}")
|
||||
}
|
||||
|
||||
@ -73,7 +73,6 @@ class NoiseSessionManager(
|
||||
fun addSession(peerID: String, session: NoiseSession) {
|
||||
val previous = sessions.put(peerID, session)
|
||||
if (previous != null && previous !== session) previous.destroy()
|
||||
Log.d(TAG, "Added new session for $peerID")
|
||||
}
|
||||
|
||||
/**
|
||||
@ -91,7 +90,6 @@ class NoiseSessionManager(
|
||||
fun removeSession(peerID: String) {
|
||||
sessions.remove(peerID)?.destroy()
|
||||
responderCandidates.remove(peerID)?.destroy()
|
||||
Log.d(TAG, "Removed session for $peerID")
|
||||
}
|
||||
|
||||
/**
|
||||
@ -99,15 +97,12 @@ class NoiseSessionManager(
|
||||
*/
|
||||
@Synchronized
|
||||
fun initiateHandshake(peerID: String, replaceEstablished: Boolean = false): ByteArray? {
|
||||
Log.d(TAG, "initiateHandshake($peerID)")
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val existing = getSession(peerID)
|
||||
if (existing != null) {
|
||||
when {
|
||||
existing.isEstablished() -> {
|
||||
if (!replaceEstablished) {
|
||||
Log.d(TAG, "Handshake already established with $peerID, skipping initiate")
|
||||
return null
|
||||
}
|
||||
val candidate = createSession(peerID, isInitiator = true)
|
||||
@ -123,10 +118,9 @@ class NoiseSessionManager(
|
||||
}
|
||||
existing.isHandshaking() -> {
|
||||
if (!isHandshakeStale(existing, now)) {
|
||||
Log.d(TAG, "Handshake already in progress with $peerID, not restarting")
|
||||
return null
|
||||
}
|
||||
Log.d(TAG, "Handshake with $peerID is stale; restarting")
|
||||
Log.d(TAG, "Restarting stale handshake with $peerID")
|
||||
removeSession(peerID)
|
||||
}
|
||||
else -> {
|
||||
@ -142,13 +136,10 @@ class NoiseSessionManager(
|
||||
localStaticPrivateKey = localStaticPrivateKey,
|
||||
localStaticPublicKey = localStaticPublicKey
|
||||
)
|
||||
Log.d(TAG, "Storing new INITIATOR session for $peerID")
|
||||
addSession(peerID, session)
|
||||
|
||||
try {
|
||||
val handshakeData = session.startHandshake()
|
||||
Log.d(TAG, "Started handshake with $peerID as INITIATOR")
|
||||
return handshakeData
|
||||
return session.startHandshake()
|
||||
} catch (e: Exception) {
|
||||
if (sessions.remove(peerID, session)) session.destroy()
|
||||
throw e
|
||||
@ -167,8 +158,6 @@ class NoiseSessionManager(
|
||||
peerID: String,
|
||||
message: ByteArray
|
||||
): NoiseHandshakeProcessingResult {
|
||||
Log.d(TAG, "processHandshakeMessage($peerID, ${message.size} bytes)")
|
||||
|
||||
var activeSession: NoiseSession? = null
|
||||
var isReplacementCandidate = false
|
||||
var establishedRemoteKey: ByteArray? = null
|
||||
@ -182,19 +171,12 @@ class NoiseSessionManager(
|
||||
if (existingCandidate.isInitiatorRole()) {
|
||||
val shouldYield = localPeerID > peerID
|
||||
if (!shouldYield) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"Replacement handshake collision with $peerID; keeping initiator role"
|
||||
)
|
||||
return NoiseHandshakeProcessingResult(
|
||||
response = null,
|
||||
establishedNow = false
|
||||
)
|
||||
}
|
||||
Log.d(
|
||||
TAG,
|
||||
"Replacement handshake collision with $peerID; yielding to responder role"
|
||||
)
|
||||
Log.d(TAG, "Replacement collision with $peerID; yielding to responder")
|
||||
}
|
||||
responderCandidates.remove(peerID, existingCandidate)
|
||||
existingCandidate.destroy()
|
||||
@ -216,25 +198,19 @@ class NoiseSessionManager(
|
||||
) {
|
||||
val shouldYield = localPeerID > peerID
|
||||
if (shouldYield) {
|
||||
Log.d(TAG, "Handshake collision with $peerID; yielding to responder role")
|
||||
Log.d(TAG, "Handshake collision with $peerID; yielding to responder")
|
||||
if (sessions.remove(peerID, session)) session.destroy()
|
||||
session = null
|
||||
} else {
|
||||
Log.d(TAG, "Handshake collision with $peerID; keeping initiator role")
|
||||
return NoiseHandshakeProcessingResult(response = null, establishedNow = false)
|
||||
}
|
||||
}
|
||||
|
||||
activeSession = when {
|
||||
session == null -> {
|
||||
Log.d(TAG, "Creating new RESPONDER session for $peerID")
|
||||
createSession(peerID, isInitiator = false).also { sessions[peerID] = it }
|
||||
}
|
||||
session.isEstablished() -> {
|
||||
Log.d(
|
||||
TAG,
|
||||
"Validating replacement handshake for $peerID while preserving active session"
|
||||
)
|
||||
isReplacementCandidate = true
|
||||
createSession(peerID, isInitiator = false).also {
|
||||
responderCandidates[peerID] = it
|
||||
@ -276,7 +252,6 @@ class NoiseSessionManager(
|
||||
|
||||
establishedRemoteKey = remoteStaticKey
|
||||
establishedSessionToken = sessionToken
|
||||
Log.d(TAG, "✅ Session ESTABLISHED with bound identity $peerID")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
val session = activeSession
|
||||
@ -368,9 +343,7 @@ class NoiseSessionManager(
|
||||
* Check if session is established with peer
|
||||
*/
|
||||
fun hasEstablishedSession(peerID: String): Boolean {
|
||||
val hasSession = getSession(peerID)?.isEstablished() ?: false
|
||||
Log.d(TAG, "hasEstablishedSession($peerID): $hasSession")
|
||||
return hasSession
|
||||
return getSession(peerID)?.isEstablished() ?: false
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -22,8 +22,6 @@
|
||||
|
||||
package com.bitchat.android.noise.southernstorm.protocol;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Arrays;
|
||||
|
||||
@ -35,8 +33,6 @@ import javax.crypto.ShortBufferException;
|
||||
*/
|
||||
public class HandshakeState implements Destroyable {
|
||||
|
||||
private static final String TAG = "AndroidHandshake";
|
||||
|
||||
private SymmetricState symmetric;
|
||||
private boolean isInitiator;
|
||||
private DHState localKeyPair;
|
||||
@ -467,17 +463,6 @@ public class HandshakeState implements Destroyable {
|
||||
// Empty value for when the prologue is not supplied.
|
||||
private static final byte[] emptyPrologue = new byte [0];
|
||||
|
||||
/**
|
||||
* Converts a byte array to hex string for logging (matching iOS hex format)
|
||||
*/
|
||||
private static String bytesToHex(byte[] bytes) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : bytes) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the handshake running.
|
||||
*
|
||||
@ -524,27 +509,18 @@ public class HandshakeState implements Destroyable {
|
||||
throw new IllegalStateException("Pre-shared key required");
|
||||
}
|
||||
|
||||
// Log the symmetric state BEFORE any mixing operations (matching iOS)
|
||||
Log.d(TAG, "=== ANDROID HANDSHAKE START - INITIAL STATE ===");
|
||||
Log.d(TAG, "Protocol: " + symmetric.getProtocolName());
|
||||
Log.d(TAG, "Role: " + (isInitiator ? "INITIATOR" : "RESPONDER"));
|
||||
Log.d(TAG, "Initial symmetric hash: " + bytesToHex(symmetric.getHandshakeHash()));
|
||||
|
||||
// Hash the prologue value.
|
||||
Log.d(TAG, "Mixing empty prologue");
|
||||
if (prologue != null)
|
||||
symmetric.mixHash(prologue, 0, prologue.length);
|
||||
else
|
||||
symmetric.mixHash(emptyPrologue, 0, 0);
|
||||
Log.d(TAG, "Hash after empty prologue: " + bytesToHex(symmetric.getHandshakeHash()));
|
||||
|
||||
|
||||
// Hash the pre-shared key into the chaining key and handshake hash.
|
||||
if (preSharedKey != null)
|
||||
symmetric.mixPreSharedKey(preSharedKey);
|
||||
|
||||
|
||||
// Mix the pre-supplied public keys into the handshake hash.
|
||||
if (isInitiator) {
|
||||
Log.d(TAG, "XX pattern - no pre-message keys to mix");
|
||||
if ((requirements & LOCAL_PREMSG) != 0)
|
||||
symmetric.mixPublicKey(localKeyPair);
|
||||
if ((requirements & FALLBACK_PREMSG) != 0) {
|
||||
@ -557,7 +533,6 @@ public class HandshakeState implements Destroyable {
|
||||
if ((requirements & REMOTE_PREMSG) != 0)
|
||||
symmetric.mixPublicKey(remotePublicKey);
|
||||
} else {
|
||||
Log.d(TAG, "XX pattern - no pre-message keys to mix");
|
||||
if ((requirements & REMOTE_PREMSG) != 0)
|
||||
symmetric.mixPublicKey(remotePublicKey);
|
||||
if ((requirements & FALLBACK_PREMSG) != 0) {
|
||||
@ -571,11 +546,6 @@ public class HandshakeState implements Destroyable {
|
||||
symmetric.mixPublicKey(localKeyPair);
|
||||
}
|
||||
|
||||
// Log final state after all initialization (matching iOS)
|
||||
Log.d(TAG, "=== ANDROID HANDSHAKE START - FINAL STATE ===");
|
||||
Log.d(TAG, "Final symmetric hash after mixPreMessageKeys(): " + bytesToHex(symmetric.getHandshakeHash()));
|
||||
Log.d(TAG, "===========================================");
|
||||
|
||||
// The handshake has officially started - set the first action.
|
||||
if (isInitiator)
|
||||
action = WRITE_MESSAGE;
|
||||
|
||||
@ -22,8 +22,6 @@
|
||||
|
||||
package com.bitchat.android.noise.southernstorm.protocol;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.security.DigestException;
|
||||
import java.security.MessageDigest;
|
||||
@ -37,9 +35,7 @@ import javax.crypto.ShortBufferException;
|
||||
* Symmetric state for helping manage a Noise handshake.
|
||||
*/
|
||||
class SymmetricState implements Destroyable {
|
||||
|
||||
private static final String TAG = "AndroidSymmetric";
|
||||
|
||||
|
||||
private String name;
|
||||
private CipherState cipher;
|
||||
private MessageDigest hash;
|
||||
@ -47,17 +43,6 @@ class SymmetricState implements Destroyable {
|
||||
private byte[] h;
|
||||
private byte[] prev_h;
|
||||
|
||||
/**
|
||||
* Converts a byte array to hex string for logging (matching iOS hex format)
|
||||
*/
|
||||
private static String bytesToHex(byte[] bytes) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : bytes) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new symmetric state object.
|
||||
*
|
||||
@ -94,14 +79,6 @@ class SymmetricState implements Destroyable {
|
||||
}
|
||||
|
||||
System.arraycopy(h, 0, ck, 0, hashLength);
|
||||
|
||||
// LOGGING: Initial symmetric state after protocol name initialization (matching iOS)
|
||||
Log.d(TAG, "=== ANDROID SYMMETRIC STATE INITIALIZED ===");
|
||||
Log.d(TAG, "Protocol: " + protocolName);
|
||||
Log.d(TAG, "Initial hash (h): " + bytesToHex(h));
|
||||
Log.d(TAG, "Initial chaining key (ck): " + bytesToHex(ck));
|
||||
Log.d(TAG, "Hash length: " + h.length);
|
||||
Log.d(TAG, "=========================================");
|
||||
}
|
||||
|
||||
/**
|
||||
@ -134,14 +111,6 @@ class SymmetricState implements Destroyable {
|
||||
*/
|
||||
public void mixKey(byte[] data, int offset, int length)
|
||||
{
|
||||
// LOGGING: Before mixKey operation (matching iOS)
|
||||
byte[] inputData = new byte[length];
|
||||
System.arraycopy(data, offset, inputData, 0, length);
|
||||
Log.d(TAG, "*** Android mixKey() BEFORE ***");
|
||||
Log.d(TAG, "Input data (" + length + " bytes): " + bytesToHex(inputData));
|
||||
Log.d(TAG, "Current CK: " + bytesToHex(ck));
|
||||
Log.d(TAG, "Current Hash: " + bytesToHex(h));
|
||||
|
||||
int keyLength = cipher.getKeyLength();
|
||||
byte[] tempKey = new byte [keyLength];
|
||||
try {
|
||||
@ -150,12 +119,6 @@ class SymmetricState implements Destroyable {
|
||||
} finally {
|
||||
Noise.destroy(tempKey);
|
||||
}
|
||||
|
||||
// LOGGING: After mixKey operation (matching iOS)
|
||||
Log.d(TAG, "*** Android mixKey() AFTER ***");
|
||||
Log.d(TAG, "New CK: " + bytesToHex(ck));
|
||||
Log.d(TAG, "Hash unchanged: " + bytesToHex(h));
|
||||
Log.d(TAG, "Cipher now has key: " + (cipher.getMACLength() > 0));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -167,18 +130,7 @@ class SymmetricState implements Destroyable {
|
||||
*/
|
||||
public void mixHash(byte[] data, int offset, int length)
|
||||
{
|
||||
// LOGGING: Before mixHash operation (matching iOS)
|
||||
byte[] inputData = new byte[length];
|
||||
System.arraycopy(data, offset, inputData, 0, length);
|
||||
Log.d(TAG, "*** Android mixHash() BEFORE ***");
|
||||
Log.d(TAG, "Input data (" + length + " bytes): " + bytesToHex(inputData));
|
||||
Log.d(TAG, "Current Hash: " + bytesToHex(h));
|
||||
|
||||
hashTwo(h, 0, h.length, data, offset, length, h, 0, h.length);
|
||||
|
||||
// LOGGING: After mixHash operation (matching iOS)
|
||||
Log.d(TAG, "*** Android mixHash() AFTER ***");
|
||||
Log.d(TAG, "New Hash: " + bytesToHex(h));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -406,7 +406,7 @@ class LocationNotesManager private constructor() {
|
||||
val currentNotes = _notes.value ?: emptyList()
|
||||
_notes.value = (currentNotes + note).sortedByDescending { it.createdAt }
|
||||
|
||||
Log.d(TAG, "📥 Added note: ${note.displayName} - ${note.content.take(50)}")
|
||||
Log.d(TAG, "Added note from ${note.displayName}")
|
||||
|
||||
// Trim if exceeds max
|
||||
if (noteIDs.size > MAX_NOTES_IN_MEMORY) {
|
||||
|
||||
@ -274,8 +274,6 @@ object NostrProtocol {
|
||||
giftWrap: NostrEvent,
|
||||
recipientPrivateKey: String
|
||||
): NostrEvent? {
|
||||
Log.d(TAG, "Unwrapping gift wrap; content prefix='${giftWrap.content.take(3)}' length=${giftWrap.content.length}")
|
||||
|
||||
return try {
|
||||
val decrypted = NostrCrypto.decryptNIP44(
|
||||
ciphertext = giftWrap.content,
|
||||
|
||||
@ -139,7 +139,7 @@ class NostrRelayManager private constructor() {
|
||||
return
|
||||
}
|
||||
geohashToRelays[geohash] = selected
|
||||
Log.i(TAG, "🌐 Geohash $geohash using ${selected.size} relays: ${selected.joinToString()}")
|
||||
Log.d(TAG, "Geohash $geohash using ${selected.size} relays")
|
||||
ensureConnectionsFor(selected)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to ensure relays for $geohash: ${e.message}")
|
||||
@ -166,7 +166,6 @@ class NostrRelayManager private constructor() {
|
||||
): String {
|
||||
ensureGeohashRelaysConnected(geohash, nRelays, includeDefaults)
|
||||
val relayUrls = getRelaysForGeohash(geohash)
|
||||
Log.d(TAG, "📡 Subscribing id=$id for geohash=$geohash on ${relayUrls.size} relays")
|
||||
return subscribe(
|
||||
filter = filter,
|
||||
id = id,
|
||||
@ -191,7 +190,6 @@ class NostrRelayManager private constructor() {
|
||||
sendEvent(event, Companion.defaultRelays())
|
||||
return
|
||||
}
|
||||
Log.v(TAG, "📤 Sending event kind=${event.kind} to ${relayUrls.size} relays for geohash=$geohash")
|
||||
sendEvent(event, relayUrls)
|
||||
}
|
||||
|
||||
@ -229,7 +227,6 @@ class NostrRelayManager private constructor() {
|
||||
relaysList.addAll(defaultRelayUrls.map { Relay(it) })
|
||||
_relays.value = relaysList.toList()
|
||||
updateConnectionStatus()
|
||||
Log.d(TAG, "✅ NostrRelayManager initialized with ${relaysList.size} default relays")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to initialize NostrRelayManager: ${e.message}", e)
|
||||
// Initialize with empty list as fallback
|
||||
@ -242,7 +239,7 @@ class NostrRelayManager private constructor() {
|
||||
* Connect to all configured relays
|
||||
*/
|
||||
fun connect() {
|
||||
Log.d(TAG, "🌐 Connecting to ${relaysList.size} Nostr relays")
|
||||
Log.i(TAG, "Connecting to ${relaysList.size} Nostr relays")
|
||||
|
||||
scope.launch {
|
||||
relaysList.forEach { relay ->
|
||||
@ -260,7 +257,7 @@ class NostrRelayManager private constructor() {
|
||||
* Disconnect from all relays
|
||||
*/
|
||||
fun disconnect() {
|
||||
Log.d(TAG, "Disconnecting from all relays")
|
||||
Log.i(TAG, "Disconnecting from all Nostr relays")
|
||||
|
||||
// Stop subscription validation
|
||||
stopSubscriptionValidation()
|
||||
@ -318,9 +315,7 @@ class NostrRelayManager private constructor() {
|
||||
|
||||
activeSubscriptions[id] = subscriptionInfo
|
||||
messageHandlers[id] = handler
|
||||
|
||||
Log.d(TAG, "📡 Subscribing to Nostr filter id=$id ${filter.getDebugDescription()}")
|
||||
|
||||
|
||||
// Send subscription to appropriate relays
|
||||
sendSubscriptionToRelays(subscriptionInfo)
|
||||
|
||||
@ -333,10 +328,7 @@ class NostrRelayManager private constructor() {
|
||||
private fun sendSubscriptionToRelays(subscriptionInfo: SubscriptionInfo) {
|
||||
val request = NostrRequest.Subscribe(subscriptionInfo.id, listOf(subscriptionInfo.filter))
|
||||
val message = gson.toJson(request, NostrRequest::class.java)
|
||||
|
||||
// DEBUG: Log the actual serialized message format
|
||||
Log.v(TAG, "🔍 DEBUG: Serialized subscription message: $message")
|
||||
|
||||
|
||||
scope.launch {
|
||||
val targetRelays = subscriptionInfo.targetRelayUrls?.toList() ?: connections.keys.toList()
|
||||
|
||||
@ -349,21 +341,17 @@ class NostrRelayManager private constructor() {
|
||||
// Track subscription for this relay
|
||||
val currentSubs = subscriptions[relayUrl] ?: emptySet()
|
||||
subscriptions[relayUrl] = currentSubs + subscriptionInfo.id
|
||||
|
||||
Log.v(TAG, "✅ Subscription '${subscriptionInfo.id}' sent to relay: $relayUrl")
|
||||
} else {
|
||||
Log.w(TAG, "❌ Failed to send subscription to $relayUrl: WebSocket send failed")
|
||||
Log.w(TAG, "Failed to send subscription to $relayUrl: WebSocket send failed")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Failed to send subscription to $relayUrl: ${e.message}")
|
||||
Log.e(TAG, "Failed to send subscription to $relayUrl: ${e.message}")
|
||||
}
|
||||
} else {
|
||||
Log.v(TAG, "⏳ Relay $relayUrl not connected, subscription will be sent on reconnection")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (connections.isEmpty()) {
|
||||
Log.w(TAG, "⚠️ No relay connections available for subscription, will retry on reconnection")
|
||||
Log.w(TAG, "No relay connections available for subscription, will retry on reconnection")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -377,12 +365,10 @@ class NostrRelayManager private constructor() {
|
||||
messageHandlers.remove(id)
|
||||
|
||||
if (subscriptionInfo == null) {
|
||||
Log.w(TAG, "⚠️ Attempted to unsubscribe from unknown subscription: $id")
|
||||
Log.w(TAG, "Attempted to unsubscribe from unknown subscription: $id")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "🚫 Unsubscribing from subscription: $id")
|
||||
|
||||
|
||||
val request = NostrRequest.Close(id)
|
||||
val message = gson.toJson(request, NostrRequest::class.java)
|
||||
|
||||
@ -393,7 +379,6 @@ class NostrRelayManager private constructor() {
|
||||
try {
|
||||
webSocket.send(message)
|
||||
subscriptions[relayUrl] = currentSubs - id
|
||||
Log.v(TAG, "Unsubscribed '$id' from relay: $relayUrl")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to unsubscribe from $relayUrl: ${e.message}")
|
||||
}
|
||||
@ -445,8 +430,6 @@ class NostrRelayManager private constructor() {
|
||||
* Useful for ensuring subscription consistency after network issues
|
||||
*/
|
||||
fun reestablishAllSubscriptions() {
|
||||
Log.d(TAG, "🔄 Force re-establishing all ${activeSubscriptions.size} active subscriptions")
|
||||
|
||||
scope.launch {
|
||||
connections.forEach { (relayUrl, webSocket) ->
|
||||
restoreSubscriptionsForRelay(relayUrl, webSocket)
|
||||
@ -473,7 +456,7 @@ class NostrRelayManager private constructor() {
|
||||
messageQueue.clear()
|
||||
}
|
||||
|
||||
Log.i(TAG, "🧹 Cleared all Nostr subscriptions and routing caches")
|
||||
Log.i(TAG, "Cleared all Nostr subscriptions and routing caches")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to clear subscriptions: ${e.message}")
|
||||
}
|
||||
@ -498,7 +481,6 @@ class NostrRelayManager private constructor() {
|
||||
*/
|
||||
fun clearDeduplicationCache() {
|
||||
eventDeduplicator.clear()
|
||||
Log.i(TAG, "🧹 Cleared event deduplication cache")
|
||||
}
|
||||
|
||||
/**
|
||||
@ -570,7 +552,7 @@ class NostrRelayManager private constructor() {
|
||||
try {
|
||||
val report = validateSubscriptionConsistency()
|
||||
if (!report.isConsistent && report.connectedRelayCount > 0) {
|
||||
Log.w(TAG, "⚠️ Subscription inconsistencies detected: ${report.inconsistencies}")
|
||||
Log.w(TAG, "Subscription inconsistencies detected: ${report.inconsistencies}")
|
||||
|
||||
// Auto-repair: re-establish subscriptions for relays with missing ones
|
||||
connections.forEach { (relayUrl, webSocket) ->
|
||||
@ -582,7 +564,7 @@ class NostrRelayManager private constructor() {
|
||||
|
||||
val missingSubs = expectedSubs - currentSubs
|
||||
if (missingSubs.isNotEmpty()) {
|
||||
Log.i(TAG, "🔧 Auto-repairing ${missingSubs.size} missing subscriptions for $relayUrl")
|
||||
Log.i(TAG, "Auto-repairing ${missingSubs.size} missing subscriptions for $relayUrl")
|
||||
restoreSubscriptionsForRelay(relayUrl, webSocket)
|
||||
}
|
||||
}
|
||||
@ -592,8 +574,6 @@ class NostrRelayManager private constructor() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "🔄 Started periodic subscription validation (${SUBSCRIPTION_VALIDATION_INTERVAL / 1000}s interval)")
|
||||
}
|
||||
|
||||
/**
|
||||
@ -602,7 +582,6 @@ class NostrRelayManager private constructor() {
|
||||
private fun stopSubscriptionValidation() {
|
||||
subscriptionValidationJob?.cancel()
|
||||
subscriptionValidationJob = null
|
||||
Log.v(TAG, "⏹️ Stopped subscription validation")
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
@ -612,9 +591,7 @@ class NostrRelayManager private constructor() {
|
||||
if (connections.containsKey(urlString)) {
|
||||
return
|
||||
}
|
||||
|
||||
Log.v(TAG, "Attempting to connect to Nostr relay: $urlString")
|
||||
|
||||
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url(urlString)
|
||||
@ -624,7 +601,7 @@ class NostrRelayManager private constructor() {
|
||||
connections[urlString] = webSocket
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Failed to create WebSocket connection to $urlString: ${e.message}")
|
||||
Log.e(TAG, "Failed to create WebSocket connection to $urlString: ${e.message}")
|
||||
handleDisconnection(urlString, e)
|
||||
}
|
||||
}
|
||||
@ -633,9 +610,7 @@ class NostrRelayManager private constructor() {
|
||||
try {
|
||||
val request = NostrRequest.Event(event)
|
||||
val message = gson.toJson(request, NostrRequest::class.java)
|
||||
|
||||
Log.v(TAG, "📤 Sending Nostr event (kind: ${event.kind}) to relay: $relayUrl")
|
||||
|
||||
|
||||
val success = webSocket.send(message)
|
||||
if (success) {
|
||||
// Update relay stats
|
||||
@ -643,10 +618,10 @@ class NostrRelayManager private constructor() {
|
||||
relay?.messagesSent = (relay?.messagesSent ?: 0) + 1
|
||||
updateRelaysList()
|
||||
} else {
|
||||
Log.e(TAG, "❌ Failed to send event to $relayUrl: WebSocket send failed")
|
||||
Log.e(TAG, "Failed to send event to $relayUrl: WebSocket send failed")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Failed to send event to $relayUrl: ${e.message}")
|
||||
Log.e(TAG, "Failed to send event to $relayUrl: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@ -671,24 +646,13 @@ class NostrRelayManager private constructor() {
|
||||
activeSubscriptions[response.subscriptionId]?.let { subInfo ->
|
||||
val matches = try { subInfo.filter.matches(response.event) } catch (e: Exception) { true }
|
||||
if (!matches) {
|
||||
Log.v(TAG, "🚫 Dropping event ${response.event.id.take(16)}... not matching filter for sub=${response.subscriptionId}")
|
||||
// Do NOT call deduplicator here to allow the correct subscription to process it later
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// DEDUPLICATION: Check if we've already processed this event
|
||||
val wasProcessed = eventDeduplicator.processEvent(response.event) { event ->
|
||||
// Only log non-gift-wrap events to reduce noise
|
||||
if (event.kind != NostrKind.GIFT_WRAP) {
|
||||
val originGeo = activeSubscriptions[response.subscriptionId]?.originGeohash
|
||||
if (originGeo != null) {
|
||||
Log.v(TAG, "📥 Processing event (kind=${event.kind}) from relay=$relayUrl geo=$originGeo sub=${response.subscriptionId}")
|
||||
} else {
|
||||
Log.v(TAG, "📥 Processing event (kind=${event.kind}) from relay=$relayUrl sub=${response.subscriptionId}")
|
||||
}
|
||||
}
|
||||
|
||||
eventDeduplicator.processEvent(response.event) { event ->
|
||||
// Call handler for new events only
|
||||
val handler = messageHandlers[response.subscriptionId]
|
||||
if (handler != null) {
|
||||
@ -696,35 +660,29 @@ class NostrRelayManager private constructor() {
|
||||
handler(event)
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "⚠️ No handler for subscription ${response.subscriptionId}")
|
||||
Log.d(TAG, "No handler for subscription ${response.subscriptionId}")
|
||||
}
|
||||
}
|
||||
|
||||
if (!wasProcessed) {
|
||||
//Log.v(TAG, "🔄 Duplicate event ${response.event.id.take(16)}... from relay: $relayUrl")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
is NostrResponse.EndOfStoredEvents -> {
|
||||
Log.v(TAG, "End of stored events for subscription: ${response.subscriptionId}")
|
||||
// No action needed
|
||||
}
|
||||
|
||||
|
||||
is NostrResponse.Ok -> {
|
||||
val wasGiftWrap = pendingGiftWrapIDs.remove(response.eventId)
|
||||
if (response.accepted) {
|
||||
Log.d(TAG, "✅ Event accepted id=${response.eventId.take(16)}... by relay: $relayUrl")
|
||||
} else {
|
||||
if (!response.accepted) {
|
||||
val level = if (wasGiftWrap) Log.WARN else Log.ERROR
|
||||
Log.println(level, TAG, "📮 Event ${response.eventId.take(16)}... rejected by relay: ${response.message ?: "no reason"}")
|
||||
Log.println(level, TAG, "Event rejected by relay $relayUrl: ${response.message ?: "no reason"}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
is NostrResponse.Notice -> {
|
||||
Log.i(TAG, "📢 Notice from $relayUrl: ${response.message}")
|
||||
Log.d(TAG, "Notice from $relayUrl: ${response.message}")
|
||||
}
|
||||
|
||||
|
||||
is NostrResponse.Unknown -> {
|
||||
Log.v(TAG, "Unknown message type from $relayUrl: ${response.raw}")
|
||||
Log.d(TAG, "Unknown message type from $relayUrl")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@ -820,29 +778,26 @@ class NostrRelayManager private constructor() {
|
||||
}
|
||||
|
||||
if (subscriptionsToRestore.isEmpty()) {
|
||||
Log.v(TAG, "🔄 No subscriptions to restore for relay: $relayUrl")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "🔄 Restoring ${subscriptionsToRestore.size} subscriptions for relay: $relayUrl")
|
||||
|
||||
|
||||
Log.d(TAG, "Restoring ${subscriptionsToRestore.size} subscriptions for relay: $relayUrl")
|
||||
|
||||
subscriptionsToRestore.forEach { subscriptionInfo ->
|
||||
try {
|
||||
val request = NostrRequest.Subscribe(subscriptionInfo.id, listOf(subscriptionInfo.filter))
|
||||
val message = gson.toJson(request, NostrRequest::class.java)
|
||||
|
||||
|
||||
val success = webSocket.send(message)
|
||||
if (success) {
|
||||
// Track subscription for this relay
|
||||
val currentSubs = subscriptions[relayUrl] ?: emptySet()
|
||||
subscriptions[relayUrl] = currentSubs + subscriptionInfo.id
|
||||
|
||||
Log.v(TAG, "✅ Restored subscription '${subscriptionInfo.id}' to relay: $relayUrl")
|
||||
} else {
|
||||
Log.w(TAG, "❌ Failed to restore subscription '${subscriptionInfo.id}' to $relayUrl: WebSocket send failed")
|
||||
Log.w(TAG, "Failed to restore subscription '${subscriptionInfo.id}' to $relayUrl: WebSocket send failed")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Failed to restore subscription '${subscriptionInfo.id}' to $relayUrl: ${e.message}")
|
||||
Log.e(TAG, "Failed to restore subscription '${subscriptionInfo.id}' to $relayUrl: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -853,7 +808,7 @@ class NostrRelayManager private constructor() {
|
||||
private inner class RelayWebSocketListener(private val relayUrl: String) : WebSocketListener() {
|
||||
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
Log.d(TAG, "✅ Connected to Nostr relay: $relayUrl")
|
||||
Log.i(TAG, "Connected to Nostr relay: $relayUrl")
|
||||
updateRelayStatus(relayUrl, true)
|
||||
|
||||
// Restore all active subscriptions for this relay
|
||||
@ -876,17 +831,17 @@ class NostrRelayManager private constructor() {
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
Log.d(TAG, "WebSocket closing for $relayUrl: $code $reason")
|
||||
// Server-initiated close; onClosed will follow
|
||||
}
|
||||
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
Log.d(TAG, "WebSocket closed for $relayUrl: $code $reason")
|
||||
Log.i(TAG, "Disconnected from Nostr relay $relayUrl: $code $reason")
|
||||
val error = Exception("WebSocket closed: $code $reason")
|
||||
handleDisconnection(relayUrl, error)
|
||||
}
|
||||
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
Log.e(TAG, "❌ WebSocket failure for $relayUrl: ${t.message}")
|
||||
Log.e(TAG, "WebSocket failure for $relayUrl: ${t.message}")
|
||||
handleDisconnection(relayUrl, t)
|
||||
}
|
||||
}
|
||||
|
||||
@ -165,7 +165,7 @@ class NostrTestManager(private val context: Context) {
|
||||
|
||||
// Subscribe to private messages (won't receive any in test, but tests the subscription mechanism)
|
||||
nostrClient.subscribeToPrivateMessages { content, senderNpub, timestamp ->
|
||||
Log.d(TAG, "📥 Received test private message from $senderNpub: $content")
|
||||
Log.d(TAG, "Received test private message (${content.length} chars)")
|
||||
messageReceived = true
|
||||
}
|
||||
|
||||
|
||||
@ -68,20 +68,18 @@ class NostrTransport(
|
||||
return@launch
|
||||
}
|
||||
|
||||
Log.d(TAG, "NostrTransport: preparing PM to ${recipientNostrPubkey.take(16)}... for peerID ${to.take(8)}... id=${messageID.take(8)}...")
|
||||
|
||||
val recipientHex = ContactIdentityResolver.nostrPubkeyHex(recipientNostrPubkey)
|
||||
if (recipientHex == null) {
|
||||
Log.e(TAG, "NostrTransport: recipient key is not a valid Nostr pubkey")
|
||||
return@launch
|
||||
}
|
||||
|
||||
|
||||
val recipientPeerIDForEmbed = try {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared
|
||||
.findPeerIDForNostrPubkey(recipientNostrPubkey)
|
||||
} catch (_: Exception) { null }
|
||||
if (recipientPeerIDForEmbed.isNullOrBlank()) {
|
||||
Log.e(TAG, "NostrTransport: no peerID stored for recipient npub; cannot embed PM. npub=${recipientNostrPubkey.take(16)}...")
|
||||
Log.e(TAG, "NostrTransport: no peerID stored for recipient npub; cannot embed PM")
|
||||
return@launch
|
||||
}
|
||||
val embedded = NostrEmbeddedBitChat.encodePMForNostr(
|
||||
@ -104,11 +102,10 @@ class NostrTransport(
|
||||
)
|
||||
|
||||
giftWraps.forEach { event ->
|
||||
Log.d(TAG, "NostrTransport: sending PM giftWrap id=${event.id.take(16)}...")
|
||||
NostrRelayManager.registerPendingGiftWrap(event.id)
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
}
|
||||
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send private message via Nostr: ${e.message}")
|
||||
}
|
||||
@ -153,8 +150,6 @@ class NostrTransport(
|
||||
return@launch
|
||||
}
|
||||
|
||||
Log.d(TAG, "NostrTransport: preparing READ ack for id=${item.receipt.originalMessageID.take(8)}... to ${recipientNostrPubkey.take(16)}...")
|
||||
|
||||
val recipientHex = ContactIdentityResolver.nostrPubkeyHex(recipientNostrPubkey)
|
||||
if (recipientHex == null) {
|
||||
scheduleNextReadAck()
|
||||
@ -181,11 +176,10 @@ class NostrTransport(
|
||||
)
|
||||
|
||||
giftWraps.forEach { event ->
|
||||
Log.d(TAG, "NostrTransport: sending READ ack giftWrap id=${event.id.take(16)}...")
|
||||
NostrRelayManager.registerPendingGiftWrap(event.id)
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
}
|
||||
|
||||
|
||||
scheduleNextReadAck()
|
||||
|
||||
} catch (e: Exception) {
|
||||
@ -220,9 +214,7 @@ class NostrTransport(
|
||||
}
|
||||
|
||||
val content = FavoriteControlMessage.encode(isFavorite, senderIdentity.npub)
|
||||
|
||||
Log.d(TAG, "NostrTransport: preparing FAVORITE($isFavorite) to ${recipientNostrPubkey.take(16)}...")
|
||||
|
||||
|
||||
val recipientHex = ContactIdentityResolver.nostrPubkeyHex(recipientNostrPubkey)
|
||||
if (recipientHex == null) {
|
||||
return@launch
|
||||
@ -247,11 +239,10 @@ class NostrTransport(
|
||||
)
|
||||
|
||||
giftWraps.forEach { event ->
|
||||
Log.d(TAG, "NostrTransport: sending favorite giftWrap id=${event.id.take(16)}...")
|
||||
NostrRelayManager.registerPendingGiftWrap(event.id)
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
}
|
||||
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send favorite notification via Nostr: ${e.message}")
|
||||
}
|
||||
@ -274,13 +265,11 @@ class NostrTransport(
|
||||
return@launch
|
||||
}
|
||||
|
||||
Log.d(TAG, "NostrTransport: preparing DELIVERED ack for id=${messageID.take(8)}... to ${recipientNostrPubkey.take(16)}...")
|
||||
|
||||
val recipientHex = ContactIdentityResolver.nostrPubkeyHex(recipientNostrPubkey)
|
||||
if (recipientHex == null) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
|
||||
val ack = NostrEmbeddedBitChat.encodeAckForNostr(
|
||||
type = NoisePayloadType.DELIVERED,
|
||||
messageID = messageID,
|
||||
@ -300,11 +289,10 @@ class NostrTransport(
|
||||
)
|
||||
|
||||
giftWraps.forEach { event ->
|
||||
Log.d(TAG, "NostrTransport: sending DELIVERED ack giftWrap id=${event.id.take(16)}...")
|
||||
NostrRelayManager.registerPendingGiftWrap(event.id)
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
}
|
||||
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send delivery ack via Nostr: ${e.message}")
|
||||
}
|
||||
@ -320,8 +308,6 @@ class NostrTransport(
|
||||
) {
|
||||
transportScope.launch {
|
||||
try {
|
||||
Log.d(TAG, "GeoDM: send DELIVERED -> recip=${toRecipientHex.take(8)}... mid=${messageID.take(8)}... from=${fromIdentity.publicKeyHex.take(8)}...")
|
||||
|
||||
val embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(
|
||||
type = NoisePayloadType.DELIVERED,
|
||||
messageID = messageID,
|
||||
@ -355,8 +341,6 @@ class NostrTransport(
|
||||
) {
|
||||
transportScope.launch {
|
||||
try {
|
||||
Log.d(TAG, "GeoDM: send READ -> recip=${toRecipientHex.take(8)}... mid=${messageID.take(8)}... from=${fromIdentity.publicKeyHex.take(8)}...")
|
||||
|
||||
val embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(
|
||||
type = NoisePayloadType.READ_RECEIPT,
|
||||
messageID = messageID,
|
||||
@ -414,11 +398,6 @@ class NostrTransport(
|
||||
try {
|
||||
if (toRecipientHex.isEmpty()) return@launch
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"GeoDM: send PM -> recip=${toRecipientHex.take(8)}... mid=${messageID.take(8)}... from=${fromIdentity.publicKeyHex.take(8)}... geohash=$geohash"
|
||||
)
|
||||
|
||||
// Build embedded BitChat packet without recipient peer ID
|
||||
val embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
|
||||
content = content,
|
||||
@ -436,7 +415,6 @@ class NostrTransport(
|
||||
)
|
||||
|
||||
giftWraps.forEach { event ->
|
||||
Log.d(TAG, "NostrTransport: sending geohash PM giftWrap id=${event.id.take(16)}...")
|
||||
NostrRelayManager.registerPendingGiftWrap(event.id)
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
}
|
||||
|
||||
@ -99,13 +99,12 @@ class MediaSendingManager(
|
||||
val filePacket = withContext(mediaWorkDispatcher) {
|
||||
val file = java.io.File(filePath)
|
||||
if (!file.exists()) {
|
||||
Log.e(TAG, "❌ File does not exist: $filePath")
|
||||
Log.e(TAG, "Voice note file does not exist")
|
||||
return@withContext null
|
||||
}
|
||||
Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}")
|
||||
|
||||
if (file.length() > MAX_FILE_SIZE) {
|
||||
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
|
||||
Log.e(TAG, "File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
@ -143,16 +142,14 @@ class MediaSendingManager(
|
||||
) {
|
||||
try {
|
||||
val filePacket = withContext(mediaWorkDispatcher) {
|
||||
Log.d(TAG, "🔄 Starting image send: $filePath")
|
||||
val file = java.io.File(filePath)
|
||||
if (!file.exists()) {
|
||||
Log.e(TAG, "❌ File does not exist: $filePath")
|
||||
Log.e(TAG, "Image file does not exist")
|
||||
return@withContext null
|
||||
}
|
||||
Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}")
|
||||
|
||||
if (file.length() > MAX_FILE_SIZE) {
|
||||
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
|
||||
Log.e(TAG, "File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
@ -170,10 +167,7 @@ class MediaSendingManager(
|
||||
sendPublicFile(channelOrNull, filePacket, filePath, BitchatMessageType.Image)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ CRITICAL: Image send failed completely", e)
|
||||
Log.e(TAG, "❌ Image path: $filePath")
|
||||
Log.e(TAG, "❌ Error details: ${e.message}")
|
||||
Log.e(TAG, "❌ Error type: ${e.javaClass.simpleName}")
|
||||
Log.e(TAG, "Image send failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
@ -193,16 +187,14 @@ class MediaSendingManager(
|
||||
) {
|
||||
try {
|
||||
val filePacket = withContext(mediaWorkDispatcher) {
|
||||
Log.d(TAG, "🔄 Starting file send: $filePath")
|
||||
val file = java.io.File(filePath)
|
||||
if (!file.exists()) {
|
||||
Log.e(TAG, "❌ File does not exist: $filePath")
|
||||
Log.e(TAG, "File does not exist")
|
||||
return@withContext null
|
||||
}
|
||||
Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}")
|
||||
|
||||
if (file.length() > MAX_FILE_SIZE) {
|
||||
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
|
||||
Log.e(TAG, "File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
@ -212,7 +204,6 @@ class MediaSendingManager(
|
||||
} catch (_: Exception) {
|
||||
"application/octet-stream"
|
||||
}
|
||||
Log.d(TAG, "🏷️ MIME type: $mimeType")
|
||||
|
||||
// Try to preserve the original file name if our copier prefixed it earlier
|
||||
val originalName = run {
|
||||
@ -228,7 +219,6 @@ class MediaSendingManager(
|
||||
?: base
|
||||
stripped + ext
|
||||
}
|
||||
Log.d(TAG, "📝 Original filename: $originalName")
|
||||
|
||||
BitchatFilePacket(
|
||||
fileName = originalName,
|
||||
@ -237,7 +227,6 @@ class MediaSendingManager(
|
||||
content = file.readBytes()
|
||||
)
|
||||
} ?: return
|
||||
Log.d(TAG, "📦 Created file packet successfully")
|
||||
|
||||
val messageType = when {
|
||||
filePacket.mimeType.lowercase().startsWith("image/") -> BitchatMessageType.Image
|
||||
@ -251,10 +240,7 @@ class MediaSendingManager(
|
||||
sendPublicFile(channelOrNull, filePacket, filePath, messageType)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ CRITICAL: File send failed completely", e)
|
||||
Log.e(TAG, "❌ File path: $filePath")
|
||||
Log.e(TAG, "❌ Error details: ${e.message}")
|
||||
Log.e(TAG, "❌ Error type: ${e.javaClass.simpleName}")
|
||||
Log.e(TAG, "File send failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
@ -269,17 +255,14 @@ class MediaSendingManager(
|
||||
) {
|
||||
val payload = withContext(mediaWorkDispatcher) { filePacket.encode() }
|
||||
?: run {
|
||||
Log.e(TAG, "❌ Failed to encode file packet for private send")
|
||||
Log.e(TAG, "Failed to encode file packet for private send")
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "🔒 Encoded private packet: ${payload.size} bytes")
|
||||
|
||||
val (transferId, contentHash) = withContext(mediaWorkDispatcher) {
|
||||
sha256Hex(payload) to sha256Hex(filePacket.content)
|
||||
val transferId = withContext(mediaWorkDispatcher) {
|
||||
sha256Hex(payload)
|
||||
}
|
||||
|
||||
Log.d(TAG, "📤 FILE_TRANSFER send (private): name='${filePacket.fileName}', size=${filePacket.fileSize}, mime='${filePacket.mimeType}', sha256=$contentHash, to=${toPeerID.take(8)} transferId=${transferId.take(16)}…")
|
||||
|
||||
val pending = PendingAutomaticPrivateMedia(
|
||||
requestId = UUID.randomUUID().toString(),
|
||||
peerID = toPeerID,
|
||||
@ -465,7 +448,7 @@ class MediaSendingManager(
|
||||
|
||||
PrivateMediaPreparation.NeedsHandshake -> {
|
||||
ensureAutomaticPendingTimeout(pending)
|
||||
Log.i(TAG, "Private media needs a Noise handshake; retaining first-send intent")
|
||||
Log.d(TAG, "Private media needs a Noise handshake; retaining first-send intent")
|
||||
try {
|
||||
meshService.initiateNoiseHandshake(pending.peerID)
|
||||
} catch (e: Exception) {
|
||||
@ -475,7 +458,7 @@ class MediaSendingManager(
|
||||
|
||||
PrivateMediaPreparation.AwaitingPeerState -> {
|
||||
ensureAutomaticPendingTimeout(pending)
|
||||
Log.i(TAG, "Private media is waiting for authenticated peer state; first-send intent retained")
|
||||
Log.d(TAG, "Private media is waiting for authenticated peer state; first-send intent retained")
|
||||
}
|
||||
|
||||
is PrivateMediaPreparation.Rejected -> {
|
||||
@ -614,7 +597,6 @@ class MediaSendingManager(
|
||||
)
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "✅ Private media committed using ${preparation.transfer.wireMode}")
|
||||
}
|
||||
|
||||
/**
|
||||
@ -628,16 +610,13 @@ class MediaSendingManager(
|
||||
) {
|
||||
val payload = withContext(mediaWorkDispatcher) { filePacket.encode() }
|
||||
?: run {
|
||||
Log.e(TAG, "❌ Failed to encode file packet for broadcast send")
|
||||
Log.e(TAG, "Failed to encode file packet for broadcast send")
|
||||
return
|
||||
}
|
||||
Log.d(TAG, "🔓 Encoded broadcast packet: ${payload.size} bytes")
|
||||
|
||||
val (transferId, contentHash) = withContext(mediaWorkDispatcher) {
|
||||
sha256Hex(payload) to sha256Hex(filePacket.content)
|
||||
val transferId = withContext(mediaWorkDispatcher) {
|
||||
sha256Hex(payload)
|
||||
}
|
||||
|
||||
Log.d(TAG, "📤 FILE_TRANSFER send (broadcast): name='${filePacket.fileName}', size=${filePacket.fileSize}, mime='${filePacket.mimeType}', sha256=$contentHash, transferId=${transferId.take(16)}…")
|
||||
|
||||
val message = BitchatMessage(
|
||||
id = java.util.UUID.randomUUID().toString().uppercase(), // Generate unique ID for each message
|
||||
@ -667,11 +646,9 @@ class MediaSendingManager(
|
||||
com.bitchat.android.model.DeliveryStatus.PartiallyDelivered(0, 100)
|
||||
)
|
||||
|
||||
Log.d(TAG, "📤 Calling meshService.sendFileBroadcast")
|
||||
withContext(mediaWorkDispatcher) {
|
||||
meshService.sendFileBroadcast(filePacket)
|
||||
}
|
||||
Log.d(TAG, "✅ File broadcast completed successfully")
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -199,10 +199,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
registerProvisionalWifiClaim(pid, claim)
|
||||
if (!meshCore.initiateNoiseHandshakeOnLink(pid, relay, linkID)) {
|
||||
provisionalWifiClaims.remove(pid, claim)
|
||||
Log.w(
|
||||
TAG,
|
||||
"Could not send Noise challenge on exact Wi-Fi link for ${pid.take(8)}"
|
||||
)
|
||||
Log.w(TAG, "Could not send Noise challenge on Wi-Fi link for ${pid.take(8)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -255,16 +252,13 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
* Broadcasts raw bytes to currently connected peer.
|
||||
*/
|
||||
private fun broadcastRaw(bytes: ByteArray) {
|
||||
var sent = 0
|
||||
connectionTracker.peerSockets.forEach { (pid, sock) ->
|
||||
try {
|
||||
sock.write(bytes)
|
||||
sent++
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "TX: write failed to ${pid.take(8)}: ${e.message}")
|
||||
}
|
||||
}
|
||||
Log.i(TAG, "TX: broadcast via Wi-Fi Aware to $sent peers (bytes=${bytes.size})")
|
||||
}
|
||||
|
||||
// TransportLayer implementation
|
||||
@ -282,22 +276,17 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
* Broadcasts routed packet to currently connected peers.
|
||||
*/
|
||||
private fun broadcastPacket(routed: RoutedPacket) {
|
||||
Log.d(TAG, "TX: packet type=${routed.packet.type} broadcast (ttl=${routed.packet.ttl})")
|
||||
|
||||
val packet = routed.packet
|
||||
if (packet.senderID.toHexString() == myPeerID && !packet.route.isNullOrEmpty()) {
|
||||
val firstHop = packet.route!![0].toHexString()
|
||||
if (sendRoutedPacketToPeer(firstHop, routed)) {
|
||||
Log.d(TAG, "TX: source-routed packet sent only to first Wi-Fi hop ${firstHop.take(8)}")
|
||||
return
|
||||
}
|
||||
Log.w(TAG, "TX: first Wi-Fi source-route hop ${firstHop.take(8)} unavailable; falling back to broadcast")
|
||||
}
|
||||
|
||||
val recipientId = packet.recipientID?.toHexString()
|
||||
if (recipientId != null && !packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)) {
|
||||
if (sendRoutedPacketToPeer(recipientId, routed)) {
|
||||
Log.d(TAG, "TX: addressed packet sent directly to Wi-Fi peer ${recipientId.take(8)}")
|
||||
return
|
||||
}
|
||||
}
|
||||
@ -321,7 +310,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
|
||||
private fun sendRoutedPacketToPeer(peerID: String, routed: RoutedPacket): Boolean {
|
||||
if (connectionTracker.getSocketForPeer(peerID) == null) {
|
||||
Log.w(TAG, "TX: no socket for ${peerID.take(8)}")
|
||||
return false
|
||||
}
|
||||
return fragmentingSender.send(routed, "Wi-Fi Aware peer ${peerID.take(8)}") { single ->
|
||||
@ -339,12 +327,11 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
val data = packet.toBinaryData() ?: return false
|
||||
val sock = connectionTracker.getSocketForPeer(peerID)
|
||||
if (sock == null) {
|
||||
Log.w(TAG, "TX: no socket for ${peerID.take(8)}")
|
||||
Log.d(TAG, "TX: no socket for ${peerID.take(8)}")
|
||||
return false
|
||||
}
|
||||
try {
|
||||
sock.write(data)
|
||||
Log.d(TAG, "TX: packet type=${packet.type} to ${peerID.take(8)} (bytes=${data.size})")
|
||||
return true
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "TX: write to ${peerID.take(8)} failed: ${e.message}")
|
||||
@ -409,7 +396,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
return
|
||||
}
|
||||
wifiAwareSession = session
|
||||
Log.i(TAG, "Wi-Fi Aware attached; starting publish & subscribe (peerID=$myPeerID)")
|
||||
Log.i(TAG, "Wi-Fi Aware attached; starting publish & subscribe")
|
||||
|
||||
// PUBLISH (server role)
|
||||
session.publish(
|
||||
@ -424,7 +411,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
return
|
||||
}
|
||||
publishSession = pub
|
||||
Log.d(TAG, "PUBLISH: onPublishStarted()")
|
||||
Log.d(TAG, "Wi-Fi Aware publish started")
|
||||
try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi-Fi Aware Publish Started")) } catch (_: Exception) {}
|
||||
}
|
||||
override fun onServiceDiscovered(
|
||||
@ -438,12 +425,10 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
if (peerId.isNotBlank()) {
|
||||
rememberDiscoveredPeer(peerId)
|
||||
publishHandles[peerId] = peerHandle
|
||||
Log.i(TAG, "PUBLISH: Discovered subscriber '$peerId' via Aware")
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
offerServerPathIfAppropriate(peerId, peerHandle, "publish discovery")
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "PUBLISH: onServiceDiscovered ssi='${peerId.take(16)}' len=${serviceSpecificInfo.size}")
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
@ -466,18 +451,16 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
rememberDiscoveredPeer(subscriberId)
|
||||
publishHandles[subscriberId] = peerHandle
|
||||
}
|
||||
Log.i(TAG, "PUBLISH: Received discovery ping from subscriber '$subscriberId'")
|
||||
handleSubscriberPing(publishSession!!, peerHandle)
|
||||
}
|
||||
|
||||
override fun onSessionTerminated() {
|
||||
if (!isCurrentSession(generation)) return
|
||||
Log.e(TAG, "PUBLISH: onSessionTerminated()")
|
||||
publishSession = null
|
||||
val shouldRestart = isActive && com.bitchat.android.wifiaware.WifiAwareController.enabled.value
|
||||
Log.w(TAG, "Wi-Fi Aware publish session terminated (restart=$shouldRestart)")
|
||||
handleUnexpectedStop(generation)
|
||||
if (shouldRestart) {
|
||||
Log.i(TAG, "PUBLISH: Scheduling Wi-Fi Aware restart")
|
||||
com.bitchat.android.wifiaware.WifiAwareController.restartIfStillEnabled(2000)
|
||||
}
|
||||
}
|
||||
@ -498,7 +481,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
return
|
||||
}
|
||||
subscribeSession = sub
|
||||
Log.d(TAG, "SUBSCRIBE: onSubscribeStarted()")
|
||||
Log.d(TAG, "Wi-Fi Aware subscribe started")
|
||||
try { com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().addDebugMessage(com.bitchat.android.ui.debug.DebugMessage.SystemMessage("Wi-Fi Aware Subscribe Started")) } catch (_: Exception) {}
|
||||
}
|
||||
override fun onServiceDiscovered(
|
||||
@ -528,12 +511,11 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
|
||||
override fun onSessionTerminated() {
|
||||
if (!isCurrentSession(generation)) return
|
||||
Log.e(TAG, "SUBSCRIBE: onSessionTerminated()")
|
||||
subscribeSession = null
|
||||
val shouldRestart = isActive && com.bitchat.android.wifiaware.WifiAwareController.enabled.value
|
||||
Log.w(TAG, "Wi-Fi Aware subscribe session terminated (restart=$shouldRestart)")
|
||||
handleUnexpectedStop(generation)
|
||||
if (shouldRestart) {
|
||||
Log.i(TAG, "SUBSCRIBE: Scheduling Wi-Fi Aware restart")
|
||||
com.bitchat.android.wifiaware.WifiAwareController.restartIfStillEnabled(2000)
|
||||
}
|
||||
}
|
||||
@ -552,7 +534,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
|
||||
override fun onAwareSessionTerminated() {
|
||||
if (!isCurrentSession(generation)) return
|
||||
Log.e(TAG, "Aware Session Terminated unexpectedly")
|
||||
Log.e(TAG, "Wi-Fi Aware session terminated unexpectedly")
|
||||
wifiAwareSession = null
|
||||
val shouldRestart = com.bitchat.android.wifiaware.WifiAwareController.enabled.value
|
||||
handleUnexpectedStop(generation)
|
||||
@ -679,7 +661,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
if (peerId.isBlank() || peerId == myPeerID || !amIServerFor(peerId)) return
|
||||
if (!connectionTracker.isConnectionAttemptAllowed(peerId)) return
|
||||
|
||||
Log.d(TAG, "PUBLISH: offering server path to ${peerId.take(8)} after $reason")
|
||||
handleSubscriberPing(pubSession, peerHandle)
|
||||
}
|
||||
|
||||
@ -691,7 +672,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
if ((now - lastRefresh) < DISCOVERY_SESSION_REFRESH_MIN_INTERVAL_MS) return false
|
||||
if (!lastDiscoveryRefreshAt.compareAndSet(lastRefresh, now)) return false
|
||||
|
||||
Log.i(TAG, "Maintenance: refreshing Wi-Fi Aware discovery sessions ($reason)")
|
||||
Log.i(TAG, "Refreshing Wi-Fi Aware discovery sessions ($reason)")
|
||||
handleUnexpectedStop(sessionGeneration.get())
|
||||
return true
|
||||
}
|
||||
@ -701,7 +682,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
*/
|
||||
private fun startPeriodicConnectionMaintenance() {
|
||||
serviceScope.launch {
|
||||
Log.d(TAG, "Starting periodic connection maintenance loop")
|
||||
while (isActive) {
|
||||
try {
|
||||
delay(15_000) // Check every 15 seconds
|
||||
@ -719,7 +699,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
handleToPeerId.entries.removeIf { it.value in staleIds }
|
||||
staleIds.forEach { subscribeHandles.remove(it) }
|
||||
staleIds.forEach { publishHandles.remove(it) }
|
||||
Log.d(TAG, "Maintenance: pruned ${staleIds.size} stale discovery entries")
|
||||
}
|
||||
|
||||
// 1. Identify peers that are discovered (recently seen) but not currently connected
|
||||
@ -745,7 +724,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
}
|
||||
if (!connectionTracker.isConnectionAttemptAllowed(peerId)) continue
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
Log.i(TAG, "Maintenance: offering Wi-Fi Aware server path to ${peerId.take(8)}")
|
||||
offerServerPathIfAppropriate(peerId, handle, "maintenance")
|
||||
attemptedReconnect = true
|
||||
}
|
||||
@ -763,7 +741,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
// Check tracker policy
|
||||
if (!connectionTracker.isConnectionAttemptAllowed(peerId)) continue
|
||||
|
||||
Log.i(TAG, "Maintenance: attempting Wi-Fi Aware reconnect to ${peerId.take(8)}")
|
||||
sendSubscribePing(peerId, handle, "maintenance")
|
||||
attemptedReconnect = true
|
||||
}
|
||||
@ -795,9 +772,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
val msgId = (System.nanoTime() and 0x7fffffff).toInt()
|
||||
try {
|
||||
subscribeSession?.sendMessage(peerHandle, msgId, myPeerID.toByteArray())
|
||||
Log.d(TAG, "SUBSCRIBE: sent $reason ping to '${peerId.take(16)}' (msgId=$msgId)")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to send $reason ping to ${peerId.take(8)}: ${e.message}")
|
||||
Log.d(TAG, "Failed to send $reason ping to ${peerId.take(8)}: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@ -809,7 +785,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
|
||||
val handle = subscribeHandles[peerId]
|
||||
if (handle == null) {
|
||||
Log.i(TAG, "CLIENT: role reversal queued for ${peerId.take(8)} until subscribe handle is available")
|
||||
Log.d(TAG, "CLIENT: role reversal queued for ${peerId.take(8)} until subscribe handle is available")
|
||||
return
|
||||
}
|
||||
|
||||
@ -817,7 +793,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
val payload = "$ROLE_REVERSAL_PREFIX$myPeerID".toByteArray()
|
||||
try {
|
||||
subscribeSession?.sendMessage(handle, msgId, payload)
|
||||
Log.i(TAG, "CLIENT: requested Wi-Fi Aware role reversal with ${peerId.take(8)} (msgId=$msgId)")
|
||||
Log.d(TAG, "CLIENT: requested role reversal with ${peerId.take(8)}")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "CLIENT: failed to request role reversal with ${peerId.take(8)}: ${e.message}")
|
||||
}
|
||||
@ -830,9 +806,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
val shouldReverse = failures >= CLIENT_ROLE_REVERSAL_FAILURES
|
||||
if (shouldReverse) {
|
||||
clientSocketFailures.remove(peerId)
|
||||
Log.i(TAG, "CLIENT: ${peerId.take(8)} failed $failures client socket attempts; requesting role reversal")
|
||||
} else {
|
||||
Log.d(TAG, "CLIENT: ${peerId.take(8)} failed client socket attempt $failures/$CLIENT_ROLE_REVERSAL_FAILURES; retrying same role")
|
||||
Log.d(TAG, "CLIENT: ${peerId.take(8)} failed $failures client socket attempts; requesting role reversal")
|
||||
}
|
||||
return shouldReverse
|
||||
}
|
||||
@ -843,7 +817,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
discoveredTimestamps[requesterId] = System.currentTimeMillis()
|
||||
forcedClientPeers.add(requesterId)
|
||||
forcedServerPeers.remove(requesterId)
|
||||
Log.i(TAG, "PUBLISH: role reversal requested by ${requesterId.take(8)}; switching to client role")
|
||||
Log.i(TAG, "Role reversal requested by ${requesterId.take(8)}; switching to client role")
|
||||
|
||||
subscribeHandles[requesterId]?.let { handle ->
|
||||
sendSubscribePing(requesterId, handle, "role-reversal")
|
||||
@ -865,16 +839,12 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
if (!amIServerFor(peerId)) return
|
||||
|
||||
if (connectionTracker.isConnected(peerId)) {
|
||||
Log.v(TAG, "↪ already connected to $peerId, skipping serve")
|
||||
return
|
||||
}
|
||||
if (connectionTracker.hasOpenServerSocket(peerId)) {
|
||||
Log.v(TAG, "↪ already serving $peerId, skipping")
|
||||
return
|
||||
}
|
||||
if (connectionTracker.hasPendingDataPathRequest(peerId)) {
|
||||
val pending = connectionTracker.pendingDataPathPeerIds(peerId).joinToString(", ") { it.take(8) }
|
||||
Log.d(TAG, "SERVER: deferring serve for ${peerId.take(8)}; pending Aware data path(s): $pending")
|
||||
return
|
||||
}
|
||||
if (!connectionTracker.addPendingConnection(peerId)) {
|
||||
@ -895,8 +865,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
connectionTracker.addServerSocket(peerId, ss)
|
||||
val port = ss.localPort
|
||||
|
||||
Log.d(TAG, "SERVER: listening for ${peerId.take(8)} on ${ss.localSocketAddress}")
|
||||
|
||||
val spec = WifiAwareNetworkSpecifier.Builder(pubSession, peerHandle)
|
||||
.setPskPassphrase(PSK)
|
||||
.setPort(port)
|
||||
@ -914,7 +882,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
private val acceptStarted = AtomicBoolean(false)
|
||||
|
||||
override fun onAvailable(network: Network) {
|
||||
Log.i(TAG, "SERVER: onAvailable() - Aware network is ready for ${peerId.take(8)}")
|
||||
// Only accept once per network request
|
||||
if (!acceptStarted.compareAndSet(false, true)) return
|
||||
// Offload the blocking accept() off the callback thread so we never stall
|
||||
@ -923,10 +890,9 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
try {
|
||||
try { ss.soTimeout = ACCEPT_TIMEOUT_MS } catch (_: Exception) {}
|
||||
val client = ss.accept()
|
||||
Log.i(TAG, "SERVER: Accepted raw TCP connection from ${peerId.take(8)}")
|
||||
try { network.bindSocket(client) } catch (e: Exception) { Log.w(TAG, "Server bindSocket EPERM: ${e.message}") }
|
||||
client.keepAlive = true
|
||||
Log.i(TAG, "SERVER: Bound and established TCP with ${peerId.take(8)} addr=${client.inetAddress?.hostAddress}")
|
||||
Log.i(TAG, "Connected to ${peerId.take(8)} (server)")
|
||||
val synced = SyncedSocket(client)
|
||||
activeSocket = synced
|
||||
connectionTracker.onClientConnected(peerId, synced)
|
||||
@ -943,14 +909,11 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
// Kick off Noise handshake for this logical peer
|
||||
if (myPeerID < peerId) {
|
||||
meshCore.initiateNoiseHandshake(peerId)
|
||||
Log.i(TAG, "SERVER: Initiating Noise handshake to ${peerId.take(8)}")
|
||||
}
|
||||
// Ensure fast presence even before handshake settles
|
||||
serviceScope.launch { delay(150); sendBroadcastAnnounce() }
|
||||
} catch (ioe: IOException) {
|
||||
if (ss.isClosed || !isActive) {
|
||||
Log.d(TAG, "SERVER: accept stopped for ${peerId.take(8)} after socket cleanup")
|
||||
} else {
|
||||
if (!ss.isClosed && isActive) {
|
||||
Log.e(TAG, "SERVER: accept failed for ${peerId.take(8)}", ioe)
|
||||
handleNetworkFailure(peerId)
|
||||
}
|
||||
@ -959,18 +922,17 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
}
|
||||
|
||||
override fun onUnavailable() {
|
||||
Log.e(TAG, "SERVER: onUnavailable() - Failed to acquire Aware network for ${peerId.take(8)} (timeout or refused)")
|
||||
Log.e(TAG, "SERVER: failed to acquire Aware network for ${peerId.take(8)}")
|
||||
handleNetworkFailure(peerId)
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
handlePeerDisconnection(peerId, activeSocket)
|
||||
Log.i(TAG, "SERVER: WiFi Aware network lost for ${peerId.take(8)}")
|
||||
Log.i(TAG, "Disconnected from ${peerId.take(8)} (server: network lost)")
|
||||
}
|
||||
}
|
||||
|
||||
connectionTracker.addNetworkCallback(peerId, cb)
|
||||
Log.i(TAG, "SERVER: [Calling requestNetwork] for ${peerId.take(8)} with port $port")
|
||||
try {
|
||||
// use requestNetwork with a timeout to trigger onUnavailable if it fails
|
||||
cm.requestNetwork(req, cb, NETWORK_REQUEST_TIMEOUT_MS)
|
||||
@ -983,10 +945,9 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
val readyPayload = buildServerReadyPayload(port)
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
try {
|
||||
val sent = pubSession.sendMessage(peerHandle, readyId, readyPayload)
|
||||
Log.d(TAG, "PUBLISH: server-ready sent=$sent (msgId=$readyId, port=$port)")
|
||||
pubSession.sendMessage(peerHandle, readyId, readyPayload)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "PUBLISH: Exception sending server-ready to $peerHandle", e)
|
||||
Log.e(TAG, "PUBLISH: failed to send server-ready to ${peerId.take(8)}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1054,15 +1015,12 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
sock.tcpNoDelay = true
|
||||
sock.keepAlive = true
|
||||
sock.connect(java.net.InetSocketAddress(scopedAddr, port), CLIENT_CONNECT_TIMEOUT_MS)
|
||||
if (attempt > 1) {
|
||||
Log.i(TAG, "CLIENT: socket connect succeeded for ${peerId.take(8)} on attempt $attempt")
|
||||
}
|
||||
return sock
|
||||
} catch (e: IOException) {
|
||||
lastFailure = e
|
||||
try { sock?.close() } catch (_: Exception) { }
|
||||
if (attempt < CLIENT_SOCKET_ATTEMPTS) {
|
||||
Log.w(TAG, "CLIENT: socket attempt $attempt/$CLIENT_SOCKET_ATTEMPTS failed for ${peerId.take(8)}: ${e.message}; retrying")
|
||||
Log.d(TAG, "CLIENT: socket attempt $attempt/$CLIENT_SOCKET_ATTEMPTS failed for ${peerId.take(8)}: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1096,16 +1054,12 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
val mappedPeerId = handleToPeerId[peerHandle]?.takeIf { it.isNotBlank() }
|
||||
val peerId = advertisedPeerId ?: mappedPeerId
|
||||
if (peerId == null) {
|
||||
Log.w(TAG, "SUBSCRIBE: dropped server-ready with no peer mapping and no peer ID payload (payload=${payload.size}B)")
|
||||
return null
|
||||
}
|
||||
|
||||
handleToPeerId[peerHandle] = peerId
|
||||
subscribeHandles[peerId] = peerHandle
|
||||
rememberDiscoveredPeer(peerId)
|
||||
if (advertisedPeerId != null && mappedPeerId != null && advertisedPeerId != mappedPeerId) {
|
||||
Log.d(TAG, "SUBSCRIBE: server-ready remapped handle ${mappedPeerId.take(8)} -> ${advertisedPeerId.take(8)}")
|
||||
}
|
||||
return peerId
|
||||
}
|
||||
|
||||
@ -1118,7 +1072,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
payload: ByteArray
|
||||
) {
|
||||
if (payload.size < Int.SIZE_BYTES) {
|
||||
Log.w(TAG, "handleServerReady called with invalid payload size=${payload.size}, dropping")
|
||||
return
|
||||
}
|
||||
|
||||
@ -1126,17 +1079,14 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
if (peerId == myPeerID) return
|
||||
if (amIServerFor(peerId)) return
|
||||
if (connectionTracker.peerSockets.containsKey(peerId)) {
|
||||
Log.v(TAG, "↪ already client-connected to $peerId, skipping")
|
||||
return
|
||||
}
|
||||
val cancelledServerOffers = connectionTracker.cancelPendingServerDataPaths(peerId)
|
||||
if (cancelledServerOffers.isNotEmpty()) {
|
||||
val cancelled = cancelledServerOffers.joinToString(", ") { it.take(8) }
|
||||
Log.i(TAG, "CLIENT: preempted pending server offer(s) for $cancelled to connect ${peerId.take(8)}")
|
||||
Log.d(TAG, "CLIENT: preempted pending server offer(s) for $cancelled to connect ${peerId.take(8)}")
|
||||
}
|
||||
if (connectionTracker.hasPendingDataPathRequest(peerId)) {
|
||||
val pending = connectionTracker.pendingDataPathPeerIds(peerId).joinToString(", ") { it.take(8) }
|
||||
Log.d(TAG, "CLIENT: deferring server-ready for ${peerId.take(8)}; pending Aware data path(s): $pending")
|
||||
return
|
||||
}
|
||||
if (!connectionTracker.addPendingConnection(peerId)) {
|
||||
@ -1144,10 +1094,9 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
}
|
||||
|
||||
val port = ByteBuffer.wrap(payload, 0, Int.SIZE_BYTES).order(ByteOrder.BIG_ENDIAN).int
|
||||
Log.i(TAG, "CLIENT: Received server-ready from ${peerId.take(8)} on port $port (payload=${payload.size}B). Requesting network...")
|
||||
|
||||
val subSession = subscribeSession ?: run {
|
||||
Log.w(TAG, "CLIENT: subscribe session missing for server-ready from ${peerId.take(8)}")
|
||||
Log.d(TAG, "CLIENT: subscribe session missing for server-ready from ${peerId.take(8)}")
|
||||
connectionTracker.removePendingConnection(peerId)
|
||||
return
|
||||
}
|
||||
@ -1164,12 +1113,11 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
private val connectStarted = AtomicBoolean(false)
|
||||
|
||||
override fun onAvailable(network: Network) {
|
||||
Log.i(TAG, "CLIENT: onAvailable() - Aware network is ready for ${peerId.take(8)}")
|
||||
// Do not bind process for Aware; use per-socket binding instead
|
||||
}
|
||||
|
||||
|
||||
override fun onUnavailable() {
|
||||
Log.e(TAG, "CLIENT: onUnavailable() - Failed to acquire Aware network for ${peerId.take(8)}")
|
||||
Log.e(TAG, "CLIENT: failed to acquire Aware network for ${peerId.take(8)}")
|
||||
if (shouldRequestRoleReversalAfterClientFailure(peerId)) {
|
||||
requestRoleReversal(peerId, allowForcedClientOverride = true)
|
||||
}
|
||||
@ -1183,7 +1131,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
val connectPort = if (info.port > 0) info.port else port
|
||||
// onCapabilitiesChanged can fire multiple times; only connect once
|
||||
if (!connectStarted.compareAndSet(false, true)) return
|
||||
Log.i(TAG, "CLIENT: onCapabilitiesChanged() - Peer IPv6 discovered: $addr port=$connectPort")
|
||||
|
||||
val lp = cm.getLinkProperties(network)
|
||||
val iface = lp?.interfaceName
|
||||
@ -1203,7 +1150,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
}
|
||||
|
||||
val sock = connectAwareClientSocket(network, scopedAddr, connectPort, peerId)
|
||||
Log.i(TAG, "CLIENT: TCP connected to ${peerId.take(8)} at $scopedAddr:$connectPort")
|
||||
Log.i(TAG, "Connected to ${peerId.take(8)} (client)")
|
||||
|
||||
val synced = SyncedSocket(sock)
|
||||
activeSocket = synced
|
||||
@ -1217,7 +1164,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
// Kick off Noise handshake for this logical peer
|
||||
if (myPeerID < peerId) {
|
||||
meshCore.initiateNoiseHandshake(peerId)
|
||||
Log.i(TAG, "CLIENT: Initiating Noise handshake to ${peerId.take(8)}")
|
||||
}
|
||||
// Ensure fast presence even before handshake settles
|
||||
serviceScope.launch { delay(150); sendBroadcastAnnounce() }
|
||||
@ -1232,12 +1178,11 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
}
|
||||
override fun onLost(network: Network) {
|
||||
handlePeerDisconnection(peerId, activeSocket)
|
||||
Log.i(TAG, "CLIENT: WiFi Aware network lost for ${peerId.take(8)}")
|
||||
Log.i(TAG, "Disconnected from ${peerId.take(8)} (client: network lost)")
|
||||
}
|
||||
}
|
||||
|
||||
connectionTracker.addNetworkCallback(peerId, cb)
|
||||
Log.i(TAG, "CLIENT: [Calling requestNetwork] for ${peerId.take(8)}")
|
||||
try {
|
||||
cm.requestNetwork(req, cb, NETWORK_REQUEST_TIMEOUT_MS)
|
||||
} catch (e: Exception) {
|
||||
@ -1305,10 +1250,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
ingressLinkID
|
||||
)
|
||||
) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Ignoring unsolicited or cross-link Noise promotion for ${canonicalPeerId.take(8)}"
|
||||
)
|
||||
Log.w(TAG, "Ignoring unsolicited or cross-link Noise promotion for ${canonicalPeerId.take(8)}")
|
||||
return
|
||||
}
|
||||
provisionalWifiClaims.remove(canonicalPeerId, expectedClaim)
|
||||
@ -1332,18 +1274,12 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
return
|
||||
}
|
||||
if (existingCanonical != provisionalPeerId) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Refusing authenticated Wi-Fi rebind ${existingCanonical.take(8)} -> ${canonicalPeerId.take(8)} on an existing alias"
|
||||
)
|
||||
Log.w(TAG, "Refusing authenticated Wi-Fi rebind ${existingCanonical.take(8)} -> ${canonicalPeerId.take(8)} on existing alias")
|
||||
return
|
||||
}
|
||||
|
||||
if (!connectionTracker.rebindPeerIdIfCurrent(provisionalPeerId, canonicalPeerId, link.transport)) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Ignoring Noise link promotion for ${canonicalPeerId.take(8)}: provisional socket changed before rebind"
|
||||
)
|
||||
Log.w(TAG, "Ignoring Noise link promotion for ${canonicalPeerId.take(8)}: provisional socket changed")
|
||||
return
|
||||
}
|
||||
authenticatedWifiLinks[canonicalPeerId] =
|
||||
@ -1362,10 +1298,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
try { meshCore.setDirectConnection(canonicalPeerId, true) } catch (_: Exception) { }
|
||||
try { meshCore.gossipSyncManager.scheduleInitialSyncToPeer(canonicalPeerId, 1_000) } catch (_: Exception) { }
|
||||
|
||||
Log.i(
|
||||
TAG,
|
||||
"Noise-authenticated Wi-Fi peer ${provisionalPeerId.take(8)} -> ${canonicalPeerId.take(8)} on exact ingress link"
|
||||
)
|
||||
Log.i(TAG, "Noise-authenticated Wi-Fi peer ${provisionalPeerId.take(8)} -> ${canonicalPeerId.take(8)}")
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1396,16 +1329,12 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
// The socket's discovery identity remains provisional until Noise proves possession
|
||||
// of the claimed static key on this link. A canonical self-signed announcement is
|
||||
// only TOFU and cannot safely rebind/remove transport state on its own.
|
||||
Log.w(
|
||||
TAG,
|
||||
"RX: deferred Wi-Fi peer rebind ${logicalPeerId.take(8)} -> ${senderPeerHex.take(8)} pending Noise proof"
|
||||
)
|
||||
Log.d(TAG, "RX: deferred Wi-Fi peer rebind ${logicalPeerId.take(8)} -> ${senderPeerHex.take(8)} pending Noise proof")
|
||||
}
|
||||
|
||||
// Route the packet:
|
||||
|
||||
// Route the packet:
|
||||
// - peerID = Originator (who signed it)
|
||||
// - relayAddress = Neighbor (who sent it to us over this socket)
|
||||
Log.d(TAG, "RX: packet type=${pkt.type} from ${senderPeerHex.take(8)} via ${logicalPeerId.take(8)} (bytes=${raw.size})")
|
||||
meshCore.processIncoming(pkt, senderPeerHex, logicalPeerId, ingressLinkID)
|
||||
}
|
||||
|
||||
@ -1413,7 +1342,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
clearProvisionalWifiClaimsForLink(logicalPeerId, ingressLinkID)
|
||||
|
||||
// Breaking out of the loop means the socket is dead or service is stopping.
|
||||
Log.i(TAG, "Socket loop terminated for ${logicalPeerId.take(8)} removing peer.")
|
||||
Log.i(TAG, "Disconnected from ${logicalPeerId.take(8)} (socket closed)")
|
||||
handlePeerDisconnection(logicalPeerId, socket)
|
||||
socket.close()
|
||||
}
|
||||
@ -1427,8 +1356,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
delay(WIFI_AUTHENTICATION_TIMEOUT_MS)
|
||||
if (provisionalWifiClaims.remove(peerID, claim)) {
|
||||
Log.d(TAG, "Expired provisional Wi-Fi authentication claim for ${peerID.take(8)}")
|
||||
}
|
||||
}
|
||||
} }
|
||||
}
|
||||
|
||||
private fun clearProvisionalWifiClaimsForLink(relayAddress: String, linkID: String) {
|
||||
@ -1442,7 +1370,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
|
||||
private fun handleNetworkFailure(peerId: String) {
|
||||
serviceScope.launch {
|
||||
Log.d(TAG, "Network failure cleanup for: $peerId")
|
||||
if (!connectionTracker.isConnected(peerId)) {
|
||||
val canonicalPeerId = connectionTracker.canonicalPeerId(peerId)
|
||||
connectionTracker.disconnect(peerId)
|
||||
@ -1450,8 +1377,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
if (canonicalPeerId != peerId) {
|
||||
meshCore.removePeer(peerId)
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Network failure ignored for $peerId - another socket is active")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1462,7 +1387,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
val currentSocket = connectionTracker.getSocketForPeer(initialId)
|
||||
val canonicalPeerId = connectionTracker.canonicalPeerId(initialId)
|
||||
if (currentSocket === socket) {
|
||||
Log.d(TAG, "Cleaning up peer: $canonicalPeerId (active socket)")
|
||||
connectionTracker.disconnect(initialId)
|
||||
meshCore.removePeer(canonicalPeerId)
|
||||
if (canonicalPeerId != initialId) {
|
||||
@ -1470,16 +1394,13 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
}
|
||||
} else if (socket == null && currentSocket == null) {
|
||||
// Fallback: If we don't have a specific socket context but we are already disconnected, ensure cleanup
|
||||
Log.d(TAG, "Cleaning up peer: $initialId (no active socket)")
|
||||
connectionTracker.disconnect(initialId)
|
||||
meshCore.removePeer(canonicalPeerId)
|
||||
if (canonicalPeerId != initialId) {
|
||||
meshCore.removePeer(initialId)
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Ignored disconnection for $initialId - socket replaced or inactive")
|
||||
// Do not remove peer/session, as a new socket has likely taken over
|
||||
}
|
||||
// Else: socket replaced or inactive; do not remove peer/session, as a new socket has likely taken over
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
182
docs/security-review-jul-27.md
Normal file
182
docs/security-review-jul-27.md
Normal file
@ -0,0 +1,182 @@
|
||||
# Bitchat Android — Security Review
|
||||
|
||||
**Commit:** 92d07b22 (worktree: `~/.opencode/worktrees/bitchat-security-review`)
|
||||
**Scope:** Exploitable bugs, privacy violations, tracking opportunities, DoS vectors.
|
||||
**Method:** Read-only static review of `crypto/`, `noise/`, `identity/`, `mesh/`, `protocol/`, `net/`, `service/`, `nostr/`, `geohash/`, `ui/`, `features/`, manifest & build config. All findings verified against source.
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL
|
||||
|
||||
### C1. Noise session key material (DH shared secrets, chaining keys) written to logcat — ships in release
|
||||
`noise/southernstorm/protocol/SymmetricState.java:135-159` (also 98-104, 168-182)
|
||||
`mixKey()` logs the raw X25519 shared secret ("Input data"), current and new chaining keys in hex. `proguard-rules.pro` has **no** `assumenosideeffects` for `android.util.Log`, so this reaches the release APK. Anyone with the logcat transcript of a handshake can recompute `split()` outputs and fully decrypt the transport session. **Release-blocking.**
|
||||
|
||||
### C2. Remote decompression bomb — pre-auth memory exhaustion via ~30-byte BLE write
|
||||
`protocol/BinaryProtocol.kt:429-454`, `protocol/CompressionUtil.kt:75-118`
|
||||
For compressed v2 packets the decoder reads a 4-byte attacker-controlled `originalSize` and immediately does `ByteArray(originalSize)` — up to 2 GiB per packet. The ratio guard is skipped when `compressedSize == 0` and is 50,000:1 anyway (real deflate max ≈ 1032:1). Runs inline on the GATT callback path; any nearby unauthenticated BLE peer can OOM/crash the foreground service with tiny writes.
|
||||
|
||||
---
|
||||
|
||||
## HIGH
|
||||
|
||||
### H1. Replay-window bit shift direction is wrong — Noise transport replay protection broken
|
||||
`noise/NoiseSession.kt:69-106`
|
||||
Bit indexing is LSB-first, but the window-shift code right-shifts the byte string. A replayed captured frame (nonce 0) tests a zero bit and is accepted; only the single most-recent nonce is reliably blocked. A passive BLE observer can replay DMs, ACKs, and receipts.
|
||||
|
||||
### H2. Announcement signing key not bound to Noise key — "first announce wins" peer-ID impersonation
|
||||
`mesh/AnnouncementIdentityValidator.kt:13-36`, `mesh/SecurityManager.kt:282-313`, `mesh/MessageHandler.kt:265-299`
|
||||
An attacker can replay a victim's Noise public key (→ victim's peerID) inside an announcement signed with the *attacker's* Ed25519 key. On fresh installs or for never-authenticated peers the forged binding wins, and the genuine victim's later announce is rejected as "key replacement".
|
||||
|
||||
### H3. Decrypted private message content written to logcat
|
||||
`mesh/MessageHandler.kt:88` (also 125; `nostr/NostrProtocol.kt:277`)
|
||||
`Log.d` logs the first 30 chars of every decrypted E2E private message plus sender peerID. Not stripped in release. Defeats E2E guarantees for any logcat reader. Related: `ui/DataManager.kt:161-196` dumps the full favorite/fingerprint social graph at startup (Low).
|
||||
|
||||
### H4. E2E-encrypted DM content exposed in system notifications without lock-screen redaction
|
||||
`ui/NotificationManager.kt:224-246`
|
||||
Decrypted DM bodies (mesh + Nostr) are posted with full text in `BigTextStyle`/`InboxStyle`; no `VISIBILITY_SECRET` / lockscreen visibility set. Content renders on lock screen and to any notification-listener app; no opt-out.
|
||||
|
||||
### H5. No signature verification on incoming public Nostr events — relay can forge any user
|
||||
`nostr/GeohashMessageHandler.kt:44-105`, `nostr/LocationNotesManager.kt:358-414`, `nostr/NostrRelayManager.kt:663-706`
|
||||
`NostrEvent.isValidSignature()` (exists, `NostrEvent.kt:171`) is never called for kind 20000/20001/1. A malicious/compromised relay (auto-selected from a daily third-party list, see M9) can inject events with arbitrary pubkeys — impersonating any user in any geohash channel, spoofing participants, caching attacker-chosen nicknames, registering DM aliases.
|
||||
|
||||
### H6. Automatic delivery/read receipts over Nostr = online-presence & location oracle
|
||||
`nostr/NostrDirectMessageHandler.kt:134-138,165-175`, `nostr/NostrTransport.kt:316-384`
|
||||
Any decryptable gift-wrapped DM triggers an automatic signed DELIVERED ack. For geohash DMs the ack uses the public deterministic per-geohash identity, so anyone can probe a target's known geohash pubkey and confirm the device is online *now*, and chart activity patterns. No setting gates this.
|
||||
|
||||
### H7. Building-precision geohash published publicly and permanently in location notes
|
||||
`geohash/LocationChannel.kt:8` (precision 8 ≈ 19×38 m), `nostr/LocationNotesManager.kt:119-165`
|
||||
Kind-1 (relay-archived, persistent) notes carry the 8-char geohash, exact `created_at`, and an optional plaintext nickname, signed by a stable pubkey — a permanent public record "pubkey X was within ~30 m of this spot at time T". REQ `#g` filters also disclose precise location to relays.
|
||||
|
||||
### H8. Deterministic per-geohash identities are stable forever — long-term passive location tracking
|
||||
`nostr/NostrIdentity.kt:137-177`, `ui/GeohashViewModel.kt:184-193`
|
||||
Geohash identity = HMAC(deviceSeed, geohash), never rotated. A passive observer of a channel can recognize returning users months later and assemble their full history in that channel; the plaintext `["n", nickname]` tag (`nostr/NostrProtocol.kt:167-169`) links the same person across channels.
|
||||
|
||||
### H9. Unbounded per-peer actor map and unlimited packet queues — memory/coroutine exhaustion
|
||||
`mesh/PacketProcessor.kt:43-95`, `mesh/BluetoothPacketBroadcaster.kt:122-124`
|
||||
A new coroutine actor with `Channel.UNLIMITED` is created per attacker-chosen `senderID` *before* any security validation, with no eviction. Spraying packets with random sender IDs grows live coroutines + unbounded channels forever.
|
||||
|
||||
### H10. Unauthenticated packet types relayed mesh-wide with attacker-controlled TTL — flood/amplification
|
||||
`mesh/SecurityManager.kt:268-280`, `mesh/PacketRelayManager.kt:59-107,134-163`
|
||||
Signature verification enforced only for ANNOUNCE/MESSAGE/FILE_TRANSFER/LEAVE. FRAGMENT, REQUEST_SYNC, NOISE_* verify unconditionally and are relayed (unconditionally at TTL ≥ 4) with no rate limit. One BLE radio can make the entire mesh re-broadcast forged traffic, draining bandwidth and battery.
|
||||
|
||||
### H11. Unsigned REQUEST_SYNC forces bulk re-broadcast — amplification
|
||||
`mesh/BluetoothMeshService.kt:671-676`, `sync/GossipSyncManager.kt:168-199`
|
||||
A spoofed REQUEST_SYNC with an empty (forgeable) GCS filter makes a victim dump its entire sync cache onto the radio; repeating keeps neighbors transmitting continuously. No rate limit or response budget.
|
||||
|
||||
### H12. ANNOUNCE replay with TTL=7 forces Noise session teardown + fake "direct neighbor"
|
||||
`mesh/SecurityManager.kt:78-90`, `mesh/BluetoothMeshService.kt:586-621`
|
||||
Duplicate ANNOUNCEs are re-accepted at TTL ≥ 7; direct-link is inferred from TTL alone (excluded from the signature, attacker-settable). Replaying a victim's recent ANNOUNCE at TTL=7 repeatedly tears down the victim's Noise sessions, breaking in-flight DMs.
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM
|
||||
|
||||
### M1. No low-order point / all-zero DH rejection — Noise spec violation, key-compromise downgrade
|
||||
`noise/southernstorm/protocol/HandshakeState.java:750-762,1034-1040,1050-1068`
|
||||
Only the all-zero ephemeral key is rejected; other low-order Curve25519 points pass, DH outputs never checked, remote static key unvalidated. A malicious identity with low-order keys yields all-zero DH outputs → publicly derivable session keys. `SecureIdentityStateManager.validatePublicKey()` (identity/…:403-417) is debug-only and its blocklist misses real low-order points.
|
||||
|
||||
### M2. Unbounded crypto work + session state per forged handshake — crypto/memory DoS
|
||||
`noise/NoiseSessionManager.kt:228-252,312-316`
|
||||
Any frame from any spoofed peerID creates a responder session (~3 X25519 scalar mults) inside a `@Synchronized` method, with no cap on half-open responder sessions (staleness check only on the initiate path).
|
||||
|
||||
### M3. Stable 8-byte peerID in BLE scan response — passive long-term tracking
|
||||
`mesh/BluetoothGattServerManager.kt:400-412`, `mesh/BluetoothMeshService.kt:54`
|
||||
The scan response embeds the truncated fingerprint of the *persistent* static Noise identity, surviving restarts and MAC rotation (by design, for dedup). Any passive sniffer can track a user across time/place. Nickname is broadcast in every ANNOUNCE; ANNOUNCE gossip TLVs disclose the user's neighbor graph.
|
||||
|
||||
### M4. Fragment reassembly poisoning — cross-sender fragment-ID collision
|
||||
`mesh/FragmentManager.kt:32,202-263`
|
||||
Reassembly keyed by 8-byte fragment ID only (not sender). Fragments are unsigned; an attacker injects one colliding fragment to destroy a victim's in-flight (up to 1 MB) transfer. Bounded by existing caps; DoS/corruption, not forgery.
|
||||
|
||||
### M5. Legacy plaintext Ed25519 private key can persist after "migration"
|
||||
`crypto/EncryptionService.kt:505-524`
|
||||
Old plaintext key is deleted only if the encrypted store doesn't already have one; otherwise the plaintext private key remains on disk indefinitely.
|
||||
|
||||
### M6. Attacker-controlled image decode bombs / main-thread decode
|
||||
`ui/media/ImageMessageItem.kt:74`, `ui/media/FullScreenImageViewer.kt:75`, `ui/MessageComponents.kt:294-304`
|
||||
Received images decoded with `BitmapFactory.decodeFile` — no bounds check, no `inSampleSize`, on the main thread during composition. A small PNG with huge dimensions → instant OOM when the chat renders. `readBytes()` re-reads whole files on every recomposition.
|
||||
|
||||
### M7. Received files auto-downloaded unencrypted; size limit only on send path
|
||||
`features/file/FileUtils.kt:194-263`, `nostr/NostrDirectMessageHandler.kt:191-194`
|
||||
Nostr DM path accepts up to 10 MB per message (`AppConstants.kt:71`); a malicious contact can fill `cacheDir` indefinitely. Filenames are sanitized (path traversal verified not exploitable).
|
||||
|
||||
### M8. Exported MainActivity acts on attacker-supplied intent extras
|
||||
`AndroidManifest.xml:98-115`, `MainActivity.kt:828-894`
|
||||
Any app can fire intents with `EXTRA_OPEN_PRIVATE_CHAT`/`EXTRA_PEER_ID` to open arbitrary chat sheets and silently clear the victim's pending notifications, or trigger the verification sheet UI. QR payload itself is cryptographically validated — no verification forgery.
|
||||
|
||||
### M9. Relay directory auto-updates daily from third-party GitHub CSV, unsigned/unpinned
|
||||
`nostr/RelayDirectory.kt:29,152-191`
|
||||
Compromise of `permissionlesstech/georelays` steers all users to attacker relays → precise `#g` filters + forged-event injection (H5).
|
||||
|
||||
### M10. Geohash subscription filters & geo-nearest relay selection leak location to relays/observers
|
||||
`nostr/NostrRelayManager.kt:131-147`, `nostr/RelayDirectory.kt:88-106`, `nostr/NostrFilter.kt:37-71`
|
||||
Each relay learns subscribed cells; `#p` DM filters reveal owned pubkeys. Mitigated: Tor ON by default, fail-closed proxy config (`net/ArtiTorManager.kt:151,224`).
|
||||
|
||||
### M11. Unbounded outbound Nostr message queue — never drained, re-sent on reconnect
|
||||
`nostr/NostrRelayManager.kt:107,286-288,862-871`
|
||||
`messageQueue` entries are never removed after send; every relay reconnect re-sends full history (duplicate gift wraps, memory growth, extra metadata).
|
||||
|
||||
### M12. Unbounded identity-keyed caches — memory DoS by malicious relay (compounds H5)
|
||||
`nostr/GeohashRepository.kt:22-29,61,104-118`
|
||||
`geohashParticipants`, `geoNicknames`, etc. grow per unique pubkey with no eviction; forged events from unlimited fresh pubkeys exhaust memory. No WebSocket frame size cap (`NostrRelayManager.kt:874-876`).
|
||||
|
||||
### M13. "NIP-44" DM encryption omits padding — exact plaintext length leakage; not real NIP-44
|
||||
`nostr/NostrCrypto.kt:211-260,267-293`
|
||||
Raw XChaCha20-Poly1305 over unpadded UTF-8; relays/observers see exact DM lengths (receipt vs message vs file) and it's incompatible with real NIP-44 clients. AEAD itself sound.
|
||||
|
||||
### M14. Peer-table flooding with throwaway identities
|
||||
`mesh/PeerManager.kt:93,228-240,551-564`
|
||||
No cap on the peers map; cheap self-signed ANNOUNCEs create verified entries and can each trigger Noise handshakes. 3-minute sweep is the only bound.
|
||||
|
||||
### M15. No rate limiting on GATT writes / inbound packet processing
|
||||
`mesh/BluetoothGattServerManager.kt:232-277`, `mesh/BluetoothGattClientManager.kt:644-656`
|
||||
Every write is fully decoded/verified with no per-connection or global rate limit; combined with H9 guarantees backlog growth.
|
||||
|
||||
### M16. Two independent persistent Ed25519 signing identities per device
|
||||
`crypto/EncryptionService.kt:485-503` vs `identity/SecureIdentityStateManager.kt:145-195`
|
||||
Different keys in different pref files; same device presents two signing identities; panic wipe doesn't rotate both. Also: `EncryptionService.sign()` returns an empty signature and `verify()` ignores its inputs entirely (EncryptionService.kt:226-242) — a latent trap.
|
||||
|
||||
---
|
||||
|
||||
## LOW
|
||||
|
||||
- **L1. Metadata leakage:** only NOISE_* frames padded; public MESSAGE/ANNOUNCE/FILE_TRANSFER leak exact sizes; every packet carries ms wall-clock timestamps (`mesh/BLEPacketPaddingPolicy.kt:11-17`, `protocol/BinaryProtocol.kt:42-43,76`).
|
||||
- **L2. Signed MESSAGE/FILE_TRANSFER replayable after 5-min dedup expiry** — no freshness check (`mesh/SecurityManager.kt:55-73,101`).
|
||||
- **L3. Silent identity regeneration on key-store corruption** — masks tampering, destroys identity on transient Keystore failure (`crypto/EncryptionService.kt:466-483`, `noise/NoiseEncryptionService.kt:85-119`).
|
||||
- **L4. Channel KDF salt = channel name; PBKDF2 mislabeled as "Argon2id" in comments; passwords retained in memory; dead plaintext channel-key-sharing packet code** (`noise/NoiseChannelEncryption.kt:148-168,203-243`).
|
||||
- **L5. Reverse geocoding sends precise GPS to OSM Nominatim (or Google Fused) with app-identifying UA** — rides Tor when on (`geohash/OpenStreetMapGeocoderProvider.kt:21-29`).
|
||||
- **L6. Persistent account Nostr identity links all DM activity; `#p` filters announce pubkey ownership to relays** — mitigated by randomized gift-wrap timestamps (`nostr/NostrIdentity.kt:108-128`).
|
||||
- **L7. Predictable subscription IDs (`sub-<millis>-<rand>`) aid per-session correlation** (`nostr/NostrRelayManager.kt:809-811`).
|
||||
- **L8. NIP-17 validation gaps:** rumor kind not checked to be 14; replay window uses attacker-controlled `created_at`. Sender spoofing inside gift wraps *is* correctly prevented (`nostr/NostrProtocol.kt:77-96`).
|
||||
- **L9. No FLAG_SECURE anywhere; Recents preview leaks chats on API < 33** (`MainActivity.kt:84-86`).
|
||||
- **L10. Clipboard copies not marked sensitive** (`ui/ChatUserSheet.kt:87`, `ui/SecurityVerificationSheet.kt:411`).
|
||||
- **L11. WebView with JS + file access + unescaped geohash interpolation into `evaluateJavascript`** — activity not exported, limited impact (`ui/GeohashPickerActivity.kt:104-148`).
|
||||
- **L12. `packet.recipientID != SpecialRecipients.BROADCAST` is ByteArray reference comparison — always true** (`mesh/BluetoothPacketBroadcaster.kt:340`).
|
||||
- **L13. GATT server ignores preparedWrite/offset; force-unwrap NPEs on hot paths degrade gracefully** (`mesh/BluetoothGattServerManager.kt:232-239`, `mesh/PacketProcessor.kt:127`).
|
||||
|
||||
---
|
||||
|
||||
## Verified positives
|
||||
|
||||
- SecureRandom everywhere; no weak RNG.
|
||||
- Keys in EncryptedSharedPreferences (AES256-GCM Keystore master key); `allowBackup="false"` + backup rules.
|
||||
- No plaintext message DB; messages in-memory only; message IDs in encrypted prefs.
|
||||
- PeerID = SHA-256(static key)[:8] with session establishment refusing mismatches; session-generation binding defeats re-handshake downgrade.
|
||||
- AEAD verify-before-decrypt, constant-time tag check, nonce-wraparound enforcement, rekey limits, zeroization on destroy.
|
||||
- NIP-17 seal signature verified + `seal.pubkey == rumor.pubkey` enforced; Schnorr nonces use SecureRandom.
|
||||
- Tor fail-closed (proxy set before bootstrap; clients rebuilt on mode change); no telemetry/analytics SDKs; all traffic wss/https.
|
||||
- Filename sanitization blocks path traversal; FileProvider per-URI grants; no auto-opening URLs; links require explicit tap and are coerced to https.
|
||||
- Fragment caps (256/ID, 1 MB/set, 4 MB global, 30 s timeout); dedup caches bounded; connection limits + backoff; presence heartbeats only at city-level precision with jitter.
|
||||
|
||||
## Top remediations (priority order)
|
||||
|
||||
1. Strip all key/content logging (`SymmetricState`, `MessageHandler.kt:88`); add ProGuard `assumenosideeffects` for `android.util.Log`. *(C1, H3)*
|
||||
2. Hard-cap `originalSize` in `CompressionUtil.decompress` (~1–2 MB); tighten ratio to ≈1032:1. *(C2)*
|
||||
3. Fix replay-window shift direction + unit tests; reject low-order points / all-zero DH in `mixDH`. *(H1, M1)*
|
||||
4. Verify `isValidSignature()` on all incoming Nostr events; pin/sign the relay directory. *(H5, M9)*
|
||||
5. Cross-sign Ed key with Noise key (proof-of-possession) in announcements. *(H2)*
|
||||
6. Bound PacketProcessor actor map (validate before actor creation), bound queues, authenticate/rate-limit FRAGMENT & REQUEST_SYNC relay, budget sync responses. *(H9–H11)*
|
||||
7. Key fragment reassembly by (senderID, fragmentID); rotate the advertised peerID. *(M4, M3)*
|
||||
8. Gate auto delivery/read receipts behind a privacy setting; warn about location-note precision/permanence. *(H6, H7)*
|
||||
9. Lock-screen-redact DM notifications; image decode bounds checks off main thread. *(H4, M6)*
|
||||
10. Bound Nostr `messageQueue` (drain after send) and `GeohashRepository` caches. *(M11, M12)*
|
||||
Loading…
x
Reference in New Issue
Block a user