From d615fc9cc07faa8ba243c25bb769fb617ecc38ea Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:17:44 +0200 Subject: [PATCH] Gate nearby notes behind tap-to-reveal consent (#771) * Add nearby notes tap-to-reveal consent * Stop nearby notes while app is backgrounded --- .../android/nostr/LocationNotesManager.kt | 39 ++- .../android/nostr/NearbyNotesController.kt | 130 ++++++++ .../java/com/bitchat/android/ui/ChatScreen.kt | 277 ++++++++++++++---- .../android/ui/LocationChannelsSheet.kt | 11 +- .../bitchat/android/ui/LocationNotesSheet.kt | 29 +- app/src/main/res/values-ar/strings.xml | 3 + app/src/main/res/values-bn/strings.xml | 3 + app/src/main/res/values-de/strings.xml | 3 + app/src/main/res/values-es/strings.xml | 3 + app/src/main/res/values-fa/strings.xml | 3 + app/src/main/res/values-fil/strings.xml | 3 + app/src/main/res/values-fr/strings.xml | 3 + app/src/main/res/values-he/strings.xml | 3 + app/src/main/res/values-hi/strings.xml | 3 + app/src/main/res/values-id/strings.xml | 3 + app/src/main/res/values-it/strings.xml | 3 + app/src/main/res/values-ja/strings.xml | 3 + app/src/main/res/values-ka/strings.xml | 3 + app/src/main/res/values-ko/strings.xml | 3 + app/src/main/res/values-mg/strings.xml | 3 + app/src/main/res/values-ms/strings.xml | 3 + app/src/main/res/values-ne/strings.xml | 3 + app/src/main/res/values-nl/strings.xml | 3 + app/src/main/res/values-pa-rPK/strings.xml | 3 + app/src/main/res/values-pl/strings.xml | 3 + app/src/main/res/values-pt-rBR/strings.xml | 3 + app/src/main/res/values-pt/strings.xml | 3 + app/src/main/res/values-ru/strings.xml | 3 + app/src/main/res/values-sv/strings.xml | 3 + app/src/main/res/values-ta/strings.xml | 3 + app/src/main/res/values-th/strings.xml | 3 + app/src/main/res/values-tr/strings.xml | 3 + app/src/main/res/values-uk/strings.xml | 3 + app/src/main/res/values-ur/strings.xml | 3 + app/src/main/res/values-vi/strings.xml | 3 + app/src/main/res/values-zh-rCN/strings.xml | 4 +- app/src/main/res/values-zh-rTW/strings.xml | 4 +- app/src/main/res/values-zh/strings.xml | 3 + app/src/main/res/values/strings.xml | 4 + .../nostr/NearbyNotesControllerTest.kt | 159 ++++++++++ 40 files changed, 678 insertions(+), 72 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/nostr/NearbyNotesController.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/nostr/NearbyNotesControllerTest.kt diff --git a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt index dc1a8e85..8873c271 100644 --- a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt @@ -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() + } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NearbyNotesController.kt b/app/src/main/java/com/bitchat/android/nostr/NearbyNotesController.kt new file mode 100644 index 00000000..a5f1a8ec --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NearbyNotesController.kt @@ -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 = _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, + bookmarks: Collection, + notesRevealed: Boolean, +): List = buildSet { + availableChannels + .filter { notesRevealed || it.level != GeohashChannelLevel.BUILDING } + .mapTo(this) { it.geohash } + addAll(bookmarks) +}.toList() diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 78d5b571..0514217d 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -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, diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 99ed10cc..0732d912 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -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() diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt index 29ead6a6..6a224cf2 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheet.kt @@ -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() } } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 4d2ffec0..c81bdb26 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -401,4 +401,7 @@ You verified %1$s verified %1$s فتح قسم حول + تحقّق من الملاحظات المتروكة هنا + تُركت ملاحظة واحدة هنا — انقر للقراءة + تُركت %d ملاحظات هنا — انقر للقراءة diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index c5902ee2..95b3662a 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -388,4 +388,7 @@ You verified %1$s verified %1$s পরিচিতি খুলুন + এখানে রাখা নোট আছে কি না দেখুন + এখানে 1টি নোট রাখা আছে — পড়তে ট্যাপ করুন + এখানে %dটি নোট রাখা আছে — পড়তে ট্যাপ করুন diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9002f9bd..a80dc4c4 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -402,4 +402,7 @@ Du hast %1$s verifiziert verifiziert %1$s Info öffnen + nachsehen, ob hier notizen hinterlassen wurden + 1 notiz hier hinterlassen — tippen zum lesen + %d notizen hier hinterlassen — tippen zum lesen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index cf6b336e..157ea1b4 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -401,4 +401,7 @@ Verificaste a %1$s verificado %1$s Abrir Acerca de + buscar notas dejadas aquí + 1 nota dejada aquí — toca para leer + %d notas dejadas aquí — toca para leer diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 84273529..8d7d6ae9 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -388,4 +388,7 @@ You verified %1$s verified %1$s باز کردن درباره + یادداشت‌های باقی‌مانده در اینجا را بررسی کنید + ۱ یادداشت اینجا باقی مانده — برای خواندن ضربه بزنید + %d یادداشت اینجا باقی مانده — برای خواندن ضربه بزنید diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index 65e4cbd2..829079a6 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -400,4 +400,7 @@ You verified %1$s verified %1$s Buksan ang Tungkol + tingnan kung may mga note na naiwan dito + 1 note ang naiwan dito — i-tap para basahin + %d note ang naiwan dito — i-tap para basahin diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index aee12bf1..46e9c023 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -414,4 +414,7 @@ Vous avez vérifié %1$s vérifié %1$s Ouvrir À propos + vérifier s\'il y a des notes laissées ici + 1 note laissée ici — appuyez pour lire + %d notes laissées ici — appuyez pour lire diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml index a78d83b6..3793942b 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -54,4 +54,7 @@ You verified %1$s verified %1$s פתיחת אודות + בדיקה אם הושארו כאן פתקים + פתק אחד הושאר כאן — הקש לקריאה + %d פתקים הושארו כאן — הקש לקריאה diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 926cc3a4..ffb4dac0 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -401,4 +401,7 @@ You verified %1$s verified %1$s परिचय खोलें + देखें कि यहाँ नोट छोड़े गए हैं या नहीं + यहाँ 1 नोट छोड़ा गया है — पढ़ने के लिए टैप करें + यहाँ %d नोट छोड़े गए हैं — पढ़ने के लिए टैप करें diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index bb15e3bb..e67caded 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -401,4 +401,7 @@ You verified %1$s verified %1$s Buka Tentang + periksa catatan yang ditinggalkan di sini + 1 catatan ditinggalkan di sini — ketuk untuk membaca + %d catatan ditinggalkan di sini — ketuk untuk membaca diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index c10a8575..92bf4ad0 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -434,4 +434,7 @@ Hai verificato %1$s verificato %1$s Apri Informazioni + controlla se ci sono note lasciate qui + 1 nota lasciata qui — tocca per leggere + %d note lasciate qui — tocca per leggere diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index eb9d6ba1..1b25aa95 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -401,4 +401,7 @@ %1$s を検証しました %1$s を検証しました このアプリについてを開く + ここに残されたメモを確認 + ここに1件のメモがあります — タップして読む + ここに%d件のメモがあります — タップして読む diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index 1e3187d0..c7620e19 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -388,4 +388,7 @@ You verified %1$s verified %1$s აპის შესახებ გახსნა + აქ დატოვებული ჩანაწერების შემოწმება + აქ 1 ჩანაწერია დატოვებული — წასაკითხად შეეხეთ + აქ %d ჩანაწერია დატოვებული — წასაკითხად შეეხეთ diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 829cd1f5..8c0cc624 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -401,4 +401,7 @@ You verified %1$s verified %1$s 정보 열기 + 여기 남겨진 쪽지 확인 + 여기 남겨진 쪽지 1개 — 탭하여 읽기 + 여기 남겨진 쪽지 %d개 — 탭하여 읽기 diff --git a/app/src/main/res/values-mg/strings.xml b/app/src/main/res/values-mg/strings.xml index a7edd747..ddff38ad 100644 --- a/app/src/main/res/values-mg/strings.xml +++ b/app/src/main/res/values-mg/strings.xml @@ -414,4 +414,7 @@ You verified %1$s verified %1$s Sokafy ny momba + hizaha raha misy naoty navela teto + naoty 1 no navela teto — tsindrio raha hamaky + naoty %d no navela teto — tsindrio raha hamaky diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 9b6f2bb8..add7b467 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -41,4 +41,7 @@ You verified %1$s verified %1$s Buka Perihal + semak nota yang ditinggalkan di sini + 1 nota ditinggalkan di sini — ketik untuk baca + %d nota ditinggalkan di sini — ketik untuk baca diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index f1dbb3c6..3e50146a 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -400,4 +400,7 @@ You verified %1$s verified %1$s परिचय खोल्नुहोस् + यहाँ छोडिएका नोटहरू छन् कि हेर्नुहोस् + यहाँ 1 नोट छोडिएको छ — पढ्न ट्याप गर्नुहोस् + यहाँ %d नोटहरू छोडिएका छन् — पढ्न ट्याप गर्नुहोस् diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 6a014f87..38385885 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -432,4 +432,7 @@ You verified %1$s verified %1$s Info openen + kijk of hier notities zijn achtergelaten + 1 notitie hier achtergelaten — tik om te lezen + %d notities hier achtergelaten — tik om te lezen diff --git a/app/src/main/res/values-pa-rPK/strings.xml b/app/src/main/res/values-pa-rPK/strings.xml index 3b94bd8c..e92d2d51 100644 --- a/app/src/main/res/values-pa-rPK/strings.xml +++ b/app/src/main/res/values-pa-rPK/strings.xml @@ -388,4 +388,7 @@ You verified %1$s verified %1$s ایپ بارے کھولو + ایتھے چھڈے نوٹس ویکھو + ایتھے 1 نوٹ چھڈیا گیا — پڑھݨ لئی ٹیپ کرو + ایتھے %d نوٹس چھڈے گئے — پڑھݨ لئی ٹیپ کرو diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 1579f9b3..f1a67349 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -54,4 +54,7 @@ You verified %1$s verified %1$s Otwórz informacje + sprawdź, czy zostawiono tutaj notatki + 1 notatka zostawiona tutaj — stuknij, aby przeczytać + %d notatek zostawionych tutaj — stuknij, aby przeczytać diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 45f41512..67503cc6 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -400,4 +400,7 @@ Verificado Você verificou %1$s verificou %1$s + ver se há notas deixadas aqui + 1 nota deixada aqui — toque para ler + %d notas deixadas aqui — toque para ler diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 274bbaae..3bcad4ab 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -401,4 +401,7 @@ Você verificou %1$s verificou %1$s Abrir Sobre + ver se há notas deixadas aqui + 1 nota deixada aqui — toque para ler + %d notas deixadas aqui — toque para ler diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index d1d27acd..9ce4a560 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -390,4 +390,7 @@ Вы проверили %1$s проверен %1$s Открыть раздел «О приложении» + проверить, есть ли здесь заметки + здесь оставлена 1 заметка — нажмите, чтобы прочитать + здесь оставлено заметок: %d — нажмите, чтобы прочитать diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index aa3194ed..566f940e 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -388,4 +388,7 @@ You verified %1$s verified %1$s Öppna Om + kolla om anteckningar lämnats här + 1 anteckning lämnad här — tryck för att läsa + %d anteckningar lämnade här — tryck för att läsa diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index a47d4dbd..05bd4750 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -41,4 +41,7 @@ You verified %1$s verified %1$s அறிமுகத்தைத் திற + இங்கே விடப்பட்ட குறிப்புகள் உள்ளதா எனப் பார்க்கவும் + இங்கே 1 குறிப்பு விடப்பட்டுள்ளது — படிக்க தட்டவும் + இங்கே %d குறிப்புகள் விடப்பட்டுள்ளன — படிக்க தட்டவும் diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 5b2ba927..817cd335 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -388,4 +388,7 @@ You verified %1$s verified %1$s เปิดเกี่ยวกับ + ดูว่ามีโน้ตทิ้งไว้ที่นี่หรือไม่ + มี 1 โน้ตทิ้งไว้ที่นี่ — แตะเพื่ออ่าน + มี %d โน้ตทิ้งไว้ที่นี่ — แตะเพื่ออ่าน diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index d27d1b59..93888483 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -388,4 +388,7 @@ You verified %1$s verified %1$s Hakkında’yı aç + buraya bırakılan notlara bak + buraya 1 not bırakıldı — okumak için dokun + buraya %d not bırakıldı — okumak için dokun diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 9ce840ac..9ca2d2d0 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -41,4 +41,7 @@ You verified %1$s verified %1$s Відкрити розділ «Про застосунок» + перевірити, чи залишено тут нотатки + тут залишено 1 нотатку — торкніться, щоб прочитати + тут залишено %d нотаток — торкніться, щоб прочитати diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index 1d8399de..3f1c735a 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -401,4 +401,7 @@ You verified %1$s verified %1$s تعارف کھولیں + دیکھیں کہ یہاں نوٹ چھوڑے گئے ہیں یا نہیں + یہاں 1 نوٹ چھوڑا گیا ہے — پڑھنے کے لیے تھپتھپائیں + یہاں %d نوٹ چھوڑے گئے ہیں — پڑھنے کے لیے تھپتھپائیں diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 6ab72813..4dd4ad37 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -388,4 +388,7 @@ You verified %1$s verified %1$s Mở phần Giới thiệu + kiểm tra ghi chú để lại ở đây + có 1 ghi chú để lại ở đây — chạm để đọc + có %d ghi chú để lại ở đây — chạm để đọc diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 69bff70c..438131b0 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -53,5 +53,7 @@ 已验证 你已验证 %1$s 已验证 %1$s + 查看这里留下的留言 + 这里留有 1 条留言 — 点按阅读 + 这里留有 %d 条留言 — 点按阅读 - diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 1dcf6b27..328696a0 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -53,5 +53,7 @@ 已验证 你已验证 %1$s 已验证 %1$s + 查看這裡留下的留言 + 這裡留有 1 則留言 — 點按閱讀 + 這裡留有 %d 則留言 — 點按閱讀 - diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 8a28e5f7..f8879cc8 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -413,4 +413,7 @@ 你已验证 %1$s 已验证 %1$s 打开“关于” + 查看这里留下的留言 + 这里留有 1 条留言 — 点按阅读 + 这里留有 %d 条留言 — 点按阅读 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e674112c..b8086f0a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -234,6 +234,10 @@ region + check for notes left here + 1 note left here — tap to read + + %d notes left here — tap to read #%1$s ± 1 • %2$d note #%1$s ± 1 • %2$d notes diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NearbyNotesControllerTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NearbyNotesControllerTest.kt new file mode 100644 index 00000000..6bd86042 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NearbyNotesControllerTest.kt @@ -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() + 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() + 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, + ), + ) + } +}