mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 23:45:19 +00:00
* feat(debug): add DebugSettingsManager + DebugSettingsSheet scaffold and AboutSheet entry; groundwork for verbose logging, GATT controls, relay stats, device/scan views * fix(debug): wire debug sheet launch via onShowDebug in AboutSheet from ChatScreen; add public start/stop server/client methods} * fix(about): remove misplaced debug item block inside PoW section; keep debug launcher managed by ChatScreen only * feat(debug): wire DebugSettingsSheet role switches to explicit startServer/stopServer and startClient/stopClient; expose role controls + disconnectAll on BluetoothConnectionManager; fix syntax error; build passes * feat(debug): add connect/disconnect helpers; wire DebugSettingsSheet connect/disconnect actions to BluetoothConnectionManager * feat(debug): add debug settings button at bottom of AboutSheet; wire onShowDebug callback to open DebugSettingsSheet from ChatScreen * feat(debug): wire verbose logging into chat view via DebugSettingsManager; add rolling relay stats; push connected devices and scan results; ensure GATT role stop closes connections; log incoming packets and relay events; add UI polling for devices; build passes * chore(debug-branch): remove unrelated files that were mistakenly added in first commit; keep only intended debug settings changes * fix(chat): prevent mesh→geohash leak Root cause: - DebugSettingsManager→chat bridge appended system logs to the global timeline regardless of active channel - MeshDelegateHandler added public mesh messages to UI unconditionally Fixes: - Only inject debug system messages when selected location channel is Mesh - Only add public mesh messages to UI when Mesh is selected (still send notifications) Build: ./gradlew assembleDebug } * fix(timeline): restore message persistence across channel switches Root cause: - NostrGeohashService.switchLocationChannel() called messageManager.clearMessages() which wiped ALL messages (mesh + debug + geohash) from main timeline - Geohash events were adding to main timeline, causing cross-contamination Fixes: - Remove messageManager.clearMessages() from channel switching - ChatScreen displayMessages now routes to separate storage: - Mesh: messages (main timeline, includes debug logs when Mesh selected) - Geohash: viewModel.getGeohashMessages(geohash) from separate history - Private: privateChats[peerID] - Channels: channelMessages[channel] - Geohash events no longer add to main timeline, only to geohash history - Mesh messages always stored to preserve history when switching away Result: Each chat type has persistent separate storage, no message loss * Debug/relay logs: use MessageType names; include device route in verbose packet logs. * Verbose device-peer assignment and connection/disconnection logs with peerId, deviceId, nickname; packet relay log uses MessageType names.} * Debug logs API: include nickname and deviceId for incoming/relay; update callers.} * Fix compile: import MessageType; correct debug manager API usage and remove stray insertion; build.} * Respect relay toggle; helper to log relay with deviceId.} * PacketRelayManager: add relay toggle and unified logging helper; clean file header comments; build fixes.} * changes * Geohash local echo: do not add to mesh timeline; rely on geohash history and ChatScreen display for location channels. * persisting * persist debug settings * gitignore * gatt server / client controls * debugger * max connection settings and graph * more graph * better logging * refactor logging
1842 lines
80 KiB
Kotlin
1842 lines
80 KiB
Kotlin
|
|
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<String>()
|
|
private val processedNostrEventOrder = mutableListOf<String>()
|
|
private val maxProcessedNostrEvents = 2000
|
|
// removed unused processedNostrAcks
|
|
private val nostrKeyMapping = mutableMapOf<String, String>() // senderPeerID -> nostrPubkey
|
|
|
|
// MARK: - Geohash Participant Tracking Properties
|
|
|
|
private val geohashParticipants = mutableMapOf<String, MutableMap<String, Date>>() // geohash -> participantId -> lastSeen
|
|
private var geohashSamplingJob: Job? = null
|
|
private var geoParticipantsTimer: Job? = null
|
|
|
|
// MARK: - Geohash Message History Properties
|
|
|
|
private val geohashMessageHistory = mutableMapOf<String, MutableList<BitchatMessage>>() // geohash -> messages
|
|
private val maxGeohashMessages = 1000 // Maximum messages per geohash
|
|
|
|
// 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<String, String> = 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()
|
|
geohashMessageHistory.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
|
|
)
|
|
|
|
// 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)
|
|
|
|
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<String> = 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<String>) {
|
|
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
|
|
}
|
|
}
|
|
|
|
// 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<BitchatMessage> {
|
|
return geohashMessageHistory[geohash]?.toList() ?: emptyList()
|
|
}
|
|
|
|
/**
|
|
* Clear geohash message history
|
|
*/
|
|
fun clearGeohashMessageHistory() {
|
|
geohashMessageHistory.clear()
|
|
}
|
|
|
|
// 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<String>) {
|
|
// 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<String, Int>()
|
|
|
|
// 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<String, String> {
|
|
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 {
|
|
// NOTE: Don't clear messages here - let ChatScreen's displayMessages logic handle what to show
|
|
// This preserves mesh message 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
|
|
// Note: Don't load messages here - ChatScreen will get them via getGeohashMessages()
|
|
|
|
// 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
|
|
)
|
|
|
|
// 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()
|
|
|
|
// 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 {
|
|
// Get the message history for this geohash
|
|
val messageHistory = geohashMessageHistory[geohash] ?: return true
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
}
|