Merge 5cc4ba344a89fa395896426352742d8415bac712 into e07a38f6344cbb4af15c0bec1c13c6a5da82bd81

This commit is contained in:
AleksPlekhov 2026-07-31 21:43:32 -04:00 committed by GitHub
commit 1fef051f01
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 1118 additions and 153 deletions

View File

@ -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<List<Note>>(emptyList())
val notes: StateFlow<List<Note>> = _notes.asStateFlow()
private val _localPubkey = MutableStateFlow<String?>(null)
val localPubkey: StateFlow<String?> = _localPubkey.asStateFlow()
private val _geohash = MutableStateFlow<String?>(null)
val geohash: StateFlow<String?> = _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<String> = 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
*/

View File

@ -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

View File

@ -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

View File

@ -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)
}
}

View File

@ -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<LocationNotesManager.Note?>(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(),
)
}
},
)
}
}

View File

@ -184,6 +184,8 @@
<string name="action_hug_subtitle">إرسال عناق ودي</string>
<string name="action_block_title">حظر %1$s</string>
<string name="action_block_subtitle">حظر جميع رسائل هذا المستخدم</string>
<string name="action_delete_note_title">حذف الملاحظة</string>
<string name="action_delete_note_subtitle">يرسل طلب حذف nostr إلى المرحلات</string>
<!-- ورقة قنوات geohash -->
<string name="location_channels_title">#قنوات الموقع</string>

View File

@ -184,6 +184,8 @@
<string name="action_hug_subtitle">একটি বন্ধুত্বপূর্ণ আলিঙ্গন পাঠান</string>
<string name="action_block_title">%1$s কে ব্লক করুন</string>
<string name="action_block_subtitle">এই ব্যবহারকারীর সমস্ত বার্তা ব্লক করুন</string>
<string name="action_delete_note_title">নোট মুছুন</string>
<string name="action_delete_note_subtitle">রিলেতে nostr মুছে ফেলার অনুরোধ পাঠায়</string>
<!-- জিওহ্যাশ চ্যানেল শিট -->
<string name="location_channels_title">#অবস্থান চ্যানেল</string>

View File

@ -184,6 +184,8 @@
<string name="action_hug_subtitle">Eine freundliche Umarmung senden</string>
<string name="action_block_title">%1$s blockieren</string>
<string name="action_block_subtitle">Alle Nachrichten dieses Benutzers blockieren</string>
<string name="action_delete_note_title">Notiz löschen</string>
<string name="action_delete_note_subtitle">sendet eine nostr-Löschanfrage an Relays</string>
<!-- GeohashKanäle Sheet -->
<string name="location_channels_title">#StandortKanäle</string>

View File

@ -184,6 +184,8 @@
<string name="action_hug_subtitle">Enviar un abrazo amistoso</string>
<string name="action_block_title">Bloquear a %1$s</string>
<string name="action_block_subtitle">Bloquear todos los mensajes de este usuario</string>
<string name="action_delete_note_title">eliminar nota</string>
<string name="action_delete_note_subtitle">envía una solicitud de eliminación de nostr a los relays</string>
<!-- Hoja de canales geohash -->
<string name="location_channels_title">#Canales de ubicación</string>

View File

@ -184,6 +184,8 @@
<string name="action_hug_subtitle">یک بغل دوستانه ارسال کن</string>
<string name="action_block_title">%1$s را مسدود کن</string>
<string name="action_block_subtitle">همهٔ پیام‌های این کاربر را مسدود کن</string>
<string name="action_delete_note_title">حذف یادداشت</string>
<string name="action_delete_note_subtitle">درخواست حذف nostr را به رله‌ها ارسال می‌کند</string>
<!-- برگهٔ کانال‌های geohash -->
<string name="location_channels_title">#کانال‌های مکانی</string>

View File

@ -177,6 +177,8 @@
<string name="action_hug_subtitle">magpadala ng magiliw na yakap</string>
<string name="action_block_title">harangan si %1$s</string>
<string name="action_block_subtitle">harangan ang lahat ng mensahe mula sa user na ito</string>
<string name="action_delete_note_title">tanggalin ang tala</string>
<string name="action_delete_note_subtitle">nagpapadala ng kahilingan sa pagtanggal ng nostr sa mga relay</string>
<!-- Location channels sheet -->
<string name="location_channels_title">#mga channel sa lokasyon</string>

View File

