Merge a89103e543b8086bf17760320e5624457f2d4f1c into c127eb83ab94c069c32d37530d2faecd381cd2a8

This commit is contained in:
Iván David Romero More 2026-09-16 18:02:24 +00:00 committed by GitHub
commit 8a172ebe15
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 944 additions and 4 deletions

View File

@ -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<FavoriteRelationship> = favorites.values.filter { it.isMutual }
fun getOurFavorites(): List<FavoriteRelationship> = favorites.values.filter { it.isFavorite }
fun getPrivateContacts(): List<FavoriteRelationship> = favorites.values.filter { it.isPrivateContact }
fun getAllRelationships(): List<FavoriteRelationship> = 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)
)

View File

@ -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<GeoLocation>,
radiusMeters: Double
): List<NearbyPeer> {
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<String, String>
): List<String> {
// 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<Double, Double, Double, Double> {
// 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<A, B, C, D>(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<Double, Double> {
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)
)
}
}

View File

@ -243,6 +243,7 @@ class ChatViewModel(
private val conversationPresencePeers = MutableStateFlow<List<String>>(emptyList())
private val conversationPresenceRemovalJobs = mutableMapOf<String, Job>()
private val conversationDirectoryRevision = MutableStateFlow(0L)
private val privateContactConversationIDs = MutableStateFlow<Set<String>>(emptySet())
private var favoriteRelationshipListenerRegistered = false
private val favoriteRelationshipChangeListener = object : FavoritesChangeListener {
override fun onFavoriteChanged(noiseKeyHex: String) {
@ -257,11 +258,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,
@ -359,14 +375,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]
)
}
@ -1126,6 +1145,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

View File

@ -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<ConversationSummary>
): List<ConversationSummary> = conversations.sortedWith(
compareByDescending<ConversationSummary> { it.isConnected }
.thenByDescending { it.isPrivateContact }
.thenByDescending { it.isPinned }
.thenByDescending { it.unreadCount > 0 }
.thenByDescending { it.latestActivityOrder }

View File

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

View File

@ -73,6 +73,10 @@
<!-- Encabezado de chat y accesibilidad -->
<string name="cd_nostr_reachable">Accesible vía Nostr</string>
<string name="cd_unread_private_messages">Mensajes privados sin leer</string>
<string name="private_contacts">Contactos privados</string>
<string name="private_contact_badge">Contacto privado</string>
<string name="add_private_contact">Agregar a contactos privados</string>
<string name="remove_private_contact">Quitar de contactos privados</string>
<string name="cd_teleported">Teletransportado</string>
<string name="cd_open_location_channels">Abrir ajustes de ubicación y canales</string>
<string name="cd_connected_peers">Pares conectados</string>

View File

@ -82,6 +82,10 @@
<string name="cd_nostr_reachable">Nostr reachable</string>
<string name="cd_unread_private_messages">Messages</string>
<string name="conversations">Conversations</string>
<string name="private_contacts">Private contacts</string>
<string name="private_contact_badge">Private contact</string>
<string name="add_private_contact">Add to private contacts</string>
<string name="remove_private_contact">Remove from private contacts</string>
<string name="offline_not_in_mesh">Offline · not in mesh</string>
<string name="offline_reachable_via_nostr">Offline from mesh · Nostr available</string>
<string name="delete">Delete</string>

View File

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

View File

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

View File

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