diff --git a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt index a6927d71..620fb174 100644 --- a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt @@ -19,7 +19,7 @@ class LocationNotesManager private constructor() { companion object { private const val TAG = "LocationNotesManager" private const val MAX_NOTES_IN_MEMORY = 500 - + private const val DELETIONS_THROTTLE_MS = 500L @Volatile private var INSTANCE: LocationNotesManager? = null @@ -69,6 +69,9 @@ class LocationNotesManager private constructor() { // Published state (StateFlow for Android) private val _notes = MutableStateFlow>(emptyList()) val notes: StateFlow> = _notes.asStateFlow() + + private val _localPubkey = MutableStateFlow(null) + val localPubkey: StateFlow = _localPubkey.asStateFlow() private val _geohash = MutableStateFlow(null) val geohash: StateFlow = _geohash.asStateFlow() @@ -96,6 +99,7 @@ class LocationNotesManager private constructor() { // Coroutine scope for background operations private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private var lastDeletionsSubscribeTime = 0L private var liveLocationToken: Long? = null private var subscribeRetryJob: Job? = null private var initialLoadJob: Job? = null @@ -103,7 +107,7 @@ class LocationNotesManager private constructor() { init { LiveLocationPrivacyGate.addRevocationListener(::stop) } - + /** * Initialize dependencies */ @@ -159,6 +163,14 @@ class LocationNotesManager private constructor() { noteIDs.clear() _geohash.value = normalized + // Derive and cache local pubkey for ownership checks + scope.launch { + runCatching { + val identity = withContext(Dispatchers.IO) { deriveIdentityFunc?.invoke(normalized) } + if (identity != null) _localPubkey.value = identity.publicKeyHex + } + } + // Compute target geohashes: center + neighbors (±1) val neighbors = try { com.bitchat.android.geohash.Geohash.neighborsSamePrecision(normalized) @@ -320,6 +332,75 @@ class LocationNotesManager private constructor() { } } + /** + * Delete a location note by sending a NIP-09 kind:5 deletion event to relays. + * Removes the note locally immediately for optimistic UI, then broadcasts the + * deletion event. Only notes authored by the local user can be deleted. + */ + fun deleteNote(noteId: String) { + val token = LiveLocationPrivacyGate.captureToken() ?: run { + stop() + return + } + val currentGeohash = _geohash.value ?: run { + Log.w(TAG, "Cannot delete note - no geohash set") + return + } + val targetNote = _notes.value.firstOrNull { it.id == noteId } ?: run { + Log.w(TAG, "Cannot delete note - note not found: ${noteId.take(16)}") + return + } + val deriveIdentity = deriveIdentityFunc ?: run { + Log.e(TAG, "Cannot delete note - deriveIdentity not initialized") + return + } + var relays: List = emptyList() + try { + LiveLocationPrivacyGate.runIfAllowed(token) { + relays = RelayDirectory.closestRelaysForGeohash(currentGeohash, 5) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to lookup relays for location-note deletion") + } + if (!LiveLocationPrivacyGate.accepts(token)) { + stop() + return + } + + scope.launch { + try { + val identity = withContext(Dispatchers.IO) { deriveIdentity(currentGeohash) } + if (targetNote.pubkey != identity.publicKeyHex) { + Log.w(TAG, "Blocked delete for non-owned note: ${noteId.take(16)}") + return@launch + } + + val deletionEvent = withContext(Dispatchers.IO) { + NostrProtocol.createDeletionEvent( + targetEventId = noteId, + senderIdentity = identity + ) + } + if (!LiveLocationPrivacyGate.accepts(token)) return@launch + + // Optimistic local removal + _notes.value = _notes.value.filter { it.id != noteId } + noteIDs.remove(noteId) + + // Broadcast to geo relays + withContext(Dispatchers.IO) { + LiveLocationPrivacyGate.runIfAllowed(token) { + sendEventFunc?.invoke(deletionEvent, relays, token) + } + } + + Log.d(TAG, "✅ Note deleted: ${noteId.take(16)}...") + } catch (e: Exception) { + Log.e(TAG, "Failed to delete note: ${e.message}") + } + } + } + /** * Subscribe to location notes for current geohash */ @@ -371,7 +452,10 @@ class LocationNotesManager private constructor() { _state.value = State.LOADING - // Subscribe for each geohash in the ±1 set + // Subscribe for each geohash in the ±1 set — kind:1 only. + // kind:5 deletion events carry an #e tag (referencing the deleted event ID) but + // NOT a #g tag, so they would always fail this filter's matches() check. + // A separate deletion subscription is opened after initial notes load (below). subscribedGeohashes.forEach { gh -> if (!LiveLocationPrivacyGate.accepts(token)) return val filter = NostrFilter.geohashNotes( @@ -390,8 +474,9 @@ class LocationNotesManager private constructor() { Log.e(TAG, "Failed to subscribe to location notes") } } - - // Mark initial load complete after brief delay to allow relay responses + + // Mark initial load complete after brief delay to allow relay responses, + // then open a kind:5 subscription filtered by the IDs of the notes we loaded. initialLoadJob = scope.launch { delay(2000) // Wait 2 seconds for initial batch if (_geohash.value == currentGeohash && @@ -401,9 +486,43 @@ class LocationNotesManager private constructor() { _initialLoadComplete.value = true _state.value = State.READY } + subscribeDeletions() } } + /** + * Open (or refresh) a kind:5 subscription covering the notes currently in memory. + * + * NIP-09 deletion events do not carry a #g (geohash) tag — they only reference the + * target event via an #e tag — so we cannot reuse the geohash-scoped subscription. + * Instead we build a filter keyed on the #e values of every note we already hold. + * + * This is called once after initial note load and re-called whenever new notes arrive + * (see [handleEvent]), so deletions published after the initial batch are also caught. + */ + private fun subscribeDeletions() { + val subscribe = subscribeFunc ?: return + val ids = noteIDs.toList() + if (ids.isEmpty()) return + + // Cancel any previous deletion subscription before re-subscribing with updated IDs. + subscriptionIDs["__deletions__"]?.let { + try { unsubscribeFunc?.invoke(it) } catch (_: Exception) {} + } + + val filter = NostrFilter( + kinds = listOf(NostrKind.DELETION), + tagFilters = mapOf("e" to ids) + ) + try { + val id = subscribe(filter, "location-deletions") { event -> handleEvent(event) } + subscriptionIDs["__deletions__"] = id + Log.d(TAG, "📡 Subscribed to kind:5 deletions for ${ids.size} note(s)") + } catch (e: Exception) { + Log.e(TAG, "Failed to subscribe for deletions: ${e.message}") + } + } + /** * Handle incoming event from subscription */ @@ -411,7 +530,31 @@ class LocationNotesManager private constructor() { val token = liveLocationToken if (token == null || !LiveLocationPrivacyGate.accepts(token)) return - // Validate event + // Handle NIP-09 deletion events: remove notes authored by the sender. + if (event.kind == NostrKind.DELETION) { + // Verify the Schnorr signature before trusting event.pubkey. + // Without this check any relay or client could forge a kind:5 event with + // someone else's pubkey and silently hide that user's notes. + if (!event.isValidSignature()) { + Log.w(TAG, "Ignoring kind:5 with invalid signature from ${event.pubkey.take(8)}") + return + } + val targetIds = event.tags + .filter { it.size >= 2 && it[0] == "e" } + .map { it[1] }.toSet() + if (targetIds.isNotEmpty()) { + val before = _notes.value + val removed = before.filter { it.id in targetIds && it.pubkey == event.pubkey } + if (removed.isNotEmpty()) { + _notes.value = before.filter { it.id !in targetIds || it.pubkey != event.pubkey } + removed.forEach { noteIDs.remove(it.id) } + Log.d(TAG, "🗑️ Removed ${removed.size} note(s) via kind:5 from ${event.pubkey.take(8)}") + } + } + return + } + + // Validate event — only TEXT_NOTE beyond this point if (event.kind != NostrKind.TEXT_NOTE) { Log.v(TAG, "Ignoring non-text-note event: kind=${event.kind}") return @@ -452,19 +595,34 @@ class LocationNotesManager private constructor() { noteIDs.add(event.id) val currentNotes = _notes.value ?: emptyList() _notes.value = (currentNotes + note).sortedByDescending { it.createdAt } - + + Log.d(TAG, "📥 Added note: ${note.displayName} - ${note.content.take(50)}") + // Trim if exceeds max if (noteIDs.size > MAX_NOTES_IN_MEMORY) { trimOldestNotes() } - + + // Refresh the kind:5 deletion subscription to include this newly arrived note, + // so deletions published after initial load are also streamed in real-time. + maybeResubscribeDeletions() + // Update state if (!_initialLoadComplete.value!!) { _initialLoadComplete.value = true } _state.value = State.READY } - + + + private fun maybeResubscribeDeletions() { + val now = System.currentTimeMillis() + if (now - lastDeletionsSubscribeTime > DELETIONS_THROTTLE_MS) { + lastDeletionsSubscribeTime = now + subscribeDeletions() + } + } + /** * Trim oldest notes to stay within memory limit */ diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt b/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt index 92752b17..399930b7 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt @@ -210,6 +210,7 @@ data class NostrEvent( object NostrKind { const val METADATA = 0 const val TEXT_NOTE = 1 + const val DELETION = 5 // NIP-09 event deletion request const val DIRECT_MESSAGE = 14 // NIP-17 direct message (unsigned) const val FILE_MESSAGE = 15 // NIP-17 file message (unsigned) const val SEAL = 13 // NIP-17 sealed event diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt index 4376d1e9..9d6dc178 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt @@ -128,6 +128,24 @@ object NostrProtocol { return@withContext senderIdentity.signEvent(event) } + /** + * Create a NIP-09 deletion event (kind 5) for a given note + * Signals to relays and other clients that the note should be removed + */ + suspend fun createDeletionEvent( + targetEventId: String, + senderIdentity: NostrIdentity + ): NostrEvent = withContext(Dispatchers.Default) { + val event = NostrEvent( + pubkey = senderIdentity.publicKeyHex, + createdAt = (System.currentTimeMillis() / 1000).toInt(), + kind = NostrKind.DELETION, + tags = listOf(listOf("e", targetEventId)), + content = "" + ) + return@withContext senderIdentity.signEvent(event) + } + /** * Create a geohash-scoped presence event (kind 20001) * Has no content and no nickname, used for participant counting diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt index f3c49480..2d0dacfe 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt @@ -5,6 +5,7 @@ import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -16,22 +17,26 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource import com.bitchat.android.ui.theme.BitchatFontFamily -import com.bitchat.android.R import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.R import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet -import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle +import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar import com.bitchat.android.geohash.GeohashChannelLevel import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.nostr.LocationNotesManager import com.bitchat.android.nostr.NearbyNotesController +import com.bitchat.android.ui.theme.BASE_FONT_SIZE import java.text.SimpleDateFormat import java.util.* import java.util.Calendar @@ -47,17 +52,20 @@ fun LocationNotesSheet( locationName: String?, nickname: String?, onDismiss: () -> Unit, - modifier: Modifier = Modifier + onNoteLongClick: (LocationNotesManager.Note) -> Unit = {}, + modifier: Modifier = Modifier, ) { val context = LocalContext.current val colorScheme = MaterialTheme.colorScheme val accentGreen = colorScheme.primary - + + // Managers val notesManager = remember { LocationNotesManager.getInstance() } val locationManager = remember { LocationChannelManager.getInstance(context) } val nearbyNotesController = remember { NearbyNotesController.shared } - + + // State val notes by notesManager.notes.collectAsStateWithLifecycle() val state by notesManager.state.collectAsStateWithLifecycle(LocationNotesManager.State.IDLE) @@ -65,19 +73,22 @@ fun LocationNotesSheet( val initialLoadComplete by notesManager.initialLoadComplete.collectAsStateWithLifecycle(false) val permissionState by locationManager.permissionState.collectAsStateWithLifecycle() val locationEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle(false) - + + // SIMPLIFIED: Get count directly from notes list (no separate counter needed) val count = notes.size - + // Get location name (building or block) - matches iOS locationNames lookup val locationNames by locationManager.locationNames.collectAsStateWithLifecycle() - val displayLocationName = locationNames[GeohashChannelLevel.BUILDING]?.takeIf { it.isNotEmpty() } - ?: locationNames[GeohashChannelLevel.BLOCK]?.takeIf { it.isNotEmpty() } - + val displayLocationName = + locationNames[GeohashChannelLevel.BUILDING]?.takeIf { it.isNotEmpty() } + ?: locationNames[GeohashChannelLevel.BLOCK]?.takeIf { it.isNotEmpty() } + // Input field state var draft by remember { mutableStateOf("") } - val sendButtonEnabled = draft.trim().isNotEmpty() && state != LocationNotesManager.State.NO_RELAYS - + val sendButtonEnabled = + draft.trim().isNotEmpty() && state != LocationNotesManager.State.NO_RELAYS + // Scroll state val listState = rememberLazyListState() val isScrolled by remember { @@ -87,7 +98,7 @@ fun LocationNotesSheet( } val topBarAlpha by animateFloatAsState( targetValue = if (isScrolled) 0.95f else 0f, - label = "topBarAlpha" + label = "topBarAlpha", ) // Refresh location when sheet opens @@ -123,8 +134,11 @@ fun LocationNotesSheet( Box(modifier = Modifier.fillMaxWidth()) { LazyColumn( state = listState, - modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), - contentPadding = PaddingValues(top = 64.dp, bottom = 20.dp) + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + contentPadding = PaddingValues(top = 64.dp, bottom = 20.dp), ) { item(key = "notes_header") { LocationNotesHeader( @@ -139,23 +153,29 @@ fun LocationNotesSheet( state == LocationNotesManager.State.NO_RELAYS -> { item { NoRelaysRow( - onRetry = { notesManager.refresh() } + onRetry = { notesManager.refresh() }, ) } } + state == LocationNotesManager.State.LOADING && !initialLoadComplete -> { item { LoadingRow() } } + notes.isEmpty() -> { item { EmptyRow() } } + else -> { items(notes, key = { it.id }) { note -> - NoteRow(note = note) + NoteRow( + note = note, + onLongClick = onNoteLongClick, + ) Spacer(modifier = Modifier.height(24.dp)) } item { @@ -170,7 +190,7 @@ fun LocationNotesSheet( item { ErrorRow( message = error, - onDismiss = { notesManager.clearError() } + onDismiss = { notesManager.clearError() }, ) } } @@ -183,27 +203,29 @@ fun LocationNotesSheet( modifier = Modifier.align(Alignment.TopCenter), title = { BitchatSheetTitle( - text = pluralStringResource( - id = R.plurals.location_notes_title, - count = count, - geohash, - count - ) + text = + pluralStringResource( + id = R.plurals.location_notes_title, + count = count, + geohash, + count, + ), ) - } + }, ) Box( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - ){ + modifier = + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth(), + ) { Column { // Divider before input (matches iOS overlay) HorizontalDivider( modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f), - thickness = 1.dp + thickness = 1.dp, ) // Input section (matches iOS inputSection) @@ -219,7 +241,7 @@ fun LocationNotesSheet( notesManager.send(content, nickname) draft = "" } - } + }, ) } } @@ -238,10 +260,11 @@ private fun LocationNotesHeader( accentGreen: Color, ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 12.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 12.dp), ) { // Location name in green (building or block) locationName?.let { name -> @@ -250,20 +273,20 @@ private fun LocationNotesHeader( text = name, fontFamily = BitchatFontFamily, fontSize = 12.sp, - color = accentGreen + color = accentGreen, ) Spacer(modifier = Modifier.height(8.dp)) } } - + // Description Text( text = stringResource(R.string.location_notes_description), fontFamily = BitchatFontFamily, fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), ) - + // Relays paused message if no relays if (state == LocationNotesManager.State.NO_RELAYS) { Spacer(modifier = Modifier.height(4.dp)) @@ -271,7 +294,7 @@ private fun LocationNotesHeader( text = stringResource(R.string.location_notes_relays_unavailable), fontFamily = BitchatFontFamily, fontSize = 11.sp, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), ) } } @@ -282,28 +305,40 @@ private fun LocationNotesHeader( * Shows @basename then timestamp, then content below */ @Composable -private fun NoteRow(note: LocationNotesManager.Note) { +private fun NoteRow( + note: LocationNotesManager.Note, + onLongClick: (LocationNotesManager.Note) -> Unit, +) { // Extract baseName (before #suffix like iOS) val baseName = note.displayName.split("#", limit = 2).firstOrNull() ?: note.displayName val ts = timestampText(note.createdAt) - + val haptic = LocalHapticFeedback.current + Column( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) + modifier = + Modifier + .fillMaxWidth() + .pointerInput(note.id) { + detectTapGestures( + onLongPress = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onLongClick(note) + }, + ) + }.padding(vertical = 4.dp), ) { // First row: @nickname and timestamp Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Text( text = "@$baseName", fontFamily = BitchatFontFamily, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) if (ts.isNotEmpty()) { Spacer(modifier = Modifier.width(6.dp)) @@ -311,19 +346,19 @@ private fun NoteRow(note: LocationNotesManager.Note) { text = ts, fontFamily = BitchatFontFamily, fontSize = 11.sp, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), ) } } - + Spacer(modifier = Modifier.height(2.dp)) - + // Second row: content Text( text = note.content, fontFamily = BitchatFontFamily, fontSize = 14.sp, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) } } @@ -334,23 +369,24 @@ private fun NoteRow(note: LocationNotesManager.Note) { @Composable private fun NoRelaysRow(onRetry: () -> Unit) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 6.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), ) { Text( text = stringResource(R.string.location_notes_no_relays_title), fontFamily = BitchatFontFamily, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(4.dp)) Text( text = stringResource(R.string.location_notes_no_relays_desc), fontFamily = BitchatFontFamily, fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), ) Spacer(modifier = Modifier.height(4.dp)) Text( @@ -358,7 +394,7 @@ private fun NoRelaysRow(onRetry: () -> Unit) { fontFamily = BitchatFontFamily, fontSize = 12.sp, color = MaterialTheme.colorScheme.primary, - modifier = Modifier.clickable(onClick = onRetry) + modifier = Modifier.clickable(onClick = onRetry), ) } } @@ -369,22 +405,23 @@ private fun NoRelaysRow(onRetry: () -> Unit) { @Composable private fun LoadingRow() { Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { CircularProgressIndicator( modifier = Modifier.size(16.dp), - strokeWidth = 2.dp + strokeWidth = 2.dp, ) Spacer(modifier = Modifier.width(10.dp)) Text( text = stringResource(R.string.loading_location_notes), fontFamily = BitchatFontFamily, fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), ) } } @@ -395,23 +432,24 @@ private fun LoadingRow() { @Composable private fun EmptyRow() { Column( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 6.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), ) { Text( text = stringResource(R.string.location_notes_empty_title), fontFamily = BitchatFontFamily, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(4.dp)) Text( text = stringResource(R.string.location_notes_empty_desc), fontFamily = BitchatFontFamily, fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), ) } } @@ -420,26 +458,30 @@ private fun EmptyRow() { * Error row - matches iOS errorRow */ @Composable -private fun ErrorRow(message: String, onDismiss: () -> Unit) { +private fun ErrorRow( + message: String, + onDismiss: () -> Unit, +) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 6.dp) + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 6.dp), ) { Row( horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Text( text = "⚠", - fontSize = 12.sp + fontSize = 12.sp, ) Spacer(modifier = Modifier.width(6.dp)) Text( text = message, fontFamily = BitchatFontFamily, fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) } Spacer(modifier = Modifier.height(4.dp)) @@ -448,7 +490,7 @@ private fun ErrorRow(message: String, onDismiss: () -> Unit) { fontFamily = BitchatFontFamily, fontSize = 12.sp, color = MaterialTheme.colorScheme.primary, - modifier = Modifier.clickable(onClick = onDismiss) + modifier = Modifier.clickable(onClick = onDismiss), ) } } @@ -495,7 +537,7 @@ private fun LocationNotesInputSection( ) { // Text input with placeholder overlay (matches main chat exactly) Box( - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) { androidx.compose.foundation.text.BasicTextField( value = draft, @@ -513,7 +555,7 @@ private fun LocationNotesInputSection( ), modifier = Modifier.fillMaxWidth() ) - + // Placeholder when empty (matches main chat) if (draft.isEmpty()) { Text( @@ -522,29 +564,31 @@ private fun LocationNotesInputSection( fontFamily = BitchatFontFamily ), color = colorScheme.onSurface.copy(alpha = 0.5f), - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) } } - + // Send button - circular with icon (matches main chat exactly) IconButton( onClick = { if (sendButtonEnabled) onSend() }, enabled = sendButtonEnabled, - modifier = Modifier.size(32.dp) + modifier = Modifier.size(32.dp), ) { Box( - modifier = Modifier - .size(30.dp) - .background( - color = if (!sendButtonEnabled) { - colorScheme.onSurface.copy(alpha = 0.3f) - } else { - accentGreen.copy(alpha = 0.75f) - }, - shape = CircleShape - ), - contentAlignment = Alignment.Center + modifier = + Modifier + .size(30.dp) + .background( + color = + if (!sendButtonEnabled) { + colorScheme.onSurface.copy(alpha = 0.3f) + } else { + accentGreen.copy(alpha = 0.75f) + }, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, ) { Icon( imageVector = Icons.Filled.ArrowUpward, @@ -562,6 +606,118 @@ private fun LocationNotesInputSection( } } +/** + * Note Actions Sheet - shown on long-press of a note + * Reuses BitchatBottomSheet pattern from ChatUserSheet + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun NoteActionsSheet( + note: LocationNotesManager.Note, + onDelete: () -> Unit, + onDismiss: () -> Unit, +) { + val colorScheme = MaterialTheme.colorScheme + val isDark = + colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f + val standardRed = Color(0xFFFF3B30) // iOS red + + BitchatBottomSheet( + onDismissRequest = onDismiss, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Header: note author + val baseName = note.displayName.split("#", limit = 2).firstOrNull() ?: note.displayName + Text( + text = "@$baseName", + fontSize = 18.sp, + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + + // Action list + LazyColumn( + modifier = Modifier.fillMaxWidth(), + ) { + item { + NoteActionRow( + title = stringResource(R.string.action_delete_note_title), + subtitle = stringResource(R.string.action_delete_note_subtitle), + titleColor = standardRed, + onClick = onDelete, + ) + } + } + + // Cancel button + Button( + onClick = onDismiss, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f), + contentColor = MaterialTheme.colorScheme.onSurface, + ), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.cancel_lower), + fontSize = BASE_FONT_SIZE.sp, + fontFamily = BitchatFontFamily, + ) + } + } + } +} + +/** + * Single action row inside NoteActionsSheet + * Matches UserActionRow pattern from ChatUserSheet + */ +@Composable +private fun NoteActionRow( + title: String, + subtitle: String, + titleColor: Color, + onClick: () -> Unit, +) { + // iOS-style list row (plain button, no card background) + Surface( + onClick = onClick, + color = Color.Transparent, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = title, + fontSize = BASE_FONT_SIZE.sp, + fontFamily = BitchatFontFamily, + fontWeight = FontWeight.Medium, + color = titleColor, + ) + Text( + text = subtitle, + fontSize = 12.sp, + fontFamily = BitchatFontFamily, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + ) + } + } +} + /** * Timestamp text - matches iOS timestampText exactly * Shows relative time for < 7 days, absolute date otherwise @@ -569,40 +725,47 @@ private fun LocationNotesInputSection( private fun timestampText(createdAt: Int): String { val date = Date(createdAt * 1000L) val now = Date() - + // Calculate days difference val calendar = Calendar.getInstance() calendar.time = date val dateDay = calendar.get(Calendar.DAY_OF_YEAR) val dateYear = calendar.get(Calendar.YEAR) - + calendar.time = now val nowDay = calendar.get(Calendar.DAY_OF_YEAR) val nowYear = calendar.get(Calendar.YEAR) - - val daysDiff = if (dateYear == nowYear) { - nowDay - dateDay - } else { - // Simplified: just check if less than 7 days by timestamp - val diff = (now.time - date.time) / (1000 * 60 * 60 * 24) - diff.toInt() - } - + + val daysDiff = + if (dateYear == nowYear) { + nowDay - dateDay + } else { + // Simplified: just check if less than 7 days by timestamp + val diff = (now.time - date.time) / (1000 * 60 * 60 * 24) + diff.toInt() + } + return if (daysDiff < 7) { // Relative formatting (abbreviated) val diffMillis = now.time - date.time val diffSeconds = diffMillis / 1000 - + when { - diffSeconds < 60 -> "" // Don't show "just now" in iOS + diffSeconds < 60 -> { + "" + } + + // Don't show "just now" in iOS diffSeconds < 3600 -> { val minutes = (diffSeconds / 60).toInt() "${minutes}m ago" } + diffSeconds < 86400 -> { val hours = (diffSeconds / 3600).toInt() "${hours}h ago" } + else -> { val days = (diffSeconds / 86400).toInt() "${days}d ago" @@ -611,11 +774,12 @@ private fun timestampText(createdAt: Int): String { } else { // Absolute date formatting val sameYear = dateYear == nowYear - val formatter = if (sameYear) { - SimpleDateFormat("MMM d", Locale.getDefault()) - } else { - SimpleDateFormat("MMM d, y", Locale.getDefault()) - } + val formatter = + if (sameYear) { + SimpleDateFormat("MMM d", Locale.getDefault()) + } else { + SimpleDateFormat("MMM d, y", Locale.getDefault()) + } formatter.format(date) } } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt index 9a8cf9e7..cb0476b0 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt @@ -4,19 +4,22 @@ import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitchat.android.R import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet -import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle +import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar import com.bitchat.android.geohash.GeohashChannelLevel import com.bitchat.android.geohash.LocationChannelManager -import com.bitchat.android.R +import com.bitchat.android.nostr.LocationNotesManager /** * Presenter component for LocationNotesSheet @@ -27,29 +30,38 @@ import com.bitchat.android.R @Composable fun LocationNotesSheetPresenter( viewModel: ChatViewModel, - onDismiss: () -> Unit + onDismiss: () -> Unit, ) { val context = LocalContext.current val locationManager = remember { LocationChannelManager.getInstance(context) } + val notesManager = remember { LocationNotesManager.getInstance() } val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle() val permissionState by locationManager.permissionState.collectAsStateWithLifecycle() val isLoadingLocation by locationManager.isLoadingLocation.collectAsStateWithLifecycle() val nickname by viewModel.nickname.collectAsStateWithLifecycle() - + + // Note long-press state — owned here so NoteActionsSheet is a sibling of + // LocationNotesSheet (not nested inside it), which avoids Dialog window conflicts + var selectedNote by remember { mutableStateOf(null) } + val localPubkey by notesManager.localPubkey.collectAsStateWithLifecycle() + // iOS pattern: notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash - val buildingGeohash = availableChannels.firstOrNull { it.level == GeohashChannelLevel.BUILDING }?.geohash - + val buildingGeohash = + availableChannels.firstOrNull { it.level == GeohashChannelLevel.BUILDING }?.geohash + if (buildingGeohash != null) { // Get location name from locationManager val locationNames by locationManager.locationNames.collectAsStateWithLifecycle() - val locationName = locationNames[GeohashChannelLevel.BUILDING] - ?: locationNames[GeohashChannelLevel.BLOCK] - + val locationName = + locationNames[GeohashChannelLevel.BUILDING] + ?: locationNames[GeohashChannelLevel.BLOCK] + LocationNotesSheet( geohash = buildingGeohash, locationName = locationName, nickname = nickname, - onDismiss = onDismiss + onDismiss = onDismiss, + onNoteLongClick = { note -> selectedNote = note }, ) } else if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && isLoadingLocation) { LocationNotesAcquiringSheet(onDismiss = onDismiss) @@ -57,7 +69,19 @@ fun LocationNotesSheetPresenter( // No building geohash available - show error state (matches iOS) LocationNotesErrorSheet( onDismiss = onDismiss, - locationManager = locationManager + locationManager = locationManager, + ) + } + + // Note actions sheet + selectedNote?.takeIf { it.pubkey == localPubkey }?.let { note -> + NoteActionsSheet( + note = note, + onDelete = { + notesManager.deleteNote(note.id) + selectedNote = null + }, + onDismiss = { selectedNote = null }, ) } } @@ -67,33 +91,32 @@ fun LocationNotesSheetPresenter( */ @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun LocationNotesAcquiringSheet( - onDismiss: () -> Unit -) { +private fun LocationNotesAcquiringSheet(onDismiss: () -> Unit) { BitchatBottomSheet( onDismissRequest = onDismiss, ) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally + modifier = + Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = "Acquiring Location", style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(24.dp)) CircularProgressIndicator( modifier = Modifier.size(48.dp), - color = MaterialTheme.colorScheme.primary + color = MaterialTheme.colorScheme.primary, ) Spacer(modifier = Modifier.height(24.dp)) Text( text = "Please wait while your location is being determined", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -106,30 +129,31 @@ private fun LocationNotesAcquiringSheet( @Composable private fun LocationNotesErrorSheet( onDismiss: () -> Unit, - locationManager: LocationChannelManager + locationManager: LocationChannelManager, ) { BitchatBottomSheet( onDismissRequest = onDismiss, ) { Box(modifier = Modifier.fillMaxWidth()) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(top = 80.dp, bottom = 24.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(top = 80.dp, bottom = 24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Text( text = "Location Unavailable", style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(16.dp)) Text( text = "Location permission is required for notes", style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.height(24.dp)) Button(onClick = { @@ -148,9 +172,9 @@ private fun LocationNotesErrorSheet( modifier = Modifier.align(Alignment.TopCenter), title = { BitchatSheetTitle( - text = stringResource(R.string.cd_location_notes).uppercase() + text = stringResource(R.string.cd_location_notes).uppercase(), ) - } + }, ) } } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 772e48a5..7e1dd0e5 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -184,6 +184,8 @@ إرسال عناق ودي حظر %1$s حظر جميع رسائل هذا المستخدم + حذف الملاحظة + يرسل طلب حذف nostr إلى المرحلات #قنوات الموقع diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index b6557fe2..b65e04fe 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -184,6 +184,8 @@ একটি বন্ধুত্বপূর্ণ আলিঙ্গন পাঠান %1$s কে ব্লক করুন এই ব্যবহারকারীর সমস্ত বার্তা ব্লক করুন + নোট মুছুন + রিলেতে nostr মুছে ফেলার অনুরোধ পাঠায় #অবস্থান চ্যানেল diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 75cc8927..6e0cd82c 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -184,6 +184,8 @@ Eine freundliche Umarmung senden %1$s blockieren Alle Nachrichten dieses Benutzers blockieren + Notiz löschen + sendet eine nostr-Löschanfrage an Relays #Standort‑Kanäle diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 3f08ead0..d146192f 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -184,6 +184,8 @@ Enviar un abrazo amistoso Bloquear a %1$s Bloquear todos los mensajes de este usuario + eliminar nota + envía una solicitud de eliminación de nostr a los relays #Canales de ubicación diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 1e092b64..c0825855 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -184,6 +184,8 @@ یک بغل دوستانه ارسال کن %1$s را مسدود کن همهٔ پیام‌های این کاربر را مسدود کن + حذف یادداشت + درخواست حذف nostr را به رله‌ها ارسال می‌کند #کانال‌های مکانی diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index 66cd93bd..be557bbf 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -177,6 +177,8 @@ magpadala ng magiliw na yakap harangan si %1$s harangan ang lahat ng mensahe mula sa user na ito + tanggalin ang tala + nagpapadala ng kahilingan sa pagtanggal ng nostr sa mga relay #mga channel sa lokasyon diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index aa857f44..4ebfe987 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -177,6 +177,8 @@ envoyer une accolade amicale bloquer %1$s bloquer tous les messages de cet utilisateur + supprimer la note + envoie une demande de suppression nostr aux relais #canaux de lieu diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml index 86e4bbcb..16fc1013 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -398,4 +398,11 @@ שפת האפליקציה ברירת המחדל של המערכת בחירת שפה + + שולח בקשת מחיקה של nostr לממסרים + מחק הערה + פתיחת אודות + %d פתקים הושארו כאן — הקש לקריאה + פתק אחד הושאר כאן — הקש לקריאה + בדיקה אם הושארו כאן פתקים diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 4cb18e38..326370d5 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -184,6 +184,8 @@ दोस्ताना आलिंगन भेजें %1$s को ब्लॉक करें इस उपयोगकर्ता के सभी संदेश ब्लॉक करें + नोट हटाएं + रिले को nostr हटाने का अनुरोध भेजता है #स्थान चैनल diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 02c86a68..af28984d 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -184,6 +184,8 @@ Kirim pelukan yang ramah Blokir %1$s Blokir semua pesan dari pengguna ini + hapus catatan + mengirim permintaan penghapusan nostr ke relay #Channel lokasi diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index a8ecb688..18b6c494 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -184,6 +184,8 @@ invia un abbraccio amichevole blocca %1$s blocca tutti i messaggi da questo utente + elimina nota + invia una richiesta di eliminazione nostr ai relay #canali di posizione diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 17841dcc..97d6fb33 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -184,6 +184,8 @@ フレンドリーなハグを送る %1$s をブロック このユーザーからのすべてのメッセージをブロック + ノートを削除 + nostr の削除リクエストをリレーに送信します #ロケーション・チャンネル diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index 8fe186cc..833e304a 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -184,6 +184,8 @@ გაგზავნეთ მეგობრული ჩახუტება %1$s-ის დაბლოკვა ამ მომხმარებლის ყველა შეტყობინების დაბლოკვა + ჩანაწერის წაშლა + nostr-ის წაშლის მოთხოვნას გზავნის relay-ებზე #მდებარეობის არხები diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 030165d6..2ded3bee 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -184,6 +184,8 @@ 친근한 포옹 보내기 %1$s 차단 이 사용자의 모든 메시지 차단 + 노트 삭제 + nostr 삭제 요청을 릴레이에 전송합니다 #위치 채널 diff --git a/app/src/main/res/values-mg/strings.xml b/app/src/main/res/values-mg/strings.xml index 52617e72..ef14ac56 100644 --- a/app/src/main/res/values-mg/strings.xml +++ b/app/src/main/res/values-mg/strings.xml @@ -184,6 +184,8 @@ handefasa hafatra honofinofy sariaka hanorina %1$s hanorina ny hafatra rehetra avy amin\'ity mpampiasa ity + fafao ny fanamarihana + mandefa fangatahana fafana nostr any amin\'ny relay #fantsona toerana diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index c6391e6f..5ad142d6 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -439,4 +439,11 @@ Bahasa aplikasi Lalai sistem Pilih bahasa + + menghantar permintaan pemadaman nostr ke relay + padam nota + Buka Perihal + %d nota ditinggalkan di sini — ketik untuk baca + 1 nota ditinggalkan di sini — ketik untuk baca + semak nota yang ditinggalkan di sini diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index 7a3da0bd..3bad886b 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -177,6 +177,8 @@ मैत्रीपूर्ण अँगालो पठाउनुहोस् %1$s लाई ब्लक गर्नुहोस् यस प्रयोगकर्ताबाट सबै सन्देश ब्लक गर्नुहोस् + नोट मेट्नुहोस् + रिलेहरूमा nostr मेटाउने अनुरोध पठाउँछ #स्थान च्यानल diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 64326ae5..a3975f05 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -184,6 +184,8 @@ stuur een vriendelijke knuffel %1$s blokkeren blokkeer alle berichten van deze gebruiker + notitie verwijderen + stuurt een nostr-verwijderverzoek naar relays #locatiekanalen diff --git a/app/src/main/res/values-pa-rPK/strings.xml b/app/src/main/res/values-pa-rPK/strings.xml index a3f67735..dba85fd6 100644 --- a/app/src/main/res/values-pa-rPK/strings.xml +++ b/app/src/main/res/values-pa-rPK/strings.xml @@ -184,6 +184,8 @@ دوستانہ گلے ملنا پھجو %1$s نوں بلاک کرو اس یوزر دے سارے پیغام بلاک کرو + ਨੋਟ ਮਿਟਾਓ + ਰਿਲੇਅਾਂ ਨੂੰ nostr ਮਿਟਾਉਣ ਦੀ ਬੇਨਤੀ ਭੇਜਦਾ ਹੈ #لوکیشن چینل diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 0d2f2689..8aadcceb 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -404,4 +404,11 @@ Język aplikacji Domyślny systemu Wybierz język + + wysyła żądanie usunięcia nostr do przekaźników + usuń notatkę + Otwórz informacje + %d notatek zostawionych tutaj — stuknij, aby przeczytać + 1 notatka zostawiona tutaj — stuknij, aby przeczytać + sprawdź, czy zostawiono tutaj notatki diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 42340857..623a1a5d 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -184,6 +184,8 @@ Enviar um abraço amigável Bloquear %1$s Bloquear todas as mensagens deste usuário + excluir nota + envia uma solicitação de exclusão nostr para os relays #Canais de localização diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 0e01bb07..cbfa09d4 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -184,6 +184,8 @@ Enviar um abraço amigável Bloquear %1$s Bloquear todas as mensagens deste utilizador + eliminar nota + envia um pedido de eliminação nostr para os relays #Canais de localização diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 613b008d..0b5f3590 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -164,6 +164,8 @@ отправить дружеские объятия заблокировать %1$s заблокировать все сообщения от этого пользователя + удалить заметку + отправляет запрос на удаление nostr в ретрансляторы #каналы локации общайся с людьми рядом через каналы geohash. делится только грубый geohash, никогда точный gps. не делай скриншоты и не делись экраном для защиты приватности. diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index c0457997..79a0167a 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -164,6 +164,8 @@ skicka en vänlig kram blockera %1$s blockera alla meddelanden från den här användaren + ta bort anteckning + skickar en nostr-borttagningsbegäran till reläer #platskanaler chatta med folk nära dig via geohash‑kanaler. endast en grov geohash delas, aldrig exakt gps. ta inte skärmdumpar eller dela skärmen för att skydda din integritet. diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 5e7f8bdd..882aebe0 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -396,4 +396,11 @@ பயன்பாட்டு மொழி கணினி இயல்புநிலை மொழியைத் தேர்ந்தெடுக்கவும் + + relay-களுக்கு nostr நீக்கும் கோரிக்கையை அனுப்புகிறது + குறிப்பை நீக்கு + அறிமுகத்தைத் திற + இங்கே %d குறிப்புகள் விடப்பட்டுள்ளன — படிக்க தட்டவும் + இங்கே 1 குறிப்பு விடப்பட்டுள்ளது — படிக்க தட்டவும் + இங்கே விடப்பட்ட குறிப்புகள் உள்ளதா எனப் பார்க்கவும் diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index b21049c1..94b3512f 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -184,6 +184,8 @@ ส่งกอดแบบเป็นมิตร บล็อก %1$s บล็อกข้อความทั้งหมดจากผู้ใช้นี้ + ลบบันทึก + ส่งคำขอลบ nostr ไปยัง relay #ช่องตามตำแหน่ง diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 533e3252..b62662b0 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -164,6 +164,8 @@ samimi bir sarılma gönder %1$s kişisini engelle bu kullanıcıdan gelen tüm mesajları engelle + notu sil + rölelere bir nostr silme isteği gönderir #konum kanalları yakındaki insanlarla geohash kanallarıyla sohbet et. yalnızca kabaca geohash paylaşılır, kesin gps asla değil. gizliliğin için ekran görüntüsü alma ve paylaşma. diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index cdc73c91..6ccc6986 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -406,4 +406,11 @@ Мова застосунку Системна мова Вибрати мову + + надсилає запит на видалення nostr до ретрансляторів + видалити нотатку + Відкрити розділ «Про застосунок» + тут залишено %d нотаток — торкніться, щоб прочитати + тут залишено 1 нотатку — торкніться, щоб прочитати + перевірити, чи залишено тут нотатки diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index 785338fb..03c50f8d 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -184,6 +184,8 @@ دوستانہ آلنگن بھیجیں %1$s کو بلاک کریں اس صارف کے تمام پیغامات بلاک کریں + نوٹ حذف کریں + ریلے پر nostr حذف کی درخواست بھیجتا ہے #مقام چینلز diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index b97c3fc5..f983b3fd 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -184,6 +184,8 @@ Gửi một cái ôm thân thiện Chặn %1$s Chặn tất cả tin nhắn từ người dùng này + xóa ghi chú + gửi yêu cầu xóa nostr tới các relay #Kênh vị trí diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index b9e35372..42b0172d 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -394,4 +394,10 @@ 应用语言 跟随系统 选择语言 + + 向中继发送 nostr 删除请求 + 删除笔记 + 这里留有 %d 条留言 — 点按阅读 + 这里留有 1 条留言 — 点按阅读 + 查看这里留下的留言 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 36b6bbdd..f7ac2bbf 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -395,4 +395,10 @@ 應用程式語言 跟隨系統 選擇語言 + + 向中繼發送 nostr 刪除請求 + 刪除筆記 + 這裡留有 %d 則留言 — 點按閱讀 + 這裡留有 1 則留言 — 點按閱讀 + 查看這裡留下的留言 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 751580a6..bf25876f 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -177,6 +177,8 @@ 发送一个友好的拥抱 屏蔽 %1$s 屏蔽该用户的所有消息 + 删除笔记 + 向中继发送 nostr 删除请求 #地点频道 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f705c5ad..6ec24b9a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -23,7 +23,7 @@ No one connected Triple tap to clear all data Network - + Battery Optimization Detected Battery Optimization Disabled @@ -42,7 +42,7 @@ Continue Retry Skip - + and %1$d more %1$d messages from %2$d people @@ -352,6 +352,8 @@ Block all messages from this user Message %1$s Send a private message + Delete note + Sends a nostr deletion request to relays diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/LocationNotesManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/LocationNotesManagerTest.kt new file mode 100644 index 00000000..5afd3b39 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/LocationNotesManagerTest.kt @@ -0,0 +1,398 @@ +package com.bitchat.android.nostr + +import com.bitchat.android.geohash.LiveLocationPrivacyGate +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * Unit tests for [LocationNotesManager] covering: + * - Incoming kind:1 (text note) events — adding, deduplication, geohash filtering + * - Incoming kind:5 (NIP-09 deletion) events — signature-verified, pubkey-gated removal + * - [LocationNotesManager.deleteNote] — optimistic local removal + relay broadcast + * + * The manager's private [handleEvent] method is exercised through the event-handler + * callbacks captured from the [subscribe] lambda injected in [initialize]. + * + * Two subscriptions are created: + * - kind:1 handler stored under "location-notes-" + * - kind:5 deletion handler stored under "location-deletions", registered + * synchronously the first time a kind:1 note arrives (via maybeResubscribeDeletions) + * + * No Robolectric is required because [NostrCrypto] / [NostrIdentity] depend only on + * BouncyCastle (pure JVM), and [android.util.Log] is stubbed by the project-level + * test mock at src/test/kotlin/android/util/Log.kt. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class LocationNotesManagerTest { + + private lateinit var manager: LocationNotesManager + + /** + * All event handlers registered via subscribe(), keyed by subscription ID. + * The kind:1 text-note handler is stored under "location-notes-". + * The kind:5 deletion handler is stored under "location-deletions". + */ + private val capturedHandlers = mutableMapOf Unit>() + + /** Convenience accessor for the kind:1 note subscription handler. + * Subscription IDs are now "location-notes-"; every geohash cell's + * subscription shares the same handleEvent callback, so any entry works. */ + private val capturedEventHandler: ((NostrEvent) -> Unit)? + get() = capturedHandlers.entries.firstOrNull { it.key.startsWith("location-notes-") }?.value + + /** Convenience accessor for the kind:5 deletion subscription handler. */ + private val capturedDeletionHandler: ((NostrEvent) -> Unit)? + get() = capturedHandlers["location-deletions"] + + /** Mutable hook so individual tests can install their own send-event spy. */ + @Volatile + private var sendEventCallback: (NostrEvent, List?) -> Unit = { _, _ -> } + + /** Two stable secp256k1 identities derived from fixed seeds (no Android context needed). */ + private val authorIdentity = NostrIdentity.fromSeed("location-notes-test-author-seed") + private val attackerIdentity = NostrIdentity.fromSeed("location-notes-test-attacker-seed") + + /** A valid 8-character base32 geohash (building-level precision). */ + private val testGeohash = "u4pruydq" + + // ─── Test lifecycle ─────────────────────────────────────────────────────── + + @Before + fun setup() { + // Dispatchers.Unconfined has isDispatchNeeded()=false — continuations that resume + // after withContext(IO) run inline on the IO thread without being posted back to a + // TestCoroutineScheduler, so deleteNote's latch-based sync works without pumping. + // No virtual-clock control is needed: maybeResubscribeDeletions() is synchronous. + Dispatchers.setMain(Dispatchers.Unconfined) + + // Arm the process-wide live-location consent gate (fail-closed by default); + // without it setGeohash/handleEvent/deleteNote are all no-ops. + LiveLocationPrivacyGate.update(true) + + // Create a brand-new manager instance via reflection, bypassing the singleton. + // This guarantees a fresh, non-cancelled CoroutineScope for every test, + // regardless of what any previous test (or its @After) did to the singleton. + manager = createFreshInstance() + + manager.initialize( + relayManager = { error("relayManager should not be called in these tests") }, + subscribe = { _, subId, handler -> + capturedHandlers[subId] = handler + subId + }, + unsubscribe = { subId -> capturedHandlers.remove(subId) }, + sendEvent = { event, relays, _ -> sendEventCallback(event, relays) }, + deriveIdentity = { _ -> authorIdentity }, + ) + + // Triggers subscribeAll() synchronously, which populates capturedHandlers. + manager.setGeohash(testGeohash) + } + + @After + fun teardown() { + manager.cleanup() + capturedHandlers.clear() + // Restore the gate to its fail-closed default so other test classes + // sharing this JVM see the untouched state. + LiveLocationPrivacyGate.update(false) + Dispatchers.resetMain() + } + + // ─── handleEvent: kind:1 (text notes) ──────────────────────────────────── + + @Test + fun `kind 1 event adds note to the list`() { + capturedEventHandler!!.invoke( + makeTextNoteEvent(id = "note-001", pubkey = authorIdentity.publicKeyHex, content = "Hello!") + ) + + assertEquals(1, manager.notes.value.size) + assertEquals("note-001", manager.notes.value[0].id) + assertEquals("Hello!", manager.notes.value[0].content) + } + + @Test + fun `kind 1 event with same id is deduplicated`() { + val event = makeTextNoteEvent(id = "dup-note", pubkey = authorIdentity.publicKeyHex) + capturedEventHandler!!.invoke(event) + capturedEventHandler!!.invoke(event) // second delivery of the same event + + assertEquals("Duplicate event must not be stored twice", 1, manager.notes.value.size) + } + + @Test + fun `kind 1 event without geohash tag is ignored`() { + val eventWithoutGtag = NostrEvent( + id = "no-gtag", + pubkey = authorIdentity.publicKeyHex, + createdAt = 1_700_000_000, + kind = NostrKind.TEXT_NOTE, + tags = emptyList(), // intentionally no "g" tag + content = "no location", + ) + + capturedEventHandler!!.invoke(eventWithoutGtag) + + assertEquals("Event without geohash tag must be ignored", 0, manager.notes.value.size) + } + + @Test + fun `kind 1 event with different geohash is ignored`() { + val eventForOtherCell = makeTextNoteEvent( + id = "other-cell", + pubkey = authorIdentity.publicKeyHex, + geohash = "s000000a", // different geohash, not subscribed + ) + + capturedEventHandler!!.invoke(eventForOtherCell) + + assertEquals("Event for a non-subscribed geohash must be ignored", 0, manager.notes.value.size) + } + + @Test + fun `kind 1 event stores nickname from n-tag`() { + val eventWithNick = NostrEvent( + id = "nick-note", + pubkey = authorIdentity.publicKeyHex, + createdAt = 1_700_000_000, + kind = NostrKind.TEXT_NOTE, + tags = listOf(listOf("g", testGeohash), listOf("n", "Alice")), + content = "Hi from Alice", + ) + + capturedEventHandler!!.invoke(eventWithNick) + + assertEquals("Alice", manager.notes.value[0].nickname) + } + + // ─── handleEvent: kind:5 (NIP-09 deletion) ─────────────────────────────── + // + // maybeResubscribeDeletions() is synchronous — capturedDeletionHandler is + // populated immediately when the first kind:1 note arrives, no time-advancement + // needed. Deletion events must be properly signed; handleEvent() calls + // isValidSignature() before honouring any kind:5 request. + + @Test + fun `kind 5 removes the matching note authored by the same pubkey`() { + capturedEventHandler!!.invoke( + makeTextNoteEvent(id = "del-target", pubkey = authorIdentity.publicKeyHex, content = "To be deleted") + ) + assertEquals(1, manager.notes.value.size) + + capturedDeletionHandler!!.invoke( + makeDeletionEvent(targetId = "del-target", identity = authorIdentity) + ) + + assertEquals("Note must be removed by kind:5 from the same author", 0, manager.notes.value.size) + } + + @Test + fun `kind 5 does NOT remove a note when pubkey does not match`() { + capturedEventHandler!!.invoke( + makeTextNoteEvent(id = "protected", pubkey = authorIdentity.publicKeyHex, content = "Protected") + ) + assertEquals(1, manager.notes.value.size) + + // Deletion request from a *different* identity — must be rejected + capturedDeletionHandler!!.invoke( + makeDeletionEvent(targetId = "protected", identity = attackerIdentity) + ) + + assertEquals("Note must survive a deletion attempt from a different pubkey", 1, manager.notes.value.size) + } + + @Test + fun `kind 5 removes only the referenced note and leaves others intact`() { + capturedEventHandler!!.invoke( + makeTextNoteEvent(id = "note-A", pubkey = authorIdentity.publicKeyHex, content = "Note A") + ) + // note-A arrival registers capturedDeletionHandler synchronously (throttle window opens). + // note-B arrival is throttled (< 1000 ms), but handleEvent() still processes all e-tags + // regardless of which IDs were in the subscription filter at registration time. + capturedEventHandler!!.invoke( + makeTextNoteEvent(id = "note-B", pubkey = authorIdentity.publicKeyHex, content = "Note B") + ) + assertEquals(2, manager.notes.value.size) + + capturedDeletionHandler!!.invoke( + makeDeletionEvent(targetId = "note-A", identity = authorIdentity) + ) + + assertEquals("Only the referenced note should be removed", 1, manager.notes.value.size) + assertEquals("note-B", manager.notes.value[0].id) + } + + @Test + fun `kind 5 with no e-tags does nothing`() { + capturedEventHandler!!.invoke( + makeTextNoteEvent(id = "safe-note", pubkey = authorIdentity.publicKeyHex, content = "Safe") + ) + assertEquals(1, manager.notes.value.size) + + // A properly signed kind:5 with no e-tags — passes the signature guard but + // targetIds will be empty so no note should be removed. + val malformed = NostrEvent( + pubkey = authorIdentity.publicKeyHex, + createdAt = (System.currentTimeMillis() / 1000).toInt(), + kind = NostrKind.DELETION, + tags = emptyList(), + content = "", + ).sign(authorIdentity.privateKeyHex) + capturedDeletionHandler!!.invoke(malformed) + + assertEquals("Malformed kind:5 with no e-tags must not remove any note", 1, manager.notes.value.size) + } + + @Test + fun `kind 5 can delete multiple notes in one event via multiple e-tags`() { + capturedEventHandler!!.invoke(makeTextNoteEvent(id = "batch-A", pubkey = authorIdentity.publicKeyHex, content = "A")) + capturedEventHandler!!.invoke(makeTextNoteEvent(id = "batch-B", pubkey = authorIdentity.publicKeyHex, content = "B")) + capturedEventHandler!!.invoke(makeTextNoteEvent(id = "keep-C", pubkey = authorIdentity.publicKeyHex, content = "C")) + assertEquals(3, manager.notes.value.size) + + // A single kind:5 event referencing two note IDs simultaneously. + val batchDeletion = NostrEvent( + pubkey = authorIdentity.publicKeyHex, + createdAt = (System.currentTimeMillis() / 1000).toInt(), + kind = NostrKind.DELETION, + tags = listOf(listOf("e", "batch-A"), listOf("e", "batch-B")), + content = "", + ).sign(authorIdentity.privateKeyHex) + capturedDeletionHandler!!.invoke(batchDeletion) + + assertEquals("Both referenced notes must be removed", 1, manager.notes.value.size) + assertEquals("keep-C", manager.notes.value[0].id) + } + + // ─── deleteNote (optimistic local removal + relay broadcast) ───────────── + + @Test + fun `deleteNote removes note optimistically before relay broadcast`() { + capturedEventHandler!!.invoke( + makeTextNoteEvent(id = "rm-note", pubkey = authorIdentity.publicKeyHex, content = "Will be deleted") + ) + assertEquals(1, manager.notes.value.size) + + // sendEvent is called AFTER the optimistic removal — use it as a sync point. + val latch = CountDownLatch(1) + sendEventCallback = { _, _ -> latch.countDown() } + + manager.deleteNote("rm-note") + + assertTrue("deleteNote coroutine must complete within 3 s", latch.await(3, TimeUnit.SECONDS)) + assertEquals("Note must be removed from the list", 0, manager.notes.value.size) + } + + @Test + fun `deleteNote broadcasts a kind 5 event with the correct e-tag`() { + capturedEventHandler!!.invoke( + makeTextNoteEvent(id = "broadcast-me", pubkey = authorIdentity.publicKeyHex, content = "Broadcast target") + ) + + val captured = mutableListOf() + val latch = CountDownLatch(1) + sendEventCallback = { event, _ -> captured.add(event); latch.countDown() } + + manager.deleteNote("broadcast-me") + + assertTrue("Deletion event must be sent within 3 s", latch.await(3, TimeUnit.SECONDS)) + assertEquals("Exactly one event must be broadcast", 1, captured.size) + assertEquals("Broadcast event must be kind 5", NostrKind.DELETION, captured[0].kind) + + val eTag = captured[0].tags.firstOrNull { it.size >= 2 && it[0] == "e" } + assertNotNull("Broadcast kind:5 must have an e-tag", eTag) + assertEquals("e-tag must reference the deleted note id", "broadcast-me", eTag!![1]) + } + + @Test + fun `deleteNote does nothing when geohash is not set`() { + // Create a separate bare instance with no geohash configured. + val bare = createFreshInstance() + bare.initialize( + relayManager = { error("not needed") }, + subscribe = { _, subId, _ -> subId }, + unsubscribe = { }, + sendEvent = { _, _, _ -> error("sendEvent must not be called") }, + deriveIdentity = { _ -> authorIdentity }, + ) + // setGeohash intentionally NOT called — deleteNote should silently no-op. + bare.deleteNote("irrelevant-id") + bare.cleanup() + } + + @Test + fun `deleteNote does not delete or broadcast when note is not owned by local identity`() { + capturedEventHandler!!.invoke( + makeTextNoteEvent(id = "foreign-note", pubkey = attackerIdentity.publicKeyHex, content = "Not mine") + ) + assertEquals(1, manager.notes.value.size) + + var sendCalled = false + sendEventCallback = { _, _ -> sendCalled = true } + + manager.deleteNote("foreign-note") + + assertEquals("Foreign note must remain", 1, manager.notes.value.size) + assertEquals("foreign-note", manager.notes.value[0].id) + assertTrue("Deletion event must not be broadcast for non-owned note", !sendCalled) + } + + // ─── Helpers ───────────────────────────────────────────────────────────── + + /** + * Build a minimal kind:1 text-note event for a given geohash cell. + * Left unsigned — [LocationNotesManager.handleEvent] does not verify signatures on kind:1. + */ + private fun makeTextNoteEvent( + id: String, + pubkey: String, + geohash: String = testGeohash, + content: String = "test note content", + ) = NostrEvent( + id = id, + pubkey = pubkey, + createdAt = 1_700_000_000, + kind = NostrKind.TEXT_NOTE, + tags = listOf(listOf("g", geohash)), + content = content, + ) + + /** + * Build a properly signed kind:5 deletion event targeting [targetId], authored by [identity]. + * + * The event is signed via BIP-340 Schnorr because [LocationNotesManager.handleEvent] + * calls [NostrEvent.isValidSignature] before honouring any kind:5 request. + */ + private fun makeDeletionEvent( + targetId: String, + identity: NostrIdentity, + ) = NostrEvent( + pubkey = identity.publicKeyHex, + createdAt = (System.currentTimeMillis() / 1000).toInt(), + kind = NostrKind.DELETION, + tags = listOf(listOf("e", targetId)), + content = "", + ).sign(identity.privateKeyHex) + + /** + * Create a fresh [LocationNotesManager] instance by invoking its private constructor + * via reflection. This bypasses the singleton so every test gets an independent + * object with a non-cancelled [CoroutineScope]. + */ + private fun createFreshInstance(): LocationNotesManager { + val ctor = LocationNotesManager::class.java.getDeclaredConstructor() + ctor.isAccessible = true + return ctor.newInstance() as LocationNotesManager + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt index a5bd9561..218c7de4 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt @@ -1,13 +1,26 @@ package com.bitchat.android.nostr import com.google.gson.Gson +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test +/** + * Unit tests for [NostrProtocol]. + * + * Covers gift-wrap decryption seal authentication, and the structure and + * validity of the NIP-09 (kind:5) deletion event produced when a user + * requests removal of one of their own location notes. + */ class NostrProtocolTest { private val gson = Gson() + // Deterministic secp256k1 identity derived from a fixed seed — no Android context required. + private val senderIdentity = NostrIdentity.fromSeed("test-sender-seed-nostr-protocol") + @Test fun decryptPrivateMessage_acceptsAuthenticatedSeal() { val sender = NostrIdentity.generate() @@ -82,4 +95,92 @@ class NostrProtocolTest { content = giftWrapContent ).sign(wrapPrivateKey) } + + // ─── NIP-09 deletion event (kind:5) ────────────────────────────────────── + + // ─── kind ──────────────────────────────────────────────────────────────── + + @Test + fun `createDeletionEvent has kind 5 (NIP-09)`() = runBlocking { + val event = NostrProtocol.createDeletionEvent("target123", senderIdentity) + assertEquals("Event kind must be 5 per NIP-09", NostrKind.DELETION, event.kind) + } + + // ─── e-tag ─────────────────────────────────────────────────────────────── + + @Test + fun `createDeletionEvent contains e-tag referencing the target event`() = runBlocking { + val targetId = "abc123def456deadbeef" + val event = NostrProtocol.createDeletionEvent(targetId, senderIdentity) + + val eTag = event.tags.firstOrNull { it.size >= 2 && it[0] == "e" } + assertNotNull("Must contain an e-tag", eTag) + assertEquals("e-tag value must equal targetEventId", targetId, eTag!![1]) + } + + @Test + fun `createDeletionEvent contains exactly one e-tag`() = runBlocking { + val event = NostrProtocol.createDeletionEvent("singleTarget", senderIdentity) + + val eTags = event.tags.filter { it.size >= 2 && it[0] == "e" } + assertEquals("Exactly one e-tag expected for a single-note deletion", 1, eTags.size) + } + + // ─── content ───────────────────────────────────────────────────────────── + + @Test + fun `createDeletionEvent has empty content per NIP-09`() = runBlocking { + val event = NostrProtocol.createDeletionEvent("anyid", senderIdentity) + assertEquals("Content must be empty string", "", event.content) + } + + // ─── pubkey ────────────────────────────────────────────────────────────── + + @Test + fun `createDeletionEvent pubkey matches sender identity`() = runBlocking { + val event = NostrProtocol.createDeletionEvent("anyid", senderIdentity) + assertEquals("Pubkey must equal sender's public key", senderIdentity.publicKeyHex, event.pubkey) + } + + // ─── signature ─────────────────────────────────────────────────────────── + + @Test + fun `createDeletionEvent has a non-null signature`() = runBlocking { + val event = NostrProtocol.createDeletionEvent("anyid", senderIdentity) + assertNotNull("Event must be signed", event.sig) + } + + @Test + fun `createDeletionEvent has a valid BIP-340 Schnorr signature`() = runBlocking { + val event = NostrProtocol.createDeletionEvent("anyid", senderIdentity) + assertTrue("Schnorr signature must verify correctly", event.isValidSignature()) + } + + // ─── timestamp ─────────────────────────────────────────────────────────── + + @Test + fun `createDeletionEvent timestamp is within the current second`() = runBlocking { + val before = (System.currentTimeMillis() / 1000).toInt() + val event = NostrProtocol.createDeletionEvent("ts-check", senderIdentity) + val after = (System.currentTimeMillis() / 1000).toInt() + + assertTrue("createdAt must be >= start of test", event.createdAt >= before) + assertTrue("createdAt must be <= end of test", event.createdAt <= after) + } + + // ─── event ID ──────────────────────────────────────────────────────────── + + @Test + fun `createDeletionEvent has a non-empty event id`() = runBlocking { + val event = NostrProtocol.createDeletionEvent("anyid", senderIdentity) + assertTrue("Event id must not be empty", event.id.isNotEmpty()) + } + + @Test + fun `createDeletionEvent id is consistent with content (NIP-01 hash)`() = runBlocking { + val event = NostrProtocol.createDeletionEvent("anyid", senderIdentity) + // isValidSignature() internally recalculates the id and checks it matches the stored id, + // so a passing signature check implies the id is correct too. + assertTrue("Event id and signature must be mutually consistent", event.isValidSignature()) + } }