@ -177,6 +177,8 @@
<string name="action_hug_subtitle">envoyer une accolade amicale</string>
<string name="action_block_title">bloquer %1$s</string>
<string name="action_block_subtitle">bloquer tous les messages de cet utilisateur</string>
<string name="action_delete_note_title">supprimer la note</string>
<string name="action_delete_note_subtitle">envoie une demande de suppression nostr aux relais</string>
<!-- Feuille des canaux de lieu -->
<string name="location_channels_title">#canaux de lieu</string>

View File

@ -398,4 +398,11 @@
<string name="about_app_language">שפת האפליקציה</string>
<string name="about_system_default">ברירת המחדל של המערכת</string>
<string name="about_select_language">בחירת שפה</string>
<string name="action_delete_note_subtitle">שולח בקשת מחיקה של nostr לממסרים</string>
<string name="action_delete_note_title">מחק הערה</string>
<string name="cd_open_about">פתיחת אודות</string>
<string name="nearby_notes_many">%d פתקים הושארו כאן — הקש לקריאה</string>
<string name="nearby_notes_one">פתק אחד הושאר כאן — הקש לקריאה</string>
<string name="nearby_notes_reveal">בדיקה אם הושארו כאן פתקים</string>
</resources>

View File

@ -184,6 +184,8 @@
<string name="action_hug_subtitle">दोस्ताना आलिंगन भेजें</string>
<string name="action_block_title">%1$s को ब्लॉक करें</string>
<string name="action_block_subtitle">इस उपयोगकर्ता के सभी संदेश ब्लॉक करें</string>
<string name="action_delete_note_title">नोट हटाएं</string>
<string name="action_delete_note_subtitle">रिले को nostr हटाने का अनुरोध भेजता है</string>
<!-- geohash चैनल शीट -->
<string name="location_channels_title">#स्थान चैनल</string>

View File

@ -184,6 +184,8 @@
<string name="action_hug_subtitle">Kirim pelukan yang ramah</string>
<string name="action_block_title">Blokir %1$s</string>
<string name="action_block_subtitle">Blokir semua pesan dari pengguna ini</string>
<string name="action_delete_note_title">hapus catatan</string>
<string name="action_delete_note_subtitle">mengirim permintaan penghapusan nostr ke relay</string>
<!-- Sheet channel geohash -->
<string name="location_channels_title">#Channel lokasi</string>

View File

@ -184,6 +184,8 @@
<string name="action_hug_subtitle">invia un abbraccio amichevole</string>
<string name="action_block_title">blocca %1$s</string>
<string name="action_block_subtitle">blocca tutti i messaggi da questo utente</string>
<string name="action_delete_note_title">elimina nota</string>
<string name="action_delete_note_subtitle">invia una richiesta di eliminazione nostr ai relay</string>
<!-- Schermata canali di posizione -->
<string name="location_channels_title">#canali di posizione</string>

View File

@ -184,6 +184,8 @@
<string name="action_hug_subtitle">フレンドリーなハグを送る</string>
<string name="action_block_title">%1$s をブロック</string>
<string name="action_block_subtitle">このユーザーからのすべてのメッセージをブロック</string>
<string name="action_delete_note_title">ノートを削除</string>
<string name="action_delete_note_subtitle">nostr の削除リクエストをリレーに送信します</string>
<!-- Geohash チャンネル シート -->
<string name="location_channels_title">#ロケーション・チャンネル</string>

View File

@ -184,6 +184,8 @@
<string name="action_hug_subtitle">გაგზავნეთ მეგობრული ჩახუტება</string>
<string name="action_block_title">%1$s-ის დაბლოკვა</string>
<string name="action_block_subtitle">ამ მომხმარებლის ყველა შეტყობინების დაბლოკვა</string>
<string name="action_delete_note_title">ჩანაწერის წაშლა</string>
<string name="action_delete_note_subtitle">nostr-ის წაშლის მოთხოვნას გზავნის relay-ებზე</string>
<!-- geohash არხების შიტი -->
<string name="location_channels_title">#მდებარეობის არხები</string>

View File

@ -184,6 +184,8 @@
<string name="action_hug_subtitle">친근한 포옹 보내기</string>
<string name="action_block_title">%1$s 차단</string>
<string name="action_block_subtitle">이 사용자의 모든 메시지 차단</string>
<string name="action_delete_note_title">노트 삭제</string>
<string name="action_delete_note_subtitle">nostr 삭제 요청을 릴레이에 전송합니다</string>
<!-- geohash 채널 시트 -->
<string name="location_channels_title">#위치 채널</string>

View File

