From 9518d386d0f6ab3859a7373cb98a16f18fb934b5 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 6 Sep 2025 10:41:51 +0200 Subject: [PATCH 1/9] better tor management (#380) * better tor management * better tracking --- app/src/main/java/com/bitchat/android/net/TorManager.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/bitchat/android/net/TorManager.kt b/app/src/main/java/com/bitchat/android/net/TorManager.kt index e04c2240..efa2eab5 100644 --- a/app/src/main/java/com/bitchat/android/net/TorManager.kt +++ b/app/src/main/java/com/bitchat/android/net/TorManager.kt @@ -344,7 +344,8 @@ object TorManager { bindRetryAttempts = 0 startInactivityMonitoring() } - s.contains("AMEx: state changed to Running", ignoreCase = true) -> { + //s.contains("AMEx: state changed to Running", ignoreCase = true) -> { + s.contains("We have found that guard [scrubbed] is usable.", ignoreCase = true) -> { // If we already saw Sufficiently bootstrapped, mark as RUNNING and ready. val bp = if (_status.value.bootstrapPercent >= 100) 100 else 100 // treat Running as ready _status.value = _status.value.copy(state = TorState.RUNNING, bootstrapPercent = bp, running = true) From cc45f477fbfde0bf9bf2958adf458e665a6f5e81 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 6 Sep 2025 10:58:39 +0200 Subject: [PATCH 2/9] init noise handshake on queue DMs (#379) * init noise handshake on queue DMs * establish noise more aggressively --- .../bitchat/android/services/MessageRouter.kt | 2 + .../bitchat/android/ui/PrivateChatManager.kt | 79 +------------------ 2 files changed, 4 insertions(+), 77 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt index bdf3e126..b968aa96 100644 --- a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt +++ b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt @@ -67,6 +67,8 @@ class MessageRouter private constructor( Log.d(TAG, "Queued PM for ${toPeerID.take(8)}… (no mesh, no Nostr mapping) id=${messageID.take(8)}…") val q = outbox.getOrPut(toPeerID) { mutableListOf() } q.add(Triple(content, recipientNickname, messageID)) + Log.d(TAG, "Initiating noise handshake after queueing PM for ${toPeerID.take(8)}…") + mesh.initiateNoiseHandshake(toPeerID) } } diff --git a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt index cf549d76..3d849ac1 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -365,87 +365,12 @@ class PrivateChatManager( "Our peer ID lexicographically >= target peer ID, sending identity announcement to prompt handshake from $peerID" ) meshService.sendAnnouncementToPeer(peerID) + Log.d(TAG, "Sent identity announcement to $peerID – starting handshake now from our side") + noiseSessionDelegate.initiateHandshake(peerID) } } -// /** -// * Legacy reflection-based implementation for backward compatibility -// */ -// private fun establishNoiseSessionIfNeededLegacy(peerID: String, meshService: Any) { -// try { -// // Check if we already have an established Noise session with this peer -// val hasSessionMethod = meshService::class.java.getDeclaredMethod("hasEstablishedSession", String::class.java) -// val hasSession = hasSessionMethod.invoke(meshService, peerID) as Boolean -// -// if (hasSession) { -// Log.d(TAG, "Noise session already established with $peerID") -// return -// } -// -// Log.d(TAG, "No Noise session with $peerID, determining who should initiate handshake") -// -// // Get our peer ID from mesh service for lexicographical comparison -// val myPeerIDField = meshService::class.java.getField("myPeerID") -// val myPeerID = myPeerIDField.get(meshService) as String -// -// // Use lexicographical comparison to decide who initiates (same logic as MessageHandler) -// if (myPeerID < peerID) { -// // We should initiate the handshake -// Log.d(TAG, "Our peer ID lexicographically < target peer ID, initiating Noise handshake with $peerID") -// initiateHandshakeWithPeer(peerID, meshService) -// } else { -// // They should initiate, we send a Noise identity announcement -// Log.d(TAG, "Our peer ID lexicographically >= target peer ID, sending Noise identity announcement to prompt handshake from $peerID") -// sendNoiseIdentityAnnouncement(meshService) -// } -// -// } catch (e: Exception) { -// Log.e(TAG, "Failed to establish Noise session with $peerID: ${e.message}") -// } -// } - - /** - * Initiate handshake with specific peer using the existing delegate pattern - */ - private fun initiateHandshakeWithPeer(peerID: String, meshService: Any) { - try { - // Use the existing MessageHandler delegate approach to initiate handshake - // This calls the same code that's in MessageHandler's delegate.initiateNoiseHandshake() - val messageHandler = meshService::class.java.getDeclaredField("messageHandler") - messageHandler.isAccessible = true - val handler = messageHandler.get(meshService) - - val delegate = handler::class.java.getDeclaredField("delegate") - delegate.isAccessible = true - val handlerDelegate = delegate.get(handler) - - val method = - handlerDelegate::class.java.getMethod("initiateNoiseHandshake", String::class.java) - method.invoke(handlerDelegate, peerID) - - Log.d(TAG, "Successfully initiated Noise handshake with $peerID using delegate pattern") - } catch (e: Exception) { - Log.e(TAG, "Failed to initiate Noise handshake with $peerID: ${e.message}") - } - } - - /** - * Send Noise identity announcement to prompt other peer to initiate handshake - * This follows the same pattern as broadcastNoiseIdentityAnnouncement() in BluetoothMeshService - */ - private fun sendNoiseIdentityAnnouncement(meshService: Any) { - try { - // Call broadcastNoiseIdentityAnnouncement which sends a NoiseIdentityAnnouncement - val method = - meshService::class.java.getDeclaredMethod("broadcastNoiseIdentityAnnouncement") - method.invoke(meshService) - Log.d(TAG, "Successfully sent Noise identity announcement") - } catch (e: Exception) { - Log.e(TAG, "Failed to send Noise identity announcement: ${e.message}") - } - } - // MARK: - Utility Functions private fun getPeerIDForNickname(nickname: String, meshService: BluetoothMeshService): String? { From a804782476c922cdacb7e7c3defbecfe7ce01014 Mon Sep 17 00:00:00 2001 From: lollerfirst <43107113+lollerfirst@users.noreply.github.com> Date: Sat, 6 Sep 2025 11:49:23 +0200 Subject: [PATCH 3/9] feat(geohash): add in-app Geohash Picker (#363) * feat(geohash): add in-app Geohash Picker map with quadrant drill-down and Activity integration - New GeohashPickerActivity hosting a WebView with Leaflet-based picker - Adds map icon next to custom geohash input in LocationChannelsSheet - Picker allows quadrant selection and subquadrant drill-down; returns selected geohash - Register activity in AndroidManifest perf(picker): improve map performance and correctness - Use LayerGroup and clearLayers() on redraw to avoid stale overlays - Use Leaflet canvas renderer for rectangles to reduce DOM and improve perf - Make labels non-interactive and pointer-events: none to avoid gesture overhead - Fix initial mega-label artifact by grouping and clearing labels with cells chore: add asset geohash_picker.html with minimal geohash helpers (bounds/encode/adjacent) * remove accidentally committed submodule. * fix some issues * better geohash labels on map * design wip * countries green * wip * geohashpicker: pan + zoom; start from current geohash channel * better ui * ui elements * nice * readability --------- Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com> --- app/src/main/AndroidManifest.xml | 6 + app/src/main/assets/geohash_picker.html | 225 ++++++++++++++ .../android/ui/GeohashPickerActivity.kt | 283 ++++++++++++++++++ .../android/ui/LocationChannelsSheet.kt | 35 +++ 4 files changed, 549 insertions(+) create mode 100644 app/src/main/assets/geohash_picker.html create mode 100644 app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2b61df2f..9516695d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -41,6 +41,12 @@ android:supportsRtl="true" android:theme="@style/Theme.BitchatAndroid" tools:targetApi="31"> + + + + + + + + + +
+ + + + + \ No newline at end of file diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt new file mode 100644 index 00000000..97f1d3ce --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt @@ -0,0 +1,283 @@ +package com.bitchat.android.ui + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Intent +import android.content.res.Configuration +import android.os.Bundle +import android.view.ViewGroup +import android.webkit.JavascriptInterface +import android.webkit.WebChromeClient +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.updateLayoutParams +import com.bitchat.android.geohash.Geohash +import com.bitchat.android.geohash.LocationChannelManager +import com.bitchat.android.ui.theme.BASE_FONT_SIZE + +@OptIn(ExperimentalMaterial3Api::class) +class GeohashPickerActivity : ComponentActivity() { + + companion object { + const val EXTRA_INITIAL_GEOHASH = "initial_geohash" + const val EXTRA_RESULT_GEOHASH = "result_geohash" + } + + @SuppressLint("SetJavaScriptEnabled") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val initialGeohash = intent.getStringExtra(EXTRA_INITIAL_GEOHASH)?.trim()?.lowercase() + var geohashToFocus: String? = null + var (initLat, initLon) = 0.0 to 0.0 + + if (!initialGeohash.isNullOrEmpty()) { + geohashToFocus = initialGeohash + try { + val (lat, lon) = Geohash.decodeToCenter(initialGeohash) + initLat = lat + initLon = lon + } catch (_: Throwable) {} + } else { + // If no initial geohash, try to use the user's coarsest location + val locationManager = LocationChannelManager.getInstance(applicationContext) + val channels = locationManager.availableChannels.value + if (!channels.isNullOrEmpty()) { + val coarsestChannel = channels.minByOrNull { it.geohash.length } + if (coarsestChannel != null) { + geohashToFocus = coarsestChannel.geohash + try { + val (lat, lon) = Geohash.decodeToCenter(coarsestChannel.geohash) + initLat = lat + initLon = lon + } catch (_: Throwable) {} + } + } + } + + val initialPrecision = geohashToFocus?.length ?: 5 + + setContent { + MaterialTheme { + var currentGeohash by remember { mutableStateOf(geohashToFocus ?: "") } + var precision by remember { mutableStateOf(initialPrecision.coerceIn(1, 12)) } + var webViewRef by remember { mutableStateOf(null) } + + // iOS system-like colors used across app + val colorScheme = MaterialTheme.colorScheme + val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f + val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) + + Scaffold { padding -> + Box(Modifier.fillMaxSize()) { + AndroidView( + factory = { context -> + WebView(context).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.cacheMode = WebSettings.LOAD_DEFAULT + settings.allowFileAccess = true + settings.allowContentAccess = true + webChromeClient = WebChromeClient() + webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + // Initialize to last/initial geohash if provided, otherwise center + if (!geohashToFocus.isNullOrEmpty()) { + evaluateJavascript( + "window.focusGeohash('${geohashToFocus}')", + null + ) + } else { + evaluateJavascript( + "window.setCenter(${initLat}, ${initLon})", + null + ) + } + + // Apply theme to map tiles + val nightModeFlags = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + val theme = if (nightModeFlags == Configuration.UI_MODE_NIGHT_YES) "dark" else "light" + evaluateJavascript("window.setMapTheme('" + theme + "')", null) + } + } + addJavascriptInterface(object { + @JavascriptInterface + fun onGeohashChanged(geohash: String) { + runOnUiThread { + currentGeohash = geohash + } + } + }, "Android") + + loadUrl("file:///android_asset/geohash_picker.html") + } + }, + modifier = Modifier + .fillMaxSize() + .padding(padding), + update = { webView -> + webViewRef = webView + // ensure it fills parent + webView.updateLayoutParams { + width = ViewGroup.LayoutParams.MATCH_PARENT + height = ViewGroup.LayoutParams.MATCH_PARENT + } + }, + onRelease = { webView -> + // Best-effort cleanup to avoid leaks and timers + try { webView.evaluateJavascript("window.cleanup && window.cleanup()", null) } catch (_: Throwable) {} + try { webView.stopLoading() } catch (_: Throwable) {} + try { webView.clearHistory() } catch (_: Throwable) {} + try { webView.clearCache(true) } catch (_: Throwable) {} + try { webView.loadUrl("about:blank") } catch (_: Throwable) {} + try { webView.removeAllViews() } catch (_: Throwable) {} + try { webView.destroy() } catch (_: Throwable) {} + } + ) + + // Floating info pill + Surface( + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 20.dp) + .fillMaxWidth(0.75f), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f), + shape = RoundedCornerShape(12.dp), + tonalElevation = 3.dp, + shadowElevation = 6.dp + ) { + Text( + text = "pan and zoom to select a geohash", + fontSize = 12.sp, + textAlign = TextAlign.Center, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding(horizontal = 14.dp, vertical = 10.dp) + ) + } + + // Floating bottom controls + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 20.dp, start = 16.dp, end = 16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Geohash label (monospace, app style) + Surface( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f), + shape = RoundedCornerShape(12.dp), + tonalElevation = 3.dp, + shadowElevation = 6.dp + ) { + Text( + text = if (currentGeohash.isNotEmpty()) "#${currentGeohash}" else "select location", + fontSize = BASE_FONT_SIZE.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding(horizontal = 14.dp, vertical = 10.dp) + ) + } + + // Button row + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Decrease precision + Button( + onClick = { + precision = (precision - 1).coerceAtLeast(1) + webViewRef?.evaluateJavascript("window.setPrecision($precision)", null) + }, + colors = ButtonDefaults.buttonColors( + containerColor = standardGreen.copy(alpha = 0.12f), + contentColor = standardGreen + ) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Remove, contentDescription = "Decrease precision") + } + } + + // Increase precision + Button( + onClick = { + precision = (precision + 1).coerceAtMost(12) + webViewRef?.evaluateJavascript("window.setPrecision($precision)", null) + }, + colors = ButtonDefaults.buttonColors( + containerColor = standardGreen.copy(alpha = 0.12f), + contentColor = standardGreen + ) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Add, contentDescription = "Increase precision") + } + } + + // Select button + Button( + onClick = { + webViewRef?.evaluateJavascript("window.getGeohash()") { value -> + val gh = value?.trim('"') ?: currentGeohash + val result = Intent().apply { putExtra(EXTRA_RESULT_GEOHASH, gh) } + setResult(Activity.RESULT_OK, result) + finish() + } + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f), + contentColor = MaterialTheme.colorScheme.onSurface + ) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Check, contentDescription = "Select geohash") + Spacer(Modifier.width(6.dp)) + Text( + text = "select", + fontSize = (BASE_FONT_SIZE - 2).sp, + fontFamily = FontFamily.Monospace + ) + } + } + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 66a86ef1..e21dd8f5 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Map import androidx.compose.material.icons.filled.PinDrop import androidx.compose.material3.* import androidx.compose.ui.text.font.FontWeight @@ -30,6 +31,8 @@ import com.bitchat.android.geohash.GeohashChannel import com.bitchat.android.geohash.GeohashChannelLevel import com.bitchat.android.geohash.LocationChannelManager import java.util.* +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts /** * Location Channels Sheet for selecting geohash-based location channels @@ -71,6 +74,18 @@ fun LocationChannelsSheet( // Scroll state for LazyColumn val listState = rememberLazyListState() + val mapPickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == android.app.Activity.RESULT_OK) { + val gh = result.data?.getStringExtra(GeohashPickerActivity.EXTRA_RESULT_GEOHASH) + if (!gh.isNullOrBlank()) { + customGeohash = gh + customError = null + } + } + } + // iOS system colors (matches iOS exactly) val colorScheme = MaterialTheme.colorScheme val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f @@ -312,6 +327,26 @@ fun LocationChannelsSheet( ) val normalized = customGeohash.trim().lowercase().replace("#", "") + + // Map picker button + IconButton(onClick = { + val initial = when { + normalized.isNotBlank() -> normalized + selectedChannel is ChannelID.Location -> (selectedChannel as ChannelID.Location).channel.geohash + else -> "" + } + val intent = Intent(context, GeohashPickerActivity::class.java).apply { + putExtra(GeohashPickerActivity.EXTRA_INITIAL_GEOHASH, initial) + } + mapPickerLauncher.launch(intent) + }) { + Icon( + imageVector = Icons.Filled.Map, + contentDescription = "Open map", + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + ) + } + val isValid = validateGeohash(normalized) // iOS-style teleport button From b131554efe4689c2c11a15064bb5f5bb1142eaf4 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 6 Sep 2025 14:45:48 +0200 Subject: [PATCH 4/9] fix some debug settings (#383) --- .../mesh/BluetoothConnectionTracker.kt | 1 - .../android/ui/debug/DebugSettingsSheet.kt | 64 ++++++++++++++----- 2 files changed, 48 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt index 3494d1b2..f1c9009b 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt @@ -277,7 +277,6 @@ class BluetoothConnectionTracker( fun cleanupDeviceConnection(deviceAddress: String) { connectedDevices.remove(deviceAddress)?.let { deviceConn -> subscribedDevices.removeAll { it.address == deviceAddress } - addressPeerMap.remove(deviceAddress) } pendingConnections.remove(deviceAddress) Log.d(TAG, "Cleaned up device connection for $deviceAddress") diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt index 33fde064..03453aaf 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt @@ -172,6 +172,8 @@ fun DebugSettingsSheet( steps = 30 ) } + val overallCount = connectedDevices.size + Text("connections: $overallCount / $maxOverall", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) Row(verticalAlignment = Alignment.CenterVertically) { Text("max overall", fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp)) Slider( @@ -217,36 +219,66 @@ fun DebugSettingsSheet( } val maxValRaw = series.maxOrNull() ?: 0f val maxVal = if (maxValRaw > 0f) maxValRaw else 0f - Box(Modifier.fillMaxWidth().height(48.dp)) { + val leftGutter = 40.dp + Box(Modifier.fillMaxWidth().height(56.dp)) { + // Graph canvas androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) { - val axisPx = 28.dp.toPx() // leave room on left for ticks + val axisPx = leftGutter.toPx() // reserved left gutter for labels val barCount = series.size val availW = (size.width - axisPx).coerceAtLeast(1f) val w = availW / barCount val h = size.height - // draw baseline at y=0 (bottom) + // Baseline at bottom (y = 0) drawLine( color = Color(0x33888888), start = androidx.compose.ui.geometry.Offset(axisPx, h - 1f), end = androidx.compose.ui.geometry.Offset(size.width, h - 1f), strokeWidth = 1f ) + // Bars from bottom-up; skip zeros entirely series.forEachIndexed { i, value -> - val ratio = if (maxVal > 0f) (value / maxVal).coerceIn(0f, 1f) else 0f // min always 0 - val barHeight = h * ratio - // Draw bars from bottom up, starting after left axis area - drawRect( - color = Color(0xFF00C851), - topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = h - barHeight), - size = androidx.compose.ui.geometry.Size(w, barHeight) - ) + if (value > 0f && maxVal > 0f) { + val ratio = (value / maxVal).coerceIn(0f, 1f) + val barHeight = (h * ratio).coerceAtLeast(0f) + if (barHeight > 0.5f) { + drawRect( + color = Color(0xFF00C851), + topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = h - barHeight), + size = androidx.compose.ui.geometry.Size(w, barHeight) + ) + } + } } } - // Y-axis ticks (min/max) in the left margin - Text("0", fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.7f), modifier = Modifier.align(Alignment.BottomStart).padding(start = 4.dp, bottom = 2.dp)) - Text("${maxVal.toInt()}", fontFamily = FontFamily.Monospace, fontSize = 10.sp, color = colorScheme.onSurface.copy(alpha = 0.7f), modifier = Modifier.align(Alignment.TopStart).padding(start = 4.dp, top = 2.dp)) - // Y-axis unit label (vertical) - Text("p/s", fontFamily = FontFamily.Monospace, fontSize = 9.sp, color = colorScheme.onSurface.copy(alpha = 0.7f), modifier = Modifier.align(Alignment.CenterStart).padding(start = 2.dp).rotate(-90f)) + // Left gutter layout: unit + ticks neatly aligned + Row(Modifier.fillMaxSize()) { + Box(Modifier.width(leftGutter).fillMaxHeight()) { + // Unit label on the far left, centered vertically + Text( + "p/s", + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + color = colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.align(Alignment.CenterStart).padding(start = 2.dp).rotate(-90f) + ) + // Tick labels right-aligned in gutter, top and bottom aligned + Text( + "${maxVal.toInt()}", + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + color = colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.align(Alignment.TopEnd).padding(end = 4.dp, top = 0.dp) + ) + Text( + "0", + fontFamily = FontFamily.Monospace, + fontSize = 10.sp, + color = colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.align(Alignment.BottomEnd).padding(end = 4.dp, bottom = 0.dp) + ) + } + Spacer(Modifier.weight(1f)) + } } } } From 9b8f98ec7c846fe327fbdcb2ccadde3d91d47716 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 6 Sep 2025 14:49:00 +0200 Subject: [PATCH 5/9] bump version 1.2.3 (#384) --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d3798025..a1a06b79 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "com.bitchat.droid" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() - versionCode = 18 - versionName = "1.2.2" + versionCode = 19 + versionName = "1.2.3" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From c20e9defdec53fe9af030f19f2b0d2a7f6d5957f Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 7 Sep 2025 06:05:54 +0000 Subject: [PATCH 6/9] Automated update of relay data - Sun Sep 7 06:05:54 UTC 2025 --- app/src/main/assets/nostr_relays.csv | 520 +++++++++++++-------------- 1 file changed, 255 insertions(+), 265 deletions(-) diff --git a/app/src/main/assets/nostr_relays.csv b/app/src/main/assets/nostr_relays.csv index 47e6582c..293271d1 100644 --- a/app/src/main/assets/nostr_relays.csv +++ b/app/src/main/assets/nostr_relays.csv @@ -1,273 +1,263 @@ Relay URL,Latitude,Longitude -relay.chorus.community,50.1109,8.68213 -relay-rpi.edufeed.org,49.4543,11.0746 -nostr.chaima.info,50.1109,8.68213 -relay.nostraddress.com,43.6532,-79.3832 -nostr.middling.mydns.jp,35.8099,140.12 -nostr.myshosholoza.co.za,52.3676,4.90414 -n.ok0.org,-37.7254,176.133 -relay.21e6.cz,50.1682,14.0546 -premium.primal.net,43.6532,-79.3832 -relay.stream.labs.h3.se,59.4016,17.9455 -nostr.night7.space,50.4754,12.3683 -nostr.4rs.nl,49.0291,8.35696 -relay.notoshi.win,13.7683,100.54 -nostr.carroarmato0.be,50.9017,3.52588 -relay.angor.io,48.1046,11.6002 -relay.nostriot.com,41.5695,-83.9786 -nostr-pub.wellorder.net,45.5201,-122.99 -relay04.lnfi.network,39.0997,-94.5786 -x.kojira.io,43.6532,-79.3832 -strfry.felixzieger.de,50.1013,8.62643 -purpura.cloud,43.6532,-79.3832 -alien.macneilmediagroup.com,43.6532,-79.3832 -wot.sudocarlos.com,51.5072,-0.127586 -relay02.lnfi.network,39.0997,-94.5786 -relay.getsafebox.app,43.6532,-79.3832 -wot.sebastix.social,51.8933,4.42083 -relay.dwadziesciajeden.pl,52.2297,21.0122 -nostr.roundrockbitcoiners.com,40.8054,-74.0241 -nostr.dlsouza.lol,50.1109,8.68213 -nostr-relay.shirogaku.xyz,43.6532,-79.3832 -yabu.me,35.6092,139.73 -nostr.stakey.net,52.3676,4.90414 -relay.sincensura.org,43.6532,-79.3832 -nostr-02.czas.top,51.2277,6.77346 -soloco.nl,43.6532,-79.3832 -nostream.breadslice.com,43.6532,-79.3832 -nostr.camalolo.com,24.1469,120.684 -nostr-dev.wellorder.net,45.5201,-122.99 -nostr.pleb.one,38.6327,-90.1961 -cyberspace.nostr1.com,40.7357,-74.1724 -relay.illuminodes.com,47.6061,-122.333 -satsage.xyz,37.3986,-121.964 -nostr.data.haus,50.4754,12.3683 -nostr.jfischer.org,49.0291,8.35696 -r.bitcoinhold.net,43.6532,-79.3832 -relay.barine.co,43.6532,-79.3832 -wot.sovbit.host,64.1466,-21.9426 -nostr.vulpem.com,49.4543,11.0746 -nostr.kalf.org,52.3676,4.90414 -nostr.thebiglake.org,32.71,-96.6745 -relay.bitcoinveneto.org,64.1466,-21.9426 -wot.dergigi.com,64.1476,-21.9392 -nos.xmark.cc,50.6924,3.20113 -nostr.88mph.life,43.6532,-79.3832 -relay.mess.ch,47.3591,8.55292 -nostr.thaliyal.com,40.8218,-74.45 -nostr.notribe.net,40.8302,-74.1299 -relay.orangepill.ovh,49.1689,-0.358841 -relay.artiostr.ch,43.6532,-79.3832 -relay.13room.space,43.6532,-79.3832 -relay.primal.net,43.6532,-79.3832 -strfry.shock.network,41.8959,-88.2169 -nostr.2b9t.xyz,34.0549,-118.243 -ribo.af.nostria.app,-26.2041,28.0473 -a.nos.lol,50.4754,12.3683 -relay.mccormick.cx,52.3563,4.95714 -relay.mostro.network,40.8302,-74.1299 -relay.bitcoinartclock.com,50.4754,12.3683 -nostr-relay.cbrx.io,43.6532,-79.3832 -prl.plus,55.7623,37.6381 -relay.endfiat.money,43.6532,-79.3832 -relay.hook.cafe,43.6532,-79.3832 -relay.siamdev.cc,13.9178,100.424 -wot.nostr.party,36.1627,-86.7816 -wot.basspistol.org,49.4521,11.0767 -dev-nostr.bityacht.io,25.0797,121.234 -orangesync.tech,51.5072,-0.127586 -nostr.casa21.space,43.6532,-79.3832 -relay.digitalezukunft.cyou,45.5019,-73.5674 -relay.nostrcheck.me,43.6532,-79.3832 -inbox.mycelium.social,38.627,-90.1994 -nostr.liberty.fans,36.9104,-89.5875 -nostr-verified.wellorder.net,45.5201,-122.99 -relay.sigit.io,50.4754,12.3683 -tollbooth.stens.dev,51.223,6.78245 -relay.nostromo.social,49.4543,11.0746 -wheat.happytavern.co,43.6532,-79.3832 -relay.vrtmrz.net,43.6532,-79.3832 -relayrs.notoshi.win,43.6532,-79.3832 -travis-shears-nostr-relay-v2.fly.dev,41.8781,-87.6298 -nostr.community.ath.cx,45.5029,-73.5723 -wot.girino.org,43.6532,-79.3832 -nostr.coincrowd.fund,39.0438,-77.4874 -relay.freeplace.nl,52.3676,4.90414 -nostr-03.dorafactory.org,1.35208,103.82 -relay5.bitransfer.org,43.6532,-79.3832 -nostr.tac.lol,47.4748,-122.273 -nostr.sagaciousd.com,49.2827,-123.121 -nostr.girino.org,43.6532,-79.3832 -nostr.jerrynya.fun,31.2304,121.474 -relay.agorist.space,52.3734,4.89406 -inbox.azzamo.net,52.2633,21.0283 -wot.codingarena.top,50.4754,12.3683 -relay.degmods.com,50.4754,12.3683 -articles.layer3.news,37.3387,-121.885 -nostr.rblb.it,43.4633,11.8796 -relay.davidebtc.me,51.5072,-0.127586 -relay.hodl.ar,-34.5904,-58.629 -nostr.rtvslawenia.com,49.4543,11.0746 -relay01.lnfi.network,39.0997,-94.5786 -strfry.bonsai.com,37.8715,-122.273 -relay.nostr.place,32.7767,-96.797 -relay.bitcoindistrict.org,43.6532,-79.3832 -relay.aloftus.io,34.0881,-118.379 -nostr.massmux.com,51.5072,-0.127586 -relay.wellorder.net,45.5201,-122.99 -santo.iguanatech.net,40.8302,-74.1299 relay.damus.io,43.6532,-79.3832 -relay.javi.space,43.4633,11.8796 -relay.nosto.re,51.8933,4.42083 -relay.utxo.farm,35.6916,139.768 -relay.fr13nd5.com,52.5233,13.3426 -librerelay.aaroniumii.com,43.6532,-79.3832 -relay.cypherflow.ai,48.8566,2.35222 -relay.letsfo.com,51.098,17.0321 -nostr.0x7e.xyz,47.4988,8.72369 -relay.bullishbounty.com,43.6532,-79.3832 -relay.tagayasu.xyz,43.6715,-79.38 -nostr.plantroon.com,50.1013,8.62643 -strfry.openhoofd.nl,51.9229,4.40833 -nostrelites.org,41.8781,-87.6298 -relay.credenso.cafe,43.1149,-80.7228 -relay.conduit.market,43.6532,-79.3832 -relay.olas.app,50.4754,12.3683 -relay.nostrdice.com,-33.8688,151.209 -nostr.oxtr.dev,50.4754,12.3683 -relay.nostr.band,60.1699,24.9384 -khatru.nostrver.se,51.8933,4.42083 -relay.coinos.io,43.6532,-79.3832 -nostr.l484.com,30.2944,-97.6223 -purplerelay.com,50.1109,8.68213 -relay.nostrhub.fr,48.1046,11.6002 -black.nostrcity.club,41.8781,-87.6298 -nostr.snowbla.de,60.1699,24.9384 -no.str.cr,9.93388,-84.0849 -nostr.rikmeijer.nl,50.4754,12.3683 -nproxy.kristapsk.lv,60.1699,24.9384 -wot.soundhsa.com,34.0479,-118.256 -nostr.kungfu-g.rip,33.7946,-84.4488 -nostr.spaceshell.xyz,43.6532,-79.3832 -nostr.mikoshi.de,50.1109,8.68213 -gnostr.com,40.9017,29.1616 -noxir.kpherox.dev,34.8587,135.509 -nostr.ser1.net,12.9716,77.5946 -nostr-relay.zimage.com,34.282,-118.439 nostr2.girino.org,43.6532,-79.3832 -dev-relay.lnfi.network,39.0997,-94.5786 -nostr.hifish.org,47.3769,8.54169 -itanostr.space,52.2931,4.79099 -srtrelay.c-stellar.net,43.6532,-79.3832 -wot.dtonon.com,43.6532,-79.3832 -relay.mwaters.net,50.9871,2.12554 -relay.nostr.net,50.4754,12.3683 -relay.nostx.io,43.6532,-79.3832 -relay.laantungir.net,-19.4692,-42.5315 -pyramid.fiatjaf.com,50.1109,8.68213 -nostr.einundzwanzig.space,50.1109,8.68213 -relay.satlantis.io,32.8769,-80.0114 -nostr-relay.amethyst.name,35.7327,-78.8503 -fanfares.nostr1.com,40.7057,-74.0136 -relay.evanverma.com,40.8302,-74.1299 -relay.wavlake.com,41.2619,-95.8608 -relay.bitchat.party,53.5501,-113.469 -nostr.yael.at,52.3563,4.95714 -fenrir-s.notoshi.win,43.6532,-79.3832 -relay.nostr.wirednet.jp,34.704,135.495 -relay.origin.land,35.6673,139.751 -relay.goodmorningbitcoin.com,43.6532,-79.3832 -relay.mattybs.lol,43.6532,-79.3832 -relay.nostr.vet,52.6467,4.7395 -relay.arx-ccn.com,50.4754,12.3683 -nostr.agentcampfire.com,50.8933,6.05805 -ribo.eu.nostria.app,52.3676,4.90414 -nostr.huszonegy.world,47.4979,19.0402 -nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 -temp.iris.to,43.6532,-79.3832 -relay.etch.social,41.2619,-95.8608 -relay.hasenpfeffr.com,39.0438,-77.4874 -relay.exit.pub,50.4754,12.3683 -relay.chakany.systems,43.6532,-79.3832 -nostr.coincards.com,45.4002,-75.8064 -nostr.hekster.org,37.3986,-121.964 -nostr-relay.psfoundation.info,39.0438,-77.4874 -nostr-2.21crypto.ch,47.1928,8.81494 -nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 -relay.toastr.net,40.8054,-74.0241 -relay.wolfcoil.com,35.6092,139.73 -nostr-02.dorafactory.org,1.35208,103.82 -portal-relay.pareto.space,49.4543,11.0746 -nostr.makibisskey.work,43.6532,-79.3832 -shu04.shugur.net,25.2604,55.2989 -relay.lnfi.network,39.0438,-77.4874 -nostr.smut.cloud,43.6532,-79.3832 -nostr.diakod.com,43.6532,-79.3832 -relay.lumina.rocks,49.0291,8.35695 -nostr.now,36.55,139.733 -relay.mockingyou.com,37.5485,-121.989 -nostr.blankfors.se,60.1699,24.9384 -adre.su,59.9311,30.3609 -nostr.tadryanom.me,43.6532,-79.3832 -relay2.angor.io,48.1046,11.6002 -shu02.shugur.net,21.4902,39.2246 -nostrelay.circum.space,51.2217,6.77616 -nostr.spacecitynode.com,29.7057,-95.2706 -vidono.apps.slidestr.net,48.8566,2.35222 -relay.btcwill.win,43.6532,-79.3832 -orangepiller.org,60.1699,24.9384 -solo.itsalldance.space,32.71,-96.6745 -relay.zone667.com,60.1699,24.9384 -nostr.azzamo.net,52.2633,21.0283 -internationalright-wing.org,-22.5986,-48.8003 -shu05.shugur.net,48.8566,2.35222 -relay.lifpay.me,1.35208,103.82 -slick.mjex.me,39.048,-77.4817 -theoutpost.life,64.1476,-21.9392 -nostr.rohoss.com,50.1109,8.68213 -relay.cosmicbolt.net,37.3986,-121.964 -relay.jeffg.fyi,43.6532,-79.3832 -nostr.satstralia.com,64.1476,-21.9392 -relay-admin.thaliyal.com,40.8218,-74.45 -social.proxymana.net,60.1699,24.9384 -nostr.sathoarder.com,48.5734,7.75211 -wot.nostr.place,30.2672,-97.7431 -nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 -relay.nsnip.io,60.1699,24.9384 -nostr.zenon.network,43.5009,-70.4428 -relay.copylaradio.com,50.8198,-1.08798 -relay.nostrhub.tech,49.4543,11.0746 -ithurtswhenip.ee,51.0238,-1.33713 -nostr-01.yakihonne.com,1.32123,103.695 -nostr-relay.online,43.6532,-79.3832 -vitor.nostr1.com,40.7057,-74.0136 -relay-testnet.k8s.layer3.news,37.3387,-121.885 -schnorr.me,43.6532,-79.3832 -relay.artx.market,43.652,-79.3633 -nostr.21crypto.ch,47.1928,8.81494 -relayone.soundhsa.com,34.0479,-118.256 -relay.puresignal.news,43.6532,-79.3832 nostr.n7ekb.net,47.4941,-122.294 -wot.brightbolt.net,47.6735,-116.781 -nostr.overmind.lol,43.6532,-79.3832 -relay.magiccity.live,25.8128,-80.2377 -kitchen.zap.cooking,43.6532,-79.3832 -relay.satsdays.com,1.35208,103.82 -nostr-02.yakihonne.com,1.32123,103.695 -offchain.pub,36.1809,-115.241 -nostr.lojong.info,43.6532,-79.3832 -relay.tapestry.ninja,40.8054,-74.0241 -zap.watch,45.5029,-73.5723 -nostrelay.memory-art.xyz,43.6532,-79.3832 -nostr.spicyz.io,43.6532,-79.3832 -nostr.mom,50.4754,12.3683 -nos.lol,50.4754,12.3683 +rnostr.breadslice.com,43.6532,-79.3832 +portal-relay.pareto.space,49.4543,11.0746 +relay.hasenpfeffr.com,39.0438,-77.4874 +nostr.blankfors.se,60.1699,24.9384 +relay5.bitransfer.org,43.6532,-79.3832 +santo.iguanatech.net,40.8302,-74.1299 +nostr.ser1.net,12.9716,77.5946 +nostr.stakey.net,52.3676,4.90414 +relayrs.notoshi.win,43.6532,-79.3832 +orangepiller.org,60.1699,24.9384 +temp.iris.to,43.6532,-79.3832 +relay.cypherflow.ai,48.8566,2.35222 +nostr.kalf.org,52.3676,4.90414 +relay.unknown.cloud,43.6532,-79.3832 +relay.artx.market,43.652,-79.3633 +relay.arx-ccn.com,50.4754,12.3683 relay.electriclifestyle.com,26.2897,-80.1293 -wot.nostr.net,43.6532,-79.3832 -nostr.primz.org,43.6532,-79.3832 +nostr-relay.online,43.6532,-79.3832 +relay.jeffg.fyi,43.6532,-79.3832 +relay.wellorder.net,45.5201,-122.99 +relay01.lnfi.network,39.0997,-94.5786 +noxir.kpherox.dev,34.8587,135.509 +wot.sebastix.social,51.8933,4.42083 +nostr.overmind.lol,43.6532,-79.3832 nostr.red5d.dev,43.6532,-79.3832 -relay.0xchat.com,1.35208,103.82 -relay.moinsen.com,50.4754,12.3683 -relay03.lnfi.network,39.0997,-94.5786 +nostr.zenon.network,43.5009,-70.4428 +cyberspace.nostr1.com,40.7128,-74.006 +fenrir-s.notoshi.win,43.6532,-79.3832 +no.str.cr,9.92857,-84.0528 +relay.nostrdice.com,-33.8688,151.209 +nostr.data.haus,50.4754,12.3683 +relay.g1sms.fr,43.9432,2.07537 +relay.fundstr.me,42.3601,-71.0589 +nostr.thaliyal.com,40.8218,-74.45 +relay.mccormick.cx,52.3563,4.95714 +nostr.makibisskey.work,43.6532,-79.3832 +shu01.shugur.net,21.4902,39.2246 +relay.angor.io,48.1046,11.6002 +nostr.primz.org,43.6532,-79.3832 +nostr-02.yakihonne.com,1.32123,103.695 +relay.freeplace.nl,52.3676,4.90414 +relay.snort.social,43.6532,-79.3832 +nostr.rohoss.com,50.1109,8.68213 +relay.getsafebox.app,43.6532,-79.3832 +nostr.88mph.life,43.6532,-79.3832 +wot.sovbit.host,64.1466,-21.9426 +nostr-02.dorafactory.org,1.35208,103.82 +itanostr.space,52.2931,4.79099 +ribo.af.nostria.app,-26.2041,28.0473 +nostr.azzamo.net,52.2633,21.0283 +relay.fountain.fm,39.0997,-94.5786 relay.holzeis.me,43.6532,-79.3832 +relay.sincensura.org,43.6532,-79.3832 +nostr-dev.wellorder.net,45.5201,-122.99 +relay.hodl.ar,-32.94,-60.6745 +nostr-relay.psfoundation.info,39.0438,-77.4874 +relay.goodmorningbitcoin.com,43.6532,-79.3832 +nostr.rtvslawenia.com,49.4543,11.0746 +relay.13room.space,43.6532,-79.3832 +nostr-02.czas.top,53.471,9.88208 +relay.chorus.community,50.1109,8.68213 +shu04.shugur.net,25.2604,55.2989 +satsage.xyz,37.3986,-121.964 +relay.bullishbounty.com,43.6532,-79.3832 +nostr.sagaciousd.com,49.2827,-123.121 +relay.nostromo.social,49.4543,11.0746 +relay.magiccity.live,25.8128,-80.2377 relay-dev.satlantis.io,40.8302,-74.1299 +nostr.coincrowd.fund,39.0438,-77.4874 +shu05.shugur.net,48.8566,2.35222 +nostrelay.memory-art.xyz,43.6532,-79.3832 +nostr.now,36.55,139.733 +nostr-pub.wellorder.net,45.5201,-122.99 +nos.lol,50.4754,12.3683 +wot.dergigi.com,64.1476,-21.9392 +inbox.azzamo.net,52.2633,21.0283 +nostr.mikoshi.de,50.1109,8.68213 +librerelay.aaroniumii.com,43.6532,-79.3832 +relay.nosto.re,51.8933,4.42083 +relay.lumina.rocks,49.0291,8.35695 +kitchen.zap.cooking,43.6532,-79.3832 +zap.watch,45.5029,-73.5723 +nostr.spacecitynode.com,29.7057,-95.2706 +nostr.bilthon.dev,25.8128,-80.2377 +relay.aloftus.io,34.0881,-118.379 +shu02.shugur.net,21.4902,39.2246 +relay.mwaters.net,50.9871,2.12554 +relay.moinsen.com,50.4754,12.3683 +nostr.kungfu-g.rip,33.7946,-84.4488 +nostr.sathoarder.com,48.5734,7.75211 +nostream.breadslice.com,43.6532,-79.3832 +slick.mjex.me,39.048,-77.4817 +nostr.jerrynya.fun,31.2304,121.474 +articles.layer3.news,37.3387,-121.885 +nostr.satstralia.com,64.1476,-21.9392 +relay.degmods.com,50.4754,12.3683 +relay.0xchat.com,1.35208,103.82 +relay.nostx.io,43.6532,-79.3832 +relay.siamdev.cc,13.9178,100.424 +relay.mattybs.lol,43.6532,-79.3832 +relay.bitcoinveneto.org,64.1466,-21.9426 +nostr.tadryanom.me,43.6532,-79.3832 +nostr.spaceshell.xyz,43.6532,-79.3832 +relay.nostr.band,60.1699,24.9384 +relay03.lnfi.network,39.0997,-94.5786 +schnorr.me,43.6532,-79.3832 +khatru.nostrver.se,51.8933,4.42083 +dealtime-footwear-angry-records.trycloudflare.com,43.6532,-79.3832 +relay.endfiat.money,43.6532,-79.3832 +internationalright-wing.org,-22.5022,-48.7114 +nostr.mom,50.4754,12.3683 +nostrue.com,40.8054,-74.0241 +relay.chakany.systems,43.6532,-79.3832 +vidono.apps.slidestr.net,48.8566,2.35222 +relay.stream.labs.h3.se,59.4016,17.9455 +gnostr.com,40.9017,29.1616 +relay.origin.land,35.6673,139.751 +relay.sigit.io,50.4754,12.3683 +theoutpost.life,64.1476,-21.9392 +adre.su,59.9311,30.3609 +soloco.nl,43.6532,-79.3832 +wot.soundhsa.com,34.0479,-118.256 +nos.xmark.cc,50.6924,3.20113 +nostr-relay.zimage.com,34.282,-118.439 +nostr.pleb.one,38.6327,-90.1961 +purpura.cloud,43.6532,-79.3832 +ribo.us.nostria.app,41.5868,-93.625 +relay.digitalezukunft.cyou,45.5019,-73.5674 +relay.mess.ch,47.3591,8.55292 +relay.javi.space,43.4633,11.8796 +nostr.jfischer.org,49.0291,8.35696 +nostr.rblb.it,43.4633,11.8796 +relay.nostriot.com,41.5695,-83.9786 +nostr.21crypto.ch,47.4988,8.72369 +relay.nostr.vet,52.6467,4.7395 +fanfares.nostr1.com,40.7057,-74.0136 +relay.copylaradio.com,51.223,6.78245 +relay.mostro.network,40.8302,-74.1299 +nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 +relay.lifpay.me,1.35208,103.82 +nostr.tac.lol,47.4748,-122.273 +relay.nostraddress.com,43.6532,-79.3832 +wot.sudocarlos.com,51.5072,-0.127586 +nostr.spicyz.io,43.6532,-79.3832 +relay02.lnfi.network,39.0997,-94.5786 +nostr.4rs.nl,49.0291,8.35696 +relay.21e6.cz,50.1682,14.0546 +nostr.casa21.space,43.6532,-79.3832 +wot.codingarena.top,50.4754,12.3683 +nostr-01.yakihonne.com,1.32123,103.695 +strfry.openhoofd.nl,51.9229,4.40833 +nostr.plantroon.com,50.1013,8.62643 +dev-nostr.bityacht.io,25.0797,121.234 +ribo.eu.nostria.app,52.3676,4.90414 +wot.nostr.place,30.2672,-97.7431 +relay.dwadziesciajeden.pl,52.2297,21.0122 +prl.plus,55.7623,37.6381 +relay.bitcoinartclock.com,50.4754,12.3683 +nostr.2b9t.xyz,34.0549,-118.243 +a.nos.lol,50.4754,12.3683 +relay.wavlake.com,41.2619,-95.8608 +relay.toastr.net,40.8054,-74.0241 +wheat.happytavern.co,43.6532,-79.3832 +relay.lnfi.network,39.0438,-77.4874 +nostr.liberty.fans,36.9104,-89.5875 +vitor.nostr1.com,40.7057,-74.0136 +nostr.thebiglake.org,32.71,-96.6745 +relay.exit.pub,50.4754,12.3683 +freelay.sovbit.host,64.1476,-21.9392 +purplerelay.com,50.1109,8.68213 +nostr.camalolo.com,24.1469,120.684 +black.nostrcity.club,41.8781,-87.6298 +relay.barine.co,43.6532,-79.3832 +nostr.0x7e.xyz,47.4988,8.72369 +relayone.soundhsa.com,34.0479,-118.256 +relay.vrtmrz.net,43.6532,-79.3832 +nostr-03.dorafactory.org,1.35208,103.82 +r.bitcoinhold.net,43.6532,-79.3832 +relay.agorist.space,52.3734,4.89406 +nostr-relay.amethyst.name,39.0067,-77.4291 +relay-rpi.edufeed.org,49.4543,11.0746 +orangesync.tech,50.1109,8.68213 +relay04.lnfi.network,39.0997,-94.5786 +nostr.hifish.org,47.4043,8.57398 +relay.nostr.wirednet.jp,34.706,135.493 +nostr.girino.org,43.6532,-79.3832 +strfry.felixzieger.de,50.1013,8.62643 +nostr.night7.space,50.4754,12.3683 +nostr.oxtr.dev,50.4754,12.3683 +relay.nostrhub.fr,48.1046,11.6002 +nostr.rikmeijer.nl,50.4754,12.3683 +wot.basspistol.org,49.4521,11.0767 +nostr.coincards.com,53.5501,-113.469 +solo.itsalldance.space,32.71,-96.6745 +relay.puresignal.news,43.6532,-79.3832 +nostrelay.circum.space,51.2217,6.77616 +relay.btcforplebs.com,43.6532,-79.3832 +nostr.diakod.com,43.6532,-79.3832 +tollbooth.stens.dev,51.223,6.78245 +relay.usefusion.ai,39.0438,-77.4874 +relay-admin.thaliyal.com,40.8218,-74.45 +nproxy.kristapsk.lv,60.1699,24.9384 +nostr-verified.wellorder.net,45.5201,-122.99 +wot.brightbolt.net,47.6735,-116.781 +relay.orangepill.ovh,49.1689,-0.358841 +relay.olas.app,50.4754,12.3683 +offchain.pub,36.1809,-115.241 +nostr.l484.com,30.2944,-97.6223 +relay.nostrhub.tech,49.4543,11.0746 +relay.tapestry.ninja,40.8054,-74.0241 +nostr.snowbla.de,60.1699,24.9384 +relay.letsfo.com,51.098,17.0321 +relay.tagayasu.xyz,43.6715,-79.38 +strfry.bonsai.com,37.8715,-122.273 +nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832 +nostr.smut.cloud,43.6532,-79.3832 +x.kojira.io,43.6532,-79.3832 +yabu.me,35.6092,139.73 +relay.zone667.com,60.1699,24.9384 +relay.bitcoindistrict.org,43.6532,-79.3832 +pyramid.fiatjaf.com,50.1109,8.68213 +nostr.hekster.org,37.3986,-121.964 +relay.laantungir.net,-19.4692,-42.5315 +relay.cosmicbolt.net,37.3986,-121.964 +strfry.shock.network,41.8959,-88.2169 +relay.coinos.io,43.6532,-79.3832 +wot.nostr.net,43.6532,-79.3832 +nostr.einundzwanzig.space,50.1109,8.68213 +premium.primal.net,43.6532,-79.3832 +dev-relay.lnfi.network,39.0997,-94.5786 +ynostr.yael.at,60.1699,24.9384 +relay.fr13nd5.com,52.5233,13.3426 +nostr.lojong.info,43.6532,-79.3832 +srtrelay.c-stellar.net,43.6532,-79.3832 +nostr.myshosholoza.co.za,52.3676,4.90414 +alien.macneilmediagroup.com,43.6532,-79.3832 +relay.utxo.farm,35.6916,139.768 +relay.ru.ac.th,13.7584,100.622 +relay2.angor.io,48.1046,11.6002 +relay.seq1.net,43.6532,-79.3832 +relay.illuminodes.com,47.6061,-122.333 +relay.satlantis.io,32.8769,-80.0114 +relay.primal.net,43.6532,-79.3832 +nostr.vulpem.com,49.4543,11.0746 +relay.evanverma.com,40.8302,-74.1299 +nostr.chaima.info,51.223,6.78245 +relay.notoshi.win,13.7829,100.546 +nostr.carroarmato0.be,50.9928,3.26317 +relay.ditto.pub,43.6532,-79.3832 +relay.credenso.cafe,43.1149,-80.7228 +relay.nostr.place,32.7767,-96.797 +relay.conduit.market,43.6532,-79.3832 +nostr.huszonegy.world,47.4979,19.0402 +nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 +wot.nostr.party,36.1627,-86.7816 +wot.dtonon.com,43.6532,-79.3832 +nostr.middling.mydns.jp,35.8099,140.12 +nostr-2.21crypto.ch,47.4988,8.72369 +nostr.dlsouza.lol,50.1109,8.68213 From 6b54c70d26ad669064060b5d326d9cb1212400c8 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 7 Sep 2025 08:31:23 +0200 Subject: [PATCH 7/9] fix remove peer on disconnect (#388) --- .../java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt index f1c9009b..3494d1b2 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt @@ -277,6 +277,7 @@ class BluetoothConnectionTracker( fun cleanupDeviceConnection(deviceAddress: String) { connectedDevices.remove(deviceAddress)?.let { deviceConn -> subscribedDevices.removeAll { it.address == deviceAddress } + addressPeerMap.remove(deviceAddress) } pendingConnections.remove(deviceAddress) Log.d(TAG, "Cleaned up device connection for $deviceAddress") From ba518269b47af5c705eed157982146e1ad3950c7 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 7 Sep 2025 09:04:21 +0200 Subject: [PATCH 8/9] fix geohash livedata wiring (#389) --- .../android/nostr/NostrGeohashService.kt | 83 ++++--------------- .../java/com/bitchat/android/ui/ChatScreen.kt | 9 +- .../com/bitchat/android/ui/ChatViewModel.kt | 7 -- .../com/bitchat/android/ui/MessageManager.kt | 14 +++- 4 files changed, 31 insertions(+), 82 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt b/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt index cb98f732..f59e4434 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt @@ -87,10 +87,8 @@ class NostrGeohashService( private var geohashSamplingJob: Job? = null private var geoParticipantsTimer: Job? = null - // MARK: - Geohash Message History Properties - - private val geohashMessageHistory = mutableMapOf>() // geohash -> messages - private val maxGeohashMessages = 1000 // Maximum messages per geohash + // MARK: - Geohash Message Routing + // Geohash messages are routed through MessageManager channel timelines using key "geo:" // MARK: - Location Channel Management Properties @@ -166,7 +164,6 @@ class NostrGeohashService( try { geohashParticipants.clear() geoNicknames.clear() - geohashMessageHistory.clear() processedNostrEvents.clear() processedNostrEventOrder.clear() currentGeohashSubscriptionId = null @@ -258,10 +255,8 @@ class NostrGeohashService( powDifficulty = if (powSettingsLocal.enabled) powSettingsLocal.difficulty else null ) - // Store immediately; UI will display from geohash history (not main mesh timeline) - storeGeohashMessage(channel.geohash, localMessage) - // IMPORTANT: Do not add to main mesh timeline to avoid duplication in mesh chat view - // messageManager.addMessage(localMessage) + // Local echo into geohash channel timeline via unified MessageManager + messageManager.addChannelMessage("geo:${channel.geohash}", localMessage) Log.d(TAG, "📝 Added geohash local echo with temp ID: $tempMessageId (not shown in mesh timeline)") @@ -692,55 +687,7 @@ class NostrGeohashService( } } - // MARK: - Geohash Message History - - /** - * Store a message in geohash history - */ - private fun storeGeohashMessage(geohash: String, message: BitchatMessage) { - val messages = geohashMessageHistory.getOrPut(geohash) { mutableListOf() } - messages.add(message) - - // Limit message history to prevent memory issues - if (messages.size > maxGeohashMessages) { - messages.removeAt(0) // Remove oldest message - } - - Log.v(TAG, "📦 Stored message in geohash $geohash history (${messages.size} total) - sender: ${message.sender}, content: '${message.content.take(30)}...'") - } - - /** - * Load stored messages for a geohash channel - */ - fun loadGeohashMessages(geohash: String) { - val storedMessages = geohashMessageHistory[geohash] - if (storedMessages == null) { - Log.d(TAG, "📥 No stored messages found for geohash $geohash") - return - } - - Log.d(TAG, "📥 Loading ${storedMessages.size} stored messages for geohash $geohash") - - // Add all stored messages to the current message timeline - storedMessages.forEach { message -> - Log.v(TAG, "📥 Loading stored message: ${message.sender} - '${message.content.take(30)}...'") - messageManager.addMessage(message) - } - } - - /** - * Get stored messages for a geohash without mutating UI state - */ - fun getGeohashMessages(geohash: String): List { - return geohashMessageHistory[geohash]?.toList() ?: emptyList() - } - - /** - * Clear geohash message history - */ - fun clearGeohashMessageHistory() { - geohashMessageHistory.clear() - } + // Geohash message history is maintained in ChatState.channelMessages; no local cache here // MARK: - Geohash Participant Tracking @@ -994,8 +941,7 @@ class NostrGeohashService( private fun switchLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) { // STEP 1: Immediate UI updates (synchronous, no blocking) try { - // NOTE: Don't clear messages here - let ChatScreen's displayMessages logic handle what to show - // This preserves mesh message history when switching between views + // Do not clear messages here - preserve mesh history when switching between views when (channel) { is com.bitchat.android.geohash.ChannelID.Mesh -> { @@ -1020,7 +966,10 @@ class NostrGeohashService( // Clear notifications for this geohash since user is now viewing it notificationManager.clearNotificationsForGeohash(channel.channel.geohash) // Note: Don't clear geoNicknames - they contain cached nicknames for all geohashes - // Note: Don't load messages here - ChatScreen will get them via getGeohashMessages() + // Clear unread for this geohash since user is now viewing it + try { + messageManager.clearChannelUnreadCount("geo:${channel.channel.geohash}") + } catch (_: Exception) { } // Immediate self-registration for instant UI feedback try { @@ -1300,11 +1249,8 @@ class NostrGeohashService( powDifficulty = actualPow.takeIf { it > 0 && eventHasNonseTag } ?: null ) - // Store in geohash history for persistence across channel switches - storeGeohashMessage(geohash, message) - - // NOTE: Don't add to main message timeline here - ChatScreen will display geohash messages - // from the separate geohash history via getGeohashMessages() + // Add to geohash channel timeline (unified pipeline) + withContext(Dispatchers.Main) { messageManager.addChannelMessage("geo:$geohash", message) } // NOTIFICATION LOGIC: Check for mentions and first messages checkAndTriggerGeohashNotifications(geohash, senderName, content, message) @@ -1403,8 +1349,9 @@ class NostrGeohashService( * Check if this is the first message in a subscribed geohash chat */ private fun checkIfFirstMessage(geohash: String, message: BitchatMessage): Boolean { - // Get the message history for this geohash - val messageHistory = geohashMessageHistory[geohash] ?: return true + // Use ChatState.channelMessages for geohash history + val channelKey = "geo:$geohash" + val messageHistory = state.getChannelMessagesValue()[channelKey] ?: emptyList() // Filter out our own messages (local echoes and messages from our identity) val otherUserMessages = messageHistory.filter { msg -> diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 6c0a5555..8e4e43b5 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -74,18 +74,17 @@ fun ChatScreen(viewModel: ChatViewModel) { // Get location channel info for timeline switching val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState() - // Determine what messages to show based on current context + // Determine what messages to show based on current context (unified timelines) val displayMessages = when { selectedPrivatePeer != null -> privateChats[selectedPrivatePeer] ?: emptyList() currentChannel != null -> channelMessages[currentChannel] ?: emptyList() else -> { val locationChannel = selectedLocationChannel if (locationChannel is com.bitchat.android.geohash.ChannelID.Location) { - // For geohash channels, get messages from geohash history - val geohash = locationChannel.channel.geohash - viewModel.getGeohashMessages(geohash) + val geokey = "geo:${locationChannel.channel.geohash}" + channelMessages[geokey] ?: emptyList() } else { - messages // Mesh/public messages + messages // Mesh timeline } } } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 74302cb9..89429b53 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -649,13 +649,6 @@ class ChatViewModel( } } - /** - * Get messages for a specific geohash timeline - */ - fun getGeohashMessages(geohash: String): List { - return nostrGeohashService.getGeohashMessages(geohash) - } - /** * Get participant count for a specific geohash (5-minute activity window) */ diff --git a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt index e554679d..276a6bbe 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt @@ -52,8 +52,18 @@ class MessageManager(private val state: ChatState) { currentChannelMessages[channel] = channelMessageList state.setChannelMessages(currentChannelMessages) - // Update unread count if not currently in this channel - if (state.getCurrentChannelValue() != channel) { + // Update unread count if not currently viewing this channel + // Consider both classic channels (state.currentChannel) and geohash location channel selection + val viewingClassicChannel = state.getCurrentChannelValue() == channel + val viewingGeohashChannel = try { + if (channel.startsWith("geo:")) { + val geo = channel.removePrefix("geo:") + val selected = state.selectedLocationChannel.value + selected is com.bitchat.android.geohash.ChannelID.Location && selected.channel.geohash.equals(geo, ignoreCase = true) + } else false + } catch (_: Exception) { false } + + if (!viewingClassicChannel && !viewingGeohashChannel) { val currentUnread = state.getUnreadChannelMessagesValue().toMutableMap() currentUnread[channel] = (currentUnread[channel] ?: 0) + 1 state.setUnreadChannelMessages(currentUnread) From 998ee606b14411fd7e1ebd3905f9d853bdef7673 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 8 Sep 2025 13:46:15 +0200 Subject: [PATCH 9/9] Nostr refactor simplify (#390) * fix bug * geoDM receive works, send doesnt, and incoming message doesnt make sender appear in peer list * fix nostr dm * geohash dms work * Geohash DM UI: stop mixing Nostr DM temp chats into mesh offline list; ensure geohash DM senders are added to geohash people list only. Removed nostr_* sidebar append in PeopleSection; kept 64-hex mesh offline favorites. Verified build. * refactor * nice * works * merging nostr -> mesh works * tripple click to delete all * fix sidebar icon * remove hash * dms have correct recipient * works * wip unread badge * geohash dms wip * dms work --- .../favorites/FavoritesPersistenceService.kt | 241 +-- .../android/mesh/BluetoothMeshService.kt | 38 +- .../bitchat/android/mesh/MessageHandler.kt | 2 + .../android/nostr/GeohashAliasRegistry.kt | 24 + .../android/nostr/GeohashMessageHandler.kt | 99 + .../android/nostr/GeohashRepository.kt | 218 ++ .../nostr/NostrDirectMessageHandler.kt | 176 ++ .../android/nostr/NostrGeohashService.kt | 1788 ----------------- .../android/nostr/NostrSubscriptionManager.kt | 38 + .../bitchat/android/nostr/NostrTransport.kt | 69 +- .../bitchat/android/services/MessageRouter.kt | 30 +- .../java/com/bitchat/android/ui/ChatHeader.kt | 75 +- .../java/com/bitchat/android/ui/ChatState.kt | 17 + .../com/bitchat/android/ui/ChatViewModel.kt | 191 +- .../bitchat/android/ui/CommandProcessor.kt | 2 +- .../bitchat/android/ui/GeohashViewModel.kt | 259 +++ .../bitchat/android/ui/MeshDelegateHandler.kt | 23 +- .../bitchat/android/ui/PrivateChatManager.kt | 30 +- .../bitchat/android/ui/SidebarComponents.kt | 71 +- docs/ANNOUNCEMENT_GOSSIP.md | 83 + docs/SOURCE_ROUTING.md | 78 + 21 files changed, 1509 insertions(+), 2043 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/nostr/GeohashAliasRegistry.kt create mode 100644 app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt create mode 100644 app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt create mode 100644 app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt delete mode 100644 app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt create mode 100644 app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt create mode 100644 docs/ANNOUNCEMENT_GOSSIP.md create mode 100644 docs/SOURCE_ROUTING.md diff --git a/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt b/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt index f1366243..fe04c7b2 100644 --- a/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt +++ b/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt @@ -9,11 +9,12 @@ import java.util.* /** * Bridging Noise and Nostr favorites - * Direct port from iOS FavoritesPersistenceService.swift + * Direct port from iOS FavoritesPersistenceService.swift, with Android-specific + * peerID (16-hex) -> npub indexing for Nostr DM routing. */ data class FavoriteRelationship( val peerNoisePublicKey: ByteArray, // Noise static public key (32 bytes) - val peerNostrPublicKey: String?, // npub bech32 string + val peerNostrPublicKey: String?, // npub bech32 string val peerNickname: String, val isFavorite: Boolean, // We favorited them val theyFavoritedUs: Boolean, // They favorited us @@ -21,22 +22,22 @@ data class FavoriteRelationship( val lastUpdated: Date ) { val isMutual: Boolean get() = isFavorite && theyFavoritedUs - + override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false - + other as FavoriteRelationship - + if (!peerNoisePublicKey.contentEquals(other.peerNoisePublicKey)) return false if (peerNostrPublicKey != other.peerNostrPublicKey) return false if (peerNickname != other.peerNickname) return false if (isFavorite != other.isFavorite) return false if (theyFavoritedUs != other.theyFavoritedUs) return false - + return true } - + override fun hashCode(): Int { var result = peerNoisePublicKey.contentHashCode() result = 31 * result + (peerNostrPublicKey?.hashCode() ?: 0) @@ -54,20 +55,21 @@ interface FavoritesChangeListener { /** * Manages favorites with Noise↔Nostr mapping - * Singleton pattern matching iOS implementation + * Singleton pattern matching iOS implementation. */ class FavoritesPersistenceService private constructor(private val context: Context) { - + companion object { private const val TAG = "FavoritesPersistenceService" - private const val FAVORITES_KEY = "favorite_relationships" - + private const val FAVORITES_KEY = "favorite_relationships" // noiseHex -> relationship + private const val PEERID_INDEX_KEY = "favorite_peerid_index" // peerID(16-hex) -> npub + @Volatile private var INSTANCE: FavoritesPersistenceService? = null - + val shared: FavoritesPersistenceService get() = INSTANCE ?: throw IllegalStateException("FavoritesPersistenceService not initialized") - + fun initialize(context: Context) { if (INSTANCE == null) { synchronized(this) { @@ -78,46 +80,40 @@ class FavoritesPersistenceService private constructor(private val context: Conte } } } - + private val stateManager = SecureIdentityStateManager(context) private val gson = Gson() - private val favorites = mutableMapOf() // noiseKeyHex -> relationship + private val favorites = mutableMapOf() // noiseHex -> relationship + // NEW: Index by current mesh peerID (16-hex) for direct lookup when sending Nostr DMs from mesh context + private val peerIdIndex = mutableMapOf() // peerID (lowercase 16-hex) -> npub private val listeners = mutableListOf() - + init { loadFavorites() + loadPeerIdIndex() } - - /** - * Get favorite status for Noise public key - */ + + /** Get favorite status for Noise public key */ fun getFavoriteStatus(noisePublicKey: ByteArray): FavoriteRelationship? { val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) } return favorites[keyHex] } - - /** - * Get favorite status for 16-hex peerID - */ + + /** Get favorite status for 16-hex peerID (by noiseHex prefix match) */ fun getFavoriteStatus(peerID: String): FavoriteRelationship? { - // For 16-hex peerIDs, we need to find the corresponding full Noise key - // This is a simplified lookup - in practice you'd use fingerprint matching + val pid = peerID.lowercase() for ((_, relationship) in favorites) { val noiseKeyHex = relationship.peerNoisePublicKey.joinToString("") { "%02x".format(it) } - if (noiseKeyHex.startsWith(peerID)) { - return relationship - } + if (noiseKeyHex.startsWith(pid)) return relationship } return null } - - /** - * Update Nostr public key for a peer - */ + + /** Update Nostr public key for a peer (indexed by Noise key) */ fun updateNostrPublicKey(noisePublicKey: ByteArray, nostrPubkey: String) { val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) } val existing = favorites[keyHex] - + if (existing != null) { val updated = existing.copy( peerNostrPublicKey = nostrPubkey, @@ -125,7 +121,6 @@ class FavoritesPersistenceService private constructor(private val context: Conte ) favorites[keyHex] = updated } else { - // Create new relationship val relationship = FavoriteRelationship( peerNoisePublicKey = noisePublicKey, peerNostrPublicKey = nostrPubkey, @@ -137,19 +132,54 @@ class FavoritesPersistenceService private constructor(private val context: Conte ) favorites[keyHex] = relationship } - + saveFavorites() notifyChanged(keyHex) Log.d(TAG, "Updated Nostr pubkey association for ${keyHex.take(16)}...") } - - /** - * Update favorite status - */ + + /** NEW: Update Nostr pubkey for specific mesh peerID (16-hex). */ + fun updateNostrPublicKeyForPeerID(peerID: String, nostrPubkey: String) { + val pid = peerID.lowercase() + if (pid.length == 16 && pid.matches(Regex("^[0-9a-f]+$"))) { + peerIdIndex[pid] = nostrPubkey + savePeerIdIndex() + Log.d(TAG, "Indexed npub for peerID ${pid.take(8)}…") + } else { + Log.w(TAG, "updateNostrPublicKeyForPeerID called with non-16hex peerID: $peerID") + } + } + + /** NEW: Resolve Nostr pubkey via current peerID mapping (fast path). */ + fun findNostrPubkeyForPeerID(peerID: String): String? { + return peerIdIndex[peerID.lowercase()] + } + + /** NEW: Resolve peerID (16-hex) for a given Nostr pubkey (npub or hex). */ + fun findPeerIDForNostrPubkey(nostrPubkey: String): String? { + // First, try direct match in peerIdIndex (values are stored as npub strings) + peerIdIndex.entries.firstOrNull { it.value.equals(nostrPubkey, ignoreCase = true) }?.let { return it.key } + + // Attempt legacy mapping via favorites Noise key association + val targetHex = normalizeNostrKeyToHex(nostrPubkey) + if (targetHex != null) { + // Find relationship with matching nostr pubkey (normalized to hex) and then try to map to current peerID via noise key prefix + val rel = favorites.values.firstOrNull { it.peerNostrPublicKey?.let { stored -> normalizeNostrKeyToHex(stored) } == targetHex } + if (rel != null) { + val noiseHex = rel.peerNoisePublicKey.joinToString("") { "%02x".format(it) } + // Return 16-hex prefix as best-effort if no explicit mapping exists + return noiseHex.take(16) + } + } + return null + } + + /** Update favorite status */ fun updateFavoriteStatus(noisePublicKey: ByteArray, nickname: String, isFavorite: Boolean) { val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) } + val existing = favorites[keyHex] - + val updated = if (existing != null) { existing.copy( peerNickname = nickname, @@ -168,21 +198,19 @@ class FavoritesPersistenceService private constructor(private val context: Conte lastUpdated = Date() ) } - + favorites[keyHex] = updated saveFavorites() notifyChanged(keyHex) Log.d(TAG, "Updated favorite status for $nickname: $isFavorite") } - - /** - * Update peer favorited us status - */ + + /** Update peer favorited-us flag */ fun updatePeerFavoritedUs(noisePublicKey: ByteArray, theyFavoritedUs: Boolean) { val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) } val existing = favorites[keyHex] - + if (existing != null) { val updated = existing.copy( theyFavoritedUs = theyFavoritedUs, @@ -191,101 +219,98 @@ class FavoritesPersistenceService private constructor(private val context: Conte favorites[keyHex] = updated saveFavorites() notifyChanged(keyHex) - + Log.d(TAG, "Updated peer favorited us for ${keyHex.take(16)}...: $theyFavoritedUs") } } - - /** - * Get all mutual favorites - */ - fun getMutualFavorites(): List { - return favorites.values.filter { it.isMutual } - } - - /** - * Get all favorites we have - */ - fun getOurFavorites(): List { - return favorites.values.filter { it.isFavorite } - } - - /** - * Clear all favorites - */ + + fun getMutualFavorites(): List = favorites.values.filter { it.isMutual } + fun getOurFavorites(): List = favorites.values.filter { it.isFavorite } + fun clearAllFavorites() { favorites.clear() saveFavorites() + peerIdIndex.clear() + savePeerIdIndex() Log.i(TAG, "Cleared all favorites") notifyAllCleared() } - - /** - * Find Noise key by Nostr pubkey - */ + + /** Find Noise key by Nostr pubkey */ fun findNoiseKey(forNostrPubkey: String): ByteArray? { val targetHex = normalizeNostrKeyToHex(forNostrPubkey) ?: return null return favorites.values.firstOrNull { rel -> - rel.peerNostrPublicKey?.let { stored -> - normalizeNostrKeyToHex(stored) - } == targetHex + rel.peerNostrPublicKey?.let { stored -> normalizeNostrKeyToHex(stored) } == targetHex }?.peerNoisePublicKey } - - /** - * Find Nostr pubkey by Noise key - */ + + /** Find Nostr pubkey by Noise key */ fun findNostrPubkey(forNoiseKey: ByteArray): String? { val keyHex = forNoiseKey.joinToString("") { "%02x".format(it) } return favorites[keyHex]?.peerNostrPublicKey } - - // MARK: - Private Methods - + + // MARK: - Persistence + private fun loadFavorites() { try { - // Use public methods instead of reflection to access encrypted preferences val favoritesJson = stateManager.getSecureValue(FAVORITES_KEY) if (favoritesJson != null) { val type = object : TypeToken>() {}.type val data: Map = gson.fromJson(favoritesJson, type) - + favorites.clear() data.forEach { (key, relationshipData) -> - val relationship = relationshipData.toFavoriteRelationship() - favorites[key] = relationship + favorites[key] = relationshipData.toFavoriteRelationship() } - Log.d(TAG, "Loaded ${favorites.size} favorite relationships") } } catch (e: Exception) { Log.e(TAG, "Failed to load favorites: ${e.message}") } } - + private fun saveFavorites() { try { - // Convert to serializable format val data = favorites.mapValues { (_, relationship) -> FavoriteRelationshipData.fromFavoriteRelationship(relationship) } - val favoritesJson = gson.toJson(data) - - // Use public methods instead of reflection to access encrypted preferences stateManager.storeSecureValue(FAVORITES_KEY, favoritesJson) - Log.d(TAG, "Saved ${favorites.size} favorite relationships") } catch (e: Exception) { Log.e(TAG, "Failed to save favorites: ${e.message}") } } + private fun loadPeerIdIndex() { + try { + val json = stateManager.getSecureValue(PEERID_INDEX_KEY) + if (json != null) { + val type = object : TypeToken>() {}.type + val data: Map = gson.fromJson(json, type) + peerIdIndex.clear() + peerIdIndex.putAll(data) + Log.d(TAG, "Loaded ${peerIdIndex.size} peerID→npub mappings") + } + } catch (e: Exception) { + Log.e(TAG, "Failed to load peerID index: ${e.message}") + } + } + + private fun savePeerIdIndex() { + try { + val json = gson.toJson(peerIdIndex) + stateManager.storeSecureValue(PEERID_INDEX_KEY, json) + Log.d(TAG, "Saved ${peerIdIndex.size} peerID→npub mappings") + } catch (e: Exception) { + Log.e(TAG, "Failed to save peerID index: ${e.message}") + } + } + // MARK: - Listeners fun addListener(listener: FavoritesChangeListener) { - synchronized(listeners) { - if (!listeners.contains(listener)) listeners.add(listener) - } + synchronized(listeners) { if (!listeners.contains(listener)) listeners.add(listener) } } fun removeListener(listener: FavoritesChangeListener) { synchronized(listeners) { listeners.remove(listener) } @@ -299,26 +324,16 @@ class FavoritesPersistenceService private constructor(private val context: Conte snapshot.forEach { runCatching { it.onAllCleared() } } } - /** - * Normalize a Nostr public key string (npub bech32 or hex) to lowercase hex for comparison - */ - private fun normalizeNostrKeyToHex(value: String): String? { - return try { - if (value.startsWith("npub1")) { - val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(value) - if (hrp != "npub") return null - data.joinToString("") { "%02x".format(it) } - } else { - // Assume hex - value.lowercase() - } - } catch (_: Exception) { null } - } + /** Normalize a Nostr public key string (npub bech32 or hex) to lowercase hex */ + private fun normalizeNostrKeyToHex(value: String): String? = try { + if (value.startsWith("npub1")) { + val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(value) + if (hrp != "npub") null else data.joinToString("") { "%02x".format(it) } + } else value.lowercase() + } catch (_: Exception) { null } } -/** - * Serializable data class for JSON storage - */ +/** Serializable data for JSON storage */ private data class FavoriteRelationshipData( val peerNoisePublicKeyHex: String, val peerNostrPublicKey: String?, @@ -341,7 +356,7 @@ private data class FavoriteRelationshipData( ) } } - + fun toFavoriteRelationship(): FavoriteRelationship { val noiseKeyBytes = peerNoisePublicKeyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() return FavoriteRelationship( diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index da3d1226..723d603e 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -284,6 +284,13 @@ class BluetoothMeshService(private val context: Context) { // Store fingerprint for the peer via centralized fingerprint manager val fingerprint = peerManager.storeFingerprintForPeer(newPeerID, publicKey) + + // Index existing Nostr mapping by the new peerID if we have it + try { + com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(publicKey)?.let { npub -> + com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKeyForPeerID(newPeerID, npub) + } + } catch (_: Exception) { } // If there was a previous peer ID, remove it to avoid duplicates previousPeerID?.let { oldPeerID -> @@ -539,25 +546,13 @@ class BluetoothMeshService(private val context: Context) { */ fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) { if (content.isEmpty() || recipientPeerID.isEmpty()) return - if (!recipientPeerID.startsWith("nostr_") && recipientNickname.isEmpty()) return + if (recipientNickname.isEmpty()) return serviceScope.launch { val finalMessageID = messageID ?: java.util.UUID.randomUUID().toString() Log.d(TAG, "📨 Sending PM to $recipientPeerID: ${content.take(30)}...") - // Check if this is a Nostr contact (geohash DM) - if (recipientPeerID.startsWith("nostr_")) { - // Get NostrGeohashService instance and send via Nostr - try { - val nostrGeohashService = com.bitchat.android.nostr.NostrGeohashService.getInstance(context.applicationContext as android.app.Application) - nostrGeohashService.sendNostrGeohashDM(content, recipientPeerID, finalMessageID, myPeerID) - } catch (e: Exception) { - Log.e(TAG, "Failed to send Nostr geohash DM: ${e.message}") - } - return@launch - } - // Check if we have an established Noise session if (encryptionService.hasEstablishedSession(recipientPeerID)) { try { @@ -625,15 +620,14 @@ class BluetoothMeshService(private val context: Context) { serviceScope.launch { Log.d(TAG, "📖 Sending read receipt for message $messageID to $recipientPeerID") - // Check if this is a Nostr contact (geohash DM) - if (recipientPeerID.startsWith("nostr_")) { - // Get NostrGeohashService instance and send read receipt via Nostr - try { - val nostrGeohashService = com.bitchat.android.nostr.NostrGeohashService.getInstance(context.applicationContext as android.app.Application) - nostrGeohashService.sendNostrGeohashReadReceipt(messageID, recipientPeerID, myPeerID) - } catch (e: Exception) { - Log.e(TAG, "Failed to send Nostr geohash read receipt: ${e.message}") - } + // Route geohash read receipts via MessageRouter instead of here + val geo = runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance() }.getOrNull() + val isGeoAlias = try { + val map = com.bitchat.android.nostr.GeohashAliasRegistry.snapshot() + map.containsKey(recipientPeerID) + } catch (_: Exception) { false } + if (isGeoAlias && geo != null) { + geo.sendReadReceipt(com.bitchat.android.model.ReadReceipt(messageID), recipientPeerID) return@launch } diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt index 76fea7a1..c2faffea 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -452,7 +452,9 @@ class MessageHandler(private val myPeerID: String) { if (noiseKey != null) { com.bitchat.android.favorites.FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, isFavorite) if (npub != null) { + // Index by noise key and current mesh peerID for fast Nostr routing com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, npub) + com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKeyForPeerID(fromPeerID, npub) } // Determine iOS-style guidance text diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashAliasRegistry.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashAliasRegistry.kt new file mode 100644 index 00000000..ea2ab0ba --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashAliasRegistry.kt @@ -0,0 +1,24 @@ +package com.bitchat.android.nostr + +import java.util.concurrent.ConcurrentHashMap + +/** + * GeohashAliasRegistry + * - Global, thread-safe registry for alias->Nostr pubkey mappings (e.g., nostr_ -> pubkeyHex) + * - Allows non-UI components (e.g., MessageRouter) to resolve geohash DM aliases without depending on UI ViewModels + */ +object GeohashAliasRegistry { + private val map: MutableMap = ConcurrentHashMap() + + fun put(alias: String, pubkeyHex: String) { + map[alias] = pubkeyHex + } + + fun get(alias: String): String? = map[alias] + + fun contains(alias: String): Boolean = map.containsKey(alias) + + fun snapshot(): Map = HashMap(map) + + fun clear() { map.clear() } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt new file mode 100644 index 00000000..766169fa --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt @@ -0,0 +1,99 @@ +package com.bitchat.android.nostr + +import android.app.Application +import android.util.Log +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.ui.ChatState +import com.bitchat.android.ui.MessageManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.Date + +/** + * GeohashMessageHandler + * - Processes kind=20000 Nostr events for geohash channels + * - Updates repository for participants + nicknames + * - Emits messages to MessageManager + */ +class GeohashMessageHandler( + private val application: Application, + private val state: ChatState, + private val messageManager: MessageManager, + private val repo: GeohashRepository, + private val scope: CoroutineScope +) { + companion object { private const val TAG = "GeohashMessageHandler" } + + // Simple event deduplication + private val processedIds = ArrayDeque() + private val seen = HashSet() + private val max = 2000 + + private fun dedupe(id: String): Boolean { + if (seen.contains(id)) return true + seen.add(id) + processedIds.addLast(id) + if (processedIds.size > max) { + val old = processedIds.removeFirst() + seen.remove(old) + } + return false + } + + fun onEvent(event: NostrEvent, subscribedGeohash: String) { + scope.launch(Dispatchers.Default) { + try { + if (event.kind != 20000) return@launch + val tagGeo = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }?.getOrNull(1) + if (tagGeo == null || !tagGeo.equals(subscribedGeohash, true)) return@launch + if (dedupe(event.id)) return@launch + + // PoW validation (if enabled) + val pow = PoWPreferenceManager.getCurrentSettings() + if (pow.enabled && pow.difficulty > 0) { + if (!NostrProofOfWork.validateDifficulty(event, pow.difficulty)) return@launch + } + + // Blocked users check + if (com.bitchat.android.ui.DataManager(application).isGeohashUserBlocked(event.pubkey)) return@launch + + // Update repository (participants, nickname, teleport) + // Update repository on a background-safe path; repository will post updates to LiveData + repo.updateParticipant(subscribedGeohash, event.pubkey, Date(event.createdAt * 1000L)) + event.tags.find { it.size >= 2 && it[0] == "n" }?.let { repo.cacheNickname(event.pubkey, it[1]) } + event.tags.find { it.size >= 2 && it[0] == "t" && it[1] == "teleport" }?.let { repo.markTeleported(event.pubkey) } + // Register a geohash DM alias for this participant so MessageRouter can route DMs via Nostr + try { + com.bitchat.android.nostr.GeohashAliasRegistry.put("nostr_${event.pubkey.take(16)}", event.pubkey) + } catch (_: Exception) { } + + // Skip our own events for message emission + val my = NostrIdentityBridge.deriveIdentity(subscribedGeohash, application) + if (my.publicKeyHex.equals(event.pubkey, true)) return@launch + + val isTeleportPresence = event.tags.any { it.size >= 2 && it[0] == "t" && it[1] == "teleport" } && + event.content.trim().isEmpty() + if (isTeleportPresence) return@launch + + val senderName = repo.displayNameForNostrPubkeyUI(event.pubkey) + val msg = BitchatMessage( + id = event.id, + sender = senderName, + content = event.content, + timestamp = Date(event.createdAt * 1000L), + isRelay = false, + originalSender = repo.displayNameForNostrPubkey(event.pubkey), + senderPeerID = "nostr:${event.pubkey.take(8)}", + mentions = null, + channel = "#$subscribedGeohash", + powDifficulty = try { NostrProofOfWork.calculateDifficulty(event.id).takeIf { it > 0 } } catch (_: Exception) { null } + ) + withContext(Dispatchers.Main) { messageManager.addChannelMessage("geo:$subscribedGeohash", msg) } + } catch (e: Exception) { + Log.e(TAG, "onEvent error: ${e.message}") + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt new file mode 100644 index 00000000..f5e607bc --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashRepository.kt @@ -0,0 +1,218 @@ +package com.bitchat.android.nostr + +import android.app.Application +import android.util.Log +import androidx.lifecycle.LiveData +import com.bitchat.android.ui.ChatState +import com.bitchat.android.ui.GeoPerson +import java.util.Date + +/** + * GeohashRepository + * - Owns geohash participant tracking and nickname caching + * - Maintains lightweight state for geohash-related UI + */ +class GeohashRepository( + private val application: Application, + private val state: ChatState +) { + companion object { private const val TAG = "GeohashRepository" } + + // geohash -> (participant pubkeyHex -> lastSeen) + private val geohashParticipants: MutableMap> = mutableMapOf() + + + // pubkeyHex(lowercase) -> nickname (without #hash) + private val geoNicknames: MutableMap = mutableMapOf() + + // conversation key (e.g., "nostr_") -> source geohash it belongs to + private val conversationGeohash: MutableMap = mutableMapOf() + + fun setConversationGeohash(convKey: String, geohash: String) { + if (geohash.isNotEmpty()) { + conversationGeohash[convKey] = geohash + } + } + + fun getConversationGeohash(convKey: String): String? = conversationGeohash[convKey] + + fun findPubkeyByNickname(targetNickname: String): String? { + return geoNicknames.entries.firstOrNull { (_, nickname) -> + val base = nickname.split("#").firstOrNull() ?: nickname + base == targetNickname + }?.key + } + + // peerID alias -> nostr pubkey mapping for geohash DMs and temp aliases + private val nostrKeyMapping: MutableMap = mutableMapOf() + + // Current geohash in view + private var currentGeohash: String? = null + + fun setCurrentGeohash(geo: String?) { currentGeohash = geo } + fun getCurrentGeohash(): String? = currentGeohash + + fun clearAll() { + geohashParticipants.clear() + geoNicknames.clear() + nostrKeyMapping.clear() + state.setGeohashPeople(emptyList()) + state.setTeleportedGeo(emptySet()) + state.setGeohashParticipantCounts(emptyMap()) + currentGeohash = null + } + + fun cacheNickname(pubkeyHex: String, nickname: String) { + val lower = pubkeyHex.lowercase() + val previous = geoNicknames[lower] + geoNicknames[lower] = nickname + if (previous != nickname && currentGeohash != null) { + refreshGeohashPeople() + } + } + + fun getCachedNickname(pubkeyHex: String): String? = geoNicknames[pubkeyHex.lowercase()] + + fun markTeleported(pubkeyHex: String) { + val set = state.getTeleportedGeoValue().toMutableSet() + val key = pubkeyHex.lowercase() + if (!set.contains(key)) { + set.add(key) + // Background safe update + state.postTeleportedGeo(set) + } + } + + fun isPersonTeleported(pubkeyHex: String): Boolean { + return state.getTeleportedGeoValue().contains(pubkeyHex.lowercase()) + } + + fun updateParticipant(geohash: String, participantId: String, lastSeen: Date) { + val participants = geohashParticipants.getOrPut(geohash) { mutableMapOf() } + participants[participantId] = lastSeen + if (currentGeohash == geohash) refreshGeohashPeople() + updateReactiveParticipantCounts() + } + + fun geohashParticipantCount(geohash: String): Int { + val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) + val participants = geohashParticipants[geohash] ?: return 0 + // prune expired + val it = participants.iterator() + while (it.hasNext()) { + val e = it.next() + if (e.value.before(cutoff)) it.remove() + } + return participants.size + } + + fun refreshGeohashPeople() { + val geohash = currentGeohash + if (geohash == null) { + // Use postValue for thread safety - this can be called from background threads + state.postGeohashPeople(emptyList()) + return + } + val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) + val participants = geohashParticipants[geohash] ?: mutableMapOf() + // prune expired + val it = participants.iterator() + while (it.hasNext()) { + val e = it.next() + if (e.value.before(cutoff)) it.remove() + } + geohashParticipants[geohash] = participants + val people = participants.map { (pubkeyHex, lastSeen) -> + val base = getCachedNickname(pubkeyHex) ?: "anon" + GeoPerson( + id = pubkeyHex.lowercase(), + displayName = base, // UI can add #hash if necessary + lastSeen = lastSeen + ) + }.sortedByDescending { it.lastSeen } + // Use postValue for thread safety - this can be called from background threads + state.postGeohashPeople(people) + } + + fun updateReactiveParticipantCounts() { + val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) + val counts = mutableMapOf() + for ((gh, participants) in geohashParticipants) { + val active = participants.values.count { !it.before(cutoff) } + counts[gh] = active + } + // Use postValue for thread safety - this can be called from background threads + state.postGeohashParticipantCounts(counts) + } + + fun putNostrKeyMapping(tempKeyOrPeer: String, pubkeyHex: String) { + nostrKeyMapping[tempKeyOrPeer] = pubkeyHex + } + + fun getNostrKeyMapping(): Map = nostrKeyMapping.toMap() + + fun displayNameForNostrPubkey(pubkeyHex: String): String { + val suffix = pubkeyHex.takeLast(4) + val lower = pubkeyHex.lowercase() + // Self nickname if matches current identity of current geohash + val current = currentGeohash + if (current != null) { + try { + val my = NostrIdentityBridge.deriveIdentity(current, application) + if (my.publicKeyHex.equals(lower, true)) { + return "${state.getNicknameValue()}#$suffix" + } + } catch (_: Exception) {} + } + val nick = geoNicknames[lower] ?: "anon" + return "$nick#$suffix" + } + + fun displayNameForNostrPubkeyUI(pubkeyHex: String): String { + val lower = pubkeyHex.lowercase() + val suffix = pubkeyHex.takeLast(4) + val current = currentGeohash + val base: String = try { + if (current != null) { + val my = NostrIdentityBridge.deriveIdentity(current, application) + if (my.publicKeyHex.equals(lower, true)) { + state.getNicknameValue() ?: "anon" + } else geoNicknames[lower] ?: "anon" + } else geoNicknames[lower] ?: "anon" + } catch (_: Exception) { geoNicknames[lower] ?: "anon" } + if (current == null) return base + return try { + val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) + val participants = geohashParticipants[current] ?: emptyMap() + var count = 0 + for ((k, t) in participants) { + if (t.before(cutoff)) continue + val name = if (k.equals(lower, true)) base else (geoNicknames[k.lowercase()] ?: "anon") + if (name.equals(base, true)) { count++; if (count > 1) break } + } + if (!participants.containsKey(lower)) count += 1 + if (count > 1) "$base#$suffix" else base + } catch (_: Exception) { base } + } + + /** + * Get display name for any geohash (not just current one) for header titles + */ + fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String { + val lower = pubkeyHex.lowercase() + val suffix = pubkeyHex.takeLast(4) + val base = geoNicknames[lower] ?: "anon" + return try { + val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) + val participants = geohashParticipants[sourceGeohash] ?: emptyMap() + var count = 0 + for ((k, t) in participants) { + if (t.before(cutoff)) continue + val name = if (k.equals(lower, true)) base else (geoNicknames[k.lowercase()] ?: "anon") + if (name.equals(base, true)) { count++; if (count > 1) break } + } + if (!participants.containsKey(lower)) count += 1 + if (count > 1) "$base#$suffix" else base + } catch (_: Exception) { base } + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt new file mode 100644 index 00000000..394ea3d9 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -0,0 +1,176 @@ +package com.bitchat.android.nostr + +import android.app.Application +import android.util.Log +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.DeliveryStatus +import com.bitchat.android.protocol.BitchatPacket +import com.bitchat.android.services.SeenMessageStore +import com.bitchat.android.ui.ChatState +import com.bitchat.android.ui.MeshDelegateHandler +import com.bitchat.android.ui.PrivateChatManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.Date + +class NostrDirectMessageHandler( + private val application: Application, + private val state: ChatState, + private val privateChatManager: PrivateChatManager, + private val meshDelegateHandler: MeshDelegateHandler, + private val scope: CoroutineScope, + private val repo: GeohashRepository +) { + companion object { private const val TAG = "NostrDirectMessageHandler" } + + private val seenStore by lazy { SeenMessageStore.getInstance(application) } + + // Simple event deduplication + private val processedIds = ArrayDeque() + private val seen = HashSet() + private val max = 2000 + + private fun dedupe(id: String): Boolean { + if (seen.contains(id)) return true + seen.add(id) + processedIds.addLast(id) + if (processedIds.size > max) { + val old = processedIds.removeFirst() + seen.remove(old) + } + return false + } + + fun onGiftWrap(giftWrap: NostrEvent, geohash: String, identity: NostrIdentity) { + scope.launch(Dispatchers.Default) { + try { + if (dedupe(giftWrap.id)) return@launch + + val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt + if (messageAge > 173700) return@launch // 48 hours + 15 mins + + val decryptResult = NostrProtocol.decryptPrivateMessage(giftWrap, identity) + if (decryptResult == null) { + Log.w(TAG, "Failed to decrypt Nostr message") + return@launch + } + + val (content, senderPubkey, rumorTimestamp) = decryptResult + if (!content.startsWith("bitchat1:")) return@launch + + val base64Content = content.removePrefix("bitchat1:") + val packetData = base64URLDecode(base64Content) ?: return@launch + val packet = BitchatPacket.fromBinaryData(packetData) ?: return@launch + + if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return@launch + + val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) ?: return@launch + val messageTimestamp = Date(rumorTimestamp * 1000L) + val convKey = "nostr_${senderPubkey.take(16)}" + repo.putNostrKeyMapping(convKey, senderPubkey) + com.bitchat.android.nostr.GeohashAliasRegistry.put(convKey, senderPubkey) + if (geohash.isNotEmpty()) { + // Remember which geohash this conversation belongs to so we can subscribe on-demand + repo.setConversationGeohash(convKey, geohash) + GeohashConversationRegistry.set(convKey, geohash) + } + + // Ensure sender appears in geohash people list even if they haven't posted publicly yet + if (geohash.isNotEmpty()) { + // Cache a best-effort nickname and mark as participant + val cached = repo.getCachedNickname(senderPubkey) + if (cached == null) { + val base = repo.displayNameForNostrPubkeyUI(senderPubkey).substringBefore("#") + repo.cacheNickname(senderPubkey, base) + } + repo.updateParticipant(geohash, senderPubkey, messageTimestamp) + } + + val senderNickname = repo.displayNameForNostrPubkeyUI(senderPubkey) + + processNoisePayload(noisePayload, convKey, senderNickname, messageTimestamp, senderPubkey, identity) + + } catch (e: Exception) { + Log.e(TAG, "onGiftWrap error: ${e.message}") + } + } + } + + private suspend fun processNoisePayload( + payload: com.bitchat.android.model.NoisePayload, + convKey: String, + senderNickname: String, + timestamp: Date, + senderPubkey: String, + recipientIdentity: NostrIdentity + ) { + when (payload.type) { + com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE -> { + val pm = com.bitchat.android.model.PrivateMessagePacket.decode(payload.data) ?: return + val existingMessages = state.getPrivateChatsValue()[convKey] ?: emptyList() + if (existingMessages.any { it.id == pm.messageID }) return + + val message = BitchatMessage( + id = pm.messageID, + sender = senderNickname, + content = pm.content, + timestamp = timestamp, + isRelay = false, + isPrivate = true, + recipientNickname = state.getNicknameValue(), + senderPeerID = convKey, + deliveryStatus = DeliveryStatus.Delivered(to = state.getNicknameValue() ?: "Unknown", at = Date()) + ) + + val isViewing = state.getSelectedPrivateChatPeerValue() == convKey + val suppressUnread = seenStore.hasRead(pm.messageID) + + withContext(Dispatchers.Main) { + privateChatManager.handleIncomingPrivateMessage(message, suppressUnread) + } + + if (!seenStore.hasDelivered(pm.messageID)) { + val nostrTransport = NostrTransport.getInstance(application) + nostrTransport.sendDeliveryAckGeohash(pm.messageID, senderPubkey, recipientIdentity) + seenStore.markDelivered(pm.messageID) + } + + if (isViewing && !suppressUnread) { + val nostrTransport = NostrTransport.getInstance(application) + nostrTransport.sendReadReceiptGeohash(pm.messageID, senderPubkey, recipientIdentity) + seenStore.markRead(pm.messageID) + } + } + com.bitchat.android.model.NoisePayloadType.DELIVERED -> { + val messageId = String(payload.data, Charsets.UTF_8) + withContext(Dispatchers.Main) { + meshDelegateHandler.didReceiveDeliveryAck(messageId, convKey) + } + } + com.bitchat.android.model.NoisePayloadType.READ_RECEIPT -> { + val messageId = String(payload.data, Charsets.UTF_8) + withContext(Dispatchers.Main) { + meshDelegateHandler.didReceiveReadReceipt(messageId, convKey) + } + } + } + } + + private fun base64URLDecode(input: String): ByteArray? { + return try { + val padded = input.replace("-", "+") + .replace("_", "/") + .let { str -> + val padding = (4 - str.length % 4) % 4 + str + "=".repeat(padding) + } + android.util.Base64.decode(padded, android.util.Base64.DEFAULT) + } catch (e: Exception) { + Log.e(TAG, "Failed to decode base64url: ${e.message}") + null + } + } +} + diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt b/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt deleted file mode 100644 index f59e4434..00000000 --- a/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt +++ /dev/null @@ -1,1788 +0,0 @@ - -package com.bitchat.android.nostr - -import android.app.Application -import android.util.Log -import androidx.lifecycle.viewModelScope -import com.bitchat.android.mesh.BluetoothMeshService -import com.bitchat.android.model.BitchatMessage -import com.bitchat.android.ui.ChatState -import com.bitchat.android.ui.MessageManager -import com.bitchat.android.ui.MeshDelegateHandler -import com.bitchat.android.ui.PrivateChatManager -import com.bitchat.android.ui.GeoPerson -import com.bitchat.android.ui.colorForPeerSeed -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.util.* -import kotlin.random.Random - -/** - * Service responsible for all Nostr and Geohash business logic extracted from ChatViewModel - * Maintains 100% iOS compatibility and exact same functionality - */ -class NostrGeohashService( - private val application: Application, - private val state: ChatState, - private val messageManager: MessageManager, - private val privateChatManager: PrivateChatManager, - private val meshDelegateHandler: MeshDelegateHandler, - private val coroutineScope: CoroutineScope, - private val dataManager: com.bitchat.android.ui.DataManager, - private val notificationManager: com.bitchat.android.ui.NotificationManager -) { - - companion object { - private const val TAG = "NostrGeohashService" - - @Volatile - private var INSTANCE: NostrGeohashService? = null - - fun getInstance(application: Application): NostrGeohashService { - return INSTANCE ?: synchronized(this) { - INSTANCE ?: throw IllegalStateException("NostrGeohashService not initialized. Call initialize() first.") - } - } - - fun initialize( - application: Application, - state: ChatState, - messageManager: MessageManager, - privateChatManager: PrivateChatManager, - meshDelegateHandler: MeshDelegateHandler, - coroutineScope: CoroutineScope, - dataManager: com.bitchat.android.ui.DataManager, - notificationManager: com.bitchat.android.ui.NotificationManager - ): NostrGeohashService { - return synchronized(this) { - INSTANCE ?: NostrGeohashService( - application, - state, - messageManager, - privateChatManager, - meshDelegateHandler, - coroutineScope, - dataManager, - notificationManager - ).also { INSTANCE = it } - } - } - } - - // MARK: - Nostr Message Integration Properties - - private val processedNostrEvents = mutableSetOf() - private val processedNostrEventOrder = mutableListOf() - private val maxProcessedNostrEvents = 2000 - // removed unused processedNostrAcks - private val nostrKeyMapping = mutableMapOf() // senderPeerID -> nostrPubkey - - // MARK: - Geohash Participant Tracking Properties - - private val geohashParticipants = mutableMapOf>() // geohash -> participantId -> lastSeen - private var geohashSamplingJob: Job? = null - private var geoParticipantsTimer: Job? = null - - // MARK: - Geohash Message Routing - // Geohash messages are routed through MessageManager channel timelines using key "geo:" - - // MARK: - Location Channel Management Properties - - private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null - private var currentGeohashSubscriptionId: String? = null - private var currentGeohashDmSubscriptionId: String? = null - private var currentGeohash: String? = null - private var geoNicknames: MutableMap = mutableMapOf() // pubkeyHex(lowercased) -> nickname - - // MARK: - Initialization - - /** - * Initialize Nostr relay subscriptions for gift wraps and geohash events - */ - fun initializeNostrIntegration() { - coroutineScope.launch { - val nostrRelayManager = NostrRelayManager.getInstance(application) - - // Connect to relays - nostrRelayManager.connect() - - // Get current Nostr identity - val currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(application) - if (currentIdentity == null) { - Log.w(TAG, "No Nostr identity available for subscriptions") - return@launch - } - - // Subscribe to gift wraps (NIP-17 private messages) - val dmFilter = NostrFilter.giftWrapsFor( - pubkey = currentIdentity.publicKeyHex, - since = System.currentTimeMillis() - 172800000L // Last 48 hours (align with NIP-17 randomization) - ) - - nostrRelayManager.subscribe( - filter = dmFilter, - id = "chat-messages", - handler = { event -> - handleNostrMessage(event) - } - ) - - Log.i(TAG, "✅ Nostr integration initialized with gift wrap subscription") - } - } - - /** - * Panic/reset flow for Nostr + geohash systems. - * - Disconnect and clear all Nostr relay subscriptions and caches - * - Delete device-wide Nostr keys and per-geohash seed cache - * - Clear geohash participants, nicknames, message history - * - Recreate identity and reconnect; reinitialize subscriptions from scratch - */ - fun panicResetNostrAndGeohash() { - coroutineScope.launch { - try { - val relayManager = NostrRelayManager.getInstance(application) - - // 1) Disconnect from all relays - relayManager.disconnect() - - // 2) Clear all subscription tracking and caches - relayManager.clearAllSubscriptions() - - // 3) Clear Nostr identity (npub/private) and geohash identity cache/seed - try { - NostrIdentityBridge.clearAllAssociations(application) - } catch (e: Exception) { - Log.e(TAG, "Failed to clear Nostr associations: ${e.message}") - } - - // 4) Clear local geohash state (participants, nicknames, message history) - try { - geohashParticipants.clear() - geoNicknames.clear() - processedNostrEvents.clear() - processedNostrEventOrder.clear() - currentGeohashSubscriptionId = null - currentGeohashDmSubscriptionId = null - currentGeohash = null - state.setGeohashPeople(emptyList()) - state.setTeleportedGeo(emptySet()) - state.setGeohashParticipantCounts(emptyMap()) - // Stop any timers/jobs - geohashSamplingJob?.cancel() - geohashSamplingJob = null - geoParticipantsTimer?.cancel() - geoParticipantsTimer = null - } catch (e: Exception) { - Log.e(TAG, "Failed to clear geohash state: ${e.message}") - } - - // 5) Recreate identity and reconnect, then re-subscribe - try { - // Touch identity to recreate - val identity = NostrIdentityBridge.getCurrentNostrIdentity(application) - if (identity == null) { - Log.w(TAG, "No identity after reset; skipping subscriptions") - return@launch - } - - // Reconnect to relays and reinitialize subscriptions - relayManager.connect() - initializeNostrIntegration() - // Re-establish location channel state observers and subscriptions - initializeLocationChannelState() - } catch (e: Exception) { - Log.e(TAG, "Failed to reinitialize Nostr after reset: ${e.message}") - } - } catch (e: Exception) { - Log.e(TAG, "panicResetNostrAndGeohash error: ${e.message}") - } - } - } - - /** - * Initialize location channel state - */ - fun initializeLocationChannelState() { - try { - // Initialize location channel manager safely - locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(application) - - // Observe location channel manager state and trigger channel switching - locationChannelManager?.selectedChannel?.observeForever { channel -> - state.setSelectedLocationChannel(channel) - // CRITICAL FIX: Switch to the channel when selection changes - switchLocationChannel(channel) - } - - locationChannelManager?.teleported?.observeForever { teleported -> - state.setIsTeleported(teleported) - } - - Log.d(TAG, "✅ Location channel state initialized successfully") - } catch (e: Exception) { - Log.e(TAG, "❌ Failed to initialize location channel state: ${e.message}") - // Set default values in case of failure - state.setSelectedLocationChannel(com.bitchat.android.geohash.ChannelID.Mesh) - state.setIsTeleported(false) - } - } - - // MARK: - Message Sending - - /** - * Send message to geohash channel via Nostr ephemeral event - */ - fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) { - coroutineScope.launch { - // Generate a temporary message ID for tracking animation - val tempMessageId = "temp_${System.currentTimeMillis()}_${Random.nextInt(1000)}" - try { - // Add local echo message IMMEDIATELY (with temporary ID) - val powSettingsLocal = PoWPreferenceManager.getCurrentSettings() - val localMessage = BitchatMessage( - id = tempMessageId, - sender = nickname ?: myPeerID, - content = content, - timestamp = Date(), - isRelay = false, - senderPeerID = "geohash:${channel.geohash}", - channel = "#${channel.geohash}", - powDifficulty = if (powSettingsLocal.enabled) powSettingsLocal.difficulty else null - ) - - // Local echo into geohash channel timeline via unified MessageManager - messageManager.addChannelMessage("geo:${channel.geohash}", localMessage) - - Log.d(TAG, "📝 Added geohash local echo with temp ID: $tempMessageId (not shown in mesh timeline)") - - // Check if PoW is enabled before starting animation - val powSettings = PoWPreferenceManager.getCurrentSettings() - if (powSettings.enabled && powSettings.difficulty > 0) { - // Start matrix animation for this message - com.bitchat.android.ui.PoWMiningTracker.startMiningMessage(tempMessageId) - Log.d(TAG, "🎭 Started matrix animation for message: $tempMessageId") - } - - // Now begin the async PoW process - val identity = NostrIdentityBridge.deriveIdentity( - forGeohash = channel.geohash, - context = application - ) - - val teleported = state.isTeleported.value ?: false - - val event = NostrProtocol.createEphemeralGeohashEvent( - content = content, - geohash = channel.geohash, - senderIdentity = identity, - nickname = nickname, - teleported = teleported - ) - - val nostrRelayManager = NostrRelayManager.getInstance(application) - nostrRelayManager.sendEventToGeohash( - event = event, - geohash = channel.geohash, - includeDefaults = false, - nRelays = 5 - ) - - Log.i(TAG, "📤 Sent geohash message to ${channel.geohash}: ${content.take(50)}") - - } catch (e: Exception) { - Log.e(TAG, "Failed to send geohash message: ${e.message}") - } finally { - com.bitchat.android.ui.PoWMiningTracker.stopMiningMessage(tempMessageId) - } - } - } - - // MARK: - Nostr Message Handling - - /** - * Handle incoming Nostr message (gift wrap) - */ - private fun handleNostrMessage(giftWrap: NostrEvent) { - // Offload processing to avoid blocking UI - coroutineScope.launch(kotlinx.coroutines.Dispatchers.Default) { - // Simple deduplication - if (processedNostrEvents.contains(giftWrap.id)) return@launch - processedNostrEvents.add(giftWrap.id) - - // Manage deduplication cache size - processedNostrEventOrder.add(giftWrap.id) - if (processedNostrEventOrder.size > maxProcessedNostrEvents) { - val oldestId = processedNostrEventOrder.removeAt(0) - processedNostrEvents.remove(oldestId) - } - - // Client-side filtering: ignore messages older than 24 hours + 15 minutes buffer - val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt - if (messageAge > 173700) { // 48 hours + 15 minutes - return@launch - } - - Log.d(TAG, "Processing Nostr message: ${giftWrap.id.take(16)}...") - - // Removed legacy NostrReadStore usage; rely on SeenMessageStore by message ID - - val currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(application) - if (currentIdentity == null) { - Log.w(TAG, "No Nostr identity available for decryption") - return@launch - } - - try { - val decryptResult = NostrProtocol.decryptPrivateMessage( - giftWrap = giftWrap, - recipientIdentity = currentIdentity - ) - - if (decryptResult == null) { - Log.w(TAG, "Failed to decrypt Nostr message") - return@launch - } - - val (content, senderPubkey, rumorTimestamp) = decryptResult - - // Expect embedded BitChat packet content - if (!content.startsWith("bitchat1:")) { - Log.d(TAG, "Ignoring non-embedded Nostr DM content") - return@launch - } - - val base64Content = content.removePrefix("bitchat1:") - val packetData = base64URLDecode(base64Content) - if (packetData == null) { - Log.e(TAG, "Failed to decode base64url BitChat packet") - return@launch - } - - val packet = com.bitchat.android.protocol.BitchatPacket.fromBinaryData(packetData) - if (packet == null) { - Log.e(TAG, "Failed to parse embedded BitChat packet from Nostr DM") - return@launch - } - - // Only process noiseEncrypted envelope for private messages/receipts - if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) { - Log.w(TAG, "Unsupported embedded packet type: ${packet.type}") - return@launch - } - - // Validate recipient if present - packet.recipientID?.let { rid -> - val ridHex = rid.joinToString("") { "%02x".format(it) } - // Note: myPeerID needs to be passed in as parameter - // if (ridHex != myPeerID) return - } - - // Parse plaintext typed payload (NoisePayload) - val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) - if (noisePayload == null) { - Log.e(TAG, "Failed to parse embedded NoisePayload") - return@launch - } - - // Map sender by Nostr pubkey to Noise key when possible - val senderNoiseKey = findNoiseKeyForNostrPubkey(senderPubkey) - val messageTimestamp = Date(rumorTimestamp * 1000L) - - // If we know the Noise key, try to resolve a currently connected mesh peer ID for it - val targetPeerID: String = if (senderNoiseKey != null) { - val meshPeerId = resolveMeshPeerIdForNoiseKey(senderNoiseKey) - if (meshPeerId != null) { - // Also unify existing noise-hex/nostr-temp chats into this mesh peer - val noiseHex = senderNoiseKey.joinToString("") { b -> "%02x".format(b) } - val tempKey = "nostr_${senderPubkey.take(16)}" - unifyChatsIntoPeer(meshPeerId, listOf(noiseHex, tempKey)) - - // If currently viewing the temporary or noise-hex chat, auto-switch to mesh peer - val selected = state.getSelectedPrivateChatPeerValue() - if (selected == noiseHex || selected == tempKey) { - state.setSelectedPrivateChatPeer(meshPeerId) - } - meshPeerId - } else { - senderNoiseKey.joinToString("") { b -> "%02x".format(b) } - } - } else { - "nostr_${senderPubkey.take(16)}" - } - - // Prefer nickname from mesh when connected; else fallback to favorites - val senderNickname: String = if (senderNoiseKey != null) { - val meshPeerId = resolveMeshPeerIdForNoiseKey(senderNoiseKey) - if (meshPeerId != null) { - // Use live mesh nickname if available - meshDelegateHandler.getPeerInfo(meshPeerId)?.nickname - ?: getFavoriteNickname(senderNoiseKey) - ?: "Unknown" - } else { - getFavoriteNickname(senderNoiseKey) ?: "Unknown" - } - } else { - "Unknown" - } - - // Store Nostr key mapping - nostrKeyMapping[targetPeerID] = senderPubkey - - // Process payload and update UI/state - processNoisePayload(noisePayload, targetPeerID, senderNickname, messageTimestamp) - - // If this was a private message, send a delivery ACK back over Nostr - if (noisePayload.type == com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE) { - val pm = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data) - pm?.let { pmsg -> - val seen = com.bitchat.android.services.SeenMessageStore.getInstance(application) - if (!seen.hasDelivered(pmsg.messageID)) { - val nostrTransport = NostrTransport.getInstance(application) - if (senderNoiseKey != null) { - val peerIdHex = senderNoiseKey.joinToString("") { b -> "%02x".format(b) } - nostrTransport.sendDeliveryAck(pmsg.messageID, peerIdHex) - } else { - val identity = NostrIdentityBridge.getCurrentNostrIdentity(application) - if (identity != null) { - nostrTransport.sendDeliveryAckGeohash(pmsg.messageID, senderPubkey, identity) - } - } - seen.markDelivered(pmsg.messageID) - } - } - } - - } catch (e: Exception) { - Log.e(TAG, "Error processing Nostr message: ${e.message}") - } - } - } - - /** - * Process NoisePayload from Nostr message - */ - private suspend fun processNoisePayload( - noisePayload: com.bitchat.android.model.NoisePayload, - targetPeerID: String, - senderNickname: String, - messageTimestamp: Date - ) { - when (noisePayload.type) { - com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE -> { - val pm = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data) - if (pm == null) { - Log.e(TAG, "Failed to decode PrivateMessagePacket") - return - } - - val messageId = pm.messageID - val messageContent = pm.content - val seen = com.bitchat.android.services.SeenMessageStore.getInstance(application) - val suppressUnread = seen.hasRead(messageId) - - // Handle favorite/unfavorite notifications - if (messageContent.startsWith("[FAVORITED]") || messageContent.startsWith("[UNFAVORITED]")) { - handleFavoriteNotification(messageContent, targetPeerID, senderNickname) - return - } - - // Check for duplicate message - val existingChats = state.getPrivateChatsValue() - var messageExists = false - for ((_, messages) in existingChats) { - if (messages.any { it.id == messageId }) { - messageExists = true - break - } - } - if (messageExists) return - - // Check if viewing this chat - val isViewingThisChat = state.getSelectedPrivateChatPeerValue() == targetPeerID - - // Create BitchatMessage - val message = BitchatMessage( - id = messageId, - sender = senderNickname, - content = messageContent, - timestamp = messageTimestamp, - isRelay = false, - isPrivate = true, - recipientNickname = state.getNicknameValue(), - senderPeerID = targetPeerID, - deliveryStatus = com.bitchat.android.model.DeliveryStatus.Delivered( - to = state.getNicknameValue() ?: "Unknown", - at = Date() - ) - ) - - // Add to private chats on Main - withContext(Dispatchers.Main) { - privateChatManager.handleIncomingPrivateMessage(message, suppressUnread) - } - - // Send read receipt if viewing (only once across restarts) - if (isViewingThisChat && !seen.hasRead(messageId)) { - try { - val rr = com.bitchat.android.model.ReadReceipt(originalMessageID = messageId) - NostrTransport.getInstance(application).sendReadReceipt(rr, targetPeerID) - seen.markRead(messageId) - } catch (_: Exception) { } - } - - Log.i(TAG, "📥 Processed Nostr private message from $senderNickname") - } - - com.bitchat.android.model.NoisePayloadType.DELIVERED -> { - val messageId = String(noisePayload.data, Charsets.UTF_8) - // Use the existing delegate to handle delivery acknowledgment on Main - withContext(Dispatchers.Main) { - meshDelegateHandler.didReceiveDeliveryAck(messageId, targetPeerID) - } - Log.d(TAG, "📥 Processed Nostr delivery ACK for message $messageId") - } - - com.bitchat.android.model.NoisePayloadType.READ_RECEIPT -> { - val messageId = String(noisePayload.data, Charsets.UTF_8) - // Use the existing delegate to handle read receipt on Main - withContext(Dispatchers.Main) { - meshDelegateHandler.didReceiveReadReceipt(messageId, targetPeerID) - } - Log.d(TAG, "📥 Processed Nostr read receipt for message $messageId") - } - } - } - - /** - * Find Noise key for Nostr pubkey from favorites - */ - private fun findNoiseKeyForNostrPubkey(nostrPubkey: String): ByteArray? { - return com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(nostrPubkey) - } - - /** - * Get favorite nickname for Noise key - */ - private fun getFavoriteNickname(noiseKey: ByteArray): String? { - return com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)?.peerNickname - } - - /** - * Resolve a currently-connected mesh peer ID for a given Noise public key - * by matching against the mesh service's peer info noisePublicKey values. - */ - private fun resolveMeshPeerIdForNoiseKey(noiseKey: ByteArray): String? { - return try { - val peers: List = state.getConnectedPeersValue() - peers.firstOrNull { peerId: String -> - val info = meshDelegateHandler.getPeerInfo(peerId) - info?.noisePublicKey?.contentEquals(noiseKey) == true - } - } catch (_: Exception) { null } - } - - /** - * Merge any chats stored under the given keys into the target peer's chat entry - * so messages received while offline appear in the same chat when the peer connects. - */ - private fun unifyChatsIntoPeer(targetPeerID: String, keysToMerge: List) { - com.bitchat.android.services.ConversationAliasResolver.unifyChatsIntoPeer(state, targetPeerID, keysToMerge) - } - - /** - * Handle favorite/unfavorite notification - */ - private fun handleFavoriteNotification(content: String, fromPeerID: String, senderNickname: String) { - val isFavorite = content.startsWith("[FAVORITED]") - val action = if (isFavorite) "favorited" else "unfavorited" - - // Try to extract npub after colon, if present - val npub = content.substringAfter(":", "").trim().takeIf { it.startsWith("npub1") } - - // Resolve noise key if possible and persist relationship + npub mapping - try { - var noiseKey: ByteArray? = null - // If fromPeerID looks like hex (noise key), decode - val hexRegex = Regex("^[0-9a-fA-F]+$") - if (fromPeerID.matches(hexRegex) && (fromPeerID.length % 2 == 0)) { - // Expect 64 hex chars for full Curve25519 key; accept others best-effort - val bytes = fromPeerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray() - // Use only if length is plausible - if (bytes.isNotEmpty()) noiseKey = bytes - } else { - // fromPeerID likely a temporary key like "nostr_..."; map to Nostr pubkey - val senderPubkey = nostrKeyMapping[fromPeerID] - if (senderPubkey != null) { - noiseKey = findNoiseKeyForNostrPubkey(senderPubkey) - } - } - - if (noiseKey != null) { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, isFavorite) - if (npub != null) { - com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, npub) - } - } - } catch (_: Exception) { - // Best-effort - } - - // Determine guidance text based on mutual status (iOS-style) - val guidance = try { - val rel = run { - // Try to resolve via noise key directly or mapping - var key: ByteArray? = null - val hexRegex = Regex("^[0-9a-fA-F]+$") - if (fromPeerID.matches(hexRegex) && (fromPeerID.length % 2 == 0)) { - key = fromPeerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray() - } else { - val mappedNostr = nostrKeyMapping[fromPeerID] - key = mappedNostr?.let { findNoiseKeyForNostrPubkey(it) } - } - key?.let { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(it) } - } - if (isFavorite) { - if (rel?.isFavorite == true) { - " — mutual! You can continue DMs via Nostr when out of mesh." - } else { - " — favorite back to continue DMs later." - } - } else { - ". DMs over Nostr will pause unless you both favorite again." - } - } catch (_: Exception) { "" } - - // Show system message - val systemMessage = BitchatMessage( - sender = "system", - content = "$senderNickname $action you$guidance", - timestamp = Date(), - isRelay = false - ) - messageManager.addMessage(systemMessage) - - Log.i(TAG, "📥 Processed favorite notification: $senderNickname $action you") - } - - /** - * Base64URL decode (without padding) - */ - private fun base64URLDecode(input: String): ByteArray? { - return try { - val padded = input.replace("-", "+") - .replace("_", "/") - .let { str -> - val padding = (4 - str.length % 4) % 4 - str + "=".repeat(padding) - } - android.util.Base64.decode(padded, android.util.Base64.DEFAULT) - } catch (e: Exception) { - Log.e(TAG, "Failed to decode base64url: ${e.message}") - null - } - } - - // Geohash message history is maintained in ChatState.channelMessages; no local cache here - - // MARK: - Geohash Participant Tracking - - /** - * Get participant count for a specific geohash (5-minute activity window) - */ - fun geohashParticipantCount(geohash: String): Int { - val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) // 5 minutes ago - val participants = geohashParticipants[geohash] ?: return 0 - - // Remove expired participants - val iterator = participants.iterator() - while (iterator.hasNext()) { - val entry = iterator.next() - if (entry.value.before(cutoff)) { - iterator.remove() - } - } - - return participants.size - } - - /** - * Begin sampling multiple geohashes for participant activity - */ - fun beginGeohashSampling(geohashes: List) { - // Cancel existing sampling - geohashSamplingJob?.cancel() - - if (geohashes.isEmpty()) return - - Log.d(TAG, "🌍 Beginning geohash sampling for ${geohashes.size} geohashes") - - geohashSamplingJob = coroutineScope.launch { - val nostrRelayManager = NostrRelayManager.getInstance(application) - - // Subscribe to each geohash for ephemeral events (kind 20000) using geohash-specific relays - geohashes.forEach { geohash -> - val filter = NostrFilter.geohashEphemeral( - geohash = geohash, - since = System.currentTimeMillis() - 86400000L, // Last 24 hours - limit = 200 - ) - - nostrRelayManager.subscribeForGeohash( - geohash = geohash, - filter = filter, - id = "geohash-$geohash", - handler = { event -> - handleUnifiedGeohashEvent(event, geohash) - }, - includeDefaults = false, - nRelays = 5 - ) - - Log.d(TAG, "Subscribed to geohash events for: $geohash") - } - } - } - - /** - * End geohash sampling - */ - fun endGeohashSampling() { - Log.d(TAG, "🌍 Ending geohash sampling") - geohashSamplingJob?.cancel() - geohashSamplingJob = null - } - - /** - * Update participant activity for a geohash - */ - private fun updateGeohashParticipant(geohash: String, participantId: String, lastSeen: Date) { - val participants = geohashParticipants.getOrPut(geohash) { mutableMapOf() } - participants[participantId] = lastSeen - - // Update geohash people list if this is the current geohash - if (currentGeohash == geohash) { - refreshGeohashPeople() - } - - // CRITICAL FIX: Force UI recomposition by updating reactive participant counts for location channel selector - // This ensures that the location channels sheet shows live participant counts for ALL geohashes - updateReactiveParticipantCounts() - } - - /** - * Update reactive participant counts for real-time location channel selector (CRITICAL FIX) - */ - private fun updateReactiveParticipantCounts() { - val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) // 5 minutes ago - val counts = mutableMapOf() - - // Calculate current participant counts for all geohashes with recent activity - for ((geohash, participants) in geohashParticipants) { - // CRITICAL BUG FIX: Count active participants WITHOUT mutating original data - // Don't remove from original structure - just count active ones - val activeCount = participants.values.count { lastSeen -> - !lastSeen.before(cutoff) - } - - // Store the current count - counts[geohash] = activeCount - } - - // CRITICAL: Update reactive state to trigger UI recomposition - state.setGeohashParticipantCounts(counts) - - Log.v(TAG, "🔄 Updated reactive participant counts: ${counts.size} geohashes with activity") - } - - /** - * Record geohash participant by pubkey hex (iOS-compatible) - */ - private fun recordGeoParticipant(pubkeyHex: String) { - currentGeohash?.let { geohash -> - updateGeohashParticipant(geohash, pubkeyHex, Date()) - } - } - - /** - * Refresh geohash people list from current participants (iOS-compatible) - */ - private fun refreshGeohashPeople() { - val geohash = currentGeohash - if (geohash == null) { - state.setGeohashPeople(emptyList()) - return - } - - // Use 5-minute activity window (matches iOS exactly) - val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) - val participants = geohashParticipants[geohash] ?: mutableMapOf() - - // Remove expired participants - val iterator = participants.iterator() - while (iterator.hasNext()) { - val entry = iterator.next() - if (entry.value.before(cutoff)) { - iterator.remove() - } - } - geohashParticipants[geohash] = participants - - // Build GeoPerson list - val people = participants.map { (pubkeyHex, lastSeen) -> - val displayName = displayNameForNostrPubkey(pubkeyHex) - //Log.v(TAG, "🏷️ Participant ${pubkeyHex.take(8)} -> displayName: $displayName") - GeoPerson( - id = pubkeyHex.lowercase(), - displayName = displayName, - lastSeen = lastSeen - ) - }.sortedByDescending { it.lastSeen } // Most recent first - - state.setGeohashPeople(people) - //Log.d(TAG, "🌍 Refreshed geohash people: ${people.size} participants in $geohash") - - } - - /** - * Start participant refresh timer for geohash channels (iOS-compatible) - */ - private fun startGeoParticipantsTimer() { - // Cancel existing timer - geoParticipantsTimer?.cancel() - - // Start 30-second refresh timer (matches iOS) - geoParticipantsTimer = coroutineScope.launch { - while (currentGeohash != null) { - delay(30000) // 30 seconds - refreshGeohashPeople() - } - } - } - - /** - * Stop participant refresh timer - */ - private fun stopGeoParticipantsTimer() { - geoParticipantsTimer?.cancel() - geoParticipantsTimer = null - } - - /** - * Check if a geohash person is teleported (iOS-compatible) - */ - fun isPersonTeleported(pubkeyHex: String): Boolean { - return state.getTeleportedGeoValue().contains(pubkeyHex.lowercase()) - } - - /** - * Start geohash DM with pubkey hex (iOS-compatible) - */ - fun startGeohashDM(pubkeyHex: String, onStartPrivateChat: (String) -> Unit) { - val convKey = "nostr_${pubkeyHex.take(16)}" - nostrKeyMapping[convKey] = pubkeyHex - onStartPrivateChat(convKey) - Log.d(TAG, "🗨️ Started geohash DM with $pubkeyHex -> $convKey") - } - - /** - * Get the Nostr key mapping for geohash DMs - */ - fun getNostrKeyMapping(): Map { - return nostrKeyMapping.toMap() - } - - /** - * Send read receipt for geohash DM - */ - private fun sendGeohashReadReceipt(messageID: String, recipientPubkey: String, geohash: String) { - coroutineScope.launch { - try { - // Derive geohash-specific identity for sending - val senderIdentity = NostrIdentityBridge.deriveIdentity( - forGeohash = geohash, - context = application - ) - - // Send via Nostr transport - val nostrTransport = NostrTransport.getInstance(application) - // Set sender peer ID (get from mesh service or use a placeholder) - nostrTransport.senderPeerID = "geohash:$geohash" - nostrTransport.sendReadReceiptGeohash( - messageID = messageID, - toRecipientHex = recipientPubkey, - fromIdentity = senderIdentity - ) - - Log.d(TAG, "📤 Sent geohash read receipt for $messageID to ${recipientPubkey.take(8)}...") - - } catch (e: Exception) { - Log.e(TAG, "Failed to send geohash read receipt: ${e.message}") - } - } - } - - // MARK: - Location Channel Management - - fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) { - locationChannelManager?.select(channel) ?: run { - Log.w(TAG, "Cannot select location channel - LocationChannelManager not initialized") - } - } - - /** - * Switch to location channel and set up proper Nostr subscriptions (iOS-compatible) - * Optimized for non-blocking UI with immediate feedback - */ - private fun switchLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) { - // STEP 1: Immediate UI updates (synchronous, no blocking) - try { - // Do not clear messages here - preserve mesh history when switching between views - - when (channel) { - is com.bitchat.android.geohash.ChannelID.Mesh -> { - Log.d(TAG, "📡 Switched to mesh channel") - // Immediate UI state updates - currentGeohash = null - // Update notification manager with current geohash - notificationManager.setCurrentGeohash(null) - // Clear mesh mention notifications since user is now viewing mesh chat - notificationManager.clearMeshMentionNotifications() - // Note: Don't clear geoNicknames - keep cached for when we return to location channels - stopGeoParticipantsTimer() - state.setGeohashPeople(emptyList()) - state.setTeleportedGeo(emptySet()) - } - - is com.bitchat.android.geohash.ChannelID.Location -> { - Log.d(TAG, "📍 Switching to geohash channel: ${channel.channel.geohash}") - currentGeohash = channel.channel.geohash - // Update notification manager with current geohash - notificationManager.setCurrentGeohash(channel.channel.geohash) - // Clear notifications for this geohash since user is now viewing it - notificationManager.clearNotificationsForGeohash(channel.channel.geohash) - // Note: Don't clear geoNicknames - they contain cached nicknames for all geohashes - // Clear unread for this geohash since user is now viewing it - try { - messageManager.clearChannelUnreadCount("geo:${channel.channel.geohash}") - } catch (_: Exception) { } - - // Immediate self-registration for instant UI feedback - try { - val identity = NostrIdentityBridge.deriveIdentity( - forGeohash = channel.channel.geohash, - context = application - ) - recordGeoParticipant(identity.publicKeyHex) - - // Mark teleported state immediately - val teleported = state.isTeleported.value ?: false - if (teleported) { - val currentTeleported = state.getTeleportedGeoValue().toMutableSet() - currentTeleported.add(identity.publicKeyHex.lowercase()) - state.setTeleportedGeo(currentTeleported) - } - - Log.d(TAG, "📍 Immediate self-registration completed for geohash UI") - } catch (e: Exception) { - Log.w(TAG, "Failed immediate identity setup: ${e.message}") - } - - // Start participant refresh timer immediately - startGeoParticipantsTimer() - - // Force immediate refresh to show any cached nicknames - refreshGeohashPeople() - } - - null -> { - Log.d(TAG, "📡 No channel selected") - currentGeohash = null - // Note: Don't clear geoNicknames - keep cached nicknames for when we return - stopGeoParticipantsTimer() - state.setGeohashPeople(emptyList()) - state.setTeleportedGeo(emptySet()) - } - } - } catch (e: Exception) { - Log.e(TAG, "❌ Error in immediate channel switch: ${e.message}") - } - - // STEP 2: Async subscription setup (non-blocking background) - coroutineScope.launch { - try { - Log.d(TAG, "🔄 Starting async subscription setup...") - - // Clear processed events when switching channels to get fresh timeline - processedNostrEvents.clear() - processedNostrEventOrder.clear() - - // Unsubscribe from previous geohash ephemeral events (async) - currentGeohashSubscriptionId?.let { subId -> - try { - val nostrRelayManager = NostrRelayManager.getInstance(application) - nostrRelayManager.unsubscribe(subId) - currentGeohashSubscriptionId = null - Log.d(TAG, "🔄 Unsubscribed from previous geohash ephemeral events: $subId") - } catch (e: Exception) { - Log.w(TAG, "Failed to unsubscribe from geohash events: ${e.message}") - } - } - - // Unsubscribe from previous geohash DMs (async) - currentGeohashDmSubscriptionId?.let { dmSubId -> - try { - val nostrRelayManager = NostrRelayManager.getInstance(application) - nostrRelayManager.unsubscribe(dmSubId) - currentGeohashDmSubscriptionId = null - Log.d(TAG, "🔄 Unsubscribed from previous geohash DMs: $dmSubId") - } catch (e: Exception) { - Log.w(TAG, "Failed to unsubscribe from DMs: ${e.message}") - } - } - - // Setup new subscriptions for location channels - if (channel is com.bitchat.android.geohash.ChannelID.Location) { - Log.d(TAG, "🌐 Setting up Nostr subscriptions for geohash: ${channel.channel.geohash}") - - try { - val nostrRelayManager = NostrRelayManager.getInstance(application) - - // Subscribe to geohash ephemeral events for this specific channel using geohash-specific relays - val geohashSubId = "geohash-${channel.channel.geohash}" - currentGeohashSubscriptionId = geohashSubId - - val geohashFilter = NostrFilter.geohashEphemeral( - geohash = channel.channel.geohash, - since = System.currentTimeMillis() - 3600000L, // Last hour for channel messages - limit = 200 - ) - - nostrRelayManager.subscribeForGeohash( - geohash = channel.channel.geohash, - filter = geohashFilter, - id = geohashSubId, - handler = { event -> - handleUnifiedGeohashEvent(event, channel.channel.geohash) - }, - includeDefaults = false, - nRelays = 5 - ) - - Log.i(TAG, "✅ Subscribed to geohash ephemeral events: #${channel.channel.geohash}") - - // Subscribe to DMs for this channel's identity - val dmIdentity = NostrIdentityBridge.deriveIdentity( - forGeohash = channel.channel.geohash, - context = application - ) - - val dmSubId = "geo-dm-${channel.channel.geohash}" - currentGeohashDmSubscriptionId = dmSubId - - val dmFilter = NostrFilter.giftWrapsFor( - pubkey = dmIdentity.publicKeyHex, - since = System.currentTimeMillis() - 172800000L // Last 48 hours (align with NIP-17 randomization) - ) - - // IMPORTANT: For geohash DMs, use default relays (iOS behavior) - nostrRelayManager.subscribe( - filter = dmFilter, - id = dmSubId, - handler = { giftWrap -> - handleGeohashDmEvent(giftWrap, channel.channel.geohash, dmIdentity) - }, - targetRelayUrls = null - ) - - Log.i(TAG, "✅ Subscribed to geohash DMs for identity: ${dmIdentity.publicKeyHex.take(16)}...") - - } catch (e: Exception) { - Log.e(TAG, "❌ Failed to setup geohash subscriptions: ${e.message}") - } - } - - Log.d(TAG, "✅ Async subscription setup completed") - - } catch (e: Exception) { - Log.e(TAG, "❌ Failed in async channel switching: ${e.message}") - } - } - } - - /** - * Unified handler for all geohash ephemeral events (kind 20000) - * Handles participant tracking, nickname caching, message display, and teleport state - */ - private fun handleUnifiedGeohashEvent(event: NostrEvent, geohash: String) { - coroutineScope.launch(Dispatchers.Default) { - try { - Log.v(TAG, "🔍 handleUnifiedGeohashEvent called - subGeohash: $geohash, currentGeohash: $currentGeohash, kind: ${event.kind}, id: ${event.id.take(8)}...") - - // Only handle ephemeral kind 20000 events - if (event.kind != 20000) { - Log.v(TAG, "❌ Skipping non-ephemeral event (kind ${event.kind})") - return@launch - } - - // VALIDATE EVENT G TAG AGAINST SUBSCRIPTION GEOHASH - val eventGeohash = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }?.getOrNull(1) - if (eventGeohash == null) { - Log.w(TAG, "🚫 Dropping kind=20000 without 'g' tag id=${event.id.take(8)}") - return@launch - } - if (!eventGeohash.equals(geohash, ignoreCase = true)) { - Log.w(TAG, "🚫 Dropping mismatched geohash event: sub=$geohash eventTag=$eventGeohash id=${event.id.take(8)}") - return@launch - } - - // Check Proof of Work validation BEFORE other processing - val powSettings = PoWPreferenceManager.getCurrentSettings() - if (powSettings.enabled && powSettings.difficulty > 0) { - if (!NostrProofOfWork.validateDifficulty(event, powSettings.difficulty)) { - Log.w(TAG, "🚫 Rejecting geohash event ${event.id.take(8)}... due to insufficient PoW (required: ${powSettings.difficulty})") - return@launch - } - Log.v(TAG, "✅ PoW validation passed for event ${event.id.take(8)}...") - } - - // Check if this user is blocked in geohash channels BEFORE any processing - if (isGeohashUserBlocked(event.pubkey)) { - Log.v(TAG, "🚫 Skipping event from blocked geohash user: ${event.pubkey.take(8)}...") - return@launch - } - - // Deduplicate events - if (processedNostrEvents.contains(event.id)) { - Log.v(TAG, "❌ Skipping duplicate event ${event.id.take(8)}...") - return@launch - } - processedNostrEvents.add(event.id) - - // Manage deduplication cache size - processedNostrEventOrder.add(event.id) - if (processedNostrEventOrder.size > maxProcessedNostrEvents) { - val oldestId = processedNostrEventOrder.removeAt(0) - processedNostrEvents.remove(oldestId) - } - - // STEP 1: Always update participant activity for all geohashes (for location channel list) - val timestamp = Date(event.createdAt * 1000L) - withContext(Dispatchers.Main) { updateGeohashParticipant(geohash, event.pubkey, timestamp) } - - // STEP 2: Always cache nickname from tag if present (for all geohashes) - event.tags.find { it.size >= 2 && it[0] == "n" }?.let { nickTag -> - val nick = nickTag[1] - val pubkeyLower = event.pubkey.lowercase() - val previousNick = geoNicknames[pubkeyLower] - geoNicknames[pubkeyLower] = nick - Log.v(TAG, "📝 Cached nickname for ${event.pubkey.take(8)}: $nick") - - // If this is a new nickname or nickname change for current geohash, refresh people list - if (previousNick != nick && currentGeohash == geohash) { - withContext(Dispatchers.Main) { refreshGeohashPeople() } - } - } - - // STEP 3: Always track teleport tag for participants (iOS-compatible) - event.tags.find { it.size >= 2 && it[0] == "t" && it[1] == "teleport" }?.let { - val key = event.pubkey.lowercase() - val currentTeleported = state.getTeleportedGeoValue().toMutableSet() - if (!currentTeleported.contains(key)) { - currentTeleported.add(key) - withContext(Dispatchers.Main) { state.setTeleportedGeo(currentTeleported) } - Log.d(TAG, "📍 Marked geohash participant as teleported: ${event.pubkey.take(8)}...") - } - } - - // STEP 4: Skip our own events for message display (we already locally echoed) - val myGeoIdentity = NostrIdentityBridge.deriveIdentity( - forGeohash = geohash, - context = application - ) - if (myGeoIdentity.publicKeyHex.lowercase() == event.pubkey.lowercase()) { - return@launch - } - - // STEP 5: Store mapping for potential geohash DM initiation - val key16 = "nostr_${event.pubkey.take(16)}" - val key8 = "nostr:${event.pubkey.take(8)}" - nostrKeyMapping[key16] = event.pubkey - nostrKeyMapping[key8] = event.pubkey - - // STEP 6: Process message content for display (only for current/visible geohash) - // Skip empty teleport presence events - val isTeleportPresence = event.tags.any { it.size >= 2 && it[0] == "t" && it[1] == "teleport" } && - event.content.trim().isEmpty() - if (isTeleportPresence) { - Log.v(TAG, "Skipping empty teleport presence event") - return@launch - } - - val senderHandle = displayNameForNostrPubkey(event.pubkey) - val senderName = displayNameForNostrPubkeyUI(event.pubkey) - val content = event.content - - // Preserve Nostr event time to avoid reordering - val messageTimestamp = Date(event.createdAt.toLong() * 1000L) - // Note: mentions parsing needs peer nicknames parameter - // val mentions = messageManager.parseMentions(content, peerNicknames, nickname) - - // Calculate actual PoW difficulty from the finalized event ID so we can show it for incoming messages too - val eventHasNonseTag = event.tags.any { it.isNotEmpty() && it[0] == "nonce" } - val actualPow = try { NostrProofOfWork.calculateDifficulty(event.id) } catch (e: Exception) { 0 } - - val message = BitchatMessage( - id = event.id, - sender = senderName, - content = content, - timestamp = messageTimestamp, - isRelay = false, - originalSender = senderHandle, - senderPeerID = "nostr:${event.pubkey.take(8)}", - mentions = null, // mentions need to be passed from outside - channel = "#$geohash", - powDifficulty = actualPow.takeIf { it > 0 && eventHasNonseTag } ?: null - ) - - // Add to geohash channel timeline (unified pipeline) - withContext(Dispatchers.Main) { messageManager.addChannelMessage("geo:$geohash", message) } - - // NOTIFICATION LOGIC: Check for mentions and first messages - checkAndTriggerGeohashNotifications(geohash, senderName, content, message) - - Log.d(TAG, "📥 Unified geohash event processed - geohash: $geohash, sender: $senderName, content: ${content.take(50)}") - - } catch (e: Exception) { - Log.e(TAG, "Error handling unified geohash event: ${e.message}") - } - } - } - - /** - * Check and trigger geohash notifications for mentions and first messages - */ - private suspend fun checkAndTriggerGeohashNotifications( - geohash: String, - senderName: String, - content: String, - message: BitchatMessage - ) { - try { - // Get user's current nickname - val currentNickname = state.getNicknameValue() - if (currentNickname.isNullOrEmpty()) { - return - } - - // Check if this message mentions the current user - val isMention = checkForMention(content, currentNickname) - - // Check if this is the first message in a subscribed geohash chat - val isFirstMessage = checkIfFirstMessage(geohash, message) - - // Only trigger notifications if we have a mention or first message - if (isMention || isFirstMessage) { - Log.d(TAG, "🔔 Triggering geohash notification - geohash: $geohash, mention: $isMention, first: $isFirstMessage") - - withContext(Dispatchers.Main) { - // Sanitize mention for notifications: hide '#hash' from @username#hash if not needed - val contentForNotification = if (isMention) { - var sanitized = content - try { - val myGeoIdentity = NostrIdentityBridge.deriveIdentity( - forGeohash = geohash, - context = application - ) - val myDisplay = displayNameForNostrPubkeyUI(myGeoIdentity.publicKeyHex) - val needsDisambiguation = myDisplay.contains("#") - if (!needsDisambiguation) { - val pattern = ("@" + java.util.regex.Pattern.quote(currentNickname) + "#[a-fA-F0-9]{4}\\b").toRegex() - sanitized = sanitized.replace(pattern, "@" + currentNickname) - } - } catch (_: Exception) { } - sanitized - } else content - - val locationName = getLocationNameForGeohash(geohash) - notificationManager.showGeohashNotification( - geohash = geohash, - senderNickname = senderName, - messageContent = contentForNotification, - isMention = isMention, - isFirstMessage = isFirstMessage, - locationName = locationName - ) - } - } - } catch (e: Exception) { - Log.e(TAG, "Error checking geohash notifications: ${e.message}") - } - } - - /** - * Check if the content mentions the current user with @nickname#hash format - */ - private fun checkForMention(content: String, currentNickname: String): Boolean { - // iOS-style mention pattern: @nickname#1234 or @nickname - val mentionPattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)".toRegex() - - return mentionPattern.findAll(content).any { match -> - val mentionWithoutAt = match.groupValues[1] - // Split the mention to get base nickname (without #hash suffix) - val baseName = if (mentionWithoutAt.contains("#")) { - mentionWithoutAt.substringBeforeLast("#") - } else { - mentionWithoutAt - } - - // Check if the base name matches current user's nickname - baseName.equals(currentNickname, ignoreCase = true) - } - } - - /** - * Check if this is the first message in a subscribed geohash chat - */ - private fun checkIfFirstMessage(geohash: String, message: BitchatMessage): Boolean { - // Use ChatState.channelMessages for geohash history - val channelKey = "geo:$geohash" - val messageHistory = state.getChannelMessagesValue()[channelKey] ?: emptyList() - - // Filter out our own messages (local echoes and messages from our identity) - val otherUserMessages = messageHistory.filter { msg -> - // Check if this is our own message by comparing sender ID with our geohash identity - try { - val myGeoIdentity = NostrIdentityBridge.deriveIdentity( - forGeohash = geohash, - context = application - ) - val myIdentityId = "nostr:${myGeoIdentity.publicKeyHex.take(8)}" - msg.senderPeerID != myIdentityId && msg.senderPeerID != message.senderPeerID - } catch (e: Exception) { - // If we can't determine identity, assume it's not our message - msg.senderPeerID != message.senderPeerID - } - } - - // This is a first message if there are no other user messages and this isn't from us - val isFromUs = try { - val myGeoIdentity = NostrIdentityBridge.deriveIdentity( - forGeohash = geohash, - context = application - ) - val myIdentityId = "nostr:${myGeoIdentity.publicKeyHex.take(8)}" - message.senderPeerID == myIdentityId - } catch (e: Exception) { - false - } - - return otherUserMessages.isEmpty() && !isFromUs - } - - /** - * Handle geohash DM event (private messages in geohash context) - iOS compatible - */ - private fun handleGeohashDmEvent( - giftWrap: NostrEvent, - geohash: String, - identity: NostrIdentity - ) { - coroutineScope.launch(kotlinx.coroutines.Dispatchers.Default) { - try { - // Deduplicate - if (processedNostrEvents.contains(giftWrap.id)) return@launch - processedNostrEvents.add(giftWrap.id) - - // Removed legacy NostrReadStore usage; rely on SeenMessageStore by message ID - - // Decrypt with per-geohash identity - val decryptResult = NostrProtocol.decryptPrivateMessage( - giftWrap = giftWrap, - recipientIdentity = identity - ) - - if (decryptResult == null) { - Log.d(TAG, "Skipping geohash DM: unwrap/open failed (non-fatal)") - return@launch - } - - val (content, senderPubkey, rumorTimestamp) = decryptResult - - // Only process BitChat embedded messages - if (!content.startsWith("bitchat1:")) return@launch - - val base64Content = content.removePrefix("bitchat1:") - val packetData = base64URLDecode(base64Content) ?: return@launch - val packet = com.bitchat.android.protocol.BitchatPacket.fromBinaryData(packetData) ?: return@launch - - if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return@launch - - val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) ?: return@launch - val messageTimestamp = Date(rumorTimestamp * 1000L) - val convKey = "nostr_${senderPubkey.take(16)}" - nostrKeyMapping[convKey] = senderPubkey - - when (noisePayload.type) { - com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE -> { - val pm = com.bitchat.android.model.PrivateMessagePacket.decode(noisePayload.data) ?: return@launch - val messageId = pm.messageID - - Log.d(TAG, "📥 Received geohash DM from ${senderPubkey.take(8)}...") - - // Check for duplicate message - val existingChats = state.getPrivateChatsValue() - var messageExists = false - for ((_, messages) in existingChats) { - if (messages.any { it.id == messageId }) { - messageExists = true - break - } - } - if (messageExists) return@launch - - val senderHandle = displayNameForNostrPubkey(senderPubkey) - val senderName = displayNameForNostrPubkeyUI(senderPubkey) - val isViewingThisChat = state.getSelectedPrivateChatPeerValue() == convKey - - val message = BitchatMessage( - id = messageId, - sender = senderName, - content = pm.content, - timestamp = messageTimestamp, - isRelay = false, - isPrivate = true, - recipientNickname = state.getNicknameValue(), - senderPeerID = convKey, - originalSender = senderHandle, - deliveryStatus = com.bitchat.android.model.DeliveryStatus.Delivered( - to = state.getNicknameValue() ?: "Unknown", - at = Date() - ) - ) - - // Add to private chats (suppress unread if already read) - val seen = com.bitchat.android.services.SeenMessageStore.getInstance(application) - privateChatManager.handleIncomingPrivateMessage(message, suppressUnread = seen.hasRead(messageId)) - - // Send delivery ACK for geohash DMs only once - if (!seen.hasDelivered(messageId)) { - val nostrTransport = NostrTransport.getInstance(application) - nostrTransport.sendDeliveryAckGeohash(messageId, senderPubkey, identity) - seen.markDelivered(messageId) - } - - // Send read receipt if viewing this chat - if (isViewingThisChat && !seen.hasRead(messageId)) { - // Send read receipt via Nostr for geohash DM - sendGeohashReadReceipt(messageId, senderPubkey, geohash) - // Mark message as read persistently - try { seen.markRead(messageId) } catch (_: Exception) { } - } - } - - com.bitchat.android.model.NoisePayloadType.DELIVERED -> { - val messageId = String(noisePayload.data, Charsets.UTF_8) - meshDelegateHandler.didReceiveDeliveryAck(messageId, convKey) - } - - com.bitchat.android.model.NoisePayloadType.READ_RECEIPT -> { - val messageId = String(noisePayload.data, Charsets.UTF_8) - meshDelegateHandler.didReceiveReadReceipt(messageId, convKey) - } - } - - } catch (e: Exception) { - Log.e(TAG, "Error handling geohash DM event: ${e.message}") - } - } - } - - /** - * Display name for Nostr pubkey (iOS-compatible) - */ - private fun displayNameForNostrPubkey(pubkeyHex: String): String { - val suffix = pubkeyHex.takeLast(4) - val pubkeyLower = pubkeyHex.lowercase() - - // If this is our per-geohash identity, use our nickname - val currentGeohash = this.currentGeohash - if (currentGeohash != null) { - try { - val myGeoIdentity = NostrIdentityBridge.deriveIdentity( - forGeohash = currentGeohash, - context = application - ) - if (myGeoIdentity.publicKeyHex.lowercase() == pubkeyLower) { - return "${state.getNicknameValue()}#$suffix" - } - } catch (e: Exception) { - // Continue with other methods - } - } - - // If we have a cached nickname for this pubkey, use it - geoNicknames[pubkeyLower]?.let { nick -> - //Log.v(TAG, "✅ Found cached nickname for ${pubkeyHex.take(8)}: $nick") - return "$nick#$suffix" - } - - // Otherwise, anonymous with collision-resistant suffix - //Log.v(TAG, "❌ No cached nickname for ${pubkeyHex.take(8)}, using anon") - return "anon#$suffix" - } - - /** - * UI display name for Nostr pubkey (conditionally hides #hash when not needed) - * Used in chat window sender labels, geohash people list, and notifications. - */ - fun displayNameForNostrPubkeyUI(pubkeyHex: String): String { - val pubkeyLower = pubkeyHex.lowercase() - val suffix = pubkeyHex.takeLast(4) - val currentGeohash = this.currentGeohash - - // Determine base name (self nickname, cached nickname, or anon) - val baseName: String = try { - if (currentGeohash != null) { - val myGeoIdentity = NostrIdentityBridge.deriveIdentity( - forGeohash = currentGeohash, - context = application - ) - if (myGeoIdentity.publicKeyHex.lowercase() == pubkeyLower) { - state.getNicknameValue() ?: "anon" - } else { - geoNicknames[pubkeyLower] ?: "anon" - } - } else { - geoNicknames[pubkeyLower] ?: "anon" - } - } catch (_: Exception) { - geoNicknames[pubkeyLower] ?: "anon" - } - - // If not in a geohash context, no need for disambiguation - if (currentGeohash == null) return baseName - - // Count active participants sharing the same base name within 5 minutes - return try { - val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000) - val participants = geohashParticipants[currentGeohash] ?: emptyMap() - - var count = 0 - for ((participantKey, lastSeen) in participants) { - if (lastSeen.before(cutoff)) continue - val participantLower = participantKey.lowercase() - val name = if (participantLower == pubkeyLower) baseName else (geoNicknames[participantLower] ?: "anon") - if (name.equals(baseName, ignoreCase = true)) { - count++ - if (count > 1) break - } - } - - if (!participants.containsKey(pubkeyLower)) { - count += 1 - } - - if (count > 1) "$baseName#$suffix" else baseName - } catch (_: Exception) { - baseName - } - } - - /** - * Mention handle for a Nostr pubkey (ALWAYS includes #hash) - * Use for mentions, hug/slap targets, and mention detection contexts. - */ - fun mentionHandleForNostrPubkey(pubkeyHex: String): String { - return displayNameForNostrPubkey(pubkeyHex) - } - - // MARK: - Color System - - /** - * Get consistent color for a Nostr pubkey (iOS-compatible) - */ - fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color { - val seed = "nostr:${pubkeyHex.lowercase()}" - return colorForPeerSeed(seed, isDark).copy() - } - - // MARK: - Nostr Direct Message Sending - - /** - * Send private message via Nostr for geohash contacts - */ - fun sendNostrGeohashDM(content: String, recipientPeerID: String, messageID: String, myPeerID: String) { - coroutineScope.launch { - try { - // Get the current geohash from location channel manager - val locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(application) - val selectedChannel = locationChannelManager.selectedChannel.value - - if (selectedChannel !is com.bitchat.android.geohash.ChannelID.Location) { - Log.w(TAG, "Cannot send geohash DM: not in a location channel") - return@launch - } - - // Get the recipient's Nostr public key from the mapping - val recipientHex = nostrKeyMapping[recipientPeerID] - - if (recipientHex == null) { - Log.w(TAG, "Cannot send geohash DM: no public key mapping for $recipientPeerID") - return@launch - } - - // Derive geohash-specific identity for sending - val senderIdentity = NostrIdentityBridge.deriveIdentity( - forGeohash = selectedChannel.channel.geohash, - context = application - ) - - // Send via Nostr transport - val nostrTransport = NostrTransport.getInstance(application) - // Ensure the senderPeerID is set properly - nostrTransport.senderPeerID = myPeerID - nostrTransport.sendPrivateMessageGeohash( - content = content, - toRecipientHex = recipientHex, - fromIdentity = senderIdentity, - messageID = messageID - ) - - Log.d(TAG, "📤 Sent geohash DM to $recipientPeerID via Nostr") - - } catch (e: Exception) { - Log.e(TAG, "Failed to send Nostr geohash DM: ${e.message}") - } - } - } - - /** - * Send read receipt via Nostr for geohash contacts - */ - fun sendNostrGeohashReadReceipt(messageID: String, recipientPeerID: String, myPeerID: String) { - coroutineScope.launch { - try { - // Get the current geohash from location channel manager - val locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(application) - val selectedChannel = locationChannelManager.selectedChannel.value - - if (selectedChannel !is com.bitchat.android.geohash.ChannelID.Location) { - Log.w(TAG, "Cannot send geohash read receipt: not in a location channel") - return@launch - } - - // Get the recipient's Nostr public key from the mapping - val recipientHex = nostrKeyMapping[recipientPeerID] - - if (recipientHex == null) { - Log.w(TAG, "Cannot send geohash read receipt: no public key mapping for $recipientPeerID") - return@launch - } - - // Derive geohash-specific identity for sending - val senderIdentity = NostrIdentityBridge.deriveIdentity( - forGeohash = selectedChannel.channel.geohash, - context = application - ) - - // Send via Nostr transport - val nostrTransport = NostrTransport.getInstance(application) - // Ensure the senderPeerID is set properly - nostrTransport.senderPeerID = myPeerID - nostrTransport.sendReadReceiptGeohash( - messageID = messageID, - toRecipientHex = recipientHex, - fromIdentity = senderIdentity - ) - - Log.d(TAG, "📤 Sent geohash read receipt for $messageID to $recipientPeerID via Nostr") - - } catch (e: Exception) { - Log.e(TAG, "Failed to send Nostr geohash read receipt: ${e.message}") - } - } - } - - // MARK: - Geohash Blocking - - /** - * Block a user in geohash channels by their nickname - */ - fun blockUserInGeohash(targetNickname: String) { - // Find the pubkey for this nickname - val pubkeyHex = geoNicknames.entries.firstOrNull { (_, nickname) -> - val baseName = nickname.split("#").firstOrNull() ?: nickname - baseName == targetNickname - }?.key - - if (pubkeyHex != null) { - // Add to geohash block list - dataManager.addGeohashBlockedUser(pubkeyHex) - - // Add system message - val systemMessage = com.bitchat.android.model.BitchatMessage( - sender = "system", - content = "blocked $targetNickname in geohash channels", - timestamp = java.util.Date(), - isRelay = false - ) - messageManager.addMessage(systemMessage) - - Log.i(TAG, "🚫 Blocked geohash user: $targetNickname (pubkey: ${pubkeyHex.take(8)}...)") - } else { - // User not found - val systemMessage = com.bitchat.android.model.BitchatMessage( - sender = "system", - content = "user '$targetNickname' not found in current geohash", - timestamp = java.util.Date(), - isRelay = false - ) - messageManager.addMessage(systemMessage) - } - } - - /** - * Check if a user is blocked in geohash channels - */ - private fun isGeohashUserBlocked(pubkeyHex: String): Boolean { - return dataManager.isGeohashUserBlocked(pubkeyHex) - } - - /** - * Get location name for a geohash if available - */ - private fun getLocationNameForGeohash(geohash: String): String? { - return try { - val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(application) - val locationNames = locationManager.locationNames.value ?: return null - val available = locationManager.availableChannels.value ?: emptyList() - - // Determine the level from geohash length - val level = when (geohash.length) { - in 0..2 -> com.bitchat.android.geohash.GeohashChannelLevel.REGION - in 3..4 -> com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE - 5 -> com.bitchat.android.geohash.GeohashChannelLevel.CITY - 6 -> com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD - 7 -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK - else -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK - } - - // Only show location name if the notification's geohash matches the device's current geohash at that level - val currentAtLevel = available.firstOrNull { it.level == level }?.geohash - if (currentAtLevel != null && currentAtLevel.equals(geohash, ignoreCase = true)) { - locationNames[level] - } else { - null - } - } catch (e: Exception) { - Log.w(TAG, "Failed to get location name for geohash $geohash: ${e.message}") - null - } - } - -} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt new file mode 100644 index 00000000..54e6a5a7 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrSubscriptionManager.kt @@ -0,0 +1,38 @@ +package com.bitchat.android.nostr + +import android.app.Application +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * NostrSubscriptionManager + * - Encapsulates subscription lifecycle with NostrRelayManager + */ +class NostrSubscriptionManager( + private val application: Application, + private val scope: CoroutineScope +) { + companion object { private const val TAG = "NostrSubscriptionManager" } + + private val relayManager get() = NostrRelayManager.getInstance(application) + + fun connect() = scope.launch { runCatching { relayManager.connect() }.onFailure { Log.e(TAG, "connect failed: ${it.message}") } } + fun disconnect() = scope.launch { runCatching { relayManager.disconnect() }.onFailure { Log.e(TAG, "disconnect failed: ${it.message}") } } + + fun subscribeGiftWraps(pubkey: String, sinceMs: Long, id: String, handler: (NostrEvent) -> Unit) { + scope.launch { + val filter = NostrFilter.giftWrapsFor(pubkey, sinceMs) + relayManager.subscribe(filter, id, handler) + } + } + + fun subscribeGeohash(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) { + scope.launch { + val filter = NostrFilter.geohashEphemeral(geohash, sinceMs, limit) + relayManager.subscribeForGeohash(geohash, filter, id, handler, includeDefaults = false, nRelays = 5) + } + } + + fun unsubscribe(id: String) { scope.launch { runCatching { relayManager.unsubscribe(id) } } } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt b/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt index 217e081d..18da6bc5 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt @@ -56,8 +56,7 @@ class NostrTransport( // Resolve favorite by full noise key or by short peerID fallback var recipientNostrPubkey: String? = null - // Try to resolve from favorites persistence service - // This would need integration with the existing favorites system + // Resolve by peerID first (new peerID→npub index), then fall back to noise key mapping recipientNostrPubkey = resolveNostrPublicKey(to) if (recipientNostrPubkey == null) { @@ -86,13 +85,23 @@ class NostrTransport( return@launch } + // Strict: lookup the recipient's current BitChat peer ID using favorites mapping + 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)}...") + return@launch + } val embedded = NostrEmbeddedBitChat.encodePMForNostr( content = content, messageID = messageID, - recipientPeerID = to, + recipientPeerID = recipientPeerIDForEmbed, senderPeerID = senderPeerID ) + if (embedded == null) { Log.e(TAG, "NostrTransport: failed to embed PM packet") return@launch @@ -412,39 +421,58 @@ class NostrTransport( fun sendPrivateMessageGeohash( content: String, toRecipientHex: String, - fromIdentity: NostrIdentity, - messageID: String + messageID: String, + sourceGeohash: String? = null ) { + // Use provided geohash or derive from current location + val geohash = sourceGeohash ?: run { + val selected = try { + com.bitchat.android.geohash.LocationChannelManager.getInstance(context).selectedChannel.value + } catch (_: Exception) { null } + if (selected !is com.bitchat.android.geohash.ChannelID.Location) { + Log.w(TAG, "NostrTransport: cannot send geohash PM - not in a location channel and no geohash provided") + return + } + selected.channel.geohash + } + + val fromIdentity = try { + NostrIdentityBridge.deriveIdentity(geohash, context) + } catch (e: Exception) { + Log.e(TAG, "NostrTransport: cannot derive geohash identity for $geohash: ${e.message}") + return + } + transportScope.launch { 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)}...") - + + 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, messageID = messageID, senderPeerID = senderPeerID - ) - - if (embedded == null) { + ) ?: run { Log.e(TAG, "NostrTransport: failed to embed geohash PM packet") return@launch } - + val giftWraps = NostrProtocol.createPrivateMessage( content = embedded, recipientPubkey = toRecipientHex, senderIdentity = fromIdentity ) - + 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) } - } catch (e: Exception) { Log.e(TAG, "Failed to send geohash private message: ${e.message}") } @@ -458,14 +486,15 @@ class NostrTransport( */ private fun resolveNostrPublicKey(peerID: String): String? { try { - // Try to resolve from favorites persistence service + // 1) Fast path: direct peerID→npub mapping (mutual favorites after mesh mapping) + com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkeyForPeerID(peerID)?.let { return it } + + // 2) Legacy path: resolve by noise public key association val noiseKey = hexStringToByteArray(peerID) val favoriteStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey) - if (favoriteStatus?.peerNostrPublicKey != null) { - return favoriteStatus.peerNostrPublicKey - } - - // Fallback: try with 16-hex peerID lookup + if (favoriteStatus?.peerNostrPublicKey != null) return favoriteStatus.peerNostrPublicKey + + // 3) Prefix match on noiseHex from 16-hex peerID if (peerID.length == 16) { val fallbackStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID) return fallbackStatus?.peerNostrPublicKey diff --git a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt index b968aa96..39119f7e 100644 --- a/app/src/main/java/com/bitchat/android/services/MessageRouter.kt +++ b/app/src/main/java/com/bitchat/android/services/MessageRouter.kt @@ -43,6 +43,7 @@ class MessageRouter private constructor( // Listener for favorites changes to flush outbox when npub mapping appears/changes private val favoriteListener = object: com.bitchat.android.favorites.FavoritesChangeListener { + override fun onFavoriteChanged(noiseKeyHex: String) { flushOutboxFor(noiseKeyHex) // Also try 16-hex short id commonly used in UI if any client used that @@ -55,16 +56,30 @@ class MessageRouter private constructor( } fun sendPrivate(content: String, toPeerID: String, recipientNickname: String, messageID: String) { + // First: if this is a geohash DM alias (nostr_), route via Nostr using global registry + if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(toPeerID)) { + Log.d(TAG, "Routing PM via Nostr (geohash) to alias ${toPeerID.take(12)}… id=${messageID.take(8)}…") + val recipientHex = com.bitchat.android.nostr.GeohashAliasRegistry.get(toPeerID) + if (recipientHex != null) { + // Resolve the conversation's source geohash, so we can send from anywhere + val sourceGeohash = com.bitchat.android.nostr.GeohashConversationRegistry.get(toPeerID) + + // If repository knows the source geohash, pass it so NostrTransport derives the correct identity + nostr.sendPrivateMessageGeohash(content, recipientHex, messageID, sourceGeohash) + return + } + } + val hasMesh = mesh.getPeerInfo(toPeerID)?.isConnected == true val hasEstablished = mesh.hasEstablishedSession(toPeerID) if (hasMesh && hasEstablished) { - Log.d(TAG, "Routing PM via mesh to ${toPeerID.take(8)}… id=${messageID.take(8)}…") + Log.d(TAG, "Routing PM via mesh to ${toPeerID} msg_id=${messageID.take(8)}…") mesh.sendPrivateMessage(content, toPeerID, recipientNickname, messageID) } else if (canSendViaNostr(toPeerID)) { - Log.d(TAG, "Routing PM via Nostr to ${toPeerID.take(8)}… id=${messageID.take(8)}…") + Log.d(TAG, "Routing PM via Nostr to ${toPeerID.take(32)}… msg_id=${messageID.take(8)}…") nostr.sendPrivateMessage(content, toPeerID, recipientNickname, messageID) } else { - Log.d(TAG, "Queued PM for ${toPeerID.take(8)}… (no mesh, no Nostr mapping) id=${messageID.take(8)}…") + Log.d(TAG, "Queued PM for ${toPeerID} (no mesh, no Nostr mapping) msg_id=${messageID.take(8)}…") val q = outbox.getOrPut(toPeerID) { mutableListOf() } q.add(Triple(content, recipientNickname, messageID)) Log.d(TAG, "Initiating noise handshake after queueing PM for ${toPeerID.take(8)}…") @@ -84,7 +99,14 @@ class MessageRouter private constructor( fun sendDeliveryAck(messageID: String, toPeerID: String) { // Mesh delivery ACKs are sent by the receiver automatically. - // Only route via Nostr when mesh path isn't available. + // Only route via Nostr when mesh path isn't available or when this is a geohash alias + if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(toPeerID)) { + val recipientHex = com.bitchat.android.nostr.GeohashAliasRegistry.get(toPeerID) + if (recipientHex != null) { + nostr.sendDeliveryAckGeohash(messageID, recipientHex, try { com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context)!! } catch (_: Exception) { return }) + return + } + } if (!((mesh.getPeerInfo(toPeerID)?.isConnected == true) && mesh.hasEstablishedSession(toPeerID))) { nostr.sendDeliveryAck(messageID, toPeerID) } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index bb161953..c33c3c14 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -1,5 +1,6 @@ package com.bitchat.android.ui + import android.util.Log import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll @@ -31,6 +32,7 @@ import com.bitchat.android.core.ui.utils.singleOrTripleClickable * Extracted from ChatScreen.kt for better organization */ + /** * Reactive helper to compute favorite state from fingerprint mapping * This eliminates the need for static isFavorite parameters and makes @@ -159,7 +161,6 @@ fun PeerCounter( connectedPeers: List, joinedChannels: Set, hasUnreadChannels: Map, - hasUnreadPrivateMessages: Set, isConnected: Boolean, selectedLocationChannel: com.bitchat.android.geohash.ChannelID?, geohashPeople: List, @@ -189,33 +190,6 @@ fun PeerCounter( verticalAlignment = Alignment.CenterVertically, modifier = modifier.clickable { onClick() }.padding(end = 8.dp) // Added right margin to match "bitchat" logo spacing ) { - if (hasUnreadChannels.values.any { it > 0 }) { - // Channel icon in a Box to ensure consistent size with other icons - Box( - modifier = Modifier.size(16.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = "#", - style = MaterialTheme.typography.bodyMedium, - color = Color(0xFF0080FF), - fontSize = 16.sp - ) - } - Spacer(modifier = Modifier.width(6.dp)) - } - - if (hasUnreadPrivateMessages.isNotEmpty()) { - // Filled mail icon to match sidebar style - Icon( - imageVector = Icons.Filled.Email, - contentDescription = "Unread private messages", - modifier = Modifier.size(16.dp), - tint = Color(0xFFFF9500) // Orange to match private message theme - ) - Spacer(modifier = Modifier.width(6.dp)) - } - Icon( imageVector = Icons.Default.Group, contentDescription = when (selectedLocationChannel) { @@ -226,6 +200,7 @@ fun PeerCounter( tint = countColor ) Spacer(modifier = Modifier.width(4.dp)) + Text( text = "$peopleCount", style = MaterialTheme.typography.bodyMedium, @@ -290,7 +265,8 @@ fun ChatHeaderContent( selectedLocationChannel = selectedLocationChannel, geohashPeople = geohashPeople, onBackClick = onBackClick, - onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) } + onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) }, + viewModel = viewModel ) } currentChannel != null -> { @@ -326,7 +302,8 @@ private fun PrivateChatHeader( selectedLocationChannel: com.bitchat.android.geohash.ChannelID?, geohashPeople: List, onBackClick: () -> Unit, - onToggleFavorite: () -> Unit + onToggleFavorite: () -> Unit, + viewModel: ChatViewModel ) { val colorScheme = MaterialTheme.colorScheme val isNostrDM = peerID.startsWith("nostr_") || peerID.startsWith("nostr:") @@ -345,12 +322,24 @@ private fun PrivateChatHeader( // Compute title text: for NIP-17 chats show "#geohash/@username" (iOS parity) val titleText: String = if (isNostrDM) { - val geohash = (selectedLocationChannel as? com.bitchat.android.geohash.ChannelID.Location)?.channel?.geohash - val shortId = peerID.removePrefix("nostr_").removePrefix("nostr:") - val person = geohashPeople.firstOrNull { it.id.startsWith(shortId, ignoreCase = true) } - val baseName = person?.displayName?.substringBefore('#') ?: peerNicknames[peerID] ?: "unknown" - val geoPart = geohash?.let { "#$it" } ?: "#geohash" - "$geoPart/@$baseName" + // For geohash DMs, get the actual source geohash and proper display name + val (conversationGeohash, baseName) = try { + val repoField = com.bitchat.android.ui.GeohashViewModel::class.java.getDeclaredField("repo") + repoField.isAccessible = true + val repo = repoField.get(viewModel.geohashViewModel) as com.bitchat.android.nostr.GeohashRepository + val gh = repo.getConversationGeohash(peerID) ?: "geohash" + val fullPubkey = com.bitchat.android.nostr.GeohashAliasRegistry.get(peerID) ?: "" + val displayName = if (fullPubkey.isNotEmpty()) { + repo.displayNameForGeohashConversation(fullPubkey, gh) + } else { + peerNicknames[peerID] ?: "unknown" + } + Pair(gh, displayName) + } catch (e: Exception) { + Pair("geohash", peerNicknames[peerID] ?: "unknown") + } + + "#$conversationGeohash/@$baseName" } else { // Prefer live mesh nickname; fallback to favorites nickname (supports 16-hex), finally short key peerNicknames[peerID] ?: run { @@ -563,6 +552,19 @@ private fun MainHeader( horizontalArrangement = Arrangement.spacedBy(5.dp) ) { + // Unread private messages badge (click to open most recent DM) + if (hasUnreadPrivateMessages.isNotEmpty()) { + // Render icon directly to avoid symbol resolution issues + Icon( + imageVector = Icons.Filled.Email, + contentDescription = "Unread private messages", + modifier = Modifier + .size(16.dp) + .clickable { viewModel.openLatestUnreadPrivateChat() }, + tint = Color(0xFFFF9500) + ) + } + // Location channels button (matching iOS implementation) LocationChannelsButton( viewModel = viewModel, @@ -582,7 +584,6 @@ private fun MainHeader( connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID }, joinedChannels = joinedChannels, hasUnreadChannels = hasUnreadChannels, - hasUnreadPrivateMessages = hasUnreadPrivateMessages, isConnected = isConnected, selectedLocationChannel = selectedLocationChannel, geohashPeople = geohashPeople, diff --git a/app/src/main/java/com/bitchat/android/ui/ChatState.kt b/app/src/main/java/com/bitchat/android/ui/ChatState.kt index 352cd807..e17c1494 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatState.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatState.kt @@ -124,6 +124,9 @@ class ChatState { // Geohash people state (iOS-compatible) private val _geohashPeople = MutableLiveData>(emptyList()) val geohashPeople: LiveData> = _geohashPeople + // For background thread updates by repositories/handlers in their own scopes + val geohashPeopleMutable: MutableLiveData> get() = _geohashPeople + private val _teleportedGeo = MutableLiveData>(emptySet()) val teleportedGeo: LiveData> = _teleportedGeo @@ -155,6 +158,16 @@ class ChatState { fun getSelectedPrivateChatPeerValue() = _selectedPrivateChatPeer.value fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value ?: emptySet() fun getJoinedChannelsValue() = _joinedChannels.value ?: emptySet() + // Thread-safe posting helpers for background updates + fun postGeohashPeople(people: List) { + _geohashPeople.postValue(people) + } + + fun postGeohashParticipantCounts(counts: Map) { + _geohashParticipantCounts.postValue(counts) + } + + fun getCurrentChannelValue() = _currentChannel.value fun getChannelMessagesValue() = _channelMessages.value ?: emptyMap() fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value ?: emptyMap() @@ -183,6 +196,10 @@ class ChatState { _connectedPeers.value = peers } + fun postTeleportedGeo(teleported: Set) { + _teleportedGeo.postValue(teleported) + } + fun setNickname(nickname: String) { _nickname.value = nickname } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 89429b53..de85c597 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -10,7 +10,8 @@ import com.bitchat.android.mesh.BluetoothMeshDelegate import com.bitchat.android.mesh.BluetoothMeshService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.protocol.BitchatPacket -import com.bitchat.android.nostr.NostrGeohashService + + import kotlinx.coroutines.launch import com.bitchat.android.util.NotificationIntervalManager import kotlinx.coroutines.delay @@ -68,18 +69,20 @@ class ChatViewModel( getMeshService = { meshService } ) - // Nostr and Geohash service - initialize singleton - private val nostrGeohashService = NostrGeohashService.initialize( + // New Geohash architecture ViewModel (replaces God object service usage in UI path) + val geohashViewModel = GeohashViewModel( application = application, state = state, messageManager = messageManager, privateChatManager = privateChatManager, meshDelegateHandler = meshDelegateHandler, - coroutineScope = viewModelScope, dataManager = dataManager, notificationManager = notificationManager ) + + + // Expose state through LiveData (maintaining the same interface) val messages: LiveData> = state.messages val connectedPeers: LiveData> = state.connectedPeers @@ -168,14 +171,12 @@ class ChatViewModel( } } - // Initialize location channel state - nostrGeohashService.initializeLocationChannelState() + // Initialize new geohash architecture + geohashViewModel.initialize() // Initialize favorites persistence service com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication()) - // Initialize Nostr integration - nostrGeohashService.initializeNostrIntegration() // Ensure NostrTransport knows our mesh peer ID for embedded packets try { @@ -213,6 +214,46 @@ class ChatViewModel( meshService.sendBroadcastAnnounce() } + /** + * Ensure Nostr DM subscription for a geohash conversation key if known + * Minimal-change approach: reflectively access GeohashViewModel internals to reuse pipeline + */ + private fun ensureGeohashDMSubscriptionIfNeeded(convKey: String) { + try { + val repoField = GeohashViewModel::class.java.getDeclaredField("repo") + repoField.isAccessible = true + val repo = repoField.get(geohashViewModel) as com.bitchat.android.nostr.GeohashRepository + val gh = repo.getConversationGeohash(convKey) + if (!gh.isNullOrEmpty()) { + val subMgrField = GeohashViewModel::class.java.getDeclaredField("subscriptionManager") + subMgrField.isAccessible = true + val subMgr = subMgrField.get(geohashViewModel) as com.bitchat.android.nostr.NostrSubscriptionManager + val identity = com.bitchat.android.nostr.NostrIdentityBridge.deriveIdentity(gh, getApplication()) + val subId = "geo-dm-$gh" + val currentDmSubField = GeohashViewModel::class.java.getDeclaredField("currentDmSubId") + currentDmSubField.isAccessible = true + val currentId = currentDmSubField.get(geohashViewModel) as String? + if (currentId != subId) { + (currentId)?.let { subMgr.unsubscribe(it) } + currentDmSubField.set(geohashViewModel, subId) + subMgr.subscribeGiftWraps( + pubkey = identity.publicKeyHex, + sinceMs = System.currentTimeMillis() - 172800000L, + id = subId, + handler = { event -> + val dmHandlerField = GeohashViewModel::class.java.getDeclaredField("dmHandler") + dmHandlerField.isAccessible = true + val dmHandler = dmHandlerField.get(geohashViewModel) as com.bitchat.android.nostr.NostrDirectMessageHandler + dmHandler.onGiftWrap(event, gh, identity) + } + ) + } + } + } catch (e: Exception) { + Log.w(TAG, "ensureGeohashDMSubscriptionIfNeeded failed: ${e.message}") + } + } + // MARK: - Channel Management (delegated) fun joinChannel(channel: String, password: String? = null): Boolean { @@ -231,6 +272,11 @@ class ChatViewModel( // MARK: - Private Chat Management (delegated) fun startPrivateChat(peerID: String) { + // For geohash conversation keys, ensure DM subscription is active + if (peerID.startsWith("nostr_")) { + ensureGeohashDMSubscriptionIfNeeded(peerID) + } + val success = privateChatManager.startPrivateChat(peerID, meshService) if (success) { // Notify notification manager about current private chat @@ -258,6 +304,66 @@ class ChatViewModel( // Clear mesh mention notifications since user is now back in mesh chat clearMeshMentionNotifications() } + + // MARK: - Open Latest Unread Private Chat + + fun openLatestUnreadPrivateChat() { + try { + val unreadKeys = state.getUnreadPrivateMessagesValue() + if (unreadKeys.isEmpty()) return + + val me = state.getNicknameValue() ?: meshService.myPeerID + val chats = state.getPrivateChatsValue() + + // Pick the latest incoming message among unread conversations + var bestKey: String? = null + var bestTime: Long = Long.MIN_VALUE + + unreadKeys.forEach { key -> + val list = chats[key] + if (!list.isNullOrEmpty()) { + // Prefer the latest incoming message (sender != me), fallback to last message + val latestIncoming = list.lastOrNull { it.sender != me } + val candidateTime = (latestIncoming ?: list.last()).timestamp.time + if (candidateTime > bestTime) { + bestTime = candidateTime + bestKey = key + } + } + } + + val targetKey = bestKey ?: unreadKeys.firstOrNull() ?: return + + val openPeer: String = if (targetKey.startsWith("nostr_")) { + // Use the exact conversation key for geohash DMs and ensure DM subscription + ensureGeohashDMSubscriptionIfNeeded(targetKey) + targetKey + } else { + // Resolve to a canonical mesh peer if needed + val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID( + selectedPeerID = targetKey, + connectedPeers = state.getConnectedPeersValue(), + meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey }, + meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true }, + nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) }, + findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) } + ) + canonical ?: targetKey + } + + startPrivateChat(openPeer) + + // If sidebar visible, hide it to focus on the private chat + if (state.getShowSidebarValue()) { + state.setShowSidebar(false) + } + } catch (e: Exception) { + Log.w(TAG, "openLatestUnreadPrivateChat failed: ${e.message}") + } + } + + // END - Open Latest Unread Private Chat + // MARK: - Message Sending @@ -270,7 +376,7 @@ class ChatViewModel( commandProcessor.processCommand(content, meshService, meshService.myPeerID, { messageContent, mentions, channel -> if (selectedLocationForCommand is com.bitchat.android.geohash.ChannelID.Location) { // Route command-generated public messages via Nostr in geohash channels - nostrGeohashService.sendGeohashMessage( + geohashViewModel.sendGeohashMessage( messageContent, selectedLocationForCommand.channel, meshService.myPeerID, @@ -298,7 +404,7 @@ class ChatViewModel( connectedPeers = state.getConnectedPeersValue(), meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey }, meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true }, - nostrPubHexForAlias = { alias -> nostrGeohashService.getNostrKeyMapping()[alias] }, + nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) }, findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) } ).also { canonical -> if (canonical != state.getSelectedPrivateChatPeerValue()) { @@ -323,7 +429,7 @@ class ChatViewModel( val selectedLocationChannel = state.selectedLocationChannel.value if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) { // Send to geohash channel via Nostr ephemeral event - nostrGeohashService.sendGeohashMessage(content, selectedLocationChannel.channel, meshService.myPeerID, state.getNicknameValue()) + geohashViewModel.sendGeohashMessage(content, selectedLocationChannel.channel, meshService.myPeerID, state.getNicknameValue()) } else { // Send public/channel message via mesh val message = BitchatMessage( @@ -376,17 +482,36 @@ class ChatViewModel( Log.d("ChatViewModel", "toggleFavorite called for peerID: $peerID") privateChatManager.toggleFavorite(peerID) - // Persist relationship in FavoritesPersistenceService when we have Noise key + // Persist relationship in FavoritesPersistenceService try { + var noiseKey: ByteArray? = null + var nickname: String = meshService.getPeerNicknames()[peerID] ?: peerID + + // Case 1: Live mesh peer with known info val peerInfo = meshService.getPeerInfo(peerID) - val noiseKey = peerInfo?.noisePublicKey - val nickname = peerInfo?.nickname ?: (meshService.getPeerNicknames()[peerID] ?: peerID) + if (peerInfo?.noisePublicKey != null) { + noiseKey = peerInfo.noisePublicKey + nickname = peerInfo.nickname + } else { + // Case 2: Offline favorite entry using 64-hex noise public key as peerID + if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) { + try { + noiseKey = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + // Prefer nickname from favorites store if available + val rel = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey!!) + if (rel != null) nickname = rel.peerNickname + } catch (_: Exception) { } + } + } + if (noiseKey != null) { - val isNowFavorite = dataManager.favoritePeers.contains( - com.bitchat.android.mesh.PeerFingerprintManager.getInstance().getFingerprintForPeer(peerID) ?: "" - ) + // Determine current favorite state from DataManager using fingerprint + val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication()) + val fingerprint = identityManager.generateFingerprint(noiseKey!!) + val isNowFavorite = dataManager.favoritePeers.contains(fingerprint) + com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateFavoriteStatus( - noisePublicKey = noiseKey, + noisePublicKey = noiseKey!!, nickname = nickname, isFavorite = isNowFavorite ) @@ -596,7 +721,7 @@ class ChatViewModel( // Clear Nostr/geohash state, keys, connections, and reinitialize from scratch try { - nostrGeohashService.panicResetNostrAndGeohash() + geohashViewModel.panicReset() } catch (e: Exception) { Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}") } @@ -638,10 +763,20 @@ class ChatViewModel( try { val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication()) identityManager.clearIdentityData() - Log.d(TAG, "✅ Cleared secure identity state") + // Also clear secure values used by FavoritesPersistenceService (favorites + peerID index) + try { + identityManager.clearSecureValues("favorite_relationships", "favorite_peerid_index") + } catch (_: Exception) { } + Log.d(TAG, "✅ Cleared secure identity state and secure favorites store") } catch (e: Exception) { Log.d(TAG, "SecureIdentityStateManager not available or already cleared: ${e.message}") } + + // Clear FavoritesPersistenceService persistent relationships + try { + com.bitchat.android.favorites.FavoritesPersistenceService.shared.clearAllFavorites() + Log.d(TAG, "✅ Cleared FavoritesPersistenceService relationships") + } catch (_: Exception) { } Log.d(TAG, "✅ Cleared all cryptographic data") } catch (e: Exception) { @@ -653,48 +788,48 @@ class ChatViewModel( * Get participant count for a specific geohash (5-minute activity window) */ fun geohashParticipantCount(geohash: String): Int { - return nostrGeohashService.geohashParticipantCount(geohash) + return geohashViewModel.geohashParticipantCount(geohash) } /** * Begin sampling multiple geohashes for participant activity */ fun beginGeohashSampling(geohashes: List) { - nostrGeohashService.beginGeohashSampling(geohashes) + geohashViewModel.beginGeohashSampling(geohashes) } /** * End geohash sampling */ fun endGeohashSampling() { - nostrGeohashService.endGeohashSampling() + // No-op in refactored architecture; sampling subscriptions are short-lived } /** * Check if a geohash person is teleported (iOS-compatible) */ fun isPersonTeleported(pubkeyHex: String): Boolean { - return nostrGeohashService.isPersonTeleported(pubkeyHex) + return geohashViewModel.isPersonTeleported(pubkeyHex) } /** * Start geohash DM with pubkey hex (iOS-compatible) */ fun startGeohashDM(pubkeyHex: String) { - nostrGeohashService.startGeohashDM(pubkeyHex) { convKey -> + geohashViewModel.startGeohashDM(pubkeyHex) { convKey -> startPrivateChat(convKey) } } fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) { - nostrGeohashService.selectLocationChannel(channel) + geohashViewModel.selectLocationChannel(channel) } /** * Block a user in geohash channels by their nickname */ fun blockUserInGeohash(targetNickname: String) { - nostrGeohashService.blockUserInGeohash(targetNickname) + geohashViewModel.blockUserInGeohash(targetNickname) } // MARK: - Navigation Management @@ -767,6 +902,6 @@ class ChatViewModel( * Get consistent color for a Nostr pubkey (iOS-compatible) */ fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color { - return nostrGeohashService.colorForNostrPubkey(pubkeyHex, isDark) + return geohashViewModel.colorForNostrPubkey(pubkeyHex, isDark) } } diff --git a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt index d36f67b4..499b3926 100644 --- a/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt +++ b/app/src/main/java/com/bitchat/android/ui/CommandProcessor.kt @@ -293,7 +293,7 @@ class CommandProcessor( val actionMessage = "* ${state.getNicknameValue() ?: "someone"} $verb $targetName $object_ *" // If we're in a geohash location channel, don't add a local echo here. - // NostrGeohashService.sendGeohashMessage() will add the local echo with proper metadata. + // GeohashViewModel.sendGeohashMessage() will add the local echo with proper metadata. val isInLocationChannel = state.selectedLocationChannel.value is com.bitchat.android.geohash.ChannelID.Location // Send as regular message diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt new file mode 100644 index 00000000..08865c1e --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -0,0 +1,259 @@ +package com.bitchat.android.ui + +import android.app.Application +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.viewModelScope +import com.bitchat.android.nostr.GeohashMessageHandler +import com.bitchat.android.nostr.GeohashRepository +import com.bitchat.android.nostr.NostrDirectMessageHandler +import com.bitchat.android.nostr.NostrIdentityBridge +import com.bitchat.android.nostr.NostrProtocol +import com.bitchat.android.nostr.NostrRelayManager +import com.bitchat.android.nostr.NostrSubscriptionManager +import com.bitchat.android.nostr.PoWPreferenceManager +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.Date + +class GeohashViewModel( + application: Application, + private val state: ChatState, + private val messageManager: MessageManager, + private val privateChatManager: PrivateChatManager, + private val meshDelegateHandler: MeshDelegateHandler, + private val dataManager: DataManager, + private val notificationManager: NotificationManager +) : AndroidViewModel(application) { + + companion object { private const val TAG = "GeohashViewModel" } + + private val repo = GeohashRepository(application, state) + private val subscriptionManager = NostrSubscriptionManager(application, viewModelScope) + private val geohashMessageHandler = GeohashMessageHandler(application, state, messageManager, repo, viewModelScope) + private val dmHandler = NostrDirectMessageHandler(application, state, privateChatManager, meshDelegateHandler, viewModelScope, repo) + + private var currentGeohashSubId: String? = null + private var currentDmSubId: String? = null + private var geoTimer: Job? = null + private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null + + val geohashPeople: LiveData> = state.geohashPeople + val geohashParticipantCounts: LiveData> = state.geohashParticipantCounts + val selectedLocationChannel: LiveData = state.selectedLocationChannel + + fun initialize() { + subscriptionManager.connect() + val identity = NostrIdentityBridge.getCurrentNostrIdentity(getApplication()) + if (identity != null) { + // Use global chat-messages only for full account DMs (mesh context). For geohash DMs, subscribe per-geohash below. + subscriptionManager.subscribeGiftWraps( + pubkey = identity.publicKeyHex, + sinceMs = System.currentTimeMillis() - 172800000L, + id = "chat-messages", + handler = { event -> dmHandler.onGiftWrap(event, "", identity) } // geohash="" means global account DM (not geohash identity) + ) + } + try { + locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication()) + locationChannelManager?.selectedChannel?.observeForever { channel -> + state.setSelectedLocationChannel(channel) + switchLocationChannel(channel) + } + locationChannelManager?.teleported?.observeForever { teleported -> + state.setIsTeleported(teleported) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to initialize location channel state: ${e.message}") + state.setSelectedLocationChannel(com.bitchat.android.geohash.ChannelID.Mesh) + state.setIsTeleported(false) + } + } + + fun panicReset() { + repo.clearAll() + subscriptionManager.disconnect() + currentGeohashSubId = null + currentDmSubId = null + geoTimer?.cancel() + geoTimer = null + try { NostrIdentityBridge.clearAllAssociations(getApplication()) } catch (_: Exception) {} + initialize() + } + + fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) { + viewModelScope.launch { + try { + val tempId = "temp_${System.currentTimeMillis()}_${kotlin.random.Random.nextInt(1000)}" + val pow = PoWPreferenceManager.getCurrentSettings() + val localMsg = com.bitchat.android.model.BitchatMessage( + id = tempId, + sender = nickname ?: myPeerID, + content = content, + timestamp = Date(), + isRelay = false, + senderPeerID = "geohash:${channel.geohash}", + channel = "#${channel.geohash}", + powDifficulty = if (pow.enabled) pow.difficulty else null + ) + messageManager.addChannelMessage("geo:${channel.geohash}", localMsg) + if (pow.enabled && pow.difficulty > 0) { + com.bitchat.android.ui.PoWMiningTracker.startMiningMessage(tempId) + } + val identity = NostrIdentityBridge.deriveIdentity(forGeohash = channel.geohash, context = getApplication()) + val teleported = state.isTeleported.value ?: false + val event = NostrProtocol.createEphemeralGeohashEvent(content, channel.geohash, identity, nickname, teleported) + val relayManager = NostrRelayManager.getInstance(getApplication()) + relayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = 5) + } catch (e: Exception) { + Log.e(TAG, "Failed to send geohash message: ${e.message}") + } + } + } + + fun beginGeohashSampling(geohashes: List) { + if (geohashes.isEmpty()) return + Log.d(TAG, "🌍 Beginning geohash sampling for ${geohashes.size} geohashes") + viewModelScope.launch { + geohashes.forEach { geohash -> + subscriptionManager.subscribeGeohash( + geohash = geohash, + sinceMs = System.currentTimeMillis() - 86400000L, + limit = 200, + id = "sampling-$geohash", + handler = { event -> geohashMessageHandler.onEvent(event, geohash) } + ) + } + } + } + + fun endGeohashSampling() { Log.d(TAG, "🌍 Ending geohash sampling") } + fun geohashParticipantCount(geohash: String): Int = repo.geohashParticipantCount(geohash) + fun isPersonTeleported(pubkeyHex: String): Boolean = repo.isPersonTeleported(pubkeyHex) + + fun startGeohashDM(pubkeyHex: String, onStartPrivateChat: (String) -> Unit) { + val convKey = "nostr_${pubkeyHex.take(16)}" + repo.putNostrKeyMapping(convKey, pubkeyHex) + onStartPrivateChat(convKey) + Log.d(TAG, "🗨️ Started geohash DM with $pubkeyHex -> $convKey") + } + + fun getNostrKeyMapping(): Map = repo.getNostrKeyMapping() + + fun blockUserInGeohash(targetNickname: String) { + val pubkey = repo.findPubkeyByNickname(targetNickname) + if (pubkey != null) { + dataManager.addGeohashBlockedUser(pubkey) + val sysMsg = com.bitchat.android.model.BitchatMessage( + sender = "system", + content = "blocked $targetNickname in geohash channels", + timestamp = Date(), + isRelay = false + ) + fun startGeohashDM(pubkeyHex: String, onStartPrivateChat: (String) -> Unit) { + val convKey = "nostr_${'$'}{pubkeyHex.take(16)}" + repo.putNostrKeyMapping(convKey, pubkeyHex) + // Record the conversation's geohash using the currently selected location channel (if any) + val current = state.selectedLocationChannel.value + val gh = (current as? com.bitchat.android.geohash.ChannelID.Location)?.channel?.geohash + if (!gh.isNullOrEmpty()) { + repo.setConversationGeohash(convKey, gh) + com.bitchat.android.nostr.GeohashConversationRegistry.set(convKey, gh) + } + onStartPrivateChat(convKey) + Log.d(TAG, "🗨️ Started geohash DM with ${'$'}pubkeyHex -> ${'$'}convKey (geohash=${'$'}gh)") + } + + messageManager.addMessage(sysMsg) + } else { + val sysMsg = com.bitchat.android.model.BitchatMessage( + sender = "system", + content = "user '$targetNickname' not found in current geohash", + timestamp = Date(), + isRelay = false + ) + messageManager.addMessage(sysMsg) + } + } + + fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) { + locationChannelManager?.select(channel) ?: run { Log.w(TAG, "Cannot select location channel - not initialized") } + } + + fun displayNameForNostrPubkeyUI(pubkeyHex: String): String = repo.displayNameForNostrPubkeyUI(pubkeyHex) + + fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color { + val seed = "nostr:${pubkeyHex.lowercase()}" + return colorForPeerSeed(seed, isDark).copy() + } + + private fun switchLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) { + geoTimer?.cancel(); geoTimer = null + currentGeohashSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashSubId = null } + currentDmSubId?.let { subscriptionManager.unsubscribe(it); currentDmSubId = null } + + when (channel) { + is com.bitchat.android.geohash.ChannelID.Mesh -> { + Log.d(TAG, "📡 Switched to mesh channel") + repo.setCurrentGeohash(null) + notificationManager.setCurrentGeohash(null) + notificationManager.clearMeshMentionNotifications() + repo.refreshGeohashPeople() + } + is com.bitchat.android.geohash.ChannelID.Location -> { + Log.d(TAG, "📍 Switching to geohash channel: ${channel.channel.geohash}") + repo.setCurrentGeohash(channel.channel.geohash) + notificationManager.setCurrentGeohash(channel.channel.geohash) + notificationManager.clearNotificationsForGeohash(channel.channel.geohash) + try { messageManager.clearChannelUnreadCount("geo:${channel.channel.geohash}") } catch (_: Exception) { } + + try { + val identity = NostrIdentityBridge.deriveIdentity(channel.channel.geohash, getApplication()) + repo.updateParticipant(channel.channel.geohash, identity.publicKeyHex, Date()) + val teleported = state.isTeleported.value ?: false + if (teleported) repo.markTeleported(identity.publicKeyHex) + } catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") } + + startGeoParticipantsTimer() + + viewModelScope.launch { + val geohash = channel.channel.geohash + val subId = "geohash-$geohash"; currentGeohashSubId = subId + subscriptionManager.subscribeGeohash( + geohash = geohash, + sinceMs = System.currentTimeMillis() - 3600000L, + limit = 200, + id = subId, + handler = { event -> geohashMessageHandler.onEvent(event, geohash) } + ) + val dmIdentity = NostrIdentityBridge.deriveIdentity(geohash, getApplication()) + val dmSubId = "geo-dm-$geohash"; currentDmSubId = dmSubId + subscriptionManager.subscribeGiftWraps( + pubkey = dmIdentity.publicKeyHex, + sinceMs = System.currentTimeMillis() - 172800000L, + id = dmSubId, + handler = { event -> dmHandler.onGiftWrap(event, geohash, dmIdentity) } + ) + // Also register alias in global registry for routing convenience + com.bitchat.android.nostr.GeohashAliasRegistry.put("nostr_${dmIdentity.publicKeyHex.take(16)}", dmIdentity.publicKeyHex) + } + } + null -> { + Log.d(TAG, "📡 No channel selected") + repo.setCurrentGeohash(null) + repo.refreshGeohashPeople() + } + } + } + + private fun startGeoParticipantsTimer() { + geoTimer = viewModelScope.launch { + while (repo.getCurrentGeohash() != null) { + delay(30000) + repo.refreshGeohashPeople() + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt index 57d755d6..ea7fff68 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshDelegateHandler.kt @@ -107,16 +107,21 @@ class MeshDelegateHandler( meshNoiseKeyForPeer = { pid -> getPeerInfo(pid)?.noisePublicKey }, meshHasPeer = { pid -> peers.contains(pid) }, nostrPubHexForAlias = { alias -> - // Best-effort: derive pub hex from favorites mapping - val prefix = alias.removePrefix("nostr_") - val favs = try { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() } catch (_: Exception) { emptyList() } - favs.firstNotNullOfOrNull { rel -> - rel.peerNostrPublicKey?.let { s -> - runCatching { com.bitchat.android.nostr.Bech32.decode(s) }.getOrNull()?.let { dec -> - if (dec.first == "npub") dec.second.joinToString("") { b -> "%02x".format(b) } else null + // Use GeohashAliasRegistry for geohash aliases, but for mesh favorites, derive from favorites mapping + if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(alias)) { + com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) + } else { + // Best-effort: derive pub hex from favorites mapping for mesh nostr_ aliases + val prefix = alias.removePrefix("nostr_") + val favs = try { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() } catch (_: Exception) { emptyList() } + favs.firstNotNullOfOrNull { rel -> + rel.peerNostrPublicKey?.let { s -> + runCatching { com.bitchat.android.nostr.Bech32.decode(s) }.getOrNull()?.let { dec -> + if (dec.first == "npub") dec.second.joinToString("") { b -> "%02x".format(b) } else null + } } - } - }?.takeIf { it.startsWith(prefix, ignoreCase = true) } + }?.takeIf { it.startsWith(prefix, ignoreCase = true) } + } }, findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) } ) diff --git a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt index 3d849ac1..e8a3a461 100644 --- a/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/PrivateChatManager.kt @@ -3,6 +3,8 @@ package com.bitchat.android.ui import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryStatus import com.bitchat.android.mesh.PeerFingerprintManager +import java.security.MessageDigest + import com.bitchat.android.mesh.BluetoothMeshService import java.util.* import android.util.Log @@ -123,21 +125,40 @@ class PrivateChatManager( } fun toggleFavorite(peerID: String) { - val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) ?: return + var fingerprint = fingerprintManager.getFingerprintForPeer(peerID) + + // Fallback: if this looks like a 64-hex Noise public key (offline favorite entry), + // compute a synthetic fingerprint (SHA-256 of public key) to allow unfollowing offline peers + if (fingerprint == null && peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) { + try { + val pubBytes = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + val digest = java.security.MessageDigest.getInstance("SHA-256") + val fpBytes = digest.digest(pubBytes) + fingerprint = fpBytes.joinToString("") { "%02x".format(it) } + Log.d(TAG, "Computed fingerprint from noise key hex for offline toggle: $fingerprint") + } catch (e: Exception) { + Log.w(TAG, "Failed to compute fingerprint from noise key hex: ${e.message}") + } + } + + if (fingerprint == null) { + Log.w(TAG, "toggleFavorite: no fingerprint for peerID=$peerID; ignoring toggle") + return + } Log.d(TAG, "toggleFavorite called for peerID: $peerID, fingerprint: $fingerprint") - val wasFavorite = dataManager.isFavorite(fingerprint) + val wasFavorite = dataManager.isFavorite(fingerprint!!) Log.d(TAG, "Current favorite status: $wasFavorite") val currentFavorites = state.getFavoritePeersValue() Log.d(TAG, "Current UI state favorites: $currentFavorites") if (wasFavorite) { - dataManager.removeFavorite(fingerprint) + dataManager.removeFavorite(fingerprint!!) Log.d(TAG, "Removed from favorites: $fingerprint") } else { - dataManager.addFavorite(fingerprint) + dataManager.addFavorite(fingerprint!!) Log.d(TAG, "Added to favorites: $fingerprint") } @@ -149,6 +170,7 @@ class PrivateChatManager( Log.d(TAG, "All peer fingerprints: ${fingerprintManager.getAllPeerFingerprints()}") } + fun isFavorite(peerID: String): Boolean { val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) ?: return false val isFav = dataManager.isFavorite(fingerprint) diff --git a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt index 502e63cb..973a817b 100644 --- a/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt @@ -413,6 +413,22 @@ fun PeopleSection( val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) } if (isMappedToConnected) return@forEach + // Resolve potential Nostr conversation key for this favorite (for unread detection) + val nostrConvKey: String? = try { + val npubOrHex = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey) + if (npubOrHex != null) { + val hex = if (npubOrHex.startsWith("npub")) { + val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npubOrHex) + if (hrp == "npub") data.joinToString("") { "%02x".format(it) } else null + } else { + npubOrHex.lowercase() + } + hex?.let { "nostr_${it.take(16)}" } + } else null + } catch (_: Exception) { null } + + val hasUnread = hasUnreadPrivateMessages.contains(favPeerID) || (nostrConvKey != null && hasUnreadPrivateMessages.contains(nostrConvKey)) + // If user clicks an offline favorite and the mapped peer is currently connected under a different ID, // open chat with the connected peerID instead of the noise hex for a seamless window val mappedConnectedPeerID = noiseHexByPeerID.entries.firstOrNull { it.value.equals(favPeerID, ignoreCase = true) }?.key @@ -420,13 +436,20 @@ fun PeopleSection( val (bName, _) = com.bitchat.android.ui.splitSuffix(dn) val showHash = (baseNameCounts[bName] ?: 0) > 1 + // Compute unreadCount from either noise conversation or Nostr conversation + val unreadCount = ( + privateChats[favPeerID]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(favPeerID) } ?: 0 + ) + ( + if (nostrConvKey != null) privateChats[nostrConvKey]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(nostrConvKey) } ?: 0 else 0 + ) + PeerItem( peerID = favPeerID, displayName = dn, isDirect = false, isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer, isFavorite = true, - hasUnreadDM = hasUnreadPrivateMessages.contains(favPeerID), + hasUnreadDM = hasUnread, colorScheme = colorScheme, viewModel = viewModel, onItemClick = { onPrivateChatStart(mappedConnectedPeerID ?: favPeerID) }, @@ -434,21 +457,23 @@ fun PeopleSection( Log.d("SidebarComponents", "Sidebar toggle favorite (offline): peerID=$favPeerID") viewModel.toggleFavorite(favPeerID) }, - unreadCount = privateChats[favPeerID]?.count { msg -> - msg.sender != nickname && hasUnreadPrivateMessages.contains(favPeerID) - } ?: if (hasUnreadPrivateMessages.contains(favPeerID)) 1 else 0, + unreadCount = if (unreadCount > 0) unreadCount else if (hasUnread) 1 else 0, showNostrGlobe = (fav.isMutual && fav.peerNostrPublicKey != null), showHashSuffix = showHash ) appendedOfflineIds.add(favPeerID) } - // Also show any incoming Nostr chats that exist locally but are not in connected peers or favorites yet - // This ensures a user can open and read Nostr messages while the sender remains offline + // NOTE: Do NOT append Nostr-only (nostr_*) conversations to the mesh people list. + // Geohash DMs should appear in the GeohashPeople list for the active geohash, not in mesh offline contacts. + // We intentionally remove previously-added behavior that mixed geohash DMs into mesh sidebar. + // If you need to surface non-geohash offline mesh conversations in the future, do it here for 64-hex noise IDs only. + /* val alreadyShownIds = connectedIds + appendedOfflineIds privateChats.keys .filter { key -> - (key.startsWith("nostr_") || hex64Regex.matches(key)) && + // Only include 64-hex noise IDs (mesh identities); exclude any nostr_* aliases + hex64Regex.matches(key) && !alreadyShownIds.contains(key) && // Skip if this key maps to a connected peer via noiseHex mapping !noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) } @@ -460,24 +485,27 @@ fun PeopleSection( val (bName, _) = com.bitchat.android.ui.splitSuffix(dn) val showHash = (baseNameCounts[bName] ?: 0) > 1 - PeerItem( - peerID = convKey, - displayName = dn, - isDirect = false, - isSelected = convKey == selectedPrivatePeer, - isFavorite = false, - hasUnreadDM = hasUnreadPrivateMessages.contains(convKey), - colorScheme = colorScheme, - viewModel = viewModel, + PeerItem( + peerID = convKey, + displayName = dn, + isDirect = false, + isSelected = convKey == selectedPrivatePeer, + isFavorite = false, + hasUnreadDM = hasUnreadPrivateMessages.contains(convKey), + colorScheme = colorScheme, + viewModel = viewModel, onItemClick = { onPrivateChatStart(convKey) }, onToggleFavorite = { viewModel.toggleFavorite(convKey) }, unreadCount = privateChats[convKey]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(convKey) } ?: if (hasUnreadPrivateMessages.contains(convKey)) 1 else 0, - showNostrGlobe = true, + showNostrGlobe = false, showHashSuffix = showHash ) } + */ + // End intentional removal + } } @@ -538,6 +566,15 @@ private fun PeerItem( modifier = Modifier.size(16.dp), tint = Color(0xFF9C27B0) // Purple ) + } else if (!isDirect && isFavorite) { + // Offline favorited user: show outlined circle icon + Icon( + //painter = androidx.compose.ui.res.painterResource(id = R.drawable.ic_offline_favorite), + imageVector = Icons.Outlined.Circle, + contentDescription = "Offline favorite", + modifier = Modifier.size(16.dp), + tint = Color.Gray + ) } else { Icon( imageVector = if (isDirect) Icons.Outlined.SettingsInputAntenna else Icons.Filled.Route, diff --git a/docs/ANNOUNCEMENT_GOSSIP.md b/docs/ANNOUNCEMENT_GOSSIP.md new file mode 100644 index 00000000..61d8f398 --- /dev/null +++ b/docs/ANNOUNCEMENT_GOSSIP.md @@ -0,0 +1,83 @@ +# Announcement Gossip TLV (Direct Neighbors) + +This document specifies an optional TLV extension to the BitChat `ANNOUNCE` message that allows peers to gossip which other peers they are currently connected to directly over Bluetooth. Implementations can use this to build a mesh topology view (nodes = peers, edges = direct connections). + +Status: optional and backward-compatible. + +## Layering Overview + +- Outer packet: BitChat binary packet with `type = 0x01` (ANNOUNCE). Header is unchanged. +- Payload: A sequence of TLVs. Unknown TLVs MUST be ignored for forward compatibility. +- Signature: The packet MAY be signed using the Ed25519 public key carried in TLV `0x03`. The gossip TLV (if present) is part of the payload and therefore covered by the signature. + +## TLV Format + +Each TLV uses a compact layout: + +- `type`: 1 byte +- `length`: 1 byte (0..255) +- `value`: `length` bytes + +Existing TLVs (unchanged): + +- `0x01` NICKNAME: UTF‑8 string (≤ 255 bytes) +- `0x02` NOISE_PUBLIC_KEY: Noise static public key bytes (typically 32 bytes for X25519) +- `0x03` SIGNING_PUBLIC_KEY: Ed25519 public key bytes (typically 32 bytes) + +New TLV (optional): + +- `0x04` DIRECT_NEIGHBORS: Concatenation of up to 10 peer IDs, each encoded as exactly 8 bytes. There is no inner count; the number of neighbors is `length / 8`. If `length` is not a multiple of 8, trailing partial bytes MUST be ignored. + +### Peer ID Binary Encoding (8 bytes) + +Peer IDs are represented as 8 raw bytes (16 hex chars) in “network order” (left‑to‑right): + +- Take the peer ID hex string, lowercase/truncate to at most 16 hex chars. +- Convert each 2 hex chars to 1 byte from left to right. +- If fewer than 16 hex chars are available, pad the remaining bytes with `0x00` at the end to reach 8 bytes. + +This matches the on‑wire 8‑byte `senderID`/`recipientID` encoding used in the BitChat packet header. + +## Sender Behavior + +- Build the base announcement payload by emitting TLVs `0x01`..`0x03` as usual. +- Optionally append TLV `0x04` with up to 10 unique, directly connected peer IDs. + - Remove duplicates before encoding. + - Order is arbitrary and not semantically significant. +- Sign the ANNOUNCE packet so the gossip TLV is covered (recommended): + - Signature algorithm: Ed25519 using the key in TLV `0x03`. + - Signature input: the binary packet encoding with the signature field omitted and the TTL normalized to `0`. This allows TTL to change during relays without invalidating the signature. +- The payload may be compressed per the base protocol; the gossip TLV is encoded prior to optional compression. + +## Receiver Behavior + +- Decompress payload if the packet’s compression flag is set, then parse TLVs in order. +- Parse TLVs `0x01`..`0x03` as usual; ignore any unknown TLVs. +- If a `0x04` TLV is present: + - Interpret the value as `N = length / 8` peer IDs (ignore trailing non‑aligned bytes). + - Each 8‑byte chunk is decoded back to a 16‑hex‑char peer ID string (lowercase). + - De‑duplicate neighbors. +- Topology maintenance guidance (optional, but recommended for consistent behavior): + - Maintain, for each announcing peer A, the last announcement timestamp and the neighbor list from TLV `0x04`. + - When a newer announcement from A arrives (use the 8‑byte unsigned `timestamp` in the BitChat packet header), replace A’s previously recorded neighbor list with the new one. If older or equal, ignore the neighbor update. + - Treat the neighbor list as a set of undirected edges `{A, B}` in your topology visualization; i.e., if A reports direct peers `[B, C]`, add edges A–B and A–C. + +## Limits and Compatibility + +- Max neighbors per TLV: 10. Senders MAY send fewer; receivers MUST accept any number `N ≥ 0` implied by `length / 8` up to the received `length`. +- Omission: If the TLV `0x04` is omitted, the announce remains valid. Peers can still chat and interoperate normally; the topology graph will just not include edges reported by that peer (other peers that include A in their neighbor lists can still introduce edges to A). +- Unknown TLVs MUST be ignored. This makes the extension safe for older implementations. + +## Minimal Example (conceptual) + +ANNOUNCE payload TLVs (concatenated): + +- `01 [len=N] [UTF‑8 nickname]` +- `02 [len=32] [32 bytes X25519 pubkey]` +- `03 [len=32] [32 bytes Ed25519 pubkey]` +- `04 [len=8*M] [peerID1(8) || peerID2(8) || ... || peerIDM(8)]` (optional) + +Where each `peerIDk(8)` is the 8‑byte binary form of the peer ID as specified above. + +That’s the entire change; the outer packet header, message type, and relay/TTL behavior are unchanged. + diff --git a/docs/SOURCE_ROUTING.md b/docs/SOURCE_ROUTING.md new file mode 100644 index 00000000..f6d101e2 --- /dev/null +++ b/docs/SOURCE_ROUTING.md @@ -0,0 +1,78 @@ +# Source-Based Routing for BitChat Packets + +This document specifies an optional source-based routing extension to the BitChat packet format. A sender may attach a hop-by-hop route (list of peer IDs) to instruct relays on the intended path. Relays that support this feature will try to forward to the next hop directly; otherwise, they fall back to regular broadcast relaying. + +Status: optional and backward-compatible. + +## Layering Overview + +- Outer packet: BitChat binary packet with unchanged fixed header (version/type/ttl/timestamp/flags/payloadLength). +- Flags: adds a new bit `HAS_ROUTE (0x08)`. +- Variable sections (when present, in order): + 1) `SenderID` (8 bytes) + 2) `RecipientID` (8 bytes) if `HAS_RECIPIENT` + 3) `Route` (if `HAS_ROUTE`): `count` (1 byte) + `count * 8` bytes hop IDs + 4) `Payload` (with optional compression preamble) + 5) `Signature` (64 bytes) if `HAS_SIGNATURE` + +Unknown flags are ignored by older implementations (they will simply not see a route and continue broadcasting as before). + +## Route Field Encoding + +- Presence: Signaled by the `HAS_ROUTE (0x08)` bit in `flags`. +- Layout (immediately after optional `RecipientID`): + - `count`: 1 byte (0..255) + - `hops`: concatenation of `count` peer IDs, each encoded as exactly 8 bytes +- Peer ID encoding (8 bytes): same as used elsewhere in BitChat (16 hex chars → 8 bytes; left-to-right conversion; pad with `0x00` if shorter). This matches the on‑wire `senderID`/`recipientID` encoding. +- Size impact: `1 + 8*N` bytes, where `N = count`. +- Empty route: `HAS_ROUTE` with `count = 0` is treated as no route (relays ignore it). + +## Sender Behavior + +- Applicability: Intended for addressed packets (i.e., where `recipientID` is set and is not the broadcast ID). For broadcast packets, omit the route. +- Path computation: Use Dijkstra’s shortest path (unit weights) on your internal mesh topology to find a route from `src` (your peerID) to `dst` (recipient peerID). The hop list SHOULD include the full path `[src, ..., dst]`. +- Encoding: Set `HAS_ROUTE`, write `count = path.length`, then the 8‑byte hop IDs in order. Keep `count <= 255`. +- Signing: The route is covered by the Ed25519 signature (recommended): + - Signature input is the canonical encoding with `signature` omitted and `ttl = 0` (TTL excluded to allow relay decrement) — same rule as base protocol. + +## Relay Behavior + +When receiving a packet that is not addressed to you: + +1) If `HAS_ROUTE` is not set, or the route is empty, relay using your normal broadcast logic (subject to TTL/probability policies). +2) If `HAS_ROUTE` is set and your peer ID appears at index `i` in the hop list: + - If there is a next hop at `i+1`, attempt a targeted unicast to that next hop if you have a direct connection to it. + - If successful, do NOT broadcast this packet further. + - If not directly connected (or the send fails), fall back to broadcast relaying. + - If you are the last hop (no `i+1`), proceed with standard handling (e.g., if not addressed to you, do not relay further). + +TTL handling remains unchanged: relays decrement TTL by 1 before forwarding (whether targeted or broadcast). If TTL reaches 0, do not relay. + +## Receiver Behavior (Destination) + +- This extension does not change how addressed packets are handled by the final recipient. If the packet is addressed to you (`recipientID == myPeerID`), process it normally (e.g., decrypt Noise payload, verify signatures, etc.). +- Signature verification MUST include the route field when present; route tampering will invalidate the signature. + +## Compatibility + +- Omission: If `HAS_ROUTE` is omitted, legacy behavior applies. Relays that don’t implement this feature will ignore the route entirely, because they won’t set or check `HAS_ROUTE`. +- Partial support: If any relay on the path cannot directly reach the next hop, it will fall back to broadcast relaying; delivery is still probabilistic like the base protocol. + +## Minimal Example (conceptual) + +- Header (fixed 13 bytes): unchanged. +- Variable sections (ordered): + - `SenderID(8)` + - `RecipientID(8)` (if present) + - `HAS_ROUTE` set → `count=3`, `hops = [H0 H1 H2]` where each `Hk` is 8 bytes + - Payload (optionally compressed) + - Signature (64) + +Where `H0` is the sender’s peer ID, `H2` is the recipient’s peer ID, and `H1` is an intermediate relay. The receiver verifies the signature over the packet encoding (with `ttl = 0` and `signature` omitted), which includes the `hops` when `HAS_ROUTE` is set. + +## Operational Notes + +- Routing optimality depends on the freshness and completeness of the topology your implementation has learned (e.g., via gossip of direct neighbors). Recompute routes as needed. +- Route length should be kept small to reduce overhead and the probability of missing a direct link at some hop. +- Implementations may introduce policy controls (e.g., disable source routing, cap max route length). +