diff --git a/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt b/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt index ae453bed1..23b707e8b 100644 --- a/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt +++ b/app/src/main/java/com/bitchat/android/favorites/FavoritesPersistenceService.kt @@ -18,6 +18,7 @@ data class FavoriteRelationship( val peerNickname: String, val isFavorite: Boolean, // We favorited them val theyFavoritedUs: Boolean, // They favorited us + val isPrivateContact: Boolean = false, // Private contacts are kept in a dedicated list val favoritedAt: Date, val lastUpdated: Date ) { @@ -34,6 +35,7 @@ data class FavoriteRelationship( if (peerNickname != other.peerNickname) return false if (isFavorite != other.isFavorite) return false if (theyFavoritedUs != other.theyFavoritedUs) return false + if (isPrivateContact != other.isPrivateContact) return false return true } @@ -44,6 +46,7 @@ data class FavoriteRelationship( result = 31 * result + peerNickname.hashCode() result = 31 * result + isFavorite.hashCode() result = 31 * result + theyFavoritedUs.hashCode() + result = 31 * result + isPrivateContact.hashCode() return result } } @@ -67,6 +70,25 @@ internal fun FavoriteRelationship?.withPeerFavoritedUs( ) } +internal fun FavoriteRelationship?.withPrivateContactStatus( + isPrivateContact: Boolean, + now: Date = Date() +): FavoriteRelationship { + return this?.copy( + isPrivateContact = isPrivateContact, + lastUpdated = now + ) ?: FavoriteRelationship( + peerNoisePublicKey = ByteArray(0), + peerNostrPublicKey = null, + peerNickname = "Unknown", + isFavorite = false, + theyFavoritedUs = false, + isPrivateContact = isPrivateContact, + favoritedAt = now, + lastUpdated = now + ) +} + interface FavoritesChangeListener { fun onFavoriteChanged(noiseKeyHex: String) fun onAllCleared() @@ -264,8 +286,21 @@ class FavoritesPersistenceService private constructor(private val context: Conte fun getMutualFavorites(): List = favorites.values.filter { it.isMutual } fun getOurFavorites(): List = favorites.values.filter { it.isFavorite } + fun getPrivateContacts(): List = favorites.values.filter { it.isPrivateContact } fun getAllRelationships(): List = favorites.values.toList() + fun updatePrivateContactStatus(noisePublicKey: ByteArray, isPrivateContact: Boolean) { + val keyHex = ContactIdentityResolver.noiseKeyHex(noisePublicKey) + val existing = favorites[keyHex] + val updated = existing.withPrivateContactStatus(isPrivateContact, Date()) + favorites[keyHex] = updated.copy( + peerNoisePublicKey = noisePublicKey, + peerNickname = existing?.peerNickname ?: updated.peerNickname + ) + saveFavorites() + notifyChanged(keyHex) + } + fun clearAllFavorites() { favorites.clear() saveFavorites() @@ -377,6 +412,7 @@ private data class FavoriteRelationshipData( val peerNickname: String, val isFavorite: Boolean, val theyFavoritedUs: Boolean, + val isPrivateContact: Boolean, val favoritedAt: Long, val lastUpdated: Long ) { @@ -388,6 +424,7 @@ private data class FavoriteRelationshipData( peerNickname = relationship.peerNickname, isFavorite = relationship.isFavorite, theyFavoritedUs = relationship.theyFavoritedUs, + isPrivateContact = relationship.isPrivateContact, favoritedAt = relationship.favoritedAt.time, lastUpdated = relationship.lastUpdated.time ) @@ -402,6 +439,7 @@ private data class FavoriteRelationshipData( peerNickname = peerNickname, isFavorite = isFavorite, theyFavoritedUs = theyFavoritedUs, + isPrivateContact = isPrivateContact, favoritedAt = Date(favoritedAt), lastUpdated = Date(lastUpdated) ) diff --git a/app/src/main/java/com/bitchat/android/geohash/DistanceLocationService.kt b/app/src/main/java/com/bitchat/android/geohash/DistanceLocationService.kt new file mode 100644 index 000000000..d25ea56d2 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/geohash/DistanceLocationService.kt @@ -0,0 +1,329 @@ +package com.bitchat.android.geohash + +import android.location.Location +import android.util.Log +import kotlin.math.* + +/** + * Service para localizar pares y dispositivos basado en distancia. + * Proporciona funciones de cálculo de distancia entre coordenadas usando la fórmula de Haversine. + * Compatible con el sistema de geohash existente de bitchat. + */ +object DistanceLocationService { + private const val TAG = "DistanceLocationService" + + // Radio de la Tierra en metros + private const val EARTH_RADIUS_METERS = 6371000.0 + + /** + * Coordenadas geográficas de un peer o ubicación + */ + data class GeoLocation( + val latitude: Double, + val longitude: Double, + val peerId: String? = null, + val nickname: String? = null, + val accuracy: Float? = null, + val timestamp: Long = System.currentTimeMillis() + ) + + /** + * Resultado de búsqueda de pares cercanos + */ + data class NearbyPeer( + val peerId: String, + val nickname: String?, + val latitude: Double, + val longitude: Double, + val distanceMeters: Double, + val bearing: Float = 0f, + val accuracy: Float? = null + ) + + /** + * Calcula la distancia entre dos ubicaciones usando la fórmula de Haversine. + * Esta fórmula es más precisa para distancias cortas y medianas. + * + * @param from Primera ubicación + * @param to Segunda ubicación + * @return Distancia en metros + */ + fun calculateDistance(from: GeoLocation, to: GeoLocation): Double { + return calculateDistance( + from.latitude, from.longitude, + to.latitude, to.longitude + ) + } + + /** + * Calcula la distancia entre dos puntos en coordenadas lat/lon. + * + * @param lat1 Latitud del primer punto en grados + * @param lon1 Longitud del primer punto en grados + * @param lat2 Latitud del segundo punto en grados + * @param lon2 Longitud del segundo punto en grados + * @return Distancia en metros + */ + fun calculateDistance( + lat1: Double, + lon1: Double, + lat2: Double, + lon2: Double + ): Double { + val dLat = Math.toRadians(lat2 - lat1) + val dLon = Math.toRadians(lon2 - lon1) + + val a = sin(dLat / 2).pow(2) + + cos(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * + sin(dLon / 2).pow(2) + + val c = 2 * asin(sqrt(a)) + return EARTH_RADIUS_METERS * c + } + + /** + * Calcula el acimut (bearing) desde el punto "from" hacia el punto "to". + * El acimut va de 0° a 360°, donde 0° es norte, 90° es este, etc. + * + * @param from Ubicación de origen + * @param to Ubicación de destino + * @return Acimut en grados (0-360) + */ + fun calculateBearing(from: GeoLocation, to: GeoLocation): Float { + return calculateBearing( + from.latitude, from.longitude, + to.latitude, to.longitude + ) + } + + /** + * Calcula el acimut entre dos puntos en coordenadas lat/lon. + * + * @param lat1 Latitud del primer punto en grados + * @param lon1 Longitud del primer punto en grados + * @param lat2 Latitud del segundo punto en grados + * @param lon2 Longitud del segundo punto en grados + * @return Acimut en grados (0-360) + */ + fun calculateBearing( + lat1: Double, + lon1: Double, + lat2: Double, + lon2: Double + ): Float { + val dLon = Math.toRadians(lon2 - lon1) + val y = sin(dLon) * cos(Math.toRadians(lat2)) + val x = cos(Math.toRadians(lat1)) * sin(Math.toRadians(lat2)) - + sin(Math.toRadians(lat1)) * cos(Math.toRadians(lat2)) * cos(dLon) + + var bearing = Math.toDegrees(atan2(y, x)).toFloat() + bearing = (bearing + 360) % 360 + return bearing + } + + /** + * Filtra una lista de pares cercanos dentro de un radio específico. + * + * @param currentLocation Ubicación actual del usuario + * @param peers Lista de pares con ubicaciones conocidas + * @param radiusMeters Radio de búsqueda en metros + * @return Lista de pares cercanos ordenados por distancia (ascendente) + */ + fun findNearbyPeers( + currentLocation: GeoLocation, + peers: List, + radiusMeters: Double + ): List { + return peers + .mapNotNull { peer -> + val distance = calculateDistance(currentLocation, peer) + if (distance <= radiusMeters) { + val bearing = calculateBearing(currentLocation, peer) + NearbyPeer( + peerId = peer.peerId ?: return@mapNotNull null, + nickname = peer.nickname, + latitude = peer.latitude, + longitude = peer.longitude, + distanceMeters = distance, + bearing = bearing, + accuracy = peer.accuracy + ) + } else { + null + } + } + .sortedBy { it.distanceMeters } + } + + /** + * Filtra pares cercanos usando un geohash de referencia. + * Los pares dentro de la misma celda de geohash y sus vecinas se consideran cercanos. + * + * @param myGeohash El geohash de la ubicación actual + * @param precision Precisión del geohash (número de caracteres) + * @param peerGeohashes Mapa de peerId -> geohash + * @return Lista de peerIds que están cerca basado en geohash + */ + fun findNearbyPeersByGeohash( + myGeohash: String, + precision: Int, + peerGeohashes: Map + ): List { + // Obtener la celda actual y sus vecinas + val neighbors = Geohash.neighborsSamePrecision(myGeohash) + val nearbyGeohashes = neighbors + myGeohash + + return peerGeohashes + .filter { (_, peerGeohash) -> + // Verificar si el geohash del peer comienza con alguno de los geohashes cercanos + nearbyGeohashes.any { nearby -> + peerGeohash.startsWith(nearby.take(precision)) + } + } + .keys + .toList() + } + + /** + * Convierte una ubicación de Android a GeoLocation. + * + * @param location Objeto Location de Android + * @param peerId ID del peer (opcional) + * @param nickname Apodo del peer (opcional) + * @return GeoLocation correspondiente + */ + fun fromAndroidLocation( + location: Location, + peerId: String? = null, + nickname: String? = null + ): GeoLocation { + return GeoLocation( + latitude = location.latitude, + longitude = location.longitude, + peerId = peerId, + nickname = nickname, + accuracy = location.accuracy, + timestamp = location.time + ) + } + + /** + * Determina la descripción de distancia de forma legible. + * + * @param distanceMeters Distancia en metros + * @return Descripción legible de la distancia + */ + fun formatDistance(distanceMeters: Double): String { + return when { + distanceMeters < 1000 -> "${distanceMeters.toInt()} m" + distanceMeters < 10000 -> "%.1f km".format(distanceMeters / 1000) + else -> "%.0f km".format(distanceMeters / 1000) + } + } + + /** + * Determina la descripción de dirección (bearing) de forma legible. + * + * @param bearing Acimut en grados (0-360) + * @return Descripción de la dirección (N, NE, E, SE, S, SW, W, NW) + */ + fun formatBearing(bearing: Float): String { + return when { + bearing < 22.5 || bearing >= 337.5 -> "N" + bearing < 67.5 -> "NE" + bearing < 112.5 -> "E" + bearing < 157.5 -> "SE" + bearing < 202.5 -> "S" + bearing < 247.5 -> "SW" + bearing < 292.5 -> "W" + else -> "NW" + } + } + + /** + * Calcula el polígono (bounding box) de una región circular. + * Útil para consultas de base de datos espaciales. + * + * @param centerLat Latitud del centro en grados + * @param centerLon Longitud del centro en grados + * @param radiusMeters Radio en metros + * @return Cuatro esquinas del bounding box: (minLat, maxLat, minLon, maxLon) + */ + fun calculateBoundingBox( + centerLat: Double, + centerLon: Double, + radiusMeters: Double + ): Quad { + // Aproximación: 1 grado ≈ 111 km en el ecuador + val latDelta = radiusMeters / 111000.0 + // La delta de longitud depende de la latitud + val lonDelta = radiusMeters / (111000.0 * cos(Math.toRadians(centerLat))) + + return Quad( + centerLat - latDelta, // minLat + centerLat + latDelta, // maxLat + centerLon - lonDelta, // minLon + centerLon + lonDelta // maxLon + ) + } + + /** + * Clase auxiliar para retornar cuatro valores + */ + data class Quad(val first: A, val second: B, val third: C, val fourth: D) + + /** + * Verifica si una ubicación está dentro de un radio circular. + * + * @param centerLat Latitud del centro en grados + * @param centerLon Longitud del centro en grados + * @param radiusMeters Radio en metros + * @param testLat Latitud del punto a verificar en grados + * @param testLon Longitud del punto a verificar en grados + * @return true si el punto está dentro del radio, false en caso contrario + */ + fun isWithinRadius( + centerLat: Double, + centerLon: Double, + radiusMeters: Double, + testLat: Double, + testLon: Double + ): Boolean { + val distance = calculateDistance(centerLat, centerLon, testLat, testLon) + return distance <= radiusMeters + } + + /** + * Calcula el punto intermedio entre dos ubicaciones. + * Útil para encontrar un punto de encuentro entre dos pares. + * + * @param lat1 Latitud del primer punto en grados + * @param lon1 Longitud del primer punto en grados + * @param lat2 Latitud del segundo punto en grados + * @param lon2 Longitud del segundo punto en grados + * @return Pair(latitudMedio, longitudMedio) + */ + fun calculateMidpoint( + lat1: Double, + lon1: Double, + lat2: Double, + lon2: Double + ): Pair { + val dLon = Math.toRadians(lon2 - lon1) + val bx = cos(Math.toRadians(lat2)) * cos(dLon) + val by = cos(Math.toRadians(lat2)) * sin(dLon) + + val lat = atan2( + sin(Math.toRadians(lat1)) + sin(Math.toRadians(lat2)), + sqrt( + (cos(Math.toRadians(lat1)) + bx).pow(2) + by.pow(2) + ) + ) + val lon = Math.toRadians(lon1) + atan2(by, cos(Math.toRadians(lat1)) + bx) + + return Pair( + Math.toDegrees(lat), + Math.toDegrees(lon) + ) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index bf9e2f7cc..a54226cb9 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -233,6 +233,7 @@ class ChatViewModel( private val conversationPresencePeers = MutableStateFlow>(emptyList()) private val conversationPresenceRemovalJobs = mutableMapOf() private val conversationDirectoryRevision = MutableStateFlow(0L) + private val privateContactConversationIDs = MutableStateFlow>(emptySet()) private var favoriteRelationshipListenerRegistered = false private val favoriteRelationshipChangeListener = object : FavoritesChangeListener { override fun onFavoriteChanged(noiseKeyHex: String) { @@ -247,11 +248,26 @@ class ChatViewModel( private fun refreshConversationDirectoryState() { viewModelScope.launch { refreshPeerFavoritedUs() + refreshPrivateContactConversationState() conversationListPreferences.canonicalizeAliases() conversationDirectoryRevision.update { it + 1L } } } + private fun refreshPrivateContactConversationState() { + val privateContacts = runCatching { + FavoritesPersistenceService.shared.getPrivateContacts() + }.getOrDefault(emptyList()) + val ids = privateContacts + .mapNotNull { relationship -> + runCatching { + ContactIdentityResolver.contactConversationIdForNoiseKey(relationship.peerNoisePublicKey) + }.getOrNull() + } + .toSet() + privateContactConversationIDs.value = ids + } + private val conversationLiveIdentityState = combine( conversationPresencePeers, state.peerNicknames, @@ -349,14 +365,17 @@ class ChatViewModel( baseConversations, conversationListPreferences.pinned, conversationListPreferences.muted, - conversationListPreferences.drafts - ) { summaries, pinned, muted, drafts -> + conversationListPreferences.drafts, + privateContactConversationIDs + ) { summaries, pinned, muted, drafts, privateContactKeys -> sortConversationSummaries( summaries.map { summary -> val key = summary.conversationID.lowercase() summary.copy( isPinned = key in pinned, isMuted = key in muted, + isPrivateContact = key in privateContactKeys || + summary.identityAliases.any { it.lowercase() in privateContactKeys }, draft = drafts[key] ) } @@ -1116,6 +1135,26 @@ class ChatViewModel( logCurrentFavoriteState() } + fun togglePrivateContact(conversationID: String) { + val resolution = ContactDirectory.resolve(conversationID) + val noiseKey = resolution.noisePublicKey + if (noiseKey == null) { + Log.w(TAG, "togglePrivateContact: no noise key for $conversationID") + return + } + val privateContacts = runCatching { + FavoritesPersistenceService.shared.getPrivateContacts() + }.getOrDefault(emptyList()) + val isCurrentlyPrivate = privateContacts.any { relationship -> + relationship.peerNoisePublicKey.contentEquals(noiseKey) + } + FavoritesPersistenceService.shared.updatePrivateContactStatus( + noisePublicKey = noiseKey, + isPrivateContact = !isCurrentlyPrivate + ) + refreshPrivateContactConversationState() + } + private fun refreshPeerFavoritedUs() { try { val fingerprints = com.bitchat.android.favorites.FavoritesPersistenceService.shared diff --git a/app/src/main/java/com/bitchat/android/ui/ConversationSummary.kt b/app/src/main/java/com/bitchat/android/ui/ConversationSummary.kt index 2178d793c..b4d1cb710 100644 --- a/app/src/main/java/com/bitchat/android/ui/ConversationSummary.kt +++ b/app/src/main/java/com/bitchat/android/ui/ConversationSummary.kt @@ -26,6 +26,7 @@ internal data class ConversationSummary( val sourceGeohash: String? = null, val isPinned: Boolean = false, val isMuted: Boolean = false, + val isPrivateContact: Boolean = false, val draft: String? = null ) @@ -162,6 +163,7 @@ internal fun sortConversationSummaries( conversations: List ): List = conversations.sortedWith( compareByDescending { it.isConnected } + .thenByDescending { it.isPrivateContact } .thenByDescending { it.isPinned } .thenByDescending { it.unreadCount > 0 } .thenByDescending { it.latestActivityOrder } diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index c6233e1c1..43b0a2792 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -143,6 +143,12 @@ fun MeshPeerListSheet( val offlineConversations = remember(filteredConversations) { filteredConversations.filterNot(ConversationSummary::isConnected) } + val privateContacts = remember(filteredConversations) { + filteredConversations.filter(ConversationSummary::isPrivateContact) + } + val publicConversations = remember(filteredConversations) { + filteredConversations.filterNot(ConversationSummary::isPrivateContact) + } val sheetScope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } @@ -282,6 +288,40 @@ fun MeshPeerListSheet( } } + if (privateContacts.isNotEmpty()) { + item(key = "private_contacts_label") { + ConversationGroupLabel( + text = stringResource(R.string.private_contacts) + ) + } + itemsIndexed( + items = privateContacts, + key = { _, conversation -> + "conversation:${conversation.conversationID}" + } + ) { index, conversation -> + ConversationSwipeItem( + conversation = conversation, + directPeerIdentityIDs = directPeerIdentityIDs, + wifiAwareIdentityIDs = wifiAwareIdentityIDs, + viewModel = viewModel, + isFirst = index == 0, + isLast = index == privateContacts.lastIndex, + onPrivateChatStart = { conversationID -> + viewModel.showPrivateChatSheet(conversationID) + onDismiss() + }, + onDeleteRequested = { pendingConversationDelete = it }, + onReadStateRequested = { item, isRead -> + sheetScope.launch { + viewModel.setConversationRead(item.conversationID, isRead) + } + }, + modifier = Modifier.animateItem() + ) + } + } + if (onlineConversations.isNotEmpty()) { item(key = "private_conversations_online_label") { ConversationGroupLabel( @@ -289,7 +329,7 @@ fun MeshPeerListSheet( ) } itemsIndexed( - items = onlineConversations, + items = onlineConversations.filterNot { it.isPrivateContact }, key = { _, conversation -> "conversation:${conversation.conversationID}" } @@ -323,7 +363,7 @@ fun MeshPeerListSheet( ) } itemsIndexed( - items = offlineConversations, + items = offlineConversations.filterNot { it.isPrivateContact }, key = { _, conversation -> "conversation:${conversation.conversationID}" } @@ -1330,6 +1370,14 @@ private fun ConversationRow( tint = palette.textTertiary ) } + if (conversation.isPrivateContact) { + Icon( + Icons.Filled.Lock, + contentDescription = stringResource(R.string.private_contact_badge), + modifier = Modifier.size(14.dp), + tint = palette.accentOrange + ) + } } Row( verticalAlignment = Alignment.CenterVertically, @@ -1407,6 +1455,24 @@ private fun ConversationRow( onToggleMuted() } ) + DropdownMenuItem( + text = { + Text( + if (conversation.isPrivateContact) { + stringResource(R.string.remove_private_contact) + } else { + stringResource(R.string.add_private_contact) + } + ) + }, + leadingIcon = { + Icon(Icons.Filled.Lock, contentDescription = null) + }, + onClick = { + showActions = false + viewModel.togglePrivateContact(conversation.conversationID) + } + ) DropdownMenuItem( text = { Text(readActionDescription) }, leadingIcon = { diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 3f08ead06..087e6cd7d 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -73,6 +73,10 @@ Accesible vía Nostr Mensajes privados sin leer + Contactos privados + Contacto privado + Agregar a contactos privados + Quitar de contactos privados Teletransportado Abrir ajustes de ubicación y canales Pares conectados diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a73b5bbb4..b11b36ad0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -82,6 +82,10 @@ Nostr reachable Messages Conversations + Private contacts + Private contact + Add to private contacts + Remove from private contacts Offline · not in mesh Offline from mesh · Nostr available Delete diff --git a/app/src/test/java/com/bitchat/android/favorites/FavoriteRelationshipTest.kt b/app/src/test/java/com/bitchat/android/favorites/FavoriteRelationshipTest.kt index 123a0ba40..f4dc2960e 100644 --- a/app/src/test/java/com/bitchat/android/favorites/FavoriteRelationshipTest.kt +++ b/app/src/test/java/com/bitchat/android/favorites/FavoriteRelationshipTest.kt @@ -45,4 +45,24 @@ class FavoriteRelationshipTest { assertTrue(relationship.theyFavoritedUs) assertTrue(relationship.isMutual) } + + @Test + fun `private contact flag can be toggled without losing favorite state`() { + val noiseKey = ByteArray(32) { it.toByte() } + val existing = FavoriteRelationship( + peerNoisePublicKey = noiseKey, + peerNostrPublicKey = null, + peerNickname = "peer", + isFavorite = true, + theyFavoritedUs = false, + favoritedAt = Date(10L), + lastUpdated = Date(20L) + ) + + val relationship = existing.withPrivateContactStatus(isPrivateContact = true, now = Date(30L)) + + assertTrue(relationship.isFavorite) + assertTrue(relationship.isPrivateContact) + assertFalse(relationship.theyFavoritedUs) + } } diff --git a/app/src/test/kotlin/com/bitchat/android/geohash/DistanceLocationServiceTest.kt b/app/src/test/kotlin/com/bitchat/android/geohash/DistanceLocationServiceTest.kt new file mode 100644 index 000000000..57bd5347b --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/geohash/DistanceLocationServiceTest.kt @@ -0,0 +1,216 @@ +package com.bitchat.android.geohash + +import org.junit.Assert.* +import org.junit.Test +import kotlin.math.abs + +class DistanceLocationServiceTest { + + @Test + fun `calculateDistance returns zero for same location`() { + val location = DistanceLocationService.GeoLocation( + latitude = 40.7128, + longitude = -74.0060, + peerId = "peer1" + ) + + val distance = DistanceLocationService.calculateDistance(location, location) + + assertEquals(0.0, distance, 0.1) + } + + @Test + fun `calculateDistance between New York and Los Angeles`() { + // NYC: 40.7128° N, 74.0060° W + // LA: 34.0522° N, 118.2437° W + // Approximate distance: 3944 km + + val distance = DistanceLocationService.calculateDistance( + lat1 = 40.7128, + lon1 = -74.0060, + lat2 = 34.0522, + lon2 = -118.2437 + ) + + // Expected: ~3944 km, with some tolerance for approximation + val expected = 3944000.0 // meters + assertTrue(abs(distance - expected) < 50000) // within 50 km + } + + @Test + fun `calculateDistance 100 meters apart`() { + // Two points roughly 100 meters apart (approximation for testing) + val distance = DistanceLocationService.calculateDistance( + lat1 = 0.0, + lon1 = 0.0, + lat2 = 0.0009, + lon2 = 0.0 // roughly 100 meters at equator + ) + + assertTrue(distance > 90 && distance < 110) + } + + @Test + fun `calculateBearing north direction`() { + val bearing = DistanceLocationService.calculateBearing( + lat1 = 0.0, + lon1 = 0.0, + lat2 = 1.0, + lon2 = 0.0 // North + ) + + assertTrue(bearing < 10 || bearing > 350) + } + + @Test + fun `calculateBearing east direction`() { + val bearing = DistanceLocationService.calculateBearing( + lat1 = 0.0, + lon1 = 0.0, + lat2 = 0.0, + lon2 = 1.0 // East + ) + + assertTrue(bearing > 80 && bearing < 100) + } + + @Test + fun `findNearbyPeers filters by radius`() { + val currentLocation = DistanceLocationService.GeoLocation( + latitude = 40.7128, + longitude = -74.0060, + peerId = "me" + ) + + val peers = listOf( + DistanceLocationService.GeoLocation( + latitude = 40.7200, + longitude = -74.0050, + peerId = "peer1", + nickname = "Alice" + ), + DistanceLocationService.GeoLocation( + latitude = 40.7400, + longitude = -74.0000, + peerId = "peer2", + nickname = "Bob" + ), + DistanceLocationService.GeoLocation( + latitude = 34.0522, + longitude = -118.2437, + peerId = "peer3", + nickname = "Charlie" + ) + ) + + val nearby = DistanceLocationService.findNearbyPeers( + currentLocation = currentLocation, + peers = peers, + radiusMeters = 5000.0 // 5 km radius + ) + + // peer1 and peer2 should be in the 5km radius, peer3 (LA) should not + assertEquals(2, nearby.size) + assertEquals("peer1", nearby[0].peerId) // Should be closest + assertEquals("peer2", nearby[1].peerId) + } + + @Test + fun `findNearbyPeers returns empty for no peers in radius`() { + val currentLocation = DistanceLocationService.GeoLocation( + latitude = 40.7128, + longitude = -74.0060, + peerId = "me" + ) + + val peers = listOf( + DistanceLocationService.GeoLocation( + latitude = 34.0522, + longitude = -118.2437, + peerId = "peer1" + ) + ) + + val nearby = DistanceLocationService.findNearbyPeers( + currentLocation = currentLocation, + peers = peers, + radiusMeters = 1000.0 // 1 km radius + ) + + assertTrue(nearby.isEmpty()) + } + + @Test + fun `formatDistance handles meters`() { + assertEquals("500 m", DistanceLocationService.formatDistance(500.0)) + assertEquals("999 m", DistanceLocationService.formatDistance(999.0)) + } + + @Test + fun `formatDistance handles kilometers`() { + assertEquals("1.5 km", DistanceLocationService.formatDistance(1500.0)) + assertEquals("50.0 km", DistanceLocationService.formatDistance(50000.0)) + } + + @Test + fun `formatBearing north`() { + assertEquals("N", DistanceLocationService.formatBearing(0f)) + assertEquals("N", DistanceLocationService.formatBearing(350f)) + } + + @Test + fun `formatBearing cardinal directions`() { + assertEquals("E", DistanceLocationService.formatBearing(90f)) + assertEquals("S", DistanceLocationService.formatBearing(180f)) + assertEquals("W", DistanceLocationService.formatBearing(270f)) + } + + @Test + fun `formatBearing intercardinal directions`() { + assertEquals("NE", DistanceLocationService.formatBearing(45f)) + assertEquals("SE", DistanceLocationService.formatBearing(135f)) + assertEquals("SW", DistanceLocationService.formatBearing(225f)) + assertEquals("NW", DistanceLocationService.formatBearing(315f)) + } + + @Test + fun `isWithinRadius detects point inside circle`() { + val isInside = DistanceLocationService.isWithinRadius( + centerLat = 0.0, + centerLon = 0.0, + radiusMeters = 1000.0, + testLat = 0.005, + testLon = 0.005 + ) + + assertTrue(isInside) + } + + @Test + fun `isWithinRadius detects point outside circle`() { + val isInside = DistanceLocationService.isWithinRadius( + centerLat = 0.0, + centerLon = 0.0, + radiusMeters = 1000.0, + testLat = 1.0, + testLon = 1.0 + ) + + assertFalse(isInside) + } + + @Test + fun `calculateMidpoint between two points`() { + // Midpoint between equator and 2 degrees north + val (midLat, midLon) = DistanceLocationService.calculateMidpoint( + lat1 = 0.0, + lon1 = 0.0, + lat2 = 2.0, + lon2 = 0.0 + ) + + // Should be approximately 1 degree north + assertTrue(midLat > 0.9 && midLat < 1.1) + assertTrue(midLon > -0.1 && midLon < 0.1) + } +} diff --git a/docs/distance-location-service.md b/docs/distance-location-service.md new file mode 100644 index 000000000..0d69ebde8 --- /dev/null +++ b/docs/distance-location-service.md @@ -0,0 +1,222 @@ +# Servicio de Localización por Distancia - DistanceLocationService + +## Descripción General + +`DistanceLocationService` es un servicio de geolocalización que permite: + +- **Calcular distancias** entre dos puntos usando la fórmula de Haversine +- **Encontrar pares cercanos** dentro de un radio específico +- **Calcular direcciones** (acimut/bearing) entre ubicaciones +- **Integración con geohash** para búsquedas espaciales eficientes +- **Formateo de distancias** en formato legible + +## Uso Básico + +### Calcular Distancia Entre Dos Puntos + +```kotlin +val distance = DistanceLocationService.calculateDistance( + lat1 = 40.7128, // NYC + lon1 = -74.0060, + lat2 = 34.0522, // LA + lon2 = -118.2437 +) +// Resultado: ~3,944,000 metros +``` + +### Crear una Ubicación + +```kotlin +val myLocation = DistanceLocationService.GeoLocation( + latitude = 40.7128, + longitude = -74.0060, + peerId = "my-peer-id", + nickname = "Alice", + accuracy = 50f, // en metros + timestamp = System.currentTimeMillis() +) + +// Convertir desde Android Location +val androidLocation = // ... obtener del LocationManager +val geoLocation = DistanceLocationService.fromAndroidLocation( + location = androidLocation, + peerId = "my-peer-id", + nickname = "Alice" +) +``` + +### Encontrar Pares Cercanos + +```kotlin +val peerLocations = listOf( + DistanceLocationService.GeoLocation( + latitude = 40.7200, + longitude = -74.0050, + peerId = "peer1", + nickname = "Bob" + ), + DistanceLocationService.GeoLocation( + latitude = 34.0522, + longitude = -118.2437, + peerId = "peer2", + nickname = "Charlie" + ) +) + +val nearby = DistanceLocationService.findNearbyPeers( + currentLocation = myLocation, + peers = peerLocations, + radiusMeters = 5000.0 // 5 km +) + +// Resultado: Lista de NearbyPeer ordenada por distancia +for (peer in nearby) { + println("${peer.nickname}: ${peer.distanceMeters}m - Dirección: ${peer.bearing}°") +} +``` + +### Calcular Acimut (Bearing) + +```kotlin +val bearing = DistanceLocationService.calculateBearing( + lat1 = 40.7128, + lon1 = -74.0060, + lat2 = 40.7200, + lon2 = -74.0050 +) +// 0° = Norte, 90° = Este, 180° = Sur, 270° = Oeste +``` + +### Formatear Distancias + +```kotlin +println(DistanceLocationService.formatDistance(500.0)) // "500 m" +println(DistanceLocationService.formatDistance(1500.0)) // "1.5 km" +println(DistanceLocationService.formatDistance(50000.0)) // "50.0 km" + +// Formatear direcciones +println(DistanceLocationService.formatBearing(0f)) // "N" +println(DistanceLocationService.formatBearing(45f)) // "NE" +println(DistanceLocationService.formatBearing(90f)) // "E" +``` + +## Integración con Geohash + +El servicio se integra con el sistema de geohash existente para búsquedas espaciales eficientes: + +```kotlin +val nearbyByGeohash = DistanceLocationService.findNearbyPeersByGeohash( + myGeohash = "u4pruydq", // Mi geohash actual + precision = 8, + peerGeohashes = mapOf( + "peer1" to "u4pruydq", // Misma celda + "peer2" to "u4pruyek", // Celda vecina + "peer3" to "u4pqzz00" // Celda lejana + ) +) +// Resultado: ["peer1", "peer2"] +``` + +## Funciones Avanzadas + +### Verificar si un Punto Está Dentro de un Radio + +```kotlin +val isClose = DistanceLocationService.isWithinRadius( + centerLat = 40.7128, + centerLon = -74.0060, + radiusMeters = 5000.0, + testLat = 40.7200, + testLon = -74.0050 +) +// true si está dentro del radio, false en caso contrario +``` + +### Calcular Bounding Box + +```kotlin +val (minLat, maxLat, minLon, maxLon) = DistanceLocationService.calculateBoundingBox( + centerLat = 40.7128, + centerLon = -74.0060, + radiusMeters = 5000.0 +) +// Útil para consultas de base de datos espaciales +``` + +### Encontrar Punto Intermedio + +```kotlin +val (midLat, midLon) = DistanceLocationService.calculateMidpoint( + lat1 = 40.7128, + lon1 = -74.0060, + lat2 = 34.0522, + lon2 = -118.2437 +) +// Punto de encuentro entre dos pares +``` + +## Estructura de Datos + +### GeoLocation +```kotlin +data class GeoLocation( + val latitude: Double, // Latitud en grados + val longitude: Double, // Longitud en grados + val peerId: String? = null, // ID del peer (opcional) + val nickname: String? = null, // Apodo del peer (opcional) + val accuracy: Float? = null, // Precisión de GPS en metros (opcional) + val timestamp: Long = ... // Timestamp de la ubicación +) +``` + +### NearbyPeer +```kotlin +data class NearbyPeer( + val peerId: String, // ID del peer + val nickname: String?, // Apodo del peer + val latitude: Double, // Latitud en grados + val longitude: Double, // Longitud en grados + val distanceMeters: Double, // Distancia en metros + val bearing: Float = 0f, // Acimut en grados (0-360) + val accuracy: Float? = null // Precisión de GPS +) +``` + +## Precisión y Limitaciones + +### Fórmula de Haversine +- **Precisión**: Muy precisa para distancias cortas y medianas (< 500 km) +- **Supuestos**: Asume la Tierra como una esfera perfecta +- **Variaciones**: La Tierra es un esferoide, lo que puede causar errores hasta del 0.5% + +### Geohash +- **Precisión**: Depende del número de caracteres + - 4 caracteres: ~20 km + - 6 caracteres: ~1.2 km + - 8 caracteres: ~150 m + - 10 caracteres: ~19 m + +## Consideraciones de Privacidad + +⚠️ **Importante**: Este servicio maneja datos de ubicación que son sensibles desde el punto de vista de privacidad. + +- Las ubicaciones se procesan localmente en el dispositivo +- No se envían ubicaciones exactas a servidores centrales +- Se utiliza geohash para aproximación espacial en lugar de coordenadas exactas +- Los datos de ubicación se cifran en tránsito + +## Pruebas Unitarias + +Ejecutar las pruebas: + +```bash +./gradlew :app:testDebugUnitTest --tests 'com.bitchat.android.geohash.DistanceLocationServiceTest' +``` + +Las pruebas verifican: +- Cálculo correcto de distancias +- Cálculo de acimut en direcciones cardinales +- Filtrado de pares cercanos +- Formato legible de distancias y direcciones +- Verificación de puntos dentro de radios +- Cálculo de puntos intermedios