@ -184,6 +184,8 @@
<string name="action_hug_subtitle">handefasa hafatra honofinofy sariaka</string>
<string name="action_block_title">hanorina %1$s</string>
<string name="action_block_subtitle">hanorina ny hafatra rehetra avy amin\'ity mpampiasa ity</string>
<string name="action_delete_note_title">fafao ny fanamarihana</string>
<string name="action_delete_note_subtitle">mandefa fangatahana fafana nostr any amin\'ny relay</string>
<!-- Takelaka fantsona toerana -->
<string name="location_channels_title">#fantsona toerana</string>

View File

@ -439,4 +439,11 @@
<string name="about_app_language">Bahasa aplikasi</string>
<string name="about_system_default">Lalai sistem</string>
<string name="about_select_language">Pilih bahasa</string>
<string name="action_delete_note_subtitle">menghantar permintaan pemadaman nostr ke relay</string>
<string name="action_delete_note_title">padam nota</string>
<string name="cd_open_about">Buka Perihal</string>
<string name="nearby_notes_many">%d nota ditinggalkan di sini — ketik untuk baca</string>
<string name="nearby_notes_one">1 nota ditinggalkan di sini — ketik untuk baca</string>
<string name="nearby_notes_reveal">semak nota yang ditinggalkan di sini</string>
</resources>

View File

@ -177,6 +177,8 @@
<string name="action_hug_subtitle">मैत्रीपूर्ण अँगालो पठाउनुहोस्</string>
<string name="action_block_title">%1$s लाई ब्लक गर्नुहोस्</string>
<string name="action_block_subtitle">यस प्रयोगकर्ताबाट सबै सन्देश ब्लक गर्नुहोस्</string>
<string name="action_delete_note_title">नोट मेट्नुहोस्</string>
<string name="action_delete_note_subtitle">रिलेहरूमा nostr मेटाउने अनुरोध पठाउँछ</string>
<!-- स्थान च्यानल शीट -->
<string name="location_channels_title">#स्थान च्यानल</string>

View File

@ -184,6 +184,8 @@
<string name="action_hug_subtitle">stuur een vriendelijke knuffel</string>
<string name="action_block_title">%1$s blokkeren</string>
<string name="action_block_subtitle">blokkeer alle berichten van deze gebruiker</string>
<string name="action_delete_note_title">notitie verwijderen</string>
<string name="action_delete_note_subtitle">stuurt een nostr-verwijderverzoek naar relays</string>
<!-- Locatiekanalen sheet -->
<string name="location_channels_title">#locatiekanalen</string>

View File

@ -184,6 +184,8 @@
<string name="action_hug_subtitle">دوستانہ گلے ملنا پھجو</string>
<string name="action_block_title">%1$s نوں بلاک کرو</string>
<string name="action_block_subtitle">اس یوزر دے سارے پیغام بلاک کرو</string>
<string name="action_delete_note_title">ਨੋਟ ਮਿਟਾਓ</string>
<string name="action_delete_note_subtitle">ਰਿਲੇਅਾਂ ਨੂੰ nostr ਮਿਟਾਉਣ ਦੀ ਬੇਨਤੀ ਭੇਜਦਾ ਹੈ</string>
<!-- geohash چینل شیٹ -->
<string name="location_channels_title">#لوکیشن چینل</string>

View File

@ -404,4 +404,11 @@
<string name="about_app_language">Język aplikacji</string>
<string name="about_system_default">Domyślny systemu</string>
<string name="about_select_language">Wybierz język</string>
<string name="action_delete_note_subtitle">wysyła żądanie usunięcia nostr do przekaźników</string>
<string name="action_delete_note_title">usuń notatkę</string>
<string name="cd_open_about">Otwórz informacje</string>
<string name="nearby_notes_many">%d notatek zostawionych tutaj — stuknij, aby przeczytać</string>
<string name="nearby_notes_one">1 notatka zostawiona tutaj — stuknij, aby przeczytać</string>
<string name="nearby_notes_reveal">sprawdź, czy zostawiono tutaj notatki</string>
</resources>

View File

@ -184,6 +184,8 @@
<string name="action_hug_subtitle">Enviar um abraço amigável</string>
<string name="action_block_title">Bloquear %1$s</string>
<string name="action_block_subtitle">Bloquear todas as mensagens deste usuário</string>
<string name="action_delete_note_title">excluir nota</string>
<string name="action_delete_note_subtitle">envia uma solicitação de exclusão nostr para os relays</string>
<!-- Canais geohash -->
<string name="location_channels_title">#Canais de localização</string>

View File

