Gate nearby notes behind tap-to-reveal consent (#771)

* Add nearby notes tap-to-reveal consent

* Stop nearby notes while app is backgrounded
This commit is contained in:
callebtc 2026-07-27 02:17:44 +02:00 committed by GitHub
parent 92d07b22fa
commit d615fc9cc0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 678 additions and 72 deletions

View File

@ -13,7 +13,7 @@ import kotlinx.coroutines.flow.asStateFlow
*/
@MainThread
class LocationNotesManager private constructor() {
companion object {
private const val TAG = "LocationNotesManager"
private const val MAX_NOTES_IN_MEMORY = 500
@ -27,7 +27,7 @@ class LocationNotesManager private constructor() {
}
}
}
/**
* Note data class matching iOS implementation
*/
@ -94,6 +94,8 @@ class LocationNotesManager private constructor() {
// Coroutine scope for background operations
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var subscribeRetryJob: Job? = null
private var initialLoadJob: Job? = null
/**
* Initialize dependencies
@ -289,6 +291,11 @@ class LocationNotesManager private constructor() {
* Subscribe to location notes for current geohash
*/
private fun subscribeAll() {
subscribeRetryJob?.cancel()
subscribeRetryJob = null
initialLoadJob?.cancel()
initialLoadJob = null
val currentGeohash = _geohash.value
if (currentGeohash == null) {
Log.w(TAG, "Cannot subscribe - no geohash set")
@ -301,7 +308,7 @@ class LocationNotesManager private constructor() {
Log.e(TAG, "Cannot subscribe - subscribe function not initialized; will retry shortly")
_state.value = State.LOADING
// Retry a few times in case initialization is racing the sheet open
scope.launch {
subscribeRetryJob = scope.launch {
var attempts = 0
while (attempts < 10 && subscribeFunc == null) {
delay(300)
@ -342,9 +349,9 @@ class LocationNotesManager private constructor() {
}
// Mark initial load complete after brief delay to allow relay responses
scope.launch {
initialLoadJob = scope.launch {
delay(2000) // Wait 2 seconds for initial batch
if (!_initialLoadComplete.value!!) {
if (_geohash.value == currentGeohash && !_initialLoadComplete.value) {
_initialLoadComplete.value = true
_state.value = State.READY
Log.d(TAG, "Initial load complete for geohash: $currentGeohash (${noteIDs.size} notes)")
@ -441,6 +448,11 @@ class LocationNotesManager private constructor() {
* Cancel subscription and clear state
*/
fun cancel() {
subscribeRetryJob?.cancel()
subscribeRetryJob = null
initialLoadJob?.cancel()
initialLoadJob = null
if (subscriptionIDs.isNotEmpty()) {
subscriptionIDs.values.forEach { subId ->
try {
@ -453,17 +465,26 @@ class LocationNotesManager private constructor() {
subscribedGeohashes = emptySet()
_state.value = State.IDLE
}
/**
* Cleanup resources
* End the nearby-notes session and discard location-correlated UI state.
* Unlike [cancel], this also clears the target so a later activation can
* safely subscribe to the same building geohash again.
*/
fun cleanup() {
fun stop() {
cancel()
scope.cancel()
_notes.value = emptyList()
noteIDs.clear()
_geohash.value = null
_initialLoadComplete.value = false
_errorMessage.value = null
}
/**
* Cleanup resources
*/
fun cleanup() {
stop()
scope.cancel()
}
}

View File

@ -0,0 +1,130 @@
package com.bitchat.android.nostr
import androidx.annotation.MainThread
import com.bitchat.android.geohash.GeohashChannel
import com.bitchat.android.geohash.GeohashChannelLevel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Session-scoped consent gate for nearby location notes.
*
* Merely rendering the mesh timeline must not open a building-precision Nostr
* subscription. A subscription is eligible only after an explicit reveal and
* while the app is foregrounded and at least one nearby-notes surface is active.
*/
@MainThread
class NearbyNotesController internal constructor(
private val subscribe: (String) -> Unit,
private val unsubscribe: () -> Unit,
) {
private val _revealed = MutableStateFlow(false)
val revealed: StateFlow<Boolean> = _revealed.asStateFlow()
private var activeHolders = 0
private var locationEnabled = false
private var locationAuthorized = false
private var appForeground = false
private var buildingGeohash: String? = null
private var subscribedGeohash: String? = null
/**
* Unlocks nearby notes for this process session. Deactivation deliberately
* does not reset consent, matching the iOS privacy model.
*/
fun reveal() {
if (_revealed.value) return
_revealed.value = true
reconcileSubscription()
}
/** Holds the subscription while a nearby-notes surface is visible. */
fun activate() {
activeHolders += 1
reconcileSubscription()
}
/** Releases a matching [activate] hold and unsubscribes after the last one. */
fun deactivate() {
activeHolders = (activeHolders - 1).coerceAtLeast(0)
reconcileSubscription()
}
/** Closes the live subscription whenever the process leaves the foreground. */
fun updateAppForeground(isForeground: Boolean) {
appForeground = isForeground
reconcileSubscription()
}
/**
* Updates the privacy-sensitive inputs independently of view activation.
* Permission revocation, location disable, or loss of the building cell
* immediately closes any live subscription.
*/
fun updateAvailability(
locationEnabled: Boolean,
locationAuthorized: Boolean,
buildingGeohash: String?,
) {
this.locationEnabled = locationEnabled
this.locationAuthorized = locationAuthorized
this.buildingGeohash = buildingGeohash
?.trim()
?.lowercase()
?.takeIf { it.isNotEmpty() }
reconcileSubscription()
}
fun offersRevealHint(): Boolean =
!_revealed.value &&
locationEnabled &&
locationAuthorized &&
buildingGeohash != null
private fun reconcileSubscription() {
val target = buildingGeohash.takeIf {
activeHolders > 0 &&
appForeground &&
_revealed.value &&
locationEnabled &&
locationAuthorized
}
if (subscribedGeohash != null && subscribedGeohash != target) {
unsubscribe()
subscribedGeohash = null
}
if (target != null && subscribedGeohash == null) {
subscribe(target)
subscribedGeohash = target
}
}
companion object {
val shared: NearbyNotesController by lazy {
val manager = LocationNotesManager.getInstance()
NearbyNotesController(
subscribe = manager::setGeohash,
unsubscribe = manager::stop,
)
}
}
}
/**
* Building precision is location-notes precision and remains private before a
* reveal. Explicit bookmarks remain eligible because saving one is itself an
* intentional location act.
*/
internal fun geohashesForSampling(
availableChannels: List<GeohashChannel>,
bookmarks: Collection<String>,
notesRevealed: Boolean,
): List<String> = buildSet {
availableChannels
.filter { notesRevealed || it.level != GeohashChannelLevel.BUILDING }
.mapTo(this) { it.geohash }
addAll(bookmarks)
}.toList()

View File

@ -12,19 +12,35 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalContext
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.IconButton
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.zIndex
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
import com.bitchat.android.geohash.ChannelID
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.nostr.LocationNotesManager
import com.bitchat.android.nostr.NearbyNotesController
import com.bitchat.android.ui.media.FullScreenImageViewer
/**
@ -86,6 +102,67 @@ fun ChatScreen(viewModel: ChatViewModel) {
// Get location channel info for timeline switching
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val context = LocalContext.current
val locationManager = remember { LocationChannelManager.getInstance(context) }
val nearbyNotesController = remember { NearbyNotesController.shared }
val nearbyNotesRevealed by nearbyNotesController.revealed.collectAsStateWithLifecycle()
val locationPermissionState by locationManager.permissionState.collectAsStateWithLifecycle()
val locationEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle(false)
val availableLocationChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
val nearbyNotes by remember { LocationNotesManager.getInstance() }
.notes
.collectAsStateWithLifecycle()
val buildingGeohash = availableLocationChannels
.firstOrNull { it.level == GeohashChannelLevel.BUILDING }
?.geohash
val isMeshTimeline =
currentChannel == null &&
selectedLocationChannel is ChannelID.Mesh &&
selectedPrivatePeer == null &&
privateChatSheetPeer == null
val processLifecycleOwner = remember { ProcessLifecycleOwner.get() }
DisposableEffect(processLifecycleOwner, nearbyNotesController) {
val lifecycle = processLifecycleOwner.lifecycle
val observer = object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
nearbyNotesController.updateAppForeground(true)
}
override fun onStop(owner: LifecycleOwner) {
nearbyNotesController.updateAppForeground(false)
}
}
lifecycle.addObserver(observer)
nearbyNotesController.updateAppForeground(
lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED),
)
onDispose {
lifecycle.removeObserver(observer)
nearbyNotesController.updateAppForeground(false)
}
}
DisposableEffect(
isMeshTimeline,
locationEnabled,
locationPermissionState,
buildingGeohash,
nearbyNotesController,
) {
nearbyNotesController.updateAvailability(
locationEnabled = locationEnabled,
locationAuthorized =
locationPermissionState == LocationChannelManager.PermissionState.AUTHORIZED,
buildingGeohash = buildingGeohash,
)
if (isMeshTimeline) nearbyNotesController.activate()
onDispose {
if (isMeshTimeline) nearbyNotesController.deactivate()
}
}
// Determine what messages to show based on current context (unified timelines)
// Legacy private chat timeline removed - private chats now exclusively use PrivateChatSheet
@ -131,58 +208,91 @@ fun ChatScreen(viewModel: ChatViewModel) {
)
// Messages area - takes up available space, will compress when keyboard appears
MessagesList(
messages = displayMessages,
currentUserNickname = nickname,
meshService = viewModel.meshServiceFacade,
modifier = Modifier.weight(1f),
forceScrollToBottom = forceScrollToBottom,
onScrolledUpChanged = { isUp -> isScrolledUp = isUp },
onNicknameClick = { fullSenderName ->
// Single click - mention user in text input
val currentText = messageText.text
// Extract base nickname and hash suffix from full sender name
val (baseName, hashSuffix) = splitSuffix(fullSenderName)
// Check if we're in a geohash channel to include hash suffix
val selectedLocationChannel = viewModel.selectedLocationChannel.value
val mentionText = if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location && hashSuffix.isNotEmpty()) {
// In geohash chat - include the hash suffix from the full display name
"@$baseName$hashSuffix"
} else {
// Regular chat - just the base nickname
"@$baseName"
}
val newText = when {
currentText.isEmpty() -> "$mentionText "
currentText.endsWith(" ") -> "$currentText$mentionText "
else -> "$currentText $mentionText "
}
messageText = TextFieldValue(
text = newText,
selection = TextRange(newText.length)
Column(modifier = Modifier.weight(1f)) {
if (isMeshTimeline && nearbyNotesRevealed && nearbyNotes.isNotEmpty()) {
NearbyNotesStrip(
noteCount = nearbyNotes.size,
onClick = { showLocationNotesSheet = true },
)
},
onMessageLongPress = { message ->
// Message long press - open user action sheet with message context
// Extract base nickname from message sender (contains all necessary info)
val (baseName, _) = splitSuffix(message.sender)
selectedUserForSheet = baseName
selectedMessageForSheet = message
showUserSheet = true
},
onCancelTransfer = { msg ->
viewModel.cancelMediaSend(msg.id)
},
onImageClick = { currentPath, allImagePaths, initialIndex ->
viewerImagePaths = allImagePaths
initialViewerIndex = initialIndex
showFullScreenImageViewer = true
}
)
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
) {
MessagesList(
messages = displayMessages,
currentUserNickname = nickname,
meshService = viewModel.meshServiceFacade,
modifier = Modifier.fillMaxSize(),
forceScrollToBottom = forceScrollToBottom,
onScrolledUpChanged = { isUp -> isScrolledUp = isUp },
onNicknameClick = { fullSenderName ->
// Single click - mention user in text input
val currentText = messageText.text
// Extract base nickname and hash suffix from full sender name
val (baseName, hashSuffix) = splitSuffix(fullSenderName)
// Check if we're in a geohash channel to include hash suffix
val selectedLocationChannel = viewModel.selectedLocationChannel.value
val mentionText = if (
selectedLocationChannel is ChannelID.Location &&
hashSuffix.isNotEmpty()
) {
// In geohash chat - include the hash suffix from the full display name
"@$baseName$hashSuffix"
} else {
// Regular chat - just the base nickname
"@$baseName"
}
val newText = when {
currentText.isEmpty() -> "$mentionText "
currentText.endsWith(" ") -> "$currentText$mentionText "
else -> "$currentText $mentionText "
}
messageText = TextFieldValue(
text = newText,
selection = TextRange(newText.length),
)
},
onMessageLongPress = { message ->
// Message long press - open user action sheet with message context
// Extract base nickname from message sender (contains all necessary info)
val (baseName, _) = splitSuffix(message.sender)
selectedUserForSheet = baseName
selectedMessageForSheet = message
showUserSheet = true
},
onCancelTransfer = { msg ->
viewModel.cancelMediaSend(msg.id)
},
onImageClick = { currentPath, allImagePaths, initialIndex ->
viewerImagePaths = allImagePaths
initialViewerIndex = initialIndex
showFullScreenImageViewer = true
},
)
if (
displayMessages.isEmpty() &&
isMeshTimeline &&
!nearbyNotesRevealed &&
locationEnabled &&
locationPermissionState ==
LocationChannelManager.PermissionState.AUTHORIZED &&
buildingGeohash != null
) {
NearbyNotesRevealHint(
onClick = nearbyNotesController::reveal,
modifier = Modifier.align(Alignment.Center),
)
}
}
}
// Input area - stays at bottom
// Bridge file share from lower-level input to ViewModel
androidx.compose.runtime.LaunchedEffect(Unit) {
@ -253,7 +363,10 @@ fun ChatScreen(viewModel: ChatViewModel) {
onShowAppInfo = { viewModel.showAppInfo() },
onPanicClear = { viewModel.panicClearAllData() },
onLocationChannelsClick = { showLocationChannelsSheet = true },
onLocationNotesClick = { showLocationNotesSheet = true }
onLocationNotesClick = {
nearbyNotesController.reveal()
showLocationNotesSheet = true
}
)
// Divider under header - positioned after status bar + header height
@ -374,6 +487,68 @@ fun ChatScreen(viewModel: ChatViewModel) {
}
}
@Composable
private fun NearbyNotesRevealHint(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val actionLabel = stringResource(R.string.nearby_notes_reveal)
TextButton(
onClick = onClick,
modifier = modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.padding(horizontal = 24.dp)
.semantics { contentDescription = actionLabel },
) {
Text(
text = "📍 $actionLabel",
modifier = Modifier.clearAndSetSemantics { },
color = MaterialTheme.colorScheme.primary,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
}
}
@Composable
private fun NearbyNotesStrip(
noteCount: Int,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
onClick = onClick,
modifier = modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.padding(horizontal = 12.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "📍 " + if (noteCount == 1) {
stringResource(R.string.nearby_notes_one)
} else {
stringResource(R.string.nearby_notes_many, noteCount)
},
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.primary,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
Text(
text = "",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 18.sp,
)
}
}
}
@Composable
fun ChatInputSection(
messageText: TextFieldValue,

View File

@ -34,6 +34,8 @@ import com.bitchat.android.geohash.GeohashChannel
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.geohash.GeohashBookmarksStore
import com.bitchat.android.nostr.NearbyNotesController
import com.bitchat.android.nostr.geohashesForSampling
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -61,6 +63,7 @@ fun LocationChannelsSheet(
// Observe location manager state
val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
val notesRevealed by NearbyNotesController.shared.revealed.collectAsStateWithLifecycle()
val selectedChannel by locationManager.selectedChannel.collectAsStateWithLifecycle()
val locationNames by locationManager.locationNames.collectAsStateWithLifecycle()
val appLocationEnabled by locationManager.locationServicesEnabled.collectAsStateWithLifecycle()
@ -534,9 +537,13 @@ fun LocationChannelsSheet(
}
// Sampling management: update sampling when channels/bookmarks change
LaunchedEffect(isPresented, availableChannels, bookmarks) {
LaunchedEffect(isPresented, availableChannels, bookmarks, notesRevealed) {
if (isPresented) {
val geohashes = (availableChannels.map { it.geohash } + bookmarks).toSet().toList()
val geohashes = geohashesForSampling(
availableChannels = availableChannels,
bookmarks = bookmarks,
notesRevealed = notesRevealed,
)
viewModel.beginGeohashSampling(geohashes)
} else {
viewModel.endGeohashSampling()

View File

@ -32,6 +32,7 @@ import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
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 java.text.SimpleDateFormat
import java.util.*
import java.util.Calendar
@ -58,12 +59,15 @@ fun LocationNotesSheet(
// 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)
val errorMessage by notesManager.errorMessage.collectAsStateWithLifecycle()
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
@ -94,15 +98,24 @@ fun LocationNotesSheet(
locationManager.refreshChannels()
}
// Effect to set geohash when sheet opens
LaunchedEffect(geohash) {
notesManager.setGeohash(geohash)
}
// Cleanup when sheet closes
DisposableEffect(Unit) {
// Opening the notes sheet is an explicit reveal. The balanced hold lets
// the mesh timeline keep the shared subscription alive after dismissal.
DisposableEffect(
geohash,
locationEnabled,
permissionState,
nearbyNotesController,
) {
nearbyNotesController.updateAvailability(
locationEnabled = locationEnabled,
locationAuthorized =
permissionState == LocationChannelManager.PermissionState.AUTHORIZED,
buildingGeohash = geohash,
)
nearbyNotesController.activate()
nearbyNotesController.reveal()
onDispose {
notesManager.cancel()
nearbyNotesController.deactivate()
}
}

View File

@ -401,4 +401,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">فتح قسم حول</string>
<string name="nearby_notes_reveal">تحقّق من الملاحظات المتروكة هنا</string>
<string name="nearby_notes_one">تُركت ملاحظة واحدة هنا — انقر للقراءة</string>
<string name="nearby_notes_many">تُركت %d ملاحظات هنا — انقر للقراءة</string>
</resources>

View File

@ -388,4 +388,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">পরিচিতি খুলুন</string>
<string name="nearby_notes_reveal">এখানে রাখা নোট আছে কি না দেখুন</string>
<string name="nearby_notes_one">এখানে 1টি নোট রাখা আছে — পড়তে ট্যাপ করুন</string>
<string name="nearby_notes_many">এখানে %dটি নোট রাখা আছে — পড়তে ট্যাপ করুন</string>
</resources>

View File

@ -402,4 +402,7 @@
<string name="verify_success_body">Du hast %1$s verifiziert</string>
<string name="verify_success_system_message">verifiziert %1$s</string>
<string name="cd_open_about">Info öffnen</string>
<string name="nearby_notes_reveal">nachsehen, ob hier notizen hinterlassen wurden</string>
<string name="nearby_notes_one">1 notiz hier hinterlassen — tippen zum lesen</string>
<string name="nearby_notes_many">%d notizen hier hinterlassen — tippen zum lesen</string>
</resources>

View File

@ -401,4 +401,7 @@
<string name="verify_success_body">Verificaste a %1$s</string>
<string name="verify_success_system_message">verificado %1$s</string>
<string name="cd_open_about">Abrir Acerca de</string>
<string name="nearby_notes_reveal">buscar notas dejadas aquí</string>
<string name="nearby_notes_one">1 nota dejada aquí — toca para leer</string>
<string name="nearby_notes_many">%d notas dejadas aquí — toca para leer</string>
</resources>

View File

@ -388,4 +388,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">باز کردن درباره</string>
<string name="nearby_notes_reveal">یادداشت‌های باقی‌مانده در اینجا را بررسی کنید</string>
<string name="nearby_notes_one">۱ یادداشت اینجا باقی مانده — برای خواندن ضربه بزنید</string>
<string name="nearby_notes_many">%d یادداشت اینجا باقی مانده — برای خواندن ضربه بزنید</string>
</resources>

View File

@ -400,4 +400,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">Buksan ang Tungkol</string>
<string name="nearby_notes_reveal">tingnan kung may mga note na naiwan dito</string>
<string name="nearby_notes_one">1 note ang naiwan dito — i-tap para basahin</string>
<string name="nearby_notes_many">%d note ang naiwan dito — i-tap para basahin</string>
</resources>

View File

@ -414,4 +414,7 @@
<string name="verify_success_body">Vous avez vérifié %1$s</string>
<string name="verify_success_system_message">vérifié %1$s</string>
<string name="cd_open_about">Ouvrir À propos</string>
<string name="nearby_notes_reveal">vérifier s\'il y a des notes laissées ici</string>
<string name="nearby_notes_one">1 note laissée ici — appuyez pour lire</string>
<string name="nearby_notes_many">%d notes laissées ici — appuyez pour lire</string>
</resources>

View File

@ -54,4 +54,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">פתיחת אודות</string>
<string name="nearby_notes_reveal">בדיקה אם הושארו כאן פתקים</string>
<string name="nearby_notes_one">פתק אחד הושאר כאן — הקש לקריאה</string>
<string name="nearby_notes_many">%d פתקים הושארו כאן — הקש לקריאה</string>
</resources>

View File

@ -401,4 +401,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">परिचय खोलें</string>
<string name="nearby_notes_reveal">देखें कि यहाँ नोट छोड़े गए हैं या नहीं</string>
<string name="nearby_notes_one">यहाँ 1 नोट छोड़ा गया है — पढ़ने के लिए टैप करें</string>
<string name="nearby_notes_many">यहाँ %d नोट छोड़े गए हैं — पढ़ने के लिए टैप करें</string>
</resources>

View File

@ -401,4 +401,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">Buka Tentang</string>
<string name="nearby_notes_reveal">periksa catatan yang ditinggalkan di sini</string>
<string name="nearby_notes_one">1 catatan ditinggalkan di sini — ketuk untuk membaca</string>
<string name="nearby_notes_many">%d catatan ditinggalkan di sini — ketuk untuk membaca</string>
</resources>

View File

@ -434,4 +434,7 @@
<string name="verify_success_body">Hai verificato %1$s</string>
<string name="verify_success_system_message">verificato %1$s</string>
<string name="cd_open_about">Apri Informazioni</string>
<string name="nearby_notes_reveal">controlla se ci sono note lasciate qui</string>
<string name="nearby_notes_one">1 nota lasciata qui — tocca per leggere</string>
<string name="nearby_notes_many">%d note lasciate qui — tocca per leggere</string>
</resources>

View File

@ -401,4 +401,7 @@
<string name="verify_success_body">%1$s を検証しました</string>
<string name="verify_success_system_message">%1$s を検証しました</string>
<string name="cd_open_about">このアプリについてを開く</string>
<string name="nearby_notes_reveal">ここに残されたメモを確認</string>
<string name="nearby_notes_one">ここに1件のメモがあります — タップして読む</string>
<string name="nearby_notes_many">ここに%d件のメモがあります — タップして読む</string>
</resources>

View File

@ -388,4 +388,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">აპის შესახებ გახსნა</string>
<string name="nearby_notes_reveal">აქ დატოვებული ჩანაწერების შემოწმება</string>
<string name="nearby_notes_one">აქ 1 ჩანაწერია დატოვებული — წასაკითხად შეეხეთ</string>
<string name="nearby_notes_many">აქ %d ჩანაწერია დატოვებული — წასაკითხად შეეხეთ</string>
</resources>

View File

@ -401,4 +401,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">정보 열기</string>
<string name="nearby_notes_reveal">여기 남겨진 쪽지 확인</string>
<string name="nearby_notes_one">여기 남겨진 쪽지 1개 — 탭하여 읽기</string>
<string name="nearby_notes_many">여기 남겨진 쪽지 %d개 — 탭하여 읽기</string>
</resources>

View File

@ -414,4 +414,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">Sokafy ny momba</string>
<string name="nearby_notes_reveal">hizaha raha misy naoty navela teto</string>
<string name="nearby_notes_one">naoty 1 no navela teto — tsindrio raha hamaky</string>
<string name="nearby_notes_many">naoty %d no navela teto — tsindrio raha hamaky</string>
</resources>

View File

@ -41,4 +41,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">Buka Perihal</string>
<string name="nearby_notes_reveal">semak nota yang ditinggalkan di sini</string>
<string name="nearby_notes_one">1 nota ditinggalkan di sini — ketik untuk baca</string>
<string name="nearby_notes_many">%d nota ditinggalkan di sini — ketik untuk baca</string>
</resources>

View File

@ -400,4 +400,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">परिचय खोल्नुहोस्</string>
<string name="nearby_notes_reveal">यहाँ छोडिएका नोटहरू छन् कि हेर्नुहोस्</string>
<string name="nearby_notes_one">यहाँ 1 नोट छोडिएको छ — पढ्न ट्याप गर्नुहोस्</string>
<string name="nearby_notes_many">यहाँ %d नोटहरू छोडिएका छन् — पढ्न ट्याप गर्नुहोस्</string>
</resources>

View File

@ -432,4 +432,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">Info openen</string>
<string name="nearby_notes_reveal">kijk of hier notities zijn achtergelaten</string>
<string name="nearby_notes_one">1 notitie hier achtergelaten — tik om te lezen</string>
<string name="nearby_notes_many">%d notities hier achtergelaten — tik om te lezen</string>
</resources>

View File

@ -388,4 +388,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">ایپ بارے کھولو</string>
<string name="nearby_notes_reveal">ایتھے چھڈے نوٹس ویکھو</string>
<string name="nearby_notes_one">ایتھے 1 نوٹ چھڈیا گیا — پڑھݨ لئی ٹیپ کرو</string>
<string name="nearby_notes_many">ایتھے %d نوٹس چھڈے گئے — پڑھݨ لئی ٹیپ کرو</string>
</resources>

View File

@ -54,4 +54,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">Otwórz informacje</string>
<string name="nearby_notes_reveal">sprawdź, czy zostawiono tutaj notatki</string>
<string name="nearby_notes_one">1 notatka zostawiona tutaj — stuknij, aby przeczytać</string>
<string name="nearby_notes_many">%d notatek zostawionych tutaj — stuknij, aby przeczytać</string>
</resources>

View File

@ -400,4 +400,7 @@
<string name="verify_success_title">Verificado</string>
<string name="verify_success_body">Você verificou %1$s</string>
<string name="verify_success_system_message">verificou %1$s</string>
<string name="nearby_notes_reveal">ver se há notas deixadas aqui</string>
<string name="nearby_notes_one">1 nota deixada aqui — toque para ler</string>
<string name="nearby_notes_many">%d notas deixadas aqui — toque para ler</string>
</resources>

View File

@ -401,4 +401,7 @@
<string name="verify_success_body">Você verificou %1$s</string>
<string name="verify_success_system_message">verificou %1$s</string>
<string name="cd_open_about">Abrir Sobre</string>
<string name="nearby_notes_reveal">ver se há notas deixadas aqui</string>
<string name="nearby_notes_one">1 nota deixada aqui — toque para ler</string>
<string name="nearby_notes_many">%d notas deixadas aqui — toque para ler</string>
</resources>

View File

@ -390,4 +390,7 @@
<string name="verify_success_body">Вы проверили %1$s</string>
<string name="verify_success_system_message">проверен %1$s</string>
<string name="cd_open_about">Открыть раздел «О приложении»</string>
<string name="nearby_notes_reveal">проверить, есть ли здесь заметки</string>
<string name="nearby_notes_one">здесь оставлена 1 заметка — нажмите, чтобы прочитать</string>
<string name="nearby_notes_many">здесь оставлено заметок: %d — нажмите, чтобы прочитать</string>
</resources>

View File

@ -388,4 +388,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">Öppna Om</string>
<string name="nearby_notes_reveal">kolla om anteckningar lämnats här</string>
<string name="nearby_notes_one">1 anteckning lämnad här — tryck för att läsa</string>
<string name="nearby_notes_many">%d anteckningar lämnade här — tryck för att läsa</string>
</resources>

View File

@ -41,4 +41,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">அறிமுகத்தைத் திற</string>
<string name="nearby_notes_reveal">இங்கே விடப்பட்ட குறிப்புகள் உள்ளதா எனப் பார்க்கவும்</string>
<string name="nearby_notes_one">இங்கே 1 குறிப்பு விடப்பட்டுள்ளது — படிக்க தட்டவும்</string>
<string name="nearby_notes_many">இங்கே %d குறிப்புகள் விடப்பட்டுள்ளன — படிக்க தட்டவும்</string>
</resources>

View File

@ -388,4 +388,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">เปิดเกี่ยวกับ</string>
<string name="nearby_notes_reveal">ดูว่ามีโน้ตทิ้งไว้ที่นี่หรือไม่</string>
<string name="nearby_notes_one">มี 1 โน้ตทิ้งไว้ที่นี่ — แตะเพื่ออ่าน</string>
<string name="nearby_notes_many">มี %d โน้ตทิ้งไว้ที่นี่ — แตะเพื่ออ่าน</string>
</resources>

View File

@ -388,4 +388,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">Hakkındayı</string>
<string name="nearby_notes_reveal">buraya bırakılan notlara bak</string>
<string name="nearby_notes_one">buraya 1 not bırakıldı — okumak için dokun</string>
<string name="nearby_notes_many">buraya %d not bırakıldı — okumak için dokun</string>
</resources>

View File

@ -41,4 +41,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">Відкрити розділ «Про застосунок»</string>
<string name="nearby_notes_reveal">перевірити, чи залишено тут нотатки</string>
<string name="nearby_notes_one">тут залишено 1 нотатку — торкніться, щоб прочитати</string>
<string name="nearby_notes_many">тут залишено %d нотаток — торкніться, щоб прочитати</string>
</resources>

View File

@ -401,4 +401,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">تعارف کھولیں</string>
<string name="nearby_notes_reveal">دیکھیں کہ یہاں نوٹ چھوڑے گئے ہیں یا نہیں</string>
<string name="nearby_notes_one">یہاں 1 نوٹ چھوڑا گیا ہے — پڑھنے کے لیے تھپتھپائیں</string>
<string name="nearby_notes_many">یہاں %d نوٹ چھوڑے گئے ہیں — پڑھنے کے لیے تھپتھپائیں</string>
</resources>

View File

@ -388,4 +388,7 @@
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_open_about">Mở phần Giới thiệu</string>
<string name="nearby_notes_reveal">kiểm tra ghi chú để lại ở đây</string>
<string name="nearby_notes_one">có 1 ghi chú để lại ở đây — chạm để đọc</string>
<string name="nearby_notes_many">có %d ghi chú để lại ở đây — chạm để đọc</string>
</resources>

View File

@ -53,5 +53,7 @@
<string name="verify_success_title">已验证</string>
<string name="verify_success_body">你已验证 %1$s</string>
<string name="verify_success_system_message">已验证 %1$s</string>
<string name="nearby_notes_reveal">查看这里留下的留言</string>
<string name="nearby_notes_one">这里留有 1 条留言 — 点按阅读</string>
<string name="nearby_notes_many">这里留有 %d 条留言 — 点按阅读</string>
</resources>

View File

@ -53,5 +53,7 @@
<string name="verify_success_title">已验证</string>
<string name="verify_success_body">你已验证 %1$s</string>
<string name="verify_success_system_message">已验证 %1$s</string>
<string name="nearby_notes_reveal">查看這裡留下的留言</string>
<string name="nearby_notes_one">這裡留有 1 則留言 — 點按閱讀</string>
<string name="nearby_notes_many">這裡留有 %d 則留言 — 點按閱讀</string>
</resources>

View File

@ -413,4 +413,7 @@
<string name="verify_success_body">你已验证 %1$s</string>
<string name="verify_success_system_message">已验证 %1$s</string>
<string name="cd_open_about">打开“关于”</string>
<string name="nearby_notes_reveal">查看这里留下的留言</string>
<string name="nearby_notes_one">这里留有 1 条留言 — 点按阅读</string>
<string name="nearby_notes_many">这里留有 %d 条留言 — 点按阅读</string>
</resources>

View File

@ -234,6 +234,10 @@
<string name="location_level_region">region</string>
<!-- Location notes sheet -->
<string name="nearby_notes_reveal">check for notes left here</string>
<string name="nearby_notes_one">1 note left here — tap to read</string>
<!-- Explicit one/many copy mirrors iOS across locales without CLDR quantity gaps. -->
<string name="nearby_notes_many" tools:ignore="PluralsCandidate">%d notes left here — tap to read</string>
<plurals name="location_notes_title">
<item quantity="one">#%1$s ± 1 • %2$d note</item>
<item quantity="other">#%1$s ± 1 • %2$d notes</item>

View File

@ -0,0 +1,159 @@
package com.bitchat.android.nostr
import com.bitchat.android.geohash.GeohashChannel
import com.bitchat.android.geohash.GeohashChannelLevel
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class NearbyNotesControllerTest {
private val subscriptions = mutableListOf<String>()
private var unsubscribeCount = 0
private fun controller() = NearbyNotesController(
subscribe = subscriptions::add,
unsubscribe = { unsubscribeCount += 1 },
)
private fun foregroundController() = controller().also {
it.updateAppForeground(true)
}
@Test
fun `active mesh timeline does not subscribe before explicit reveal`() {
val controller = foregroundController()
controller.updateAvailability(
locationEnabled = true,
locationAuthorized = true,
buildingGeohash = "u4pruydq",
)
controller.activate()
assertTrue(controller.offersRevealHint())
assertTrue(subscriptions.isEmpty())
controller.reveal()
assertFalse(controller.offersRevealHint())
assertEquals(listOf("u4pruydq"), subscriptions)
}
@Test
fun `reveal remains dormant until a nearby notes surface is active`() {
val controller = foregroundController()
controller.updateAvailability(true, true, "u4pruydq")
controller.reveal()
assertTrue(subscriptions.isEmpty())
controller.activate()
assertEquals(listOf("u4pruydq"), subscriptions)
}
@Test
fun `last deactivate unsubscribes exactly once`() {
val controller = foregroundController()
controller.updateAvailability(true, true, "u4pruydq")
controller.reveal()
controller.activate()
controller.activate()
controller.deactivate()
assertEquals(0, unsubscribeCount)
controller.deactivate()
controller.deactivate()
assertEquals(1, unsubscribeCount)
}
@Test
fun `backgrounding closes the subscription and foregrounding restores it`() {
val controller = foregroundController()
controller.updateAvailability(true, true, "u4pruydq")
controller.activate()
controller.reveal()
controller.updateAppForeground(false)
assertEquals(1, unsubscribeCount)
assertTrue(controller.revealed.value)
controller.updateAppForeground(false)
assertEquals(1, unsubscribeCount)
controller.updateAppForeground(true)
assertEquals(listOf("u4pruydq", "u4pruydq"), subscriptions)
}
@Test
fun `disable and permission revocation close the live subscription`() {
val controller = foregroundController()
controller.updateAvailability(true, true, "u4pruydq")
controller.activate()
controller.reveal()
controller.updateAvailability(false, true, "u4pruydq")
assertEquals(1, unsubscribeCount)
controller.updateAvailability(true, true, "u4pruydq")
assertEquals(listOf("u4pruydq", "u4pruydq"), subscriptions)
controller.updateAvailability(true, false, "u4pruydq")
assertEquals(2, unsubscribeCount)
}
@Test
fun `moving building cells releases old subscription before retargeting`() {
val events = mutableListOf<String>()
val controller = NearbyNotesController(
subscribe = { events += "subscribe:$it" },
unsubscribe = { events += "unsubscribe" },
)
controller.updateAppForeground(true)
controller.updateAvailability(true, true, "u4pruydq")
controller.activate()
controller.reveal()
controller.updateAvailability(true, true, "u4pruydr")
assertEquals(
listOf(
"subscribe:u4pruydq",
"unsubscribe",
"subscribe:u4pruydr",
),
events,
)
}
@Test
fun `building sampling is excluded until reveal while bookmarks remain eligible`() {
val channels = listOf(
GeohashChannel(GeohashChannelLevel.BUILDING, "u4pruydq"),
GeohashChannel(GeohashChannelLevel.BLOCK, "u4pruyd"),
GeohashChannel(GeohashChannelLevel.CITY, "u4pru"),
)
assertEquals(
listOf("u4pruyd", "u4pru", "saved123"),
geohashesForSampling(
availableChannels = channels,
bookmarks = listOf("saved123"),
notesRevealed = false,
),
)
assertEquals(
listOf("u4pruydq", "u4pruyd", "u4pru", "saved123"),
geohashesForSampling(
availableChannels = channels,
bookmarks = listOf("saved123"),
notesRevealed = true,
),
)
}
}