mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Merge a89103e543b8086bf17760320e5624457f2d4f1c into c127eb83ab94c069c32d37530d2faecd381cd2a8
This commit is contained in:
commit
8a172ebe15
@ -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)
|
||||
)
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
222
docs/distance-location-service.md
Normal file
222
docs/distance-location-service.md
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user