@ -184,6 +184,8 @@
<string name="action_hug_subtitle">Enviar um abraço amigável</string>
<string name="action_block_title">Bloquear %1$s</string>
<string name="action_block_subtitle">Bloquear todas as mensagens deste utilizador</string>
<string name="action_delete_note_title">eliminar nota</string>
<string name="action_delete_note_subtitle">envia um pedido de eliminação nostr para os relays</string>
<!-- Folha de canais geohash -->
<string name="location_channels_title">#Canais de localização</string>

View File

@ -164,6 +164,8 @@
<string name="action_hug_subtitle">отправить дружеские объятия</string>
<string name="action_block_title">заблокировать %1$s</string>
<string name="action_block_subtitle">заблокировать все сообщения от этого пользователя</string>
<string name="action_delete_note_title">удалить заметку</string>
<string name="action_delete_note_subtitle">отправляет запрос на удаление nostr в ретрансляторы</string>
<string name="location_channels_title">#каналы локации</string>
<string name="location_channels_desc">общайся с людьми рядом через каналы geohash. делится только грубый geohash, никогда точный gps. не делай скриншоты и не делись экраном для защиты приватности.</string>

View File

@ -164,6 +164,8 @@
<string name="action_hug_subtitle">skicka en vänlig kram</string>
<string name="action_block_title">blockera %1$s</string>
<string name="action_block_subtitle">blockera alla meddelanden från den här användaren</string>
<string name="action_delete_note_title">ta bort anteckning</string>
<string name="action_delete_note_subtitle">skickar en nostr-borttagningsbegäran till reläer</string>
<string name="location_channels_title">#platskanaler</string>
<string name="location_channels_desc">chatta med folk nära dig via geohashkanaler. endast en grov geohash delas, aldrig exakt gps. ta inte skärmdumpar eller dela skärmen för att skydda din integritet.</string>

View File

@ -396,4 +396,11 @@
<string name="about_app_language">பயன்பாட்டு மொழி</string>
<string name="about_system_default">கணினி இயல்புநிலை</string>
<string name="about_select_language">மொழியைத் தேர்ந்தெடுக்கவும்</string>
<string name="action_delete_note_subtitle">relay-களுக்கு nostr நீக்கும் கோரிக்கையை அனுப்புகிறது</string>
<string name="action_delete_note_title">குறிப்பை நீக்கு</string>
<string name="cd_open_about">அறிமுகத்தைத் திற</string>
<string name="nearby_notes_many">இங்கே %d குறிப்புகள் விடப்பட்டுள்ளன — படிக்க தட்டவும்</string>
<string name="nearby_notes_one">இங்கே 1 குறிப்பு விடப்பட்டுள்ளது — படிக்க தட்டவும்</string>
<string name="nearby_notes_reveal">இங்கே விடப்பட்ட குறிப்புகள் உள்ளதா எனப் பார்க்கவும்</string>
</resources>

View File

@ -184,6 +184,8 @@
<string name="action_hug_subtitle">ส่งกอดแบบเป็นมิตร</string>
<string name="action_block_title">บล็อก %1$s</string>
<string name="action_block_subtitle">บล็อกข้อความทั้งหมดจากผู้ใช้นี้</string>
<string name="action_delete_note_title">ลบบันทึก</string>
<string name="action_delete_note_subtitle">ส่งคำขอลบ nostr ไปยัง relay</string>
<!-- แผ่นช่อง geohash -->
<string name="location_channels_title">#ช่องตามตำแหน่ง</string>

View File

@ -164,6 +164,8 @@
<string name="action_hug_subtitle">samimi bir sarılma gönder</string>
<string name="action_block_title">%1$s kişisini engelle</string>
<string name="action_block_subtitle">bu kullanıcıdan gelen tüm mesajları engelle</string>
<string name="action_delete_note_title">notu sil</string>
<string name="action_delete_note_subtitle">rölelere bir nostr silme isteği gönderir</string>
<string name="location_channels_title">#konum kanalları</string>
<string name="location_channels_desc">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.</string>

View File

@ -406,4 +406,11 @@
<string name="about_app_language">Мова застосунку</string>
<string name="about_system_default">Системна мова</string>
<string name="about_select_language">Вибрати мову</string>
<string name="action_delete_note_subtitle">надсилає запит на видалення nostr до ретрансляторів</string>
<string name="action_delete_note_title">видалити нотатку</string>
<string name="cd_open_about">Відкрити розділ «Про застосунок»</string>
<string name="nearby_notes_many">тут залишено %d нотаток — торкніться, щоб прочитати</string>
<string name="nearby_notes_one">тут залишено 1 нотатку — торкніться, щоб прочитати</string>
<string name="nearby_notes_reveal">перевірити, чи залишено тут нотатки</string>
</resources>

View File

@ -184,6 +184,8 @@
<string name="action_hug_subtitle">دوستانہ آلنگن بھیجیں</string>
<string name="action_block_title">%1$s کو بلاک کریں</string>
<string name="action_block_subtitle">اس صارف کے تمام پیغامات بلاک کریں</string>
<string name="action_delete_note_title">نوٹ حذف کریں</string>
<string name="action_delete_note_subtitle">ریلے پر nostr حذف کی درخواست بھیجتا ہے</string>
<!-- geohash چینلز شیٹ -->
<string name="location_channels_title">#مقام چینلز</string>

View File

@ -184,6 +184,8 @@
<string name="action_hug_subtitle">Gửi một cái ôm thân thiện</string>
<string name="action_block_title">Chặn %1$s</string>
<string name="action_block_subtitle">Chặn tất cả tin nhắn từ người dùng này</string>
<string name="action_delete_note_title">xóa ghi chú</string>
<string name="action_delete_note_subtitle">gửi yêu cầu xóa nostr tới các relay</string>
<!-- Sheet kênh geohash -->
<string name="location_channels_title">#Kênh vị trí</string>

View File

@ -394,4 +394,10 @@
<string name="about_app_language">应用语言</string>
<string name="about_system_default">跟随系统</string>
<string name="about_select_language">选择语言</string>
<string name="action_delete_note_subtitle">向中继发送 nostr 删除请求</string>
<string name="action_delete_note_title">删除笔记</string>
<string name="nearby_notes_many">这里留有 %d 条留言 — 点按阅读</string>
<string name="nearby_notes_one">这里留有 1 条留言 — 点按阅读</string>
<string name="nearby_notes_reveal">查看这里留下的留言</string>
</resources>

View File

@ -395,4 +395,10 @@
<string name="about_app_language">應用程式語言</string>
<string name="about_system_default">跟隨系統</string>
<string name="about_select_language">選擇語言</string>
<string name="action_delete_note_subtitle">向中繼發送 nostr 刪除請求</string>
<string name="action_delete_note_title">刪除筆記</string>
<string name="nearby_notes_many">這裡留有 %d 則留言 — 點按閱讀</string>
<string name="nearby_notes_one">這裡留有 1 則留言 — 點按閱讀</string>
<string name="nearby_notes_reveal">查看這裡留下的留言</string>
</resources>

View File

@ -177,6 +177,8 @@
<string name="action_hug_subtitle">发送一个友好的拥抱</string>
<string name="action_block_title">屏蔽 %1$s</string>
<string name="action_block_subtitle">屏蔽该用户的所有消息</string>
<string name="action_delete_note_title">删除笔记</string>
<string name="action_delete_note_subtitle">向中继发送 nostr 删除请求</string>
<!-- 地点频道面板 -->
<string name="location_channels_title">#地点频道</string>

View File

@ -23,7 +23,7 @@
<string name="no_one_connected">No one connected</string>
<string name="emergency_clear_hint">Triple tap to clear all data</string>
<string name="your_network">Network</string>
<!-- Battery Optimization Strings -->
<string name="battery_optimization_detected">Battery Optimization Detected</string>
<string name="battery_optimization_disabled">Battery Optimization Disabled</string>
@ -42,7 +42,7 @@
<string name="battery_optimization_continue">Continue</string>
<string name="retry">Retry</string>
<string name="skip">Skip</string>
<!-- Notifications -->
<string name="notification_summary_more">and %1$d more</string>
<string name="notification_messages_from_people">%1$d messages from %2$d people</string>
@ -352,6 +352,8 @@
<string name="action_block_subtitle">Block all messages from this user</string>
<string name="action_private_message_title">Message %1$s</string>
<string name="action_private_message_subtitle">Send a private message</string>
<string name="action_delete_note_title">Delete note</string>
<string name="action_delete_note_subtitle">Sends a nostr deletion request to relays</string>
<!-- Location channels sheet -->

View File

@ -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-<geohash>"
* - 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-<geohash>".
* The kind:5 deletion handler is stored under "location-deletions".
*/
private val capturedHandlers = mutableMapOf<String, (NostrEvent) -> Unit>()
/** Convenience accessor for the kind:1 note subscription handler.
* Subscription IDs are now "location-notes-<UUID>"; 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<String>?) -> 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<NostrEvent>()
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
}
}

View File

@ -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())
}
}