mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 23:45:19 +00:00
Nostr geohash (#276)
* first nostr build * add test file * internet access * fix relay manager * fix serialization * demo service - remove later * fix nostr * event dedupe * dedupe * ui wip * can send messages * subscription works * works * favs * works * delete chat on change * fix mentions * remove autojoin channels * styling * adjust colors * ui changes * live updates working * use local timestamp * message history in background * robust * fixes * nicknames refresh optimization * nostr service * refactor nostr * style * geohash works * centralize colors * refactoring * disable DMs for now: click on peer nickname doesnt open chat list in geohash mode * use local time * less logging * robustness * scroll nickname * adjust some text
This commit is contained in:
@@ -92,6 +92,12 @@ dependencies {
|
||||
// Bluetooth
|
||||
implementation(libs.nordic.ble)
|
||||
|
||||
// WebSocket
|
||||
implementation(libs.okhttp)
|
||||
|
||||
// Google Play Services Location
|
||||
implementation(libs.gms.location)
|
||||
|
||||
// Security preferences
|
||||
implementation(libs.androidx.security.crypto)
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Internet permissions for Nostr relay connections -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<!-- Bluetooth permissions -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
package com.bitchat.android.favorites
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Bridging Noise and Nostr favorites
|
||||
* Direct port from iOS FavoritesPersistenceService.swift
|
||||
*/
|
||||
data class FavoriteRelationship(
|
||||
val peerNoisePublicKey: ByteArray, // Noise static public key (32 bytes)
|
||||
val peerNostrPublicKey: String?, // npub bech32 string
|
||||
val peerNickname: String,
|
||||
val isFavorite: Boolean, // We favorited them
|
||||
val theyFavoritedUs: Boolean, // They favorited us
|
||||
val favoritedAt: Date,
|
||||
val lastUpdated: Date
|
||||
) {
|
||||
val isMutual: Boolean get() = isFavorite && theyFavoritedUs
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as FavoriteRelationship
|
||||
|
||||
if (!peerNoisePublicKey.contentEquals(other.peerNoisePublicKey)) return false
|
||||
if (peerNostrPublicKey != other.peerNostrPublicKey) return false
|
||||
if (peerNickname != other.peerNickname) return false
|
||||
if (isFavorite != other.isFavorite) return false
|
||||
if (theyFavoritedUs != other.theyFavoritedUs) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = peerNoisePublicKey.contentHashCode()
|
||||
result = 31 * result + (peerNostrPublicKey?.hashCode() ?: 0)
|
||||
result = 31 * result + peerNickname.hashCode()
|
||||
result = 31 * result + isFavorite.hashCode()
|
||||
result = 31 * result + theyFavoritedUs.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages favorites with Noise↔Nostr mapping
|
||||
* Singleton pattern matching iOS implementation
|
||||
*/
|
||||
class FavoritesPersistenceService private constructor(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "FavoritesPersistenceService"
|
||||
private const val FAVORITES_KEY = "favorite_relationships"
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: FavoritesPersistenceService? = null
|
||||
|
||||
val shared: FavoritesPersistenceService
|
||||
get() = INSTANCE ?: throw IllegalStateException("FavoritesPersistenceService not initialized")
|
||||
|
||||
fun initialize(context: Context) {
|
||||
if (INSTANCE == null) {
|
||||
synchronized(this) {
|
||||
if (INSTANCE == null) {
|
||||
INSTANCE = FavoritesPersistenceService(context.applicationContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val stateManager = SecureIdentityStateManager(context)
|
||||
private val gson = Gson()
|
||||
private val favorites = mutableMapOf<String, FavoriteRelationship>() // noiseKeyHex -> relationship
|
||||
|
||||
init {
|
||||
loadFavorites()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get favorite status for Noise public key
|
||||
*/
|
||||
fun getFavoriteStatus(noisePublicKey: ByteArray): FavoriteRelationship? {
|
||||
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
|
||||
return favorites[keyHex]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get favorite status for 16-hex peerID
|
||||
*/
|
||||
fun getFavoriteStatus(peerID: String): FavoriteRelationship? {
|
||||
// For 16-hex peerIDs, we need to find the corresponding full Noise key
|
||||
// This is a simplified lookup - in practice you'd use fingerprint matching
|
||||
for ((_, relationship) in favorites) {
|
||||
val noiseKeyHex = relationship.peerNoisePublicKey.joinToString("") { "%02x".format(it) }
|
||||
if (noiseKeyHex.startsWith(peerID)) {
|
||||
return relationship
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Nostr public key for a peer
|
||||
*/
|
||||
fun updateNostrPublicKey(noisePublicKey: ByteArray, nostrPubkey: String) {
|
||||
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
|
||||
val existing = favorites[keyHex]
|
||||
|
||||
if (existing != null) {
|
||||
val updated = existing.copy(
|
||||
peerNostrPublicKey = nostrPubkey,
|
||||
lastUpdated = Date()
|
||||
)
|
||||
favorites[keyHex] = updated
|
||||
} else {
|
||||
// Create new relationship
|
||||
val relationship = FavoriteRelationship(
|
||||
peerNoisePublicKey = noisePublicKey,
|
||||
peerNostrPublicKey = nostrPubkey,
|
||||
peerNickname = "Unknown",
|
||||
isFavorite = false,
|
||||
theyFavoritedUs = false,
|
||||
favoritedAt = Date(),
|
||||
lastUpdated = Date()
|
||||
)
|
||||
favorites[keyHex] = relationship
|
||||
}
|
||||
|
||||
saveFavorites()
|
||||
Log.d(TAG, "Updated Nostr pubkey association for ${keyHex.take(16)}...")
|
||||
}
|
||||
|
||||
/**
|
||||
* Update favorite status
|
||||
*/
|
||||
fun updateFavoriteStatus(noisePublicKey: ByteArray, nickname: String, isFavorite: Boolean) {
|
||||
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
|
||||
val existing = favorites[keyHex]
|
||||
|
||||
val updated = if (existing != null) {
|
||||
existing.copy(
|
||||
peerNickname = nickname,
|
||||
isFavorite = isFavorite,
|
||||
lastUpdated = Date(),
|
||||
favoritedAt = if (isFavorite && !existing.isFavorite) Date() else existing.favoritedAt
|
||||
)
|
||||
} else {
|
||||
FavoriteRelationship(
|
||||
peerNoisePublicKey = noisePublicKey,
|
||||
peerNostrPublicKey = null,
|
||||
peerNickname = nickname,
|
||||
isFavorite = isFavorite,
|
||||
theyFavoritedUs = false,
|
||||
favoritedAt = Date(),
|
||||
lastUpdated = Date()
|
||||
)
|
||||
}
|
||||
|
||||
favorites[keyHex] = updated
|
||||
saveFavorites()
|
||||
|
||||
Log.d(TAG, "Updated favorite status for $nickname: $isFavorite")
|
||||
}
|
||||
|
||||
/**
|
||||
* Update peer favorited us status
|
||||
*/
|
||||
fun updatePeerFavoritedUs(noisePublicKey: ByteArray, theyFavoritedUs: Boolean) {
|
||||
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
|
||||
val existing = favorites[keyHex]
|
||||
|
||||
if (existing != null) {
|
||||
val updated = existing.copy(
|
||||
theyFavoritedUs = theyFavoritedUs,
|
||||
lastUpdated = Date()
|
||||
)
|
||||
favorites[keyHex] = updated
|
||||
saveFavorites()
|
||||
|
||||
Log.d(TAG, "Updated peer favorited us for ${keyHex.take(16)}...: $theyFavoritedUs")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all mutual favorites
|
||||
*/
|
||||
fun getMutualFavorites(): List<FavoriteRelationship> {
|
||||
return favorites.values.filter { it.isMutual }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all favorites we have
|
||||
*/
|
||||
fun getOurFavorites(): List<FavoriteRelationship> {
|
||||
return favorites.values.filter { it.isFavorite }
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all favorites
|
||||
*/
|
||||
fun clearAllFavorites() {
|
||||
favorites.clear()
|
||||
saveFavorites()
|
||||
Log.i(TAG, "Cleared all favorites")
|
||||
}
|
||||
|
||||
/**
|
||||
* Find Noise key by Nostr pubkey
|
||||
*/
|
||||
fun findNoiseKey(forNostrPubkey: String): ByteArray? {
|
||||
return favorites.values.firstOrNull { it.peerNostrPublicKey == forNostrPubkey }?.peerNoisePublicKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Find Nostr pubkey by Noise key
|
||||
*/
|
||||
fun findNostrPubkey(forNoiseKey: ByteArray): String? {
|
||||
val keyHex = forNoiseKey.joinToString("") { "%02x".format(it) }
|
||||
return favorites[keyHex]?.peerNostrPublicKey
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private fun loadFavorites() {
|
||||
try {
|
||||
// Use reflection to access encrypted preferences
|
||||
val clazz = stateManager.javaClass
|
||||
val prefsField = clazz.getDeclaredField("prefs")
|
||||
prefsField.isAccessible = true
|
||||
val prefs = prefsField.get(stateManager) as android.content.SharedPreferences
|
||||
|
||||
val favoritesJson = prefs.getString(FAVORITES_KEY, null)
|
||||
if (favoritesJson != null) {
|
||||
val type = object : TypeToken<Map<String, FavoriteRelationshipData>>() {}.type
|
||||
val data: Map<String, FavoriteRelationshipData> = gson.fromJson(favoritesJson, type)
|
||||
|
||||
favorites.clear()
|
||||
data.forEach { (key, relationshipData) ->
|
||||
val relationship = relationshipData.toFavoriteRelationship()
|
||||
favorites[key] = relationship
|
||||
}
|
||||
|
||||
Log.d(TAG, "Loaded ${favorites.size} favorite relationships")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load favorites: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveFavorites() {
|
||||
try {
|
||||
// Convert to serializable format
|
||||
val data = favorites.mapValues { (_, relationship) ->
|
||||
FavoriteRelationshipData.fromFavoriteRelationship(relationship)
|
||||
}
|
||||
|
||||
val favoritesJson = gson.toJson(data)
|
||||
|
||||
// Use reflection to access encrypted preferences
|
||||
val clazz = stateManager.javaClass
|
||||
val prefsField = clazz.getDeclaredField("prefs")
|
||||
prefsField.isAccessible = true
|
||||
val prefs = prefsField.get(stateManager) as android.content.SharedPreferences
|
||||
|
||||
prefs.edit()
|
||||
.putString(FAVORITES_KEY, favoritesJson)
|
||||
.apply()
|
||||
|
||||
Log.d(TAG, "Saved ${favorites.size} favorite relationships")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to save favorites: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializable data class for JSON storage
|
||||
*/
|
||||
private data class FavoriteRelationshipData(
|
||||
val peerNoisePublicKeyHex: String,
|
||||
val peerNostrPublicKey: String?,
|
||||
val peerNickname: String,
|
||||
val isFavorite: Boolean,
|
||||
val theyFavoritedUs: Boolean,
|
||||
val favoritedAt: Long,
|
||||
val lastUpdated: Long
|
||||
) {
|
||||
companion object {
|
||||
fun fromFavoriteRelationship(relationship: FavoriteRelationship): FavoriteRelationshipData {
|
||||
return FavoriteRelationshipData(
|
||||
peerNoisePublicKeyHex = relationship.peerNoisePublicKey.joinToString("") { "%02x".format(it) },
|
||||
peerNostrPublicKey = relationship.peerNostrPublicKey,
|
||||
peerNickname = relationship.peerNickname,
|
||||
isFavorite = relationship.isFavorite,
|
||||
theyFavoritedUs = relationship.theyFavoritedUs,
|
||||
favoritedAt = relationship.favoritedAt.time,
|
||||
lastUpdated = relationship.lastUpdated.time
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun toFavoriteRelationship(): FavoriteRelationship {
|
||||
val noiseKeyBytes = peerNoisePublicKeyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
return FavoriteRelationship(
|
||||
peerNoisePublicKey = noiseKeyBytes,
|
||||
peerNostrPublicKey = peerNostrPublicKey,
|
||||
peerNickname = peerNickname,
|
||||
isFavorite = isFavorite,
|
||||
theyFavoritedUs = theyFavoritedUs,
|
||||
favoritedAt = Date(favoritedAt),
|
||||
lastUpdated = Date(lastUpdated)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.bitchat.android.geohash
|
||||
|
||||
/**
|
||||
* Lightweight Geohash encoder used for Location Channels.
|
||||
* Encodes latitude/longitude to base32 geohash with a fixed precision.
|
||||
*
|
||||
* Port of iOS implementation for 100% compatibility
|
||||
*/
|
||||
object Geohash {
|
||||
|
||||
private val base32Chars = "0123456789bcdefghjkmnpqrstuvwxyz".toCharArray()
|
||||
|
||||
/**
|
||||
* Encodes the provided coordinates into a geohash string.
|
||||
* @param latitude Latitude in degrees (-90...90)
|
||||
* @param longitude Longitude in degrees (-180...180)
|
||||
* @param precision Number of geohash characters (2-12 typical). Values <= 0 return an empty string.
|
||||
* @return Base32 geohash string of length `precision`.
|
||||
*/
|
||||
fun encode(latitude: Double, longitude: Double, precision: Int): String {
|
||||
if (precision <= 0) return ""
|
||||
|
||||
var latInterval = -90.0 to 90.0
|
||||
var lonInterval = -180.0 to 180.0
|
||||
|
||||
var isEven = true
|
||||
var bit = 0
|
||||
var ch = 0
|
||||
val geohash = StringBuilder()
|
||||
|
||||
val lat = latitude.coerceIn(-90.0, 90.0)
|
||||
val lon = longitude.coerceIn(-180.0, 180.0)
|
||||
|
||||
while (geohash.length < precision) {
|
||||
if (isEven) {
|
||||
val mid = (lonInterval.first + lonInterval.second) / 2
|
||||
if (lon >= mid) {
|
||||
ch = ch or (1 shl (4 - bit))
|
||||
lonInterval = mid to lonInterval.second
|
||||
} else {
|
||||
lonInterval = lonInterval.first to mid
|
||||
}
|
||||
} else {
|
||||
val mid = (latInterval.first + latInterval.second) / 2
|
||||
if (lat >= mid) {
|
||||
ch = ch or (1 shl (4 - bit))
|
||||
latInterval = mid to latInterval.second
|
||||
} else {
|
||||
latInterval = latInterval.first to mid
|
||||
}
|
||||
}
|
||||
|
||||
isEven = !isEven
|
||||
if (bit < 4) {
|
||||
bit += 1
|
||||
} else {
|
||||
geohash.append(base32Chars[ch])
|
||||
bit = 0
|
||||
ch = 0
|
||||
}
|
||||
}
|
||||
|
||||
return geohash.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.bitchat.android.geohash
|
||||
|
||||
/**
|
||||
* Levels of location channels mapped to geohash precisions.
|
||||
* Direct port from iOS implementation for 100% compatibility
|
||||
*/
|
||||
enum class GeohashChannelLevel(val precision: Int, val displayName: String) {
|
||||
BLOCK(7, "Block"),
|
||||
NEIGHBORHOOD(6, "Neighborhood"),
|
||||
CITY(5, "City"),
|
||||
REGION(4, "Region"),
|
||||
COUNTRY(2, "Country");
|
||||
|
||||
companion object {
|
||||
fun allCases(): List<GeohashChannelLevel> = values().toList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A computed geohash channel option.
|
||||
* Direct port from iOS implementation
|
||||
*/
|
||||
data class GeohashChannel(
|
||||
val level: GeohashChannelLevel,
|
||||
val geohash: String
|
||||
) {
|
||||
val id: String get() = "${level.name}-$geohash"
|
||||
|
||||
val displayName: String get() = "${level.displayName} • $geohash"
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifier for current public chat channel (mesh or a location geohash).
|
||||
* Direct port from iOS implementation
|
||||
*/
|
||||
sealed class ChannelID {
|
||||
object Mesh : ChannelID()
|
||||
data class Location(val channel: GeohashChannel) : ChannelID()
|
||||
|
||||
/**
|
||||
* Human readable name for UI.
|
||||
*/
|
||||
val displayName: String
|
||||
get() = when (this) {
|
||||
is Mesh -> "Mesh"
|
||||
is Location -> channel.displayName
|
||||
}
|
||||
|
||||
/**
|
||||
* Nostr tag value for scoping (geohash), if applicable.
|
||||
*/
|
||||
val nostrGeohashTag: String?
|
||||
get() = when (this) {
|
||||
is Mesh -> null
|
||||
is Location -> channel.geohash
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
return when {
|
||||
this is Mesh && other is Mesh -> true
|
||||
this is Location && other is Location -> this.channel == other.channel
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return when (this) {
|
||||
is Mesh -> "mesh".hashCode()
|
||||
is Location -> channel.hashCode()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
package com.bitchat.android.geohash
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.Geocoder
|
||||
import android.location.Location
|
||||
import android.location.LocationListener
|
||||
import android.location.LocationManager
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Manages location permissions, one-shot location retrieval, and computing geohash channels.
|
||||
* Direct port from iOS LocationChannelManager for 100% compatibility
|
||||
*/
|
||||
class LocationChannelManager private constructor(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "LocationChannelManager"
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: LocationChannelManager? = null
|
||||
|
||||
fun getInstance(context: Context): LocationChannelManager {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: LocationChannelManager(context.applicationContext).also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// State enum matching iOS
|
||||
enum class PermissionState {
|
||||
NOT_DETERMINED,
|
||||
DENIED,
|
||||
RESTRICTED,
|
||||
AUTHORIZED
|
||||
}
|
||||
|
||||
private val locationManager: LocationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
private val geocoder: Geocoder = Geocoder(context, Locale.getDefault())
|
||||
private var lastLocation: Location? = null
|
||||
private var refreshTimer: Job? = null
|
||||
private var isGeocoding: Boolean = false
|
||||
|
||||
// Published state for UI bindings (matching iOS @Published properties)
|
||||
private val _permissionState = MutableLiveData(PermissionState.NOT_DETERMINED)
|
||||
val permissionState: LiveData<PermissionState> = _permissionState
|
||||
|
||||
private val _availableChannels = MutableLiveData<List<GeohashChannel>>(emptyList())
|
||||
val availableChannels: LiveData<List<GeohashChannel>> = _availableChannels
|
||||
|
||||
private val _selectedChannel = MutableLiveData<ChannelID>(ChannelID.Mesh)
|
||||
val selectedChannel: LiveData<ChannelID> = _selectedChannel
|
||||
|
||||
private val _teleported = MutableLiveData(false)
|
||||
val teleported: LiveData<Boolean> = _teleported
|
||||
|
||||
private val _locationNames = MutableLiveData<Map<GeohashChannelLevel, String>>(emptyMap())
|
||||
val locationNames: LiveData<Map<GeohashChannelLevel, String>> = _locationNames
|
||||
|
||||
init {
|
||||
updatePermissionState()
|
||||
// Load selection from preferences if needed
|
||||
// For now, default to mesh
|
||||
}
|
||||
|
||||
// MARK: - Public API (matching iOS interface)
|
||||
|
||||
/**
|
||||
* Enable location channels (request permission if needed)
|
||||
*/
|
||||
fun enableLocationChannels() {
|
||||
Log.d(TAG, "enableLocationChannels() called")
|
||||
|
||||
when (getCurrentPermissionStatus()) {
|
||||
PermissionState.NOT_DETERMINED -> {
|
||||
Log.d(TAG, "Permission not determined - user needs to grant in app settings")
|
||||
_permissionState.postValue(PermissionState.NOT_DETERMINED)
|
||||
}
|
||||
PermissionState.DENIED, PermissionState.RESTRICTED -> {
|
||||
Log.d(TAG, "Permission denied or restricted")
|
||||
_permissionState.postValue(PermissionState.DENIED)
|
||||
}
|
||||
PermissionState.AUTHORIZED -> {
|
||||
Log.d(TAG, "Permission authorized - requesting location")
|
||||
_permissionState.postValue(PermissionState.AUTHORIZED)
|
||||
requestOneShotLocation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh available channels from current location
|
||||
*/
|
||||
fun refreshChannels() {
|
||||
if (_permissionState.value == PermissionState.AUTHORIZED) {
|
||||
requestOneShotLocation()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin periodic one-shot location refreshes while a selector UI is visible
|
||||
*/
|
||||
fun beginLiveRefresh(interval: Long = 5000L) {
|
||||
Log.d(TAG, "Beginning live refresh with interval ${interval}ms")
|
||||
|
||||
if (_permissionState.value != PermissionState.AUTHORIZED) {
|
||||
Log.w(TAG, "Cannot start live refresh - permission not authorized")
|
||||
return
|
||||
}
|
||||
|
||||
// Cancel existing timer
|
||||
refreshTimer?.cancel()
|
||||
|
||||
// Start new timer with coroutines
|
||||
refreshTimer = CoroutineScope(Dispatchers.IO).launch {
|
||||
while (isActive) {
|
||||
requestOneShotLocation()
|
||||
delay(interval)
|
||||
}
|
||||
}
|
||||
|
||||
// Kick off immediately
|
||||
requestOneShotLocation()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop periodic refreshes when selector UI is dismissed
|
||||
*/
|
||||
fun endLiveRefresh() {
|
||||
Log.d(TAG, "Ending live refresh")
|
||||
refreshTimer?.cancel()
|
||||
refreshTimer = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a channel
|
||||
*/
|
||||
fun select(channel: ChannelID) {
|
||||
Log.d(TAG, "Selected channel: ${channel.displayName}")
|
||||
_selectedChannel.postValue(channel)
|
||||
// TODO: Save to preferences
|
||||
}
|
||||
|
||||
/**
|
||||
* Set teleported status (for manual geohash teleportation)
|
||||
*/
|
||||
fun setTeleported(teleported: Boolean) {
|
||||
Log.d(TAG, "Setting teleported status: $teleported")
|
||||
_teleported.postValue(teleported)
|
||||
}
|
||||
|
||||
// MARK: - Location Operations
|
||||
|
||||
private fun requestOneShotLocation() {
|
||||
if (!hasLocationPermission()) {
|
||||
Log.w(TAG, "No location permission for one-shot request")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "Requesting one-shot location")
|
||||
|
||||
try {
|
||||
// Get last known location first for quick result
|
||||
val lastKnownLocation = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)
|
||||
?: locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER)
|
||||
|
||||
if (lastKnownLocation != null) {
|
||||
Log.d(TAG, "Using last known location: ${lastKnownLocation.latitude}, ${lastKnownLocation.longitude}")
|
||||
lastLocation = lastKnownLocation
|
||||
computeChannels(lastKnownLocation)
|
||||
reverseGeocodeIfNeeded(lastKnownLocation)
|
||||
} else {
|
||||
Log.d(TAG, "No last known location available")
|
||||
// For demo purposes, use a default location (San Francisco)
|
||||
val demoLocation = Location("demo").apply {
|
||||
latitude = 37.7749
|
||||
longitude = -122.4194
|
||||
}
|
||||
Log.d(TAG, "Using demo location (San Francisco): ${demoLocation.latitude}, ${demoLocation.longitude}")
|
||||
lastLocation = demoLocation
|
||||
computeChannels(demoLocation)
|
||||
reverseGeocodeIfNeeded(demoLocation)
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.e(TAG, "Security exception requesting location: ${e.message}")
|
||||
updatePermissionState()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private fun getCurrentPermissionStatus(): PermissionState {
|
||||
return when {
|
||||
ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED -> {
|
||||
PermissionState.AUTHORIZED
|
||||
}
|
||||
ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED -> {
|
||||
PermissionState.AUTHORIZED
|
||||
}
|
||||
else -> {
|
||||
PermissionState.DENIED // In Android, we can't distinguish between denied and not determined after first ask
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePermissionState() {
|
||||
val newState = getCurrentPermissionStatus()
|
||||
Log.d(TAG, "Permission state updated to: $newState")
|
||||
_permissionState.postValue(newState)
|
||||
}
|
||||
|
||||
private fun hasLocationPermission(): Boolean {
|
||||
return ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
|
||||
ActivityCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun computeChannels(location: Location) {
|
||||
Log.d(TAG, "Computing channels for location: ${location.latitude}, ${location.longitude}")
|
||||
|
||||
val levels = GeohashChannelLevel.allCases()
|
||||
val result = mutableListOf<GeohashChannel>()
|
||||
|
||||
for (level in levels) {
|
||||
val geohash = Geohash.encode(
|
||||
latitude = location.latitude,
|
||||
longitude = location.longitude,
|
||||
precision = level.precision
|
||||
)
|
||||
result.add(GeohashChannel(level = level, geohash = geohash))
|
||||
|
||||
Log.v(TAG, "Generated ${level.displayName}: $geohash")
|
||||
}
|
||||
|
||||
_availableChannels.postValue(result)
|
||||
|
||||
// Recompute teleported status based on current location vs selected channel
|
||||
val selectedChannelValue = _selectedChannel.value
|
||||
when (selectedChannelValue) {
|
||||
is ChannelID.Mesh -> {
|
||||
_teleported.postValue(false)
|
||||
}
|
||||
is ChannelID.Location -> {
|
||||
val currentGeohash = Geohash.encode(
|
||||
latitude = location.latitude,
|
||||
longitude = location.longitude,
|
||||
precision = selectedChannelValue.channel.level.precision
|
||||
)
|
||||
val isTeleported = currentGeohash != selectedChannelValue.channel.geohash
|
||||
_teleported.postValue(isTeleported)
|
||||
Log.d(TAG, "Teleported status: $isTeleported (current: $currentGeohash, selected: ${selectedChannelValue.channel.geohash})")
|
||||
}
|
||||
null -> {
|
||||
_teleported.postValue(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun reverseGeocodeIfNeeded(location: Location) {
|
||||
if (!Geocoder.isPresent()) {
|
||||
Log.w(TAG, "Geocoder not present on this device")
|
||||
return
|
||||
}
|
||||
|
||||
if (isGeocoding) {
|
||||
Log.d(TAG, "Already geocoding, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
isGeocoding = true
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
Log.d(TAG, "Starting reverse geocoding")
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
val addresses = geocoder.getFromLocation(location.latitude, location.longitude, 1)
|
||||
|
||||
if (!addresses.isNullOrEmpty()) {
|
||||
val address = addresses[0]
|
||||
val names = namesByLevel(address)
|
||||
|
||||
Log.d(TAG, "Reverse geocoding result: $names")
|
||||
_locationNames.postValue(names)
|
||||
} else {
|
||||
Log.w(TAG, "No reverse geocoding results")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Reverse geocoding failed: ${e.message}")
|
||||
} finally {
|
||||
isGeocoding = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun namesByLevel(address: android.location.Address): Map<GeohashChannelLevel, String> {
|
||||
val dict = mutableMapOf<GeohashChannelLevel, String>()
|
||||
|
||||
// Country
|
||||
address.countryName?.takeIf { it.isNotEmpty() }?.let {
|
||||
dict[GeohashChannelLevel.COUNTRY] = it
|
||||
}
|
||||
|
||||
// Region (state/province or county)
|
||||
address.adminArea?.takeIf { it.isNotEmpty() }?.let {
|
||||
dict[GeohashChannelLevel.REGION] = it
|
||||
} ?: address.subAdminArea?.takeIf { it.isNotEmpty() }?.let {
|
||||
dict[GeohashChannelLevel.REGION] = it
|
||||
}
|
||||
|
||||
// City (locality)
|
||||
address.locality?.takeIf { it.isNotEmpty() }?.let {
|
||||
dict[GeohashChannelLevel.CITY] = it
|
||||
} ?: address.subAdminArea?.takeIf { it.isNotEmpty() }?.let {
|
||||
dict[GeohashChannelLevel.CITY] = it
|
||||
} ?: address.adminArea?.takeIf { it.isNotEmpty() }?.let {
|
||||
dict[GeohashChannelLevel.CITY] = it
|
||||
}
|
||||
|
||||
// Neighborhood
|
||||
address.subLocality?.takeIf { it.isNotEmpty() }?.let {
|
||||
dict[GeohashChannelLevel.NEIGHBORHOOD] = it
|
||||
} ?: address.locality?.takeIf { it.isNotEmpty() }?.let {
|
||||
dict[GeohashChannelLevel.NEIGHBORHOOD] = it
|
||||
}
|
||||
|
||||
// Block: reuse neighborhood/locality granularity without exposing street level
|
||||
address.subLocality?.takeIf { it.isNotEmpty() }?.let {
|
||||
dict[GeohashChannelLevel.BLOCK] = it
|
||||
} ?: address.locality?.takeIf { it.isNotEmpty() }?.let {
|
||||
dict[GeohashChannelLevel.BLOCK] = it
|
||||
}
|
||||
|
||||
return dict
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup resources
|
||||
*/
|
||||
fun cleanup() {
|
||||
Log.d(TAG, "Cleaning up LocationChannelManager")
|
||||
endLiveRefresh()
|
||||
}
|
||||
}
|
||||
@@ -472,6 +472,18 @@ class BluetoothMeshService(private val context: Context) {
|
||||
|
||||
Log.d(TAG, "📨 Sending PM to $recipientPeerID: ${content.take(30)}...")
|
||||
|
||||
// Check if this is a Nostr contact (geohash DM)
|
||||
if (recipientPeerID.startsWith("nostr_")) {
|
||||
// Get NostrGeohashService instance and send via Nostr
|
||||
try {
|
||||
val nostrGeohashService = com.bitchat.android.nostr.NostrGeohashService.getInstance(context.applicationContext as android.app.Application)
|
||||
nostrGeohashService.sendNostrGeohashDM(content, recipientPeerID, finalMessageID, myPeerID)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send Nostr geohash DM: ${e.message}")
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Check if we have an established Noise session
|
||||
if (encryptionService.hasEstablishedSession(recipientPeerID)) {
|
||||
try {
|
||||
@@ -537,6 +549,18 @@ class BluetoothMeshService(private val context: Context) {
|
||||
serviceScope.launch {
|
||||
Log.d(TAG, "📖 Sending read receipt for message $messageID to $recipientPeerID")
|
||||
|
||||
// Check if this is a Nostr contact (geohash DM)
|
||||
if (recipientPeerID.startsWith("nostr_")) {
|
||||
// Get NostrGeohashService instance and send read receipt via Nostr
|
||||
try {
|
||||
val nostrGeohashService = com.bitchat.android.nostr.NostrGeohashService.getInstance(context.applicationContext as android.app.Application)
|
||||
nostrGeohashService.sendNostrGeohashReadReceipt(messageID, recipientPeerID, myPeerID)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send Nostr geohash read receipt: ${e.message}")
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
try {
|
||||
// Create read receipt payload using NoisePayloadType exactly like iOS
|
||||
val readReceiptPayload = com.bitchat.android.model.NoisePayload(
|
||||
|
||||
@@ -79,12 +79,12 @@ class MessageHandler(private val myPeerID: String) {
|
||||
if (privateMessage != null) {
|
||||
Log.d(TAG, "🔓 Decrypted TLV PM from $peerID: ${privateMessage.content.take(30)}...")
|
||||
|
||||
// Create BitchatMessage exactly like iOS
|
||||
// Create BitchatMessage - use local system time for incoming messages
|
||||
val message = BitchatMessage(
|
||||
id = privateMessage.messageID,
|
||||
sender = delegate?.getPeerNickname(peerID) ?: "Unknown",
|
||||
content = privateMessage.content,
|
||||
timestamp = java.util.Date(packet.timestamp.toLong()),
|
||||
timestamp = java.util.Date(), // Use local system time instead of packet timestamp
|
||||
isRelay = false,
|
||||
originalSender = null,
|
||||
isPrivate = true,
|
||||
|
||||
@@ -191,3 +191,12 @@ data class PrivateMessagePacket(
|
||||
return "PrivateMessagePacket(messageID='$messageID', content='${content.take(50)}${if (content.length > 50) "..." else ""}')"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read receipt data class for transport compatibility
|
||||
*/
|
||||
@Parcelize
|
||||
data class ReadReceipt(
|
||||
val originalMessageID: String,
|
||||
val readerPeerID: String? = null
|
||||
) : Parcelable
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
/**
|
||||
* Bech32 encoding/decoding implementation for Nostr
|
||||
* Used for npub/nsec encoding
|
||||
*/
|
||||
object Bech32 {
|
||||
private const val CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
||||
private val GENERATOR = intArrayOf(0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3)
|
||||
|
||||
/**
|
||||
* Encode data with HRP (Human Readable Part)
|
||||
*/
|
||||
fun encode(hrp: String, data: ByteArray): String {
|
||||
val values = convertBits(data, 8, 5, true).toList()
|
||||
val checksum = createChecksum(hrp, values)
|
||||
val combined = values + checksum
|
||||
|
||||
return hrp + "1" + combined.map { CHARSET[it] }.joinToString("")
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode bech32 string
|
||||
* Returns (hrp, data) pair
|
||||
*/
|
||||
fun decode(bech32String: String): Pair<String, ByteArray> {
|
||||
val separatorIndex = bech32String.lastIndexOf('1')
|
||||
require(separatorIndex >= 0) { "No separator found" }
|
||||
|
||||
val hrp = bech32String.substring(0, separatorIndex)
|
||||
val dataString = bech32String.substring(separatorIndex + 1)
|
||||
|
||||
// Validate HRP contains only ASCII
|
||||
require(hrp.all { it.code < 128 }) { "Invalid HRP characters" }
|
||||
|
||||
// Convert characters to values
|
||||
val values = dataString.map { char ->
|
||||
val index = CHARSET.indexOf(char)
|
||||
require(index >= 0) { "Invalid character: $char" }
|
||||
index
|
||||
}
|
||||
|
||||
// Verify checksum
|
||||
require(values.size >= 6) { "Data too short" }
|
||||
val payloadValues = values.dropLast(6)
|
||||
val checksum = values.takeLast(6)
|
||||
val expectedChecksum = createChecksum(hrp, payloadValues)
|
||||
|
||||
require(checksum == expectedChecksum) { "Invalid checksum" }
|
||||
|
||||
// Convert back to bytes
|
||||
val bytesInt = convertBits(payloadValues.toIntArray(), 5, 8, false)
|
||||
val bytes = bytesInt.map { it.toByte() }.toByteArray()
|
||||
return Pair(hrp, bytes)
|
||||
}
|
||||
|
||||
private fun convertBits(data: ByteArray, fromBits: Int, toBits: Int, pad: Boolean): IntArray {
|
||||
return convertBits(data.map { it.toInt() and 0xFF }.toIntArray(), fromBits, toBits, pad)
|
||||
}
|
||||
|
||||
private fun convertBits(data: IntArray, fromBits: Int, toBits: Int, pad: Boolean): IntArray {
|
||||
var acc = 0
|
||||
var bits = 0
|
||||
val result = mutableListOf<Int>()
|
||||
val maxv = (1 shl toBits) - 1
|
||||
|
||||
for (value in data) {
|
||||
acc = (acc shl fromBits) or value
|
||||
bits += fromBits
|
||||
|
||||
while (bits >= toBits) {
|
||||
bits -= toBits
|
||||
result.add((acc shr bits) and maxv)
|
||||
}
|
||||
}
|
||||
|
||||
if (pad && bits > 0) {
|
||||
result.add((acc shl (toBits - bits)) and maxv)
|
||||
}
|
||||
|
||||
return result.toIntArray()
|
||||
}
|
||||
|
||||
private fun convertBits(data: List<Int>, fromBits: Int, toBits: Int, pad: Boolean): IntArray {
|
||||
return convertBits(data.toIntArray(), fromBits, toBits, pad)
|
||||
}
|
||||
|
||||
private fun createChecksum(hrp: String, values: List<Int>): List<Int> {
|
||||
val checksumValues = hrpExpand(hrp) + values + intArrayOf(0, 0, 0, 0, 0, 0)
|
||||
val polymod = polymod(checksumValues) xor 1
|
||||
|
||||
return (0 until 6).map { i ->
|
||||
(polymod shr (5 * (5 - i))) and 31
|
||||
}
|
||||
}
|
||||
|
||||
private fun hrpExpand(hrp: String): IntArray {
|
||||
val result = mutableListOf<Int>()
|
||||
|
||||
// High bits
|
||||
hrp.forEach { c ->
|
||||
result.add(c.code shr 5)
|
||||
}
|
||||
|
||||
// Separator
|
||||
result.add(0)
|
||||
|
||||
// Low bits
|
||||
hrp.forEach { c ->
|
||||
result.add(c.code and 31)
|
||||
}
|
||||
|
||||
return result.toIntArray()
|
||||
}
|
||||
|
||||
private fun polymod(values: IntArray): Int {
|
||||
var chk = 1
|
||||
|
||||
for (value in values) {
|
||||
val b = chk shr 25
|
||||
chk = (chk and 0x1ffffff) shl 5 xor value
|
||||
|
||||
for (i in 0 until 5) {
|
||||
if ((b shr i) and 1 == 1) {
|
||||
chk = chk xor GENERATOR[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return chk
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
/**
|
||||
* High-level Nostr client that manages identity, connections, and messaging
|
||||
* Provides a simple API for the rest of the application
|
||||
*/
|
||||
class NostrClient private constructor(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NostrClient"
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: NostrClient? = null
|
||||
|
||||
fun getInstance(context: Context): NostrClient {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: NostrClient(context.applicationContext).also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Core components
|
||||
private val relayManager = NostrRelayManager.shared
|
||||
private var currentIdentity: NostrIdentity? = null
|
||||
|
||||
// Client state
|
||||
private val _isInitialized = MutableLiveData<Boolean>()
|
||||
val isInitialized: LiveData<Boolean> = _isInitialized
|
||||
|
||||
private val _currentNpub = MutableLiveData<String>()
|
||||
val currentNpub: LiveData<String> = _currentNpub
|
||||
|
||||
// Message processing
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
|
||||
init {
|
||||
Log.d(TAG, "Initializing Nostr client")
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the Nostr client with identity and relay connections
|
||||
*/
|
||||
fun initialize() {
|
||||
scope.launch {
|
||||
try {
|
||||
// Load or create identity
|
||||
currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context)
|
||||
|
||||
if (currentIdentity != null) {
|
||||
_currentNpub.postValue(currentIdentity!!.npub)
|
||||
Log.i(TAG, "✅ Nostr identity loaded: ${currentIdentity!!.getShortNpub()}")
|
||||
|
||||
// Connect to relays
|
||||
relayManager.connect()
|
||||
|
||||
_isInitialized.postValue(true)
|
||||
Log.i(TAG, "✅ Nostr client initialized successfully")
|
||||
} else {
|
||||
Log.e(TAG, "❌ Failed to load/create Nostr identity")
|
||||
_isInitialized.postValue(false)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Failed to initialize Nostr client: ${e.message}")
|
||||
_isInitialized.postValue(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown the client and disconnect from relays
|
||||
*/
|
||||
fun shutdown() {
|
||||
Log.d(TAG, "Shutting down Nostr client")
|
||||
relayManager.disconnect()
|
||||
_isInitialized.postValue(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a private message using NIP-17
|
||||
*/
|
||||
fun sendPrivateMessage(
|
||||
content: String,
|
||||
recipientNpub: String,
|
||||
onSuccess: (() -> Unit)? = null,
|
||||
onError: ((String) -> Unit)? = null
|
||||
) {
|
||||
val identity = currentIdentity
|
||||
if (identity == null) {
|
||||
onError?.invoke("Nostr client not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
// Decode recipient npub to hex pubkey
|
||||
val (hrp, pubkeyBytes) = Bech32.decode(recipientNpub)
|
||||
if (hrp != "npub") {
|
||||
onError?.invoke("Invalid npub format")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val recipientPubkeyHex = pubkeyBytes.toHexString()
|
||||
|
||||
// Create and send gift wrap
|
||||
val giftWrap = NostrProtocol.createPrivateMessage(
|
||||
content = content,
|
||||
recipientPubkey = recipientPubkeyHex,
|
||||
senderIdentity = identity
|
||||
)
|
||||
|
||||
// Track this as a pending gift wrap for logging
|
||||
NostrRelayManager.registerPendingGiftWrap(giftWrap.id)
|
||||
|
||||
relayManager.sendEvent(giftWrap)
|
||||
|
||||
Log.i(TAG, "📤 Sent private message to ${recipientNpub.take(16)}...")
|
||||
onSuccess?.invoke()
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Failed to send private message: ${e.message}")
|
||||
onError?.invoke("Failed to send message: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to private messages for current identity
|
||||
*/
|
||||
fun subscribeToPrivateMessages(handler: (content: String, senderNpub: String, timestamp: Int) -> Unit) {
|
||||
val identity = currentIdentity
|
||||
if (identity == null) {
|
||||
Log.e(TAG, "Cannot subscribe to private messages: client not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
val filter = NostrFilter.giftWrapsFor(
|
||||
pubkey = identity.publicKeyHex,
|
||||
since = System.currentTimeMillis() - 86400000L // Last 24 hours
|
||||
)
|
||||
|
||||
relayManager.subscribe(filter, "private-messages", { giftWrap ->
|
||||
scope.launch {
|
||||
handlePrivateMessage(giftWrap, handler)
|
||||
}
|
||||
})
|
||||
|
||||
Log.i(TAG, "🔑 Subscribed to private messages for: ${identity.getShortNpub()}")
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a public message to a geohash channel
|
||||
*/
|
||||
fun sendGeohashMessage(
|
||||
content: String,
|
||||
geohash: String,
|
||||
nickname: String? = null,
|
||||
onSuccess: (() -> Unit)? = null,
|
||||
onError: ((String) -> Unit)? = null
|
||||
) {
|
||||
scope.launch {
|
||||
try {
|
||||
// Derive geohash-specific identity
|
||||
val geohashIdentity = NostrIdentityBridge.deriveIdentity(geohash, context)
|
||||
|
||||
// Create ephemeral event
|
||||
val event = NostrProtocol.createEphemeralGeohashEvent(
|
||||
content = content,
|
||||
geohash = geohash,
|
||||
senderIdentity = geohashIdentity,
|
||||
nickname = nickname
|
||||
)
|
||||
|
||||
relayManager.sendEvent(event)
|
||||
|
||||
Log.i(TAG, "📤 Sent geohash message to #$geohash")
|
||||
onSuccess?.invoke()
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Failed to send geohash message: ${e.message}")
|
||||
onError?.invoke("Failed to send message: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to public messages in a geohash channel
|
||||
*/
|
||||
fun subscribeToGeohash(
|
||||
geohash: String,
|
||||
handler: (content: String, senderPubkey: String, nickname: String?, timestamp: Int) -> Unit
|
||||
) {
|
||||
val filter = NostrFilter.geohashEphemeral(
|
||||
geohash = geohash,
|
||||
since = System.currentTimeMillis() - 3600000L, // Last hour
|
||||
limit = 200
|
||||
)
|
||||
|
||||
relayManager.subscribe(filter, "geohash-$geohash", { event ->
|
||||
scope.launch {
|
||||
handleGeohashMessage(event, handler)
|
||||
}
|
||||
})
|
||||
|
||||
Log.i(TAG, "🌍 Subscribed to geohash channel: #$geohash")
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from a geohash channel
|
||||
*/
|
||||
fun unsubscribeFromGeohash(geohash: String) {
|
||||
relayManager.unsubscribe("geohash-$geohash")
|
||||
Log.i(TAG, "Unsubscribed from geohash channel: #$geohash")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current identity information
|
||||
*/
|
||||
fun getCurrentIdentity(): NostrIdentity? = currentIdentity
|
||||
|
||||
/**
|
||||
* Get relay connection status
|
||||
*/
|
||||
val relayConnectionStatus: LiveData<Boolean> = relayManager.isConnected
|
||||
|
||||
/**
|
||||
* Get relay information
|
||||
*/
|
||||
val relayInfo: LiveData<List<NostrRelayManager.Relay>> = relayManager.relays
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private suspend fun handlePrivateMessage(
|
||||
giftWrap: NostrEvent,
|
||||
handler: (content: String, senderNpub: String, timestamp: Int) -> Unit
|
||||
) {
|
||||
// Age filtering (24h + 15min buffer for randomized timestamps)
|
||||
val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt
|
||||
if (messageAge > 87300) { // 24 hours + 15 minutes
|
||||
Log.v(TAG, "Ignoring old private message")
|
||||
return
|
||||
}
|
||||
|
||||
val identity = currentIdentity ?: return
|
||||
|
||||
try {
|
||||
val decryptResult = NostrProtocol.decryptPrivateMessage(giftWrap, identity)
|
||||
if (decryptResult != null) {
|
||||
val (content, senderPubkey, timestamp) = decryptResult
|
||||
|
||||
// Convert sender pubkey to npub
|
||||
val senderNpub = try {
|
||||
Bech32.encode("npub", senderPubkey.hexToByteArray())
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to encode sender npub: ${e.message}")
|
||||
"npub_decode_error"
|
||||
}
|
||||
|
||||
Log.d(TAG, "📥 Received private message from ${senderNpub.take(16)}...")
|
||||
|
||||
// Dispatch to main thread for handler
|
||||
withContext(Dispatchers.Main) {
|
||||
handler(content, senderNpub, timestamp)
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Failed to decrypt private message")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error handling private message: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleGeohashMessage(
|
||||
event: NostrEvent,
|
||||
handler: (content: String, senderPubkey: String, nickname: String?, timestamp: Int) -> Unit
|
||||
) {
|
||||
try {
|
||||
// Extract nickname from tags
|
||||
val nickname = event.tags.find { it.size >= 2 && it[0] == "n" }?.get(1)
|
||||
|
||||
Log.v(TAG, "📥 Received geohash message from ${event.pubkey.take(16)}...")
|
||||
|
||||
// Dispatch to main thread for handler
|
||||
withContext(Dispatchers.Main) {
|
||||
handler(event.content, event.pubkey, nickname, event.createdAt)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error handling geohash message: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import org.bouncycastle.crypto.ec.CustomNamedCurves
|
||||
import org.bouncycastle.crypto.params.ECDomainParameters
|
||||
import org.bouncycastle.crypto.params.ECPrivateKeyParameters
|
||||
import org.bouncycastle.crypto.params.ECPublicKeyParameters
|
||||
import org.bouncycastle.math.ec.ECPoint
|
||||
import org.bouncycastle.crypto.generators.ECKeyPairGenerator
|
||||
import org.bouncycastle.crypto.params.ECKeyGenerationParameters
|
||||
import org.bouncycastle.crypto.AsymmetricCipherKeyPair
|
||||
import org.bouncycastle.crypto.agreement.ECDHBasicAgreement
|
||||
import org.bouncycastle.crypto.digests.SHA256Digest
|
||||
import org.bouncycastle.crypto.macs.HMac
|
||||
import org.bouncycastle.crypto.params.KeyParameter
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import java.security.SecureRandom
|
||||
import java.security.MessageDigest
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* Cryptographic utilities for Nostr protocol
|
||||
* Includes secp256k1 operations, ECDH, and NIP-44 encryption
|
||||
*/
|
||||
object NostrCrypto {
|
||||
|
||||
private val secureRandom = SecureRandom()
|
||||
|
||||
// secp256k1 curve parameters
|
||||
val secp256k1Curve = CustomNamedCurves.getByName("secp256k1")
|
||||
val secp256k1Params = ECDomainParameters(
|
||||
secp256k1Curve.curve,
|
||||
secp256k1Curve.g,
|
||||
secp256k1Curve.n,
|
||||
secp256k1Curve.h
|
||||
)
|
||||
|
||||
/**
|
||||
* Generate secp256k1 key pair
|
||||
* Returns (privateKeyHex, publicKeyHex)
|
||||
*/
|
||||
fun generateKeyPair(): Pair<String, String> {
|
||||
val generator = ECKeyPairGenerator()
|
||||
val keyGenParams = ECKeyGenerationParameters(secp256k1Params, secureRandom)
|
||||
generator.init(keyGenParams)
|
||||
|
||||
val keyPair = generator.generateKeyPair()
|
||||
val privateKey = keyPair.private as ECPrivateKeyParameters
|
||||
val publicKey = keyPair.public as ECPublicKeyParameters
|
||||
|
||||
// Get private key as 32-byte hex - ensure proper padding
|
||||
val privateKeyBigInt = privateKey.d
|
||||
val privateKeyBytes = privateKeyBigInt.toByteArray()
|
||||
|
||||
val privateKeyPadded = ByteArray(32)
|
||||
if (privateKeyBytes.size <= 32) {
|
||||
val srcStart = maxOf(0, privateKeyBytes.size - 32)
|
||||
val destStart = maxOf(0, 32 - privateKeyBytes.size)
|
||||
val length = minOf(privateKeyBytes.size, 32)
|
||||
System.arraycopy(privateKeyBytes, srcStart, privateKeyPadded, destStart, length)
|
||||
} else {
|
||||
// If BigInteger added a sign byte, skip it
|
||||
System.arraycopy(privateKeyBytes, privateKeyBytes.size - 32, privateKeyPadded, 0, 32)
|
||||
}
|
||||
|
||||
// Get x-only public key (32 bytes)
|
||||
val publicKeyPoint = publicKey.q.normalize()
|
||||
val xCoord = publicKeyPoint.xCoord.encoded
|
||||
|
||||
return Pair(
|
||||
privateKeyPadded.toHexString(),
|
||||
xCoord.toHexString()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive public key from private key
|
||||
* Returns x-only public key (32 bytes hex)
|
||||
*/
|
||||
fun derivePublicKey(privateKeyHex: String): String {
|
||||
val privateKeyBytes = privateKeyHex.hexToByteArray()
|
||||
val privateKeyBigInt = BigInteger(1, privateKeyBytes)
|
||||
|
||||
val publicKeyPoint = secp256k1Params.g.multiply(privateKeyBigInt).normalize()
|
||||
val xCoord = publicKeyPoint.xCoord.encoded
|
||||
|
||||
return xCoord.toHexString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform ECDH key agreement
|
||||
* Returns shared secret
|
||||
*/
|
||||
fun performECDH(privateKeyHex: String, publicKeyHex: String): ByteArray {
|
||||
val privateKeyBytes = privateKeyHex.hexToByteArray()
|
||||
val publicKeyBytes = publicKeyHex.hexToByteArray()
|
||||
|
||||
val privateKeyBigInt = BigInteger(1, privateKeyBytes)
|
||||
val privateKeyParams = ECPrivateKeyParameters(privateKeyBigInt, secp256k1Params)
|
||||
|
||||
// Try to recover full public key point from x-only coordinate
|
||||
val publicKeyPoint = recoverPublicKeyPoint(publicKeyBytes)
|
||||
val publicKeyParams = ECPublicKeyParameters(publicKeyPoint, secp256k1Params)
|
||||
|
||||
val agreement = ECDHBasicAgreement()
|
||||
agreement.init(privateKeyParams)
|
||||
|
||||
val sharedSecret = agreement.calculateAgreement(publicKeyParams)
|
||||
val sharedSecretBytes = sharedSecret.toByteArray()
|
||||
|
||||
// Ensure 32 bytes
|
||||
val result = ByteArray(32)
|
||||
if (sharedSecretBytes.size <= 32) {
|
||||
System.arraycopy(
|
||||
sharedSecretBytes,
|
||||
maxOf(0, sharedSecretBytes.size - 32),
|
||||
result,
|
||||
maxOf(0, 32 - sharedSecretBytes.size),
|
||||
minOf(sharedSecretBytes.size, 32)
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover full EC point from x-only coordinate
|
||||
* Tries both possible y coordinates
|
||||
*/
|
||||
private fun recoverPublicKeyPoint(xOnlyBytes: ByteArray): ECPoint {
|
||||
require(xOnlyBytes.size == 32) { "X-only public key must be 32 bytes" }
|
||||
|
||||
val x = BigInteger(1, xOnlyBytes)
|
||||
|
||||
// Try even y first (0x02 prefix)
|
||||
try {
|
||||
val compressedBytes = ByteArray(33)
|
||||
compressedBytes[0] = 0x02
|
||||
System.arraycopy(xOnlyBytes, 0, compressedBytes, 1, 32)
|
||||
return secp256k1Curve.curve.decodePoint(compressedBytes)
|
||||
} catch (e: Exception) {
|
||||
// Try odd y (0x03 prefix)
|
||||
val compressedBytes = ByteArray(33)
|
||||
compressedBytes[0] = 0x03
|
||||
System.arraycopy(xOnlyBytes, 0, compressedBytes, 1, 32)
|
||||
return secp256k1Curve.curve.decodePoint(compressedBytes)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-44 key derivation using HKDF
|
||||
*/
|
||||
fun deriveNIP44Key(sharedSecret: ByteArray): ByteArray {
|
||||
val salt = "nip44-v2".toByteArray(Charsets.UTF_8)
|
||||
|
||||
// HKDF-Extract
|
||||
val hmac = HMac(SHA256Digest())
|
||||
hmac.init(KeyParameter(salt))
|
||||
hmac.update(sharedSecret, 0, sharedSecret.size)
|
||||
val prk = ByteArray(hmac.macSize)
|
||||
hmac.doFinal(prk, 0)
|
||||
|
||||
// HKDF-Expand (we need 32 bytes for AES-256)
|
||||
hmac.init(KeyParameter(prk))
|
||||
hmac.update(byteArrayOf(0x01), 0, 1) // info = empty, N = 1
|
||||
val okm = ByteArray(hmac.macSize)
|
||||
hmac.doFinal(okm, 0)
|
||||
|
||||
// Return first 32 bytes
|
||||
return okm.copyOf(32)
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-44 encryption using AES-256-GCM
|
||||
*/
|
||||
fun encryptNIP44(plaintext: String, recipientPublicKeyHex: String, senderPrivateKeyHex: String): String {
|
||||
try {
|
||||
// Perform ECDH
|
||||
val sharedSecret = performECDH(senderPrivateKeyHex, recipientPublicKeyHex)
|
||||
|
||||
// Derive encryption key
|
||||
val encryptionKey = deriveNIP44Key(sharedSecret)
|
||||
|
||||
// Generate random nonce (12 bytes for GCM)
|
||||
val nonce = ByteArray(12)
|
||||
secureRandom.nextBytes(nonce)
|
||||
|
||||
// Encrypt using AES-256-GCM
|
||||
val plaintextBytes = plaintext.toByteArray(Charsets.UTF_8)
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
val secretKey = SecretKeySpec(encryptionKey, "AES")
|
||||
val gcmSpec = GCMParameterSpec(128, nonce) // 128-bit auth tag
|
||||
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey, gcmSpec)
|
||||
val ciphertext = cipher.doFinal(plaintextBytes)
|
||||
|
||||
// Combine nonce + ciphertext (includes auth tag)
|
||||
val result = ByteArray(nonce.size + ciphertext.size)
|
||||
System.arraycopy(nonce, 0, result, 0, nonce.size)
|
||||
System.arraycopy(ciphertext, 0, result, nonce.size, ciphertext.size)
|
||||
|
||||
// Base64 encode
|
||||
return android.util.Base64.encodeToString(result, android.util.Base64.NO_WRAP)
|
||||
} catch (e: Exception) {
|
||||
throw RuntimeException("NIP-44 encryption failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-44 decryption using AES-256-GCM
|
||||
*/
|
||||
fun decryptNIP44(ciphertext: String, senderPublicKeyHex: String, recipientPrivateKeyHex: String): String {
|
||||
try {
|
||||
// Decode base64
|
||||
val encryptedData = android.util.Base64.decode(ciphertext, android.util.Base64.NO_WRAP)
|
||||
|
||||
// Extract nonce and ciphertext
|
||||
require(encryptedData.size >= 12 + 16) { "Ciphertext too short" }
|
||||
val nonce = encryptedData.copyOfRange(0, 12)
|
||||
val ciphertextBytes = encryptedData.copyOfRange(12, encryptedData.size)
|
||||
|
||||
// Perform ECDH
|
||||
val sharedSecret = performECDH(recipientPrivateKeyHex, senderPublicKeyHex)
|
||||
|
||||
// Derive decryption key
|
||||
val decryptionKey = deriveNIP44Key(sharedSecret)
|
||||
|
||||
// Decrypt using AES-256-GCM
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
val secretKey = SecretKeySpec(decryptionKey, "AES")
|
||||
val gcmSpec = GCMParameterSpec(128, nonce)
|
||||
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, gcmSpec)
|
||||
val plaintextBytes = cipher.doFinal(ciphertextBytes)
|
||||
|
||||
return String(plaintextBytes, Charsets.UTF_8)
|
||||
} catch (e: Exception) {
|
||||
throw RuntimeException("NIP-44 decryption failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate random timestamp offset for privacy (±15 minutes)
|
||||
*/
|
||||
fun randomizeTimestamp(baseTimestamp: Long = System.currentTimeMillis() / 1000): Int {
|
||||
val offset = secureRandom.nextInt(1800) - 900 // ±15 minutes in seconds
|
||||
return (baseTimestamp + offset).toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate secp256k1 private key
|
||||
*/
|
||||
fun isValidPrivateKey(privateKeyHex: String): Boolean {
|
||||
return try {
|
||||
val privateKeyBytes = privateKeyHex.hexToByteArray()
|
||||
if (privateKeyBytes.size != 32) return false
|
||||
|
||||
val privateKeyBigInt = BigInteger(1, privateKeyBytes)
|
||||
|
||||
// Must be less than curve order and greater than 0
|
||||
privateKeyBigInt > BigInteger.ZERO && privateKeyBigInt < secp256k1Params.n
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate x-only public key
|
||||
*/
|
||||
fun isValidPublicKey(publicKeyHex: String): Boolean {
|
||||
return try {
|
||||
val publicKeyBytes = publicKeyHex.hexToByteArray()
|
||||
if (publicKeyBytes.size != 32) return false
|
||||
|
||||
// Try to recover point
|
||||
recoverPublicKeyPoint(publicKeyBytes)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// BIP-340 Schnorr Signatures Implementation
|
||||
// ==============================================================================
|
||||
|
||||
/**
|
||||
* Tagged hash function for BIP-340
|
||||
*/
|
||||
private fun taggedHash(tag: String, data: ByteArray): ByteArray {
|
||||
val tagBytes = tag.toByteArray(Charsets.UTF_8)
|
||||
val tagHash = MessageDigest.getInstance("SHA-256").digest(tagBytes)
|
||||
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
digest.update(tagHash)
|
||||
digest.update(tagHash)
|
||||
digest.update(data)
|
||||
return digest.digest()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if y coordinate is even
|
||||
*/
|
||||
private fun hasEvenY(point: ECPoint): Boolean {
|
||||
val yCoord = point.normalize().yCoord.encoded
|
||||
return (yCoord[yCoord.size - 1].toInt() and 1) == 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Lift x coordinate to point with even y
|
||||
*/
|
||||
private fun liftX(xBytes: ByteArray): ECPoint? {
|
||||
return try {
|
||||
val point = recoverPublicKeyPoint(xBytes)
|
||||
val normalizedPoint = point.normalize()
|
||||
|
||||
if (hasEvenY(normalizedPoint)) {
|
||||
normalizedPoint
|
||||
} else {
|
||||
normalizedPoint.negate()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* BIP-340 Schnorr signature creation
|
||||
* Returns 64-byte signature (r || s) as hex string
|
||||
*/
|
||||
fun schnorrSign(messageHash: ByteArray, privateKeyHex: String): String {
|
||||
require(messageHash.size == 32) { "Message hash must be 32 bytes" }
|
||||
|
||||
val privateKeyBytes = privateKeyHex.hexToByteArray()
|
||||
require(privateKeyBytes.size == 32) { "Private key must be 32 bytes" }
|
||||
|
||||
val d = BigInteger(1, privateKeyBytes)
|
||||
require(d > BigInteger.ZERO && d < secp256k1Params.n) { "Invalid private key" }
|
||||
|
||||
// Compute public key point P = d * G
|
||||
val P = secp256k1Params.g.multiply(d).normalize()
|
||||
|
||||
// Ensure P has even y coordinate, adjust d if necessary
|
||||
val (adjustedD, publicKeyBytes) = if (hasEvenY(P)) {
|
||||
Pair(d, P.xCoord.encoded)
|
||||
} else {
|
||||
Pair(secp256k1Params.n.subtract(d), P.xCoord.encoded)
|
||||
}
|
||||
|
||||
// Generate nonce
|
||||
val k = generateNonce(adjustedD, messageHash, publicKeyBytes)
|
||||
|
||||
// Compute R = k * G
|
||||
val R = secp256k1Params.g.multiply(k).normalize()
|
||||
|
||||
// Ensure R has even y coordinate
|
||||
val adjustedK = if (hasEvenY(R)) k else secp256k1Params.n.subtract(k)
|
||||
val r = R.xCoord.encoded
|
||||
|
||||
// Compute challenge e = H(r || P || m)
|
||||
val challengeData = ByteArray(96) // 32 + 32 + 32
|
||||
System.arraycopy(r, 0, challengeData, 0, 32)
|
||||
System.arraycopy(publicKeyBytes, 0, challengeData, 32, 32)
|
||||
System.arraycopy(messageHash, 0, challengeData, 64, 32)
|
||||
|
||||
val eBytes = taggedHash("BIP0340/challenge", challengeData)
|
||||
val e = BigInteger(1, eBytes).mod(secp256k1Params.n)
|
||||
|
||||
// Compute s = (k + e * d) mod n
|
||||
val s = adjustedK.add(e.multiply(adjustedD)).mod(secp256k1Params.n)
|
||||
|
||||
// Return signature as r || s (64 bytes hex)
|
||||
val rPadded = ByteArray(32)
|
||||
val sPadded = ByteArray(32)
|
||||
|
||||
val rBytes = r
|
||||
val sBytes = s.toByteArray()
|
||||
|
||||
// Pad r to 32 bytes (should already be 32)
|
||||
System.arraycopy(rBytes, 0, rPadded, 0, minOf(32, rBytes.size))
|
||||
|
||||
// Pad s to 32 bytes - handle BigInteger padding correctly
|
||||
if (sBytes.size <= 32) {
|
||||
val srcStart = maxOf(0, sBytes.size - 32)
|
||||
val destStart = maxOf(0, 32 - sBytes.size)
|
||||
val length = minOf(sBytes.size, 32)
|
||||
System.arraycopy(sBytes, srcStart, sPadded, destStart, length)
|
||||
} else {
|
||||
// If BigInteger added a sign byte, skip it
|
||||
System.arraycopy(sBytes, sBytes.size - 32, sPadded, 0, 32)
|
||||
}
|
||||
|
||||
return (rPadded + sPadded).toHexString()
|
||||
}
|
||||
|
||||
/**
|
||||
* BIP-340 Schnorr signature verification
|
||||
*/
|
||||
fun schnorrVerify(messageHash: ByteArray, signatureHex: String, publicKeyHex: String): Boolean {
|
||||
return try {
|
||||
require(messageHash.size == 32) { "Message hash must be 32 bytes" }
|
||||
|
||||
val signatureBytes = signatureHex.hexToByteArray()
|
||||
require(signatureBytes.size == 64) { "Signature must be 64 bytes" }
|
||||
|
||||
val publicKeyBytes = publicKeyHex.hexToByteArray()
|
||||
require(publicKeyBytes.size == 32) { "Public key must be 32 bytes" }
|
||||
|
||||
// Parse signature
|
||||
val r = signatureBytes.copyOfRange(0, 32)
|
||||
val sBytes = signatureBytes.copyOfRange(32, 64)
|
||||
val s = BigInteger(1, sBytes)
|
||||
|
||||
// Validate r and s
|
||||
val rBigInt = BigInteger(1, r)
|
||||
if (rBigInt >= secp256k1Params.curve.field.characteristic) return false
|
||||
if (s >= secp256k1Params.n) return false
|
||||
|
||||
// Lift public key
|
||||
val P = liftX(publicKeyBytes) ?: return false
|
||||
|
||||
// Compute challenge e = H(r || P || m)
|
||||
val challengeData = ByteArray(96)
|
||||
System.arraycopy(r, 0, challengeData, 0, 32)
|
||||
System.arraycopy(publicKeyBytes, 0, challengeData, 32, 32)
|
||||
System.arraycopy(messageHash, 0, challengeData, 64, 32)
|
||||
|
||||
val eBytes = taggedHash("BIP0340/challenge", challengeData)
|
||||
val e = BigInteger(1, eBytes).mod(secp256k1Params.n)
|
||||
|
||||
// Compute R = s * G - e * P
|
||||
val sG = secp256k1Params.g.multiply(s)
|
||||
val eP = P.multiply(e)
|
||||
val R = sG.subtract(eP).normalize()
|
||||
|
||||
// Check if R has even y and x coordinate matches r
|
||||
if (!hasEvenY(R)) return false
|
||||
|
||||
val computedR = R.xCoord.encoded
|
||||
return r.contentEquals(computedR)
|
||||
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate deterministic nonce for Schnorr signature (RFC 6979 style)
|
||||
*/
|
||||
private fun generateNonce(privateKey: BigInteger, messageHash: ByteArray, publicKeyBytes: ByteArray): BigInteger {
|
||||
// Simple nonce generation - in production, use RFC 6979
|
||||
// For now, use SHA256(private_key || message || public_key || random)
|
||||
val random = ByteArray(32)
|
||||
secureRandom.nextBytes(random)
|
||||
|
||||
val privateKeyBytes = privateKey.toByteArray()
|
||||
val nonceInput = ByteArray(privateKeyBytes.size + messageHash.size + publicKeyBytes.size + random.size)
|
||||
var offset = 0
|
||||
|
||||
System.arraycopy(privateKeyBytes, 0, nonceInput, offset, privateKeyBytes.size)
|
||||
offset += privateKeyBytes.size
|
||||
|
||||
System.arraycopy(messageHash, 0, nonceInput, offset, messageHash.size)
|
||||
offset += messageHash.size
|
||||
|
||||
System.arraycopy(publicKeyBytes, 0, nonceInput, offset, publicKeyBytes.size)
|
||||
offset += publicKeyBytes.size
|
||||
|
||||
System.arraycopy(random, 0, nonceInput, offset, random.size)
|
||||
|
||||
val nonceHash = MessageDigest.getInstance("SHA-256").digest(nonceInput)
|
||||
val nonce = BigInteger(1, nonceHash)
|
||||
|
||||
// Ensure nonce is in valid range
|
||||
return if (nonce >= secp256k1Params.n) {
|
||||
nonce.mod(secp256k1Params.n)
|
||||
} else {
|
||||
nonce
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import com.bitchat.android.model.PrivateMessagePacket
|
||||
import com.bitchat.android.model.NoisePayloadType
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* BitChat-over-Nostr Adapter
|
||||
* Direct port from iOS implementation for 100% compatibility
|
||||
*/
|
||||
object NostrEmbeddedBitChat {
|
||||
|
||||
private const val TAG = "NostrEmbeddedBitChat"
|
||||
|
||||
/**
|
||||
* Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message for Nostr DMs.
|
||||
*/
|
||||
fun encodePMForNostr(
|
||||
content: String,
|
||||
messageID: String,
|
||||
recipientPeerID: String,
|
||||
senderPeerID: String
|
||||
): String? {
|
||||
try {
|
||||
// TLV-encode the private message
|
||||
val pm = PrivateMessagePacket(messageID = messageID, content = content)
|
||||
val tlv = pm.encode() ?: return null
|
||||
|
||||
// Prefix with NoisePayloadType
|
||||
val payload = ByteArray(1 + tlv.size)
|
||||
payload[0] = NoisePayloadType.PRIVATE_MESSAGE.value.toByte()
|
||||
System.arraycopy(tlv, 0, payload, 1, tlv.size)
|
||||
|
||||
// Determine 8-byte recipient ID to embed
|
||||
val recipientIDHex = normalizeRecipientPeerID(recipientPeerID)
|
||||
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = hexStringToByteArray(senderPeerID),
|
||||
recipientID = hexStringToByteArray(recipientIDHex),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload,
|
||||
signature = null,
|
||||
ttl = 7u
|
||||
)
|
||||
|
||||
val data = packet.toBinaryData() ?: return null
|
||||
return "bitchat1:" + base64URLEncode(data)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to encode PM for Nostr: ${e.message}")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack for Nostr DMs.
|
||||
*/
|
||||
fun encodeAckForNostr(
|
||||
type: NoisePayloadType,
|
||||
messageID: String,
|
||||
recipientPeerID: String,
|
||||
senderPeerID: String
|
||||
): String? {
|
||||
if (type != NoisePayloadType.DELIVERED && type != NoisePayloadType.READ_RECEIPT) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
val payload = ByteArray(1 + messageID.toByteArray(Charsets.UTF_8).size)
|
||||
payload[0] = type.value.toByte()
|
||||
val messageIDBytes = messageID.toByteArray(Charsets.UTF_8)
|
||||
System.arraycopy(messageIDBytes, 0, payload, 1, messageIDBytes.size)
|
||||
|
||||
val recipientIDHex = normalizeRecipientPeerID(recipientPeerID)
|
||||
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = hexStringToByteArray(senderPeerID),
|
||||
recipientID = hexStringToByteArray(recipientIDHex),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload,
|
||||
signature = null,
|
||||
ttl = 7u
|
||||
)
|
||||
|
||||
val data = packet.toBinaryData() ?: return null
|
||||
return "bitchat1:" + base64URLEncode(data)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to encode ACK for Nostr: ${e.message}")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs).
|
||||
*/
|
||||
fun encodeAckForNostrNoRecipient(
|
||||
type: NoisePayloadType,
|
||||
messageID: String,
|
||||
senderPeerID: String
|
||||
): String? {
|
||||
if (type != NoisePayloadType.DELIVERED && type != NoisePayloadType.READ_RECEIPT) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
val payload = ByteArray(1 + messageID.toByteArray(Charsets.UTF_8).size)
|
||||
payload[0] = type.value.toByte()
|
||||
val messageIDBytes = messageID.toByteArray(Charsets.UTF_8)
|
||||
System.arraycopy(messageIDBytes, 0, payload, 1, messageIDBytes.size)
|
||||
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = hexStringToByteArray(senderPeerID),
|
||||
recipientID = null, // No recipient for geohash DMs
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload,
|
||||
signature = null,
|
||||
ttl = 7u
|
||||
)
|
||||
|
||||
val data = packet.toBinaryData() ?: return null
|
||||
return "bitchat1:" + base64URLEncode(data)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to encode ACK for Nostr (no recipient): ${e.message}")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs).
|
||||
*/
|
||||
fun encodePMForNostrNoRecipient(
|
||||
content: String,
|
||||
messageID: String,
|
||||
senderPeerID: String
|
||||
): String? {
|
||||
try {
|
||||
val pm = PrivateMessagePacket(messageID = messageID, content = content)
|
||||
val tlv = pm.encode() ?: return null
|
||||
|
||||
val payload = ByteArray(1 + tlv.size)
|
||||
payload[0] = NoisePayloadType.PRIVATE_MESSAGE.value.toByte()
|
||||
System.arraycopy(tlv, 0, payload, 1, tlv.size)
|
||||
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = hexStringToByteArray(senderPeerID),
|
||||
recipientID = null, // No recipient for geohash DMs
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload,
|
||||
signature = null,
|
||||
ttl = 7u
|
||||
)
|
||||
|
||||
val data = packet.toBinaryData() ?: return null
|
||||
return "bitchat1:" + base64URLEncode(data)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to encode PM for Nostr (no recipient): ${e.message}")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize recipient peer ID (matches iOS implementation)
|
||||
*/
|
||||
private fun normalizeRecipientPeerID(recipientPeerID: String): String {
|
||||
try {
|
||||
val maybeData = hexStringToByteArray(recipientPeerID)
|
||||
return when (maybeData.size) {
|
||||
32 -> {
|
||||
// Treat as Noise static public key; derive peerID from fingerprint
|
||||
// For now, return first 8 bytes as hex (simplified)
|
||||
maybeData.take(8).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
8 -> {
|
||||
// Already an 8-byte peer ID
|
||||
recipientPeerID
|
||||
}
|
||||
else -> {
|
||||
// Fallback: return as-is (expecting 16 hex chars)
|
||||
recipientPeerID
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Fallback: return as-is
|
||||
return recipientPeerID
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64url encode without padding (matches iOS implementation)
|
||||
*/
|
||||
private fun base64URLEncode(data: ByteArray): String {
|
||||
val b64 = Base64.encodeToString(data, Base64.NO_WRAP)
|
||||
return b64
|
||||
.replace("+", "-")
|
||||
.replace("/", "_")
|
||||
.replace("=", "")
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex string to byte array
|
||||
*/
|
||||
private fun hexStringToByteArray(hexString: String): ByteArray {
|
||||
if (hexString.length % 2 != 0) {
|
||||
return ByteArray(8) // Return 8-byte array filled with zeros
|
||||
}
|
||||
|
||||
val result = ByteArray(8) { 0 } // Exactly 8 bytes like iOS
|
||||
var tempID = hexString
|
||||
var index = 0
|
||||
|
||||
while (tempID.length >= 2 && index < 8) {
|
||||
val hexByte = tempID.substring(0, 2)
|
||||
val byte = hexByte.toIntOrNull(16)?.toByte()
|
||||
if (byte != null) {
|
||||
result[index] = byte
|
||||
}
|
||||
tempID = tempID.substring(2)
|
||||
index++
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Nostr Event structure following NIP-01
|
||||
* Compatible with iOS implementation
|
||||
*/
|
||||
data class NostrEvent(
|
||||
var id: String = "",
|
||||
val pubkey: String,
|
||||
@SerializedName("created_at") val createdAt: Int,
|
||||
val kind: Int,
|
||||
val tags: List<List<String>>,
|
||||
val content: String,
|
||||
var sig: String? = null
|
||||
) {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Create from JSON dictionary
|
||||
*/
|
||||
fun fromJson(json: Map<String, Any>): NostrEvent? {
|
||||
return try {
|
||||
NostrEvent(
|
||||
id = json["id"] as? String ?: "",
|
||||
pubkey = json["pubkey"] as? String ?: return null,
|
||||
createdAt = (json["created_at"] as? Number)?.toInt() ?: return null,
|
||||
kind = (json["kind"] as? Number)?.toInt() ?: return null,
|
||||
tags = (json["tags"] as? List<List<String>>) ?: return null,
|
||||
content = json["content"] as? String ?: return null,
|
||||
sig = json["sig"] as? String?
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from JSON string
|
||||
*/
|
||||
fun fromJsonString(jsonString: String): NostrEvent? {
|
||||
return try {
|
||||
val gson = Gson()
|
||||
gson.fromJson(jsonString, NostrEvent::class.java)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new text note event
|
||||
*/
|
||||
fun createTextNote(
|
||||
content: String,
|
||||
publicKeyHex: String,
|
||||
privateKeyHex: String,
|
||||
tags: List<List<String>> = emptyList(),
|
||||
createdAt: Int = (System.currentTimeMillis() / 1000).toInt()
|
||||
): NostrEvent {
|
||||
val event = NostrEvent(
|
||||
pubkey = publicKeyHex,
|
||||
createdAt = createdAt,
|
||||
kind = NostrKind.TEXT_NOTE,
|
||||
tags = tags,
|
||||
content = content
|
||||
)
|
||||
return event.sign(privateKeyHex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new metadata event (kind 0)
|
||||
*/
|
||||
fun createMetadata(
|
||||
metadata: String,
|
||||
publicKeyHex: String,
|
||||
privateKeyHex: String,
|
||||
createdAt: Int = (System.currentTimeMillis() / 1000).toInt()
|
||||
): NostrEvent {
|
||||
val event = NostrEvent(
|
||||
pubkey = publicKeyHex,
|
||||
createdAt = createdAt,
|
||||
kind = NostrKind.METADATA,
|
||||
tags = emptyList(),
|
||||
content = metadata
|
||||
)
|
||||
return event.sign(privateKeyHex)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign event with secp256k1 private key
|
||||
* Returns signed event with id and signature set
|
||||
*/
|
||||
fun sign(privateKeyHex: String): NostrEvent {
|
||||
val (eventId, eventIdHash) = calculateEventId()
|
||||
|
||||
// Create signature using secp256k1
|
||||
val signature = signHash(eventIdHash, privateKeyHex)
|
||||
|
||||
return this.copy(
|
||||
id = eventId,
|
||||
sig = signature
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate event ID according to NIP-01
|
||||
* Returns (hex_id, hash_bytes)
|
||||
*/
|
||||
private fun calculateEventId(): Pair<String, ByteArray> {
|
||||
// Create serialized array for hashing according to NIP-01
|
||||
val serialized = listOf(
|
||||
0,
|
||||
pubkey,
|
||||
createdAt,
|
||||
kind,
|
||||
tags,
|
||||
content
|
||||
)
|
||||
|
||||
// Convert to JSON without escaping slashes (compact format)
|
||||
val gson = Gson()
|
||||
val jsonString = gson.toJson(serialized)
|
||||
|
||||
// SHA256 hash of the JSON string
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val jsonBytes = jsonString.toByteArray(Charsets.UTF_8)
|
||||
val hash = digest.digest(jsonBytes)
|
||||
|
||||
// Convert to hex
|
||||
val hexId = hash.joinToString("") { "%02x".format(it) }
|
||||
|
||||
return Pair(hexId, hash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign hash using BIP-340 Schnorr signatures
|
||||
*/
|
||||
private fun signHash(hash: ByteArray, privateKeyHex: String): String {
|
||||
return try {
|
||||
// Use the real BIP-340 Schnorr signature from NostrCrypto
|
||||
NostrCrypto.schnorrSign(hash, privateKeyHex)
|
||||
} catch (e: Exception) {
|
||||
throw RuntimeException("Failed to sign event: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to JSON string
|
||||
*/
|
||||
fun toJsonString(): String {
|
||||
val gson = Gson()
|
||||
return gson.toJson(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate event signature using BIP-340 Schnorr verification
|
||||
*/
|
||||
fun isValidSignature(): Boolean {
|
||||
return try {
|
||||
val signatureHex = sig ?: return false
|
||||
if (id.isEmpty() || pubkey.isEmpty()) return false
|
||||
|
||||
// Recalculate the event ID hash for verification
|
||||
val (calculatedId, messageHash) = calculateEventId()
|
||||
|
||||
// Check if the calculated ID matches the stored ID
|
||||
if (calculatedId != id) return false
|
||||
|
||||
// Verify the Schnorr signature
|
||||
NostrCrypto.schnorrVerify(messageHash, signatureHex, pubkey)
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate event structure and signature
|
||||
*/
|
||||
fun isValid(): Boolean {
|
||||
return try {
|
||||
// Basic field validation
|
||||
if (pubkey.isEmpty() || content.isEmpty()) return false
|
||||
if (createdAt <= 0 || kind < 0) return false
|
||||
if (!NostrCrypto.isValidPublicKey(pubkey)) return false
|
||||
|
||||
// Signature validation
|
||||
isValidSignature()
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nostr event kinds
|
||||
*/
|
||||
object NostrKind {
|
||||
const val METADATA = 0
|
||||
const val TEXT_NOTE = 1
|
||||
const val SEAL = 13 // NIP-17 sealed event
|
||||
const val GIFT_WRAP = 1059 // NIP-17 gift wrap
|
||||
const val EPHEMERAL_EVENT = 20000 // For geohash channels
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension functions for hex encoding/decoding
|
||||
*/
|
||||
fun String.hexToByteArray(): ByteArray {
|
||||
check(length % 2 == 0) { "Must have an even length" }
|
||||
return chunked(2)
|
||||
.map { it.toInt(16).toByte() }
|
||||
.toByteArray()
|
||||
}
|
||||
|
||||
fun ByteArray.toHexString(): String = joinToString("") { "%02x".format(it) }
|
||||
@@ -0,0 +1,260 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.util.Log
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Efficient LRU-based Nostr event deduplication system
|
||||
*
|
||||
* This class provides thread-safe deduplication of Nostr events based on their event IDs.
|
||||
* It maintains an LRU cache of up to 10,000 event IDs to prevent memory bloat while ensuring
|
||||
* duplicate events (which commonly arrive via different relays) are processed only once.
|
||||
*
|
||||
* Features:
|
||||
* - Thread-safe concurrent access
|
||||
* - LRU eviction when capacity is exceeded
|
||||
* - Configurable capacity (default 10,000)
|
||||
* - Efficient O(1) lookup and insertion
|
||||
* - Memory-bounded to prevent unbounded growth
|
||||
*/
|
||||
class NostrEventDeduplicator(
|
||||
private val maxCapacity: Int = DEFAULT_CAPACITY
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "NostrDeduplicator"
|
||||
private const val DEFAULT_CAPACITY = 10000
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: NostrEventDeduplicator? = null
|
||||
|
||||
/**
|
||||
* Get the singleton instance of the deduplicator
|
||||
*/
|
||||
fun getInstance(): NostrEventDeduplicator {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: NostrEventDeduplicator().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Node for the doubly-linked list used in LRU implementation
|
||||
*/
|
||||
private data class LRUNode(
|
||||
val eventId: String,
|
||||
var prev: LRUNode? = null,
|
||||
var next: LRUNode? = null
|
||||
)
|
||||
|
||||
// Hash map for O(1) lookup - maps event ID to node
|
||||
private val nodeMap = ConcurrentHashMap<String, LRUNode>()
|
||||
|
||||
// Doubly-linked list for LRU ordering
|
||||
private val head = LRUNode("HEAD") // Dummy head node
|
||||
private val tail = LRUNode("TAIL") // Dummy tail node
|
||||
|
||||
// Lock for thread-safe LRU operations
|
||||
private val lruLock = Any()
|
||||
|
||||
// Statistics
|
||||
@Volatile
|
||||
private var totalChecks = 0L
|
||||
@Volatile
|
||||
private var duplicateCount = 0L
|
||||
@Volatile
|
||||
private var evictionCount = 0L
|
||||
|
||||
init {
|
||||
// Initialize the doubly-linked list
|
||||
head.next = tail
|
||||
tail.prev = head
|
||||
|
||||
Log.d(TAG, "Initialized NostrEventDeduplicator with capacity: $maxCapacity")
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event has been seen before and mark it as seen
|
||||
*
|
||||
* @param eventId The Nostr event ID to check
|
||||
* @return true if the event is a duplicate (already seen), false if it's new
|
||||
*/
|
||||
fun isDuplicate(eventId: String): Boolean {
|
||||
totalChecks++
|
||||
|
||||
synchronized(lruLock) {
|
||||
val existingNode = nodeMap[eventId]
|
||||
|
||||
if (existingNode != null) {
|
||||
// Event is a duplicate - move to front (most recently used)
|
||||
moveToFront(existingNode)
|
||||
duplicateCount++
|
||||
|
||||
if (duplicateCount % 100 == 0L) {
|
||||
Log.v(TAG, "Duplicate event detected: $eventId (${duplicateCount} total duplicates)")
|
||||
}
|
||||
|
||||
return true
|
||||
} else {
|
||||
// New event - add to front
|
||||
addToFront(eventId)
|
||||
|
||||
// Check if we need to evict oldest entries
|
||||
if (nodeMap.size > maxCapacity) {
|
||||
evictOldest()
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a Nostr event with deduplication
|
||||
*
|
||||
* @param event The Nostr event to process
|
||||
* @param processor Function to call if the event is not a duplicate
|
||||
* @return true if the event was processed (not a duplicate), false if it was deduplicated
|
||||
*/
|
||||
fun processEvent(event: NostrEvent, processor: (NostrEvent) -> Unit): Boolean {
|
||||
return if (!isDuplicate(event.id)) {
|
||||
processor(event)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current statistics about the deduplicator
|
||||
*/
|
||||
fun getStats(): DeduplicationStats {
|
||||
synchronized(lruLock) {
|
||||
return DeduplicationStats(
|
||||
capacity = maxCapacity,
|
||||
currentSize = nodeMap.size,
|
||||
totalChecks = totalChecks,
|
||||
duplicateCount = duplicateCount,
|
||||
evictionCount = evictionCount,
|
||||
hitRate = if (totalChecks > 0) (duplicateCount.toDouble() / totalChecks.toDouble()) else 0.0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached event IDs (useful for testing or resetting state)
|
||||
*/
|
||||
fun clear() {
|
||||
synchronized(lruLock) {
|
||||
nodeMap.clear()
|
||||
head.next = tail
|
||||
tail.prev = head
|
||||
|
||||
// Reset statistics
|
||||
totalChecks = 0L
|
||||
duplicateCount = 0L
|
||||
evictionCount = 0L
|
||||
|
||||
Log.d(TAG, "Cleared all cached event IDs")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the deduplicator contains a specific event ID
|
||||
*/
|
||||
fun contains(eventId: String): Boolean {
|
||||
return nodeMap.containsKey(eventId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current size of the cache
|
||||
*/
|
||||
fun size(): Int = nodeMap.size
|
||||
|
||||
// MARK: - Private LRU Implementation Methods
|
||||
|
||||
/**
|
||||
* Add a new event ID to the front of the LRU list
|
||||
*/
|
||||
private fun addToFront(eventId: String) {
|
||||
val newNode = LRUNode(eventId)
|
||||
nodeMap[eventId] = newNode
|
||||
|
||||
// Insert after head
|
||||
newNode.next = head.next
|
||||
newNode.prev = head
|
||||
head.next?.prev = newNode
|
||||
head.next = newNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an existing node to the front (most recently used position)
|
||||
*/
|
||||
private fun moveToFront(node: LRUNode) {
|
||||
// Remove from current position
|
||||
node.prev?.next = node.next
|
||||
node.next?.prev = node.prev
|
||||
|
||||
// Insert at front
|
||||
node.next = head.next
|
||||
node.prev = head
|
||||
head.next?.prev = node
|
||||
head.next = node
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove and return the least recently used node (at the tail)
|
||||
*/
|
||||
private fun removeTail(): LRUNode? {
|
||||
val lastNode = tail.prev
|
||||
if (lastNode == head) {
|
||||
return null // Empty list
|
||||
}
|
||||
|
||||
// Remove from linked list
|
||||
lastNode?.prev?.next = tail
|
||||
tail.prev = lastNode?.prev
|
||||
|
||||
// Remove from hash map
|
||||
if (lastNode != null) {
|
||||
nodeMap.remove(lastNode.eventId)
|
||||
}
|
||||
|
||||
return lastNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Evict the oldest (least recently used) entries when capacity is exceeded
|
||||
*/
|
||||
private fun evictOldest() {
|
||||
while (nodeMap.size > maxCapacity) {
|
||||
val evictedNode = removeTail()
|
||||
if (evictedNode != null) {
|
||||
evictionCount++
|
||||
|
||||
if (evictionCount % 500 == 0L) {
|
||||
Log.v(TAG, "Evicted event ID: ${evictedNode.eventId} (${evictionCount} total evictions)")
|
||||
}
|
||||
} else {
|
||||
break // Should not happen, but safety check
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistics about the deduplication system
|
||||
*/
|
||||
data class DeduplicationStats(
|
||||
val capacity: Int,
|
||||
val currentSize: Int,
|
||||
val totalChecks: Long,
|
||||
val duplicateCount: Long,
|
||||
val evictionCount: Long,
|
||||
val hitRate: Double
|
||||
) {
|
||||
override fun toString(): String {
|
||||
return "DeduplicationStats(capacity=$capacity, size=$currentSize, " +
|
||||
"checks=$totalChecks, duplicates=$duplicateCount, evictions=$evictionCount, " +
|
||||
"hitRate=${"%.2f".format(hitRate * 100)}%)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import com.google.gson.*
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import java.lang.reflect.Type
|
||||
|
||||
/**
|
||||
* Nostr event filter for subscriptions
|
||||
* Compatible with iOS implementation
|
||||
*/
|
||||
data class NostrFilter(
|
||||
val ids: List<String>? = null,
|
||||
val authors: List<String>? = null,
|
||||
val kinds: List<Int>? = null,
|
||||
val since: Int? = null,
|
||||
val until: Int? = null,
|
||||
val limit: Int? = null,
|
||||
private val tagFilters: Map<String, List<String>>? = null
|
||||
) {
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Create filter for NIP-17 gift wraps
|
||||
*/
|
||||
fun giftWrapsFor(pubkey: String, since: Long? = null): NostrFilter {
|
||||
return NostrFilter(
|
||||
kinds = listOf(NostrKind.GIFT_WRAP),
|
||||
since = since?.let { (it / 1000).toInt() },
|
||||
tagFilters = mapOf("p" to listOf(pubkey)),
|
||||
limit = 100
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create filter for geohash-scoped ephemeral events (kind 20000)
|
||||
*/
|
||||
fun geohashEphemeral(geohash: String, since: Long? = null, limit: Int = 200): NostrFilter {
|
||||
return NostrFilter(
|
||||
kinds = listOf(NostrKind.EPHEMERAL_EVENT),
|
||||
since = since?.let { (it / 1000).toInt() },
|
||||
tagFilters = mapOf("g" to listOf(geohash)),
|
||||
limit = limit
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create filter for text notes from specific authors
|
||||
*/
|
||||
fun textNotesFrom(authors: List<String>, since: Long? = null, limit: Int = 50): NostrFilter {
|
||||
return NostrFilter(
|
||||
kinds = listOf(NostrKind.TEXT_NOTE),
|
||||
authors = authors,
|
||||
since = since?.let { (it / 1000).toInt() },
|
||||
limit = limit
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create filter for specific event IDs
|
||||
*/
|
||||
fun forEvents(ids: List<String>): NostrFilter {
|
||||
return NostrFilter(ids = ids)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom JSON serializer to handle tag filters properly
|
||||
*/
|
||||
class FilterSerializer : JsonSerializer<NostrFilter> {
|
||||
override fun serialize(src: NostrFilter, typeOfSrc: Type, context: JsonSerializationContext): JsonElement {
|
||||
val jsonObject = JsonObject()
|
||||
|
||||
// Standard fields
|
||||
src.ids?.let { jsonObject.add("ids", context.serialize(it)) }
|
||||
src.authors?.let { jsonObject.add("authors", context.serialize(it)) }
|
||||
src.kinds?.let { jsonObject.add("kinds", context.serialize(it)) }
|
||||
src.since?.let { jsonObject.addProperty("since", it) }
|
||||
src.until?.let { jsonObject.addProperty("until", it) }
|
||||
src.limit?.let { jsonObject.addProperty("limit", it) }
|
||||
|
||||
// Tag filters with # prefix
|
||||
src.tagFilters?.forEach { (tag, values) ->
|
||||
jsonObject.add("#$tag", context.serialize(values))
|
||||
}
|
||||
|
||||
return jsonObject
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create builder for complex filters
|
||||
*/
|
||||
class Builder {
|
||||
private var ids: List<String>? = null
|
||||
private var authors: List<String>? = null
|
||||
private var kinds: List<Int>? = null
|
||||
private var since: Int? = null
|
||||
private var until: Int? = null
|
||||
private var limit: Int? = null
|
||||
private val tagFilters = mutableMapOf<String, List<String>>()
|
||||
|
||||
fun ids(vararg ids: String) = apply { this.ids = ids.toList() }
|
||||
fun authors(vararg authors: String) = apply { this.authors = authors.toList() }
|
||||
fun kinds(vararg kinds: Int) = apply { this.kinds = kinds.toList() }
|
||||
fun since(timestamp: Long) = apply { this.since = (timestamp / 1000).toInt() }
|
||||
fun until(timestamp: Long) = apply { this.until = (timestamp / 1000).toInt() }
|
||||
fun limit(count: Int) = apply { this.limit = count }
|
||||
|
||||
fun tagP(vararg pubkeys: String) = apply { tagFilters["p"] = pubkeys.toList() }
|
||||
fun tagE(vararg eventIds: String) = apply { tagFilters["e"] = eventIds.toList() }
|
||||
fun tagG(vararg geohashes: String) = apply { tagFilters["g"] = geohashes.toList() }
|
||||
fun tag(name: String, vararg values: String) = apply { tagFilters[name] = values.toList() }
|
||||
|
||||
fun build(): NostrFilter {
|
||||
return NostrFilter(
|
||||
ids = ids,
|
||||
authors = authors,
|
||||
kinds = kinds,
|
||||
since = since,
|
||||
until = until,
|
||||
limit = limit,
|
||||
tagFilters = tagFilters.toMap()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this filter matches an event
|
||||
*/
|
||||
fun matches(event: NostrEvent): Boolean {
|
||||
// Check IDs
|
||||
if (ids != null && !ids.contains(event.id)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check authors
|
||||
if (authors != null && !authors.contains(event.pubkey)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check kinds
|
||||
if (kinds != null && !kinds.contains(event.kind)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check time bounds
|
||||
if (since != null && event.createdAt < since) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (until != null && event.createdAt > until) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check tag filters
|
||||
if (tagFilters != null) {
|
||||
for ((tagName, requiredValues) in tagFilters) {
|
||||
val eventTags = event.tags.filter { it.isNotEmpty() && it[0] == tagName }
|
||||
val eventValues = eventTags.mapNotNull { tag ->
|
||||
if (tag.size > 1) tag[1] else null
|
||||
}
|
||||
|
||||
val hasMatch = requiredValues.any { requiredValue ->
|
||||
eventValues.contains(requiredValue)
|
||||
}
|
||||
|
||||
if (!hasMatch) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Get debug description
|
||||
*/
|
||||
fun getDebugDescription(): String {
|
||||
val parts = mutableListOf<String>()
|
||||
|
||||
ids?.let { parts.add("ids=${it.size}") }
|
||||
authors?.let { parts.add("authors=${it.size}") }
|
||||
kinds?.let { parts.add("kinds=$it") }
|
||||
since?.let { parts.add("since=$it") }
|
||||
until?.let { parts.add("until=$it") }
|
||||
limit?.let { parts.add("limit=$it") }
|
||||
tagFilters?.let { filters ->
|
||||
filters.forEach { (tag, values) ->
|
||||
parts.add("#$tag=${values.size}")
|
||||
}
|
||||
}
|
||||
|
||||
return "NostrFilter(${parts.joinToString(", ")})"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,326 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
|
||||
/**
|
||||
* Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
|
||||
* Compatible with iOS implementation
|
||||
*/
|
||||
data class NostrIdentity(
|
||||
val privateKeyHex: String,
|
||||
val publicKeyHex: String,
|
||||
val npub: String,
|
||||
val createdAt: Long
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NostrIdentity"
|
||||
|
||||
/**
|
||||
* Generate a new Nostr identity
|
||||
*/
|
||||
fun generate(): NostrIdentity {
|
||||
val (privateKeyHex, publicKeyHex) = NostrCrypto.generateKeyPair()
|
||||
val npub = Bech32.encode("npub", publicKeyHex.hexToByteArrayLocal())
|
||||
|
||||
Log.d(TAG, "Generated new Nostr identity: npub=$npub")
|
||||
|
||||
return NostrIdentity(
|
||||
privateKeyHex = privateKeyHex,
|
||||
publicKeyHex = publicKeyHex,
|
||||
npub = npub,
|
||||
createdAt = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from existing private key
|
||||
*/
|
||||
fun fromPrivateKey(privateKeyHex: String): NostrIdentity {
|
||||
require(NostrCrypto.isValidPrivateKey(privateKeyHex)) {
|
||||
"Invalid private key"
|
||||
}
|
||||
|
||||
val publicKeyHex = NostrCrypto.derivePublicKey(privateKeyHex)
|
||||
val npub = Bech32.encode("npub", publicKeyHex.hexToByteArrayLocal())
|
||||
|
||||
return NostrIdentity(
|
||||
privateKeyHex = privateKeyHex,
|
||||
publicKeyHex = publicKeyHex,
|
||||
npub = npub,
|
||||
createdAt = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from a deterministic seed (for demo purposes)
|
||||
*/
|
||||
fun fromSeed(seed: String): NostrIdentity {
|
||||
// Hash the seed to create a private key
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val seedBytes = seed.toByteArray(Charsets.UTF_8)
|
||||
val privateKeyBytes = digest.digest(seedBytes)
|
||||
val privateKeyHex = privateKeyBytes.joinToString("") { "%02x".format(it) }
|
||||
|
||||
return fromPrivateKey(privateKeyHex)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a Nostr event
|
||||
*/
|
||||
fun signEvent(event: NostrEvent): NostrEvent {
|
||||
return event.sign(privateKeyHex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get short display format
|
||||
*/
|
||||
fun getShortNpub(): String {
|
||||
return if (npub.length > 16) {
|
||||
"${npub.take(8)}...${npub.takeLast(8)}"
|
||||
} else {
|
||||
npub
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge between Noise and Nostr identities
|
||||
* Manages persistent storage and per-geohash identity derivation
|
||||
*/
|
||||
object NostrIdentityBridge {
|
||||
private const val TAG = "NostrIdentityBridge"
|
||||
private const val NOSTR_PRIVATE_KEY = "nostr_private_key"
|
||||
private const val DEVICE_SEED_KEY = "nostr_device_seed"
|
||||
|
||||
// Cache for derived geohash identities to avoid repeated crypto operations
|
||||
private val geohashIdentityCache = mutableMapOf<String, NostrIdentity>()
|
||||
|
||||
/**
|
||||
* Get or create the current Nostr identity
|
||||
*/
|
||||
fun getCurrentNostrIdentity(context: Context): NostrIdentity? {
|
||||
val stateManager = SecureIdentityStateManager(context)
|
||||
|
||||
// Try to load existing Nostr private key
|
||||
val existingKey = loadNostrPrivateKey(stateManager)
|
||||
if (existingKey != null) {
|
||||
return try {
|
||||
NostrIdentity.fromPrivateKey(existingKey)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to create identity from stored key: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new identity
|
||||
val newIdentity = NostrIdentity.generate()
|
||||
saveNostrPrivateKey(stateManager, newIdentity.privateKeyHex)
|
||||
|
||||
Log.i(TAG, "Created new Nostr identity: ${newIdentity.getShortNpub()}")
|
||||
return newIdentity
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a deterministic, unlinkable Nostr identity for a given geohash
|
||||
* Uses HMAC-SHA256(deviceSeed, geohash) as private key material with fallback rehashing
|
||||
* if the candidate is not a valid secp256k1 private key.
|
||||
*
|
||||
* Direct port from iOS implementation for 100% compatibility
|
||||
* OPTIMIZED: Cached for UI responsiveness
|
||||
*/
|
||||
fun deriveIdentity(forGeohash: String, context: Context): NostrIdentity {
|
||||
// Check cache first for immediate response
|
||||
geohashIdentityCache[forGeohash]?.let { cachedIdentity ->
|
||||
//Log.v(TAG, "Using cached geohash identity for $forGeohash")
|
||||
return cachedIdentity
|
||||
}
|
||||
|
||||
val stateManager = SecureIdentityStateManager(context)
|
||||
val seed = getOrCreateDeviceSeed(stateManager)
|
||||
|
||||
val geohashBytes = forGeohash.toByteArray(Charsets.UTF_8)
|
||||
|
||||
// Try a few iterations to ensure a valid key can be formed (exactly like iOS)
|
||||
for (i in 0 until 10) {
|
||||
val candidateKey = candidateKey(seed, geohashBytes, i.toUInt())
|
||||
val candidateKeyHex = candidateKey.toHexStringLocal()
|
||||
|
||||
if (NostrCrypto.isValidPrivateKey(candidateKeyHex)) {
|
||||
val identity = NostrIdentity.fromPrivateKey(candidateKeyHex)
|
||||
|
||||
// Cache the result for future UI responsiveness
|
||||
geohashIdentityCache[forGeohash] = identity
|
||||
|
||||
Log.d(TAG, "Derived geohash identity for $forGeohash (iteration $i)")
|
||||
return identity
|
||||
}
|
||||
}
|
||||
|
||||
// As a final fallback, hash the seed+msg and try again (exactly like iOS)
|
||||
val combined = seed + geohashBytes
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val fallbackKey = digest.digest(combined)
|
||||
|
||||
val fallbackIdentity = NostrIdentity.fromPrivateKey(fallbackKey.toHexStringLocal())
|
||||
|
||||
// Cache the fallback result too
|
||||
geohashIdentityCache[forGeohash] = fallbackIdentity
|
||||
|
||||
Log.d(TAG, "Used fallback identity derivation for $forGeohash")
|
||||
return fallbackIdentity
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate candidate key for a specific iteration (matches iOS implementation)
|
||||
*/
|
||||
private fun candidateKey(seed: ByteArray, message: ByteArray, iteration: UInt): ByteArray {
|
||||
val input = message + iteration.toLittleEndianBytes()
|
||||
return hmacSha256(seed, input)
|
||||
}
|
||||
|
||||
/**
|
||||
* Associate a Nostr identity with a Noise public key (for favorites)
|
||||
*/
|
||||
fun associateNostrIdentity(nostrPubkey: String, noisePublicKey: ByteArray, context: Context) {
|
||||
val stateManager = SecureIdentityStateManager(context)
|
||||
|
||||
// We'll use the existing signing key storage mechanism for associations
|
||||
// For now, we'll store this as a preference since it's just for favorites mapping
|
||||
// In a full implementation, you'd want a proper association storage system
|
||||
|
||||
Log.d(TAG, "Associated Nostr pubkey ${nostrPubkey.take(16)}... with Noise key")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Nostr public key associated with a Noise public key
|
||||
*/
|
||||
fun getNostrPublicKey(noisePublicKey: ByteArray, context: Context): String? {
|
||||
// This would need proper implementation based on your favorites storage system
|
||||
// For now, return null as we don't have the full association system
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all Nostr identity data
|
||||
*/
|
||||
fun clearAllAssociations(context: Context) {
|
||||
val stateManager = SecureIdentityStateManager(context)
|
||||
|
||||
// Clear cache first
|
||||
geohashIdentityCache.clear()
|
||||
|
||||
// Clear Nostr private key
|
||||
try {
|
||||
val clazz = stateManager.javaClass
|
||||
val prefsField = clazz.getDeclaredField("prefs")
|
||||
prefsField.isAccessible = true
|
||||
val prefs = prefsField.get(stateManager) as android.content.SharedPreferences
|
||||
|
||||
prefs.edit()
|
||||
.remove(NOSTR_PRIVATE_KEY)
|
||||
.remove(DEVICE_SEED_KEY)
|
||||
.apply()
|
||||
|
||||
Log.i(TAG, "Cleared all Nostr identity data and cache")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to clear Nostr data: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private fun loadNostrPrivateKey(stateManager: SecureIdentityStateManager): String? {
|
||||
return try {
|
||||
// Use reflection to access the encrypted preferences
|
||||
val clazz = stateManager.javaClass
|
||||
val prefsField = clazz.getDeclaredField("prefs")
|
||||
prefsField.isAccessible = true
|
||||
val prefs = prefsField.get(stateManager) as android.content.SharedPreferences
|
||||
|
||||
prefs.getString(NOSTR_PRIVATE_KEY, null)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load Nostr private key: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveNostrPrivateKey(stateManager: SecureIdentityStateManager, privateKeyHex: String) {
|
||||
try {
|
||||
// Use reflection to access the encrypted preferences
|
||||
val clazz = stateManager.javaClass
|
||||
val prefsField = clazz.getDeclaredField("prefs")
|
||||
prefsField.isAccessible = true
|
||||
val prefs = prefsField.get(stateManager) as android.content.SharedPreferences
|
||||
|
||||
prefs.edit()
|
||||
.putString(NOSTR_PRIVATE_KEY, privateKeyHex)
|
||||
.apply()
|
||||
|
||||
Log.d(TAG, "Saved Nostr private key to secure storage")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to save Nostr private key: ${e.message}")
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun getOrCreateDeviceSeed(stateManager: SecureIdentityStateManager): ByteArray {
|
||||
try {
|
||||
// Use reflection to access the encrypted preferences
|
||||
val clazz = stateManager.javaClass
|
||||
val prefsField = clazz.getDeclaredField("prefs")
|
||||
prefsField.isAccessible = true
|
||||
val prefs = prefsField.get(stateManager) as android.content.SharedPreferences
|
||||
|
||||
val existingSeed = prefs.getString(DEVICE_SEED_KEY, null)
|
||||
if (existingSeed != null) {
|
||||
return android.util.Base64.decode(existingSeed, android.util.Base64.DEFAULT)
|
||||
}
|
||||
|
||||
// Generate new seed
|
||||
val seed = ByteArray(32)
|
||||
SecureRandom().nextBytes(seed)
|
||||
|
||||
val seedBase64 = android.util.Base64.encodeToString(seed, android.util.Base64.DEFAULT)
|
||||
prefs.edit()
|
||||
.putString(DEVICE_SEED_KEY, seedBase64)
|
||||
.apply()
|
||||
|
||||
Log.d(TAG, "Generated new device seed for geohash identity derivation")
|
||||
return seed
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to get/create device seed: ${e.message}")
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun hmacSha256(key: ByteArray, message: ByteArray): ByteArray {
|
||||
val mac = javax.crypto.Mac.getInstance("HmacSHA256")
|
||||
val secretKeySpec = javax.crypto.spec.SecretKeySpec(key, "HmacSHA256")
|
||||
mac.init(secretKeySpec)
|
||||
return mac.doFinal(message)
|
||||
}
|
||||
}
|
||||
|
||||
// Extension functions for data conversion
|
||||
private fun String.hexToByteArrayLocal(): ByteArray {
|
||||
return chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
}
|
||||
|
||||
private fun ByteArray.toHexStringLocal(): String {
|
||||
return joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun UInt.toLittleEndianBytes(): ByteArray {
|
||||
val bytes = ByteArray(4)
|
||||
bytes[0] = (this and 0xFFu).toByte()
|
||||
bytes[1] = ((this shr 8) and 0xFFu).toByte()
|
||||
bytes[2] = ((this shr 16) and 0xFFu).toByte()
|
||||
bytes[3] = ((this shr 24) and 0xFFu).toByte()
|
||||
return bytes
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonParser
|
||||
|
||||
/**
|
||||
* NIP-17 Protocol Implementation for Private Direct Messages
|
||||
* Compatible with iOS implementation
|
||||
*/
|
||||
object NostrProtocol {
|
||||
|
||||
private const val TAG = "NostrProtocol"
|
||||
private val gson = Gson()
|
||||
|
||||
/**
|
||||
* Create a NIP-17 private message
|
||||
* Returns gift-wrapped event ready for relay broadcast
|
||||
*/
|
||||
fun createPrivateMessage(
|
||||
content: String,
|
||||
recipientPubkey: String,
|
||||
senderIdentity: NostrIdentity
|
||||
): NostrEvent {
|
||||
Log.v(TAG, "Creating private message for recipient: ${recipientPubkey.take(16)}...")
|
||||
|
||||
// 1. Create the rumor (unsigned event)
|
||||
val rumor = NostrEvent(
|
||||
pubkey = senderIdentity.publicKeyHex,
|
||||
createdAt = (System.currentTimeMillis() / 1000).toInt(),
|
||||
kind = NostrKind.TEXT_NOTE,
|
||||
tags = emptyList(),
|
||||
content = content
|
||||
)
|
||||
|
||||
// 2. Create ephemeral key for this message
|
||||
val (ephemeralPrivateKey, ephemeralPublicKey) = NostrCrypto.generateKeyPair()
|
||||
Log.v(TAG, "Created ephemeral key for seal")
|
||||
|
||||
// 3. Seal the rumor (encrypt to recipient)
|
||||
val sealedEvent = createSeal(
|
||||
rumor = rumor,
|
||||
recipientPubkey = recipientPubkey,
|
||||
senderPrivateKey = ephemeralPrivateKey,
|
||||
senderPublicKey = ephemeralPublicKey
|
||||
)
|
||||
|
||||
// 4. Gift wrap the sealed event (encrypt to recipient again)
|
||||
val giftWrap = createGiftWrap(
|
||||
seal = sealedEvent,
|
||||
recipientPubkey = recipientPubkey
|
||||
)
|
||||
|
||||
Log.v(TAG, "Created gift wrap with id: ${giftWrap.id.take(16)}...")
|
||||
|
||||
return giftWrap
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a received NIP-17 message
|
||||
* Returns (content, senderPubkey, timestamp) or null if decryption fails
|
||||
*/
|
||||
fun decryptPrivateMessage(
|
||||
giftWrap: NostrEvent,
|
||||
recipientIdentity: NostrIdentity
|
||||
): Triple<String, String, Int>? {
|
||||
Log.v(TAG, "Starting decryption of gift wrap: ${giftWrap.id.take(16)}...")
|
||||
|
||||
return try {
|
||||
// 1. Unwrap the gift wrap
|
||||
val seal = unwrapGiftWrap(giftWrap, recipientIdentity.privateKeyHex)
|
||||
?: run {
|
||||
Log.w(TAG, "❌ Failed to unwrap gift wrap")
|
||||
return null
|
||||
}
|
||||
|
||||
Log.v(TAG, "Successfully unwrapped gift wrap")
|
||||
|
||||
// 2. Open the seal
|
||||
val rumor = openSeal(seal, recipientIdentity.privateKeyHex)
|
||||
?: run {
|
||||
Log.w(TAG, "❌ Failed to open seal")
|
||||
return null
|
||||
}
|
||||
|
||||
Log.v(TAG, "Successfully opened seal")
|
||||
|
||||
Triple(rumor.content, rumor.pubkey, rumor.createdAt)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Failed to decrypt private message: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a geohash-scoped ephemeral public message (kind 20000)
|
||||
*/
|
||||
fun createEphemeralGeohashEvent(
|
||||
content: String,
|
||||
geohash: String,
|
||||
senderIdentity: NostrIdentity,
|
||||
nickname: String? = null
|
||||
): NostrEvent {
|
||||
val tags = mutableListOf<List<String>>()
|
||||
tags.add(listOf("g", geohash))
|
||||
|
||||
if (!nickname.isNullOrEmpty()) {
|
||||
tags.add(listOf("n", nickname))
|
||||
}
|
||||
|
||||
val event = NostrEvent(
|
||||
pubkey = senderIdentity.publicKeyHex,
|
||||
createdAt = (System.currentTimeMillis() / 1000).toInt(),
|
||||
kind = NostrKind.EPHEMERAL_EVENT,
|
||||
tags = tags,
|
||||
content = content
|
||||
)
|
||||
|
||||
return senderIdentity.signEvent(event)
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private fun createSeal(
|
||||
rumor: NostrEvent,
|
||||
recipientPubkey: String,
|
||||
senderPrivateKey: String,
|
||||
senderPublicKey: String
|
||||
): NostrEvent {
|
||||
val rumorJSON = gson.toJson(rumor)
|
||||
|
||||
val encrypted = NostrCrypto.encryptNIP44(
|
||||
plaintext = rumorJSON,
|
||||
recipientPublicKeyHex = recipientPubkey,
|
||||
senderPrivateKeyHex = senderPrivateKey
|
||||
)
|
||||
|
||||
val seal = NostrEvent(
|
||||
pubkey = senderPublicKey,
|
||||
createdAt = NostrCrypto.randomizeTimestamp(),
|
||||
kind = NostrKind.SEAL,
|
||||
tags = emptyList(),
|
||||
content = encrypted
|
||||
)
|
||||
|
||||
// Sign with the ephemeral key
|
||||
return seal.sign(senderPrivateKey)
|
||||
}
|
||||
|
||||
private fun createGiftWrap(
|
||||
seal: NostrEvent,
|
||||
recipientPubkey: String
|
||||
): NostrEvent {
|
||||
val sealJSON = gson.toJson(seal)
|
||||
|
||||
// Create new ephemeral key for gift wrap
|
||||
val (wrapPrivateKey, wrapPublicKey) = NostrCrypto.generateKeyPair()
|
||||
Log.v(TAG, "Creating gift wrap with ephemeral key")
|
||||
|
||||
// Encrypt the seal with the new ephemeral key
|
||||
val encrypted = NostrCrypto.encryptNIP44(
|
||||
plaintext = sealJSON,
|
||||
recipientPublicKeyHex = recipientPubkey,
|
||||
senderPrivateKeyHex = wrapPrivateKey
|
||||
)
|
||||
|
||||
val giftWrap = NostrEvent(
|
||||
pubkey = wrapPublicKey,
|
||||
createdAt = NostrCrypto.randomizeTimestamp(),
|
||||
kind = NostrKind.GIFT_WRAP,
|
||||
tags = listOf(listOf("p", recipientPubkey)), // Tag recipient
|
||||
content = encrypted
|
||||
)
|
||||
|
||||
// Sign with the gift wrap ephemeral key
|
||||
return giftWrap.sign(wrapPrivateKey)
|
||||
}
|
||||
|
||||
private fun unwrapGiftWrap(
|
||||
giftWrap: NostrEvent,
|
||||
recipientPrivateKey: String
|
||||
): NostrEvent? {
|
||||
Log.v(TAG, "Unwrapping gift wrap")
|
||||
|
||||
return try {
|
||||
val decrypted = NostrCrypto.decryptNIP44(
|
||||
ciphertext = giftWrap.content,
|
||||
senderPublicKeyHex = giftWrap.pubkey,
|
||||
recipientPrivateKeyHex = recipientPrivateKey
|
||||
)
|
||||
|
||||
val jsonElement = JsonParser.parseString(decrypted)
|
||||
if (!jsonElement.isJsonObject) {
|
||||
Log.w(TAG, "Decrypted gift wrap is not a JSON object")
|
||||
return null
|
||||
}
|
||||
|
||||
val jsonObject = jsonElement.asJsonObject
|
||||
val seal = NostrEvent(
|
||||
id = jsonObject.get("id")?.asString ?: "",
|
||||
pubkey = jsonObject.get("pubkey")?.asString ?: "",
|
||||
createdAt = jsonObject.get("created_at")?.asInt ?: 0,
|
||||
kind = jsonObject.get("kind")?.asInt ?: 0,
|
||||
tags = parseTagsFromJson(jsonObject.get("tags")?.asJsonArray) ?: emptyList(),
|
||||
content = jsonObject.get("content")?.asString ?: "",
|
||||
sig = jsonObject.get("sig")?.asString
|
||||
)
|
||||
|
||||
Log.v(TAG, "Unwrapped seal with kind: ${seal.kind}")
|
||||
seal
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to unwrap gift wrap: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun openSeal(
|
||||
seal: NostrEvent,
|
||||
recipientPrivateKey: String
|
||||
): NostrEvent? {
|
||||
return try {
|
||||
val decrypted = NostrCrypto.decryptNIP44(
|
||||
ciphertext = seal.content,
|
||||
senderPublicKeyHex = seal.pubkey,
|
||||
recipientPrivateKeyHex = recipientPrivateKey
|
||||
)
|
||||
|
||||
val jsonElement = JsonParser.parseString(decrypted)
|
||||
if (!jsonElement.isJsonObject) {
|
||||
Log.w(TAG, "Decrypted seal is not a JSON object")
|
||||
return null
|
||||
}
|
||||
|
||||
val jsonObject = jsonElement.asJsonObject
|
||||
NostrEvent(
|
||||
id = jsonObject.get("id")?.asString ?: "",
|
||||
pubkey = jsonObject.get("pubkey")?.asString ?: "",
|
||||
createdAt = jsonObject.get("created_at")?.asInt ?: 0,
|
||||
kind = jsonObject.get("kind")?.asInt ?: 0,
|
||||
tags = parseTagsFromJson(jsonObject.get("tags")?.asJsonArray) ?: emptyList(),
|
||||
content = jsonObject.get("content")?.asString ?: "",
|
||||
sig = jsonObject.get("sig")?.asString
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to open seal: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseTagsFromJson(tagsArray: com.google.gson.JsonArray?): List<List<String>>? {
|
||||
if (tagsArray == null) return emptyList()
|
||||
|
||||
return try {
|
||||
tagsArray.map { tagElement ->
|
||||
if (tagElement.isJsonArray) {
|
||||
val tagArray = tagElement.asJsonArray
|
||||
tagArray.map { it.asString }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to parse tags: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,755 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonParser
|
||||
import kotlinx.coroutines.*
|
||||
import okhttp3.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
|
||||
/**
|
||||
* Manages WebSocket connections to Nostr relays
|
||||
* Compatible with iOS implementation with Android-specific optimizations
|
||||
*/
|
||||
class NostrRelayManager private constructor() {
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
val shared = NostrRelayManager()
|
||||
|
||||
private const val TAG = "NostrRelayManager"
|
||||
|
||||
/**
|
||||
* Get instance for Android compatibility (context-aware calls)
|
||||
*/
|
||||
fun getInstance(context: android.content.Context): NostrRelayManager {
|
||||
return shared
|
||||
}
|
||||
|
||||
// Default relay list (same as iOS)
|
||||
private val DEFAULT_RELAYS = listOf(
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.primal.net",
|
||||
"wss://offchain.pub",
|
||||
"wss://nostr21.com"
|
||||
)
|
||||
|
||||
// Exponential backoff configuration (same as iOS)
|
||||
private const val INITIAL_BACKOFF_INTERVAL = 1000L // 1 second
|
||||
private const val MAX_BACKOFF_INTERVAL = 300000L // 5 minutes
|
||||
private const val BACKOFF_MULTIPLIER = 2.0
|
||||
private const val MAX_RECONNECT_ATTEMPTS = 10
|
||||
|
||||
// Track gift-wraps we initiated for logging
|
||||
private val pendingGiftWrapIDs = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
fun registerPendingGiftWrap(id: String) {
|
||||
pendingGiftWrapIDs.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Relay status information
|
||||
*/
|
||||
data class Relay(
|
||||
val url: String,
|
||||
var isConnected: Boolean = false,
|
||||
var lastError: Throwable? = null,
|
||||
var lastConnectedAt: Long? = null,
|
||||
var messagesSent: Int = 0,
|
||||
var messagesReceived: Int = 0,
|
||||
var reconnectAttempts: Int = 0,
|
||||
var lastDisconnectedAt: Long? = null,
|
||||
var nextReconnectTime: Long? = null
|
||||
)
|
||||
|
||||
// Published state
|
||||
private val _relays = MutableLiveData<List<Relay>>()
|
||||
val relays: LiveData<List<Relay>> = _relays
|
||||
|
||||
private val _isConnected = MutableLiveData<Boolean>()
|
||||
val isConnected: LiveData<Boolean> = _isConnected
|
||||
|
||||
// Internal state
|
||||
private val relaysList = mutableListOf<Relay>()
|
||||
private val connections = ConcurrentHashMap<String, WebSocket>()
|
||||
private val subscriptions = ConcurrentHashMap<String, Set<String>>() // relay URL -> subscription IDs
|
||||
private val messageHandlers = ConcurrentHashMap<String, (NostrEvent) -> Unit>()
|
||||
|
||||
// Persistent subscription tracking for robust reconnection
|
||||
private val activeSubscriptions = ConcurrentHashMap<String, SubscriptionInfo>() // subscription ID -> info
|
||||
|
||||
/**
|
||||
* Information about an active subscription that needs to be maintained across reconnections
|
||||
*/
|
||||
data class SubscriptionInfo(
|
||||
val id: String,
|
||||
val filter: NostrFilter,
|
||||
val handler: (NostrEvent) -> Unit,
|
||||
val targetRelayUrls: Set<String>? = null, // null means all relays
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
// Event deduplication system
|
||||
private val eventDeduplicator = NostrEventDeduplicator.getInstance()
|
||||
|
||||
// Message queue for reliability
|
||||
private val messageQueue = mutableListOf<Pair<NostrEvent, List<String>>>()
|
||||
private val messageQueueLock = Any()
|
||||
|
||||
// Coroutine scope for background operations
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
// Subscription validation timer
|
||||
private var subscriptionValidationJob: Job? = null
|
||||
private val SUBSCRIPTION_VALIDATION_INTERVAL = 30000L // 30 seconds
|
||||
|
||||
// OkHttp client for WebSocket connections
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(0, TimeUnit.SECONDS) // No read timeout for WebSocket
|
||||
.writeTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val gson by lazy { NostrRequest.createGson() }
|
||||
|
||||
init {
|
||||
// Initialize with default relays - avoid static initialization order issues
|
||||
try {
|
||||
val defaultRelayUrls = listOf(
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.primal.net",
|
||||
"wss://offchain.pub",
|
||||
"wss://nostr21.com"
|
||||
)
|
||||
relaysList.addAll(defaultRelayUrls.map { Relay(it) })
|
||||
_relays.postValue(relaysList.toList())
|
||||
updateConnectionStatus()
|
||||
Log.d(TAG, "✅ NostrRelayManager initialized with ${relaysList.size} default relays")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to initialize NostrRelayManager: ${e.message}", e)
|
||||
// Initialize with empty list as fallback
|
||||
_relays.postValue(emptyList())
|
||||
_isConnected.postValue(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to all configured relays
|
||||
*/
|
||||
fun connect() {
|
||||
Log.d(TAG, "🌐 Connecting to ${relaysList.size} Nostr relays")
|
||||
|
||||
scope.launch {
|
||||
relaysList.forEach { relay ->
|
||||
launch {
|
||||
connectToRelay(relay.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start periodic subscription validation
|
||||
startSubscriptionValidation()
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from all relays
|
||||
*/
|
||||
fun disconnect() {
|
||||
Log.d(TAG, "Disconnecting from all relays")
|
||||
|
||||
// Stop subscription validation
|
||||
stopSubscriptionValidation()
|
||||
|
||||
connections.values.forEach { webSocket ->
|
||||
webSocket.close(1000, "Manual disconnect")
|
||||
}
|
||||
connections.clear()
|
||||
|
||||
// Clear subscriptions
|
||||
subscriptions.clear()
|
||||
|
||||
updateConnectionStatus()
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an event to specified relays (or all if none specified)
|
||||
*/
|
||||
fun sendEvent(event: NostrEvent, relayUrls: List<String>? = null) {
|
||||
val targetRelays = relayUrls ?: relaysList.map { it.url }
|
||||
|
||||
// Add to queue for reliability
|
||||
synchronized(messageQueueLock) {
|
||||
messageQueue.add(Pair(event, targetRelays))
|
||||
}
|
||||
|
||||
// Attempt immediate send
|
||||
scope.launch {
|
||||
targetRelays.forEach { relayUrl ->
|
||||
val webSocket = connections[relayUrl]
|
||||
if (webSocket != null) {
|
||||
sendToRelay(event, webSocket, relayUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to events matching a filter
|
||||
* The subscription will be automatically re-established on reconnection
|
||||
*/
|
||||
fun subscribe(
|
||||
filter: NostrFilter,
|
||||
id: String = generateSubscriptionId(),
|
||||
handler: (NostrEvent) -> Unit,
|
||||
targetRelayUrls: List<String>? = null
|
||||
): String {
|
||||
// Store subscription info for persistent tracking
|
||||
val subscriptionInfo = SubscriptionInfo(
|
||||
id = id,
|
||||
filter = filter,
|
||||
handler = handler,
|
||||
targetRelayUrls = targetRelayUrls?.toSet()
|
||||
)
|
||||
|
||||
activeSubscriptions[id] = subscriptionInfo
|
||||
messageHandlers[id] = handler
|
||||
|
||||
Log.d(TAG, "📡 Subscribing to Nostr filter id=$id ${filter.getDebugDescription()}")
|
||||
|
||||
// Send subscription to appropriate relays
|
||||
sendSubscriptionToRelays(subscriptionInfo)
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a subscription to the appropriate relays
|
||||
*/
|
||||
private fun sendSubscriptionToRelays(subscriptionInfo: SubscriptionInfo) {
|
||||
val request = NostrRequest.Subscribe(subscriptionInfo.id, listOf(subscriptionInfo.filter))
|
||||
val message = gson.toJson(request, NostrRequest::class.java)
|
||||
|
||||
// DEBUG: Log the actual serialized message format
|
||||
Log.v(TAG, "🔍 DEBUG: Serialized subscription message: $message")
|
||||
|
||||
scope.launch {
|
||||
val targetRelays = subscriptionInfo.targetRelayUrls?.toList() ?: connections.keys.toList()
|
||||
|
||||
targetRelays.forEach { relayUrl ->
|
||||
val webSocket = connections[relayUrl]
|
||||
if (webSocket != null) {
|
||||
try {
|
||||
val success = webSocket.send(message)
|
||||
if (success) {
|
||||
// Track subscription for this relay
|
||||
val currentSubs = subscriptions[relayUrl] ?: emptySet()
|
||||
subscriptions[relayUrl] = currentSubs + subscriptionInfo.id
|
||||
|
||||
Log.v(TAG, "✅ Subscription '${subscriptionInfo.id}' sent to relay: $relayUrl")
|
||||
} else {
|
||||
Log.w(TAG, "❌ Failed to send subscription to $relayUrl: WebSocket send failed")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Failed to send subscription to $relayUrl: ${e.message}")
|
||||
}
|
||||
} else {
|
||||
Log.v(TAG, "⏳ Relay $relayUrl not connected, subscription will be sent on reconnection")
|
||||
}
|
||||
}
|
||||
|
||||
if (connections.isEmpty()) {
|
||||
Log.w(TAG, "⚠️ No relay connections available for subscription, will retry on reconnection")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from a subscription
|
||||
*/
|
||||
fun unsubscribe(id: String) {
|
||||
// Remove from persistent tracking
|
||||
val subscriptionInfo = activeSubscriptions.remove(id)
|
||||
messageHandlers.remove(id)
|
||||
|
||||
if (subscriptionInfo == null) {
|
||||
Log.w(TAG, "⚠️ Attempted to unsubscribe from unknown subscription: $id")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "🚫 Unsubscribing from subscription: $id")
|
||||
|
||||
val request = NostrRequest.Close(id)
|
||||
val message = gson.toJson(request, NostrRequest::class.java)
|
||||
|
||||
scope.launch {
|
||||
connections.forEach { (relayUrl, webSocket) ->
|
||||
val currentSubs = subscriptions[relayUrl]
|
||||
if (currentSubs?.contains(id) == true) {
|
||||
try {
|
||||
webSocket.send(message)
|
||||
subscriptions[relayUrl] = currentSubs - id
|
||||
Log.v(TAG, "Unsubscribed '$id' from relay: $relayUrl")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to unsubscribe from $relayUrl: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually retry connection to a specific relay
|
||||
*/
|
||||
fun retryConnection(relayUrl: String) {
|
||||
val relay = relaysList.find { it.url == relayUrl } ?: return
|
||||
|
||||
// Reset reconnection attempts
|
||||
relay.reconnectAttempts = 0
|
||||
relay.nextReconnectTime = null
|
||||
|
||||
// Disconnect if connected
|
||||
connections[relayUrl]?.close(1000, "Manual retry")
|
||||
connections.remove(relayUrl)
|
||||
|
||||
// Attempt immediate reconnection
|
||||
scope.launch {
|
||||
connectToRelay(relayUrl)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all relay connections
|
||||
* This will automatically restore all subscriptions when reconnected
|
||||
*/
|
||||
fun resetAllConnections() {
|
||||
disconnect()
|
||||
|
||||
// Reset all relay states
|
||||
relaysList.forEach { relay ->
|
||||
relay.reconnectAttempts = 0
|
||||
relay.nextReconnectTime = null
|
||||
relay.lastError = null
|
||||
}
|
||||
|
||||
// Reconnect - subscriptions will be automatically restored in onOpen
|
||||
connect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Force re-establishment of all subscriptions on currently connected relays
|
||||
* Useful for ensuring subscription consistency after network issues
|
||||
*/
|
||||
fun reestablishAllSubscriptions() {
|
||||
Log.d(TAG, "🔄 Force re-establishing all ${activeSubscriptions.size} active subscriptions")
|
||||
|
||||
scope.launch {
|
||||
connections.forEach { (relayUrl, webSocket) ->
|
||||
restoreSubscriptionsForRelay(relayUrl, webSocket)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed status for all relays
|
||||
*/
|
||||
fun getRelayStatuses(): List<Relay> {
|
||||
return relaysList.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event deduplication statistics
|
||||
*/
|
||||
fun getDeduplicationStats(): DeduplicationStats {
|
||||
return eventDeduplicator.getStats()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the event deduplication cache (useful for testing or debugging)
|
||||
*/
|
||||
fun clearDeduplicationCache() {
|
||||
eventDeduplicator.clear()
|
||||
Log.i(TAG, "🧹 Cleared event deduplication cache")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the count of active subscriptions
|
||||
*/
|
||||
fun getActiveSubscriptionCount(): Int {
|
||||
return activeSubscriptions.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Get information about all active subscriptions (for debugging)
|
||||
*/
|
||||
fun getActiveSubscriptions(): Map<String, SubscriptionInfo> {
|
||||
return activeSubscriptions.toMap()
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate subscription consistency across all relays
|
||||
* Returns a report of any inconsistencies found
|
||||
*/
|
||||
fun validateSubscriptionConsistency(): SubscriptionConsistencyReport {
|
||||
val expectedSubs = activeSubscriptions.keys
|
||||
val actualSubsByRelay = subscriptions.toMap()
|
||||
val inconsistencies = mutableListOf<String>()
|
||||
|
||||
connections.keys.forEach { relayUrl ->
|
||||
val actualSubs = actualSubsByRelay[relayUrl] ?: emptySet()
|
||||
val expectedForRelay = expectedSubs.filter { subId ->
|
||||
val subInfo = activeSubscriptions[subId]
|
||||
subInfo?.targetRelayUrls == null || subInfo.targetRelayUrls.contains(relayUrl)
|
||||
}.toSet()
|
||||
|
||||
val missing = expectedForRelay - actualSubs
|
||||
val extra = actualSubs - expectedForRelay
|
||||
|
||||
if (missing.isNotEmpty()) {
|
||||
inconsistencies.add("Relay $relayUrl missing subscriptions: $missing")
|
||||
}
|
||||
if (extra.isNotEmpty()) {
|
||||
inconsistencies.add("Relay $relayUrl has extra subscriptions: $extra")
|
||||
}
|
||||
}
|
||||
|
||||
return SubscriptionConsistencyReport(
|
||||
isConsistent = inconsistencies.isEmpty(),
|
||||
inconsistencies = inconsistencies,
|
||||
totalActiveSubscriptions = activeSubscriptions.size,
|
||||
connectedRelayCount = connections.size
|
||||
)
|
||||
}
|
||||
|
||||
data class SubscriptionConsistencyReport(
|
||||
val isConsistent: Boolean,
|
||||
val inconsistencies: List<String>,
|
||||
val totalActiveSubscriptions: Int,
|
||||
val connectedRelayCount: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* Start periodic subscription validation to ensure robustness
|
||||
*/
|
||||
private fun startSubscriptionValidation() {
|
||||
stopSubscriptionValidation() // Stop any existing validation
|
||||
|
||||
subscriptionValidationJob = scope.launch {
|
||||
while (isActive) {
|
||||
delay(SUBSCRIPTION_VALIDATION_INTERVAL)
|
||||
|
||||
try {
|
||||
val report = validateSubscriptionConsistency()
|
||||
if (!report.isConsistent && report.connectedRelayCount > 0) {
|
||||
Log.w(TAG, "⚠️ Subscription inconsistencies detected: ${report.inconsistencies}")
|
||||
|
||||
// Auto-repair: re-establish subscriptions for relays with missing ones
|
||||
connections.forEach { (relayUrl, webSocket) ->
|
||||
val currentSubs = subscriptions[relayUrl] ?: emptySet()
|
||||
val expectedSubs = activeSubscriptions.keys.filter { subId ->
|
||||
val subInfo = activeSubscriptions[subId]
|
||||
subInfo?.targetRelayUrls == null || subInfo.targetRelayUrls.contains(relayUrl)
|
||||
}.toSet()
|
||||
|
||||
val missingSubs = expectedSubs - currentSubs
|
||||
if (missingSubs.isNotEmpty()) {
|
||||
Log.i(TAG, "🔧 Auto-repairing ${missingSubs.size} missing subscriptions for $relayUrl")
|
||||
restoreSubscriptionsForRelay(relayUrl, webSocket)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error during subscription validation: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "🔄 Started periodic subscription validation (${SUBSCRIPTION_VALIDATION_INTERVAL / 1000}s interval)")
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop periodic subscription validation
|
||||
*/
|
||||
private fun stopSubscriptionValidation() {
|
||||
subscriptionValidationJob?.cancel()
|
||||
subscriptionValidationJob = null
|
||||
Log.v(TAG, "⏹️ Stopped subscription validation")
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private suspend fun connectToRelay(urlString: String) {
|
||||
// Skip if we already have a connection
|
||||
if (connections.containsKey(urlString)) {
|
||||
return
|
||||
}
|
||||
|
||||
Log.v(TAG, "Attempting to connect to Nostr relay: $urlString")
|
||||
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url(urlString)
|
||||
.build()
|
||||
|
||||
val webSocket = httpClient.newWebSocket(request, RelayWebSocketListener(urlString))
|
||||
connections[urlString] = webSocket
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Failed to create WebSocket connection to $urlString: ${e.message}")
|
||||
handleDisconnection(urlString, e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendToRelay(event: NostrEvent, webSocket: WebSocket, relayUrl: String) {
|
||||
try {
|
||||
val request = NostrRequest.Event(event)
|
||||
val message = gson.toJson(request, NostrRequest::class.java)
|
||||
|
||||
Log.v(TAG, "📤 Sending Nostr event (kind: ${event.kind}) to relay: $relayUrl")
|
||||
|
||||
val success = webSocket.send(message)
|
||||
if (success) {
|
||||
// Update relay stats
|
||||
val relay = relaysList.find { it.url == relayUrl }
|
||||
relay?.messagesSent = (relay?.messagesSent ?: 0) + 1
|
||||
updateRelaysList()
|
||||
} else {
|
||||
Log.e(TAG, "❌ Failed to send event to $relayUrl: WebSocket send failed")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Failed to send event to $relayUrl: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMessage(message: String, relayUrl: String) {
|
||||
try {
|
||||
val jsonElement = JsonParser.parseString(message)
|
||||
if (!jsonElement.isJsonArray) {
|
||||
Log.w(TAG, "Received non-array message from $relayUrl")
|
||||
return
|
||||
}
|
||||
|
||||
val response = NostrResponse.fromJsonArray(jsonElement.asJsonArray)
|
||||
|
||||
when (response) {
|
||||
is NostrResponse.Event -> {
|
||||
// Update relay stats
|
||||
val relay = relaysList.find { it.url == relayUrl }
|
||||
relay?.messagesReceived = (relay?.messagesReceived ?: 0) + 1
|
||||
updateRelaysList()
|
||||
|
||||
// DEDUPLICATION: Check if we've already processed this event
|
||||
val wasProcessed = eventDeduplicator.processEvent(response.event) { event ->
|
||||
// Only log non-gift-wrap events to reduce noise
|
||||
if (event.kind != NostrKind.GIFT_WRAP) {
|
||||
Log.v(TAG, "📥 Processing new Nostr event (kind: ${event.kind}) from relay: $relayUrl")
|
||||
}
|
||||
|
||||
// Call handler for new events only
|
||||
val handler = messageHandlers[response.subscriptionId]
|
||||
if (handler != null) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
handler(event)
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "⚠️ No handler for subscription ${response.subscriptionId}")
|
||||
}
|
||||
}
|
||||
|
||||
if (!wasProcessed) {
|
||||
//Log.v(TAG, "🔄 Duplicate event ${response.event.id.take(16)}... from relay: $relayUrl")
|
||||
}
|
||||
}
|
||||
|
||||
is NostrResponse.EndOfStoredEvents -> {
|
||||
Log.v(TAG, "End of stored events for subscription: ${response.subscriptionId}")
|
||||
}
|
||||
|
||||
is NostrResponse.Ok -> {
|
||||
val wasGiftWrap = pendingGiftWrapIDs.remove(response.eventId)
|
||||
if (response.accepted) {
|
||||
Log.d(TAG, "✅ Event accepted id=${response.eventId.take(16)}... by relay: $relayUrl")
|
||||
} else {
|
||||
val level = if (wasGiftWrap) Log.WARN else Log.ERROR
|
||||
Log.println(level, TAG, "📮 Event ${response.eventId.take(16)}... rejected by relay: ${response.message ?: "no reason"}")
|
||||
}
|
||||
}
|
||||
|
||||
is NostrResponse.Notice -> {
|
||||
Log.i(TAG, "📢 Notice from $relayUrl: ${response.message}")
|
||||
}
|
||||
|
||||
is NostrResponse.Unknown -> {
|
||||
Log.v(TAG, "Unknown message type from $relayUrl: ${response.raw}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to parse message from $relayUrl: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDisconnection(relayUrl: String, error: Throwable) {
|
||||
connections.remove(relayUrl)
|
||||
// NOTE: Don't remove subscriptions here - keep them for restoration on reconnection
|
||||
// subscriptions.remove(relayUrl) // REMOVED - this was causing subscription loss
|
||||
|
||||
updateRelayStatus(relayUrl, false, error)
|
||||
|
||||
// Check if this is a DNS error
|
||||
val errorMessage = error.message?.lowercase() ?: ""
|
||||
if (errorMessage.contains("hostname could not be found") ||
|
||||
errorMessage.contains("dns") ||
|
||||
errorMessage.contains("unable to resolve host")) {
|
||||
|
||||
val relay = relaysList.find { it.url == relayUrl }
|
||||
if (relay?.lastError == null) {
|
||||
Log.w(TAG, "Nostr relay DNS failure for $relayUrl - not retrying")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Implement exponential backoff for non-DNS errors
|
||||
val relay = relaysList.find { it.url == relayUrl } ?: return
|
||||
relay.reconnectAttempts++
|
||||
|
||||
// Stop attempting after max attempts
|
||||
if (relay.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||
Log.w(TAG, "Max reconnection attempts ($MAX_RECONNECT_ATTEMPTS) reached for $relayUrl")
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate backoff interval
|
||||
val backoffInterval = min(
|
||||
INITIAL_BACKOFF_INTERVAL * BACKOFF_MULTIPLIER.pow(relay.reconnectAttempts - 1.0),
|
||||
MAX_BACKOFF_INTERVAL.toDouble()
|
||||
).toLong()
|
||||
|
||||
relay.nextReconnectTime = System.currentTimeMillis() + backoffInterval
|
||||
|
||||
Log.d(TAG, "Scheduling reconnection to $relayUrl in ${backoffInterval / 1000}s (attempt ${relay.reconnectAttempts})")
|
||||
|
||||
// Schedule reconnection
|
||||
scope.launch {
|
||||
delay(backoffInterval)
|
||||
connectToRelay(relayUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateRelayStatus(url: String, isConnected: Boolean, error: Throwable? = null) {
|
||||
val relay = relaysList.find { it.url == url } ?: return
|
||||
|
||||
relay.isConnected = isConnected
|
||||
relay.lastError = error
|
||||
|
||||
if (isConnected) {
|
||||
relay.lastConnectedAt = System.currentTimeMillis()
|
||||
relay.reconnectAttempts = 0
|
||||
relay.nextReconnectTime = null
|
||||
} else {
|
||||
relay.lastDisconnectedAt = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
updateRelaysList()
|
||||
updateConnectionStatus()
|
||||
}
|
||||
|
||||
private fun updateRelaysList() {
|
||||
_relays.postValue(relaysList.toList())
|
||||
}
|
||||
|
||||
private fun updateConnectionStatus() {
|
||||
val connected = relaysList.any { it.isConnected }
|
||||
_isConnected.postValue(connected)
|
||||
}
|
||||
|
||||
private fun generateSubscriptionId(): String {
|
||||
return "sub-${System.currentTimeMillis()}-${(Math.random() * 1000).toInt()}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore all active subscriptions for a specific relay that just reconnected
|
||||
*/
|
||||
private fun restoreSubscriptionsForRelay(relayUrl: String, webSocket: WebSocket) {
|
||||
val subscriptionsToRestore = activeSubscriptions.values.filter { subscriptionInfo ->
|
||||
// Include subscription if it targets all relays or specifically targets this relay
|
||||
subscriptionInfo.targetRelayUrls == null || subscriptionInfo.targetRelayUrls.contains(relayUrl)
|
||||
}
|
||||
|
||||
if (subscriptionsToRestore.isEmpty()) {
|
||||
Log.v(TAG, "🔄 No subscriptions to restore for relay: $relayUrl")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "🔄 Restoring ${subscriptionsToRestore.size} subscriptions for relay: $relayUrl")
|
||||
|
||||
subscriptionsToRestore.forEach { subscriptionInfo ->
|
||||
try {
|
||||
val request = NostrRequest.Subscribe(subscriptionInfo.id, listOf(subscriptionInfo.filter))
|
||||
val message = gson.toJson(request, NostrRequest::class.java)
|
||||
|
||||
val success = webSocket.send(message)
|
||||
if (success) {
|
||||
// Track subscription for this relay
|
||||
val currentSubs = subscriptions[relayUrl] ?: emptySet()
|
||||
subscriptions[relayUrl] = currentSubs + subscriptionInfo.id
|
||||
|
||||
Log.v(TAG, "✅ Restored subscription '${subscriptionInfo.id}' to relay: $relayUrl")
|
||||
} else {
|
||||
Log.w(TAG, "❌ Failed to restore subscription '${subscriptionInfo.id}' to $relayUrl: WebSocket send failed")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Failed to restore subscription '${subscriptionInfo.id}' to $relayUrl: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket listener for relay connections
|
||||
*/
|
||||
private inner class RelayWebSocketListener(private val relayUrl: String) : WebSocketListener() {
|
||||
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
Log.d(TAG, "✅ Connected to Nostr relay: $relayUrl")
|
||||
updateRelayStatus(relayUrl, true)
|
||||
|
||||
// Restore all active subscriptions for this relay
|
||||
restoreSubscriptionsForRelay(relayUrl, webSocket)
|
||||
|
||||
// Process any queued messages for this relay
|
||||
synchronized(messageQueueLock) {
|
||||
val iterator = messageQueue.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val (event, targetRelays) = iterator.next()
|
||||
if (relayUrl in targetRelays) {
|
||||
sendToRelay(event, webSocket, relayUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
handleMessage(text, relayUrl)
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
Log.d(TAG, "WebSocket closing for $relayUrl: $code $reason")
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
Log.d(TAG, "WebSocket closed for $relayUrl: $code $reason")
|
||||
val error = Exception("WebSocket closed: $code $reason")
|
||||
handleDisconnection(relayUrl, error)
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
Log.e(TAG, "❌ WebSocket failure for $relayUrl: ${t.message}")
|
||||
handleDisconnection(relayUrl, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import com.google.gson.*
|
||||
import java.lang.reflect.Type
|
||||
|
||||
/**
|
||||
* Nostr protocol request messages
|
||||
* Supports EVENT, REQ, and CLOSE message types
|
||||
*/
|
||||
sealed class NostrRequest {
|
||||
|
||||
/**
|
||||
* EVENT message - publish an event
|
||||
*/
|
||||
data class Event(val event: NostrEvent) : NostrRequest()
|
||||
|
||||
/**
|
||||
* REQ message - subscribe to events
|
||||
*/
|
||||
data class Subscribe(
|
||||
val subscriptionId: String,
|
||||
val filters: List<NostrFilter>
|
||||
) : NostrRequest()
|
||||
|
||||
/**
|
||||
* CLOSE message - close a subscription
|
||||
*/
|
||||
data class Close(val subscriptionId: String) : NostrRequest()
|
||||
|
||||
/**
|
||||
* Custom JSON serializer for NostrRequest
|
||||
*/
|
||||
class RequestSerializer : JsonSerializer<NostrRequest> {
|
||||
override fun serialize(src: NostrRequest, typeOfSrc: Type, context: JsonSerializationContext): JsonElement {
|
||||
val array = JsonArray()
|
||||
|
||||
when (src) {
|
||||
is Event -> {
|
||||
array.add("EVENT")
|
||||
array.add(context.serialize(src.event))
|
||||
}
|
||||
|
||||
is Subscribe -> {
|
||||
array.add("REQ")
|
||||
array.add(src.subscriptionId)
|
||||
src.filters.forEach { filter ->
|
||||
array.add(context.serialize(filter, NostrFilter::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
is Close -> {
|
||||
array.add("CLOSE")
|
||||
array.add(src.subscriptionId)
|
||||
}
|
||||
}
|
||||
|
||||
return array
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Create Gson instance with proper serializers
|
||||
*/
|
||||
fun createGson(): Gson {
|
||||
return GsonBuilder()
|
||||
.registerTypeAdapter(NostrRequest::class.java, RequestSerializer())
|
||||
.registerTypeAdapter(NostrFilter::class.java, NostrFilter.FilterSerializer())
|
||||
.disableHtmlEscaping()
|
||||
.create()
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize request to JSON string
|
||||
*/
|
||||
fun toJson(request: NostrRequest): String {
|
||||
return createGson().toJson(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nostr protocol response messages
|
||||
* Handles EVENT, EOSE, OK, and NOTICE responses
|
||||
*/
|
||||
sealed class NostrResponse {
|
||||
|
||||
/**
|
||||
* EVENT response - received event from subscription
|
||||
*/
|
||||
data class Event(
|
||||
val subscriptionId: String,
|
||||
val event: NostrEvent
|
||||
) : NostrResponse()
|
||||
|
||||
/**
|
||||
* EOSE response - end of stored events
|
||||
*/
|
||||
data class EndOfStoredEvents(
|
||||
val subscriptionId: String
|
||||
) : NostrResponse()
|
||||
|
||||
/**
|
||||
* OK response - event publication result
|
||||
*/
|
||||
data class Ok(
|
||||
val eventId: String,
|
||||
val accepted: Boolean,
|
||||
val message: String?
|
||||
) : NostrResponse()
|
||||
|
||||
/**
|
||||
* NOTICE response - relay notice
|
||||
*/
|
||||
data class Notice(
|
||||
val message: String
|
||||
) : NostrResponse()
|
||||
|
||||
/**
|
||||
* Unknown response type
|
||||
*/
|
||||
data class Unknown(
|
||||
val raw: String
|
||||
) : NostrResponse()
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Parse JSON array response
|
||||
*/
|
||||
fun fromJsonArray(jsonArray: JsonArray): NostrResponse {
|
||||
return try {
|
||||
when (val type = jsonArray[0].asString) {
|
||||
"EVENT" -> {
|
||||
if (jsonArray.size() >= 3) {
|
||||
val subscriptionId = jsonArray[1].asString
|
||||
val eventJson = jsonArray[2].asJsonObject
|
||||
val event = parseEventFromJson(eventJson)
|
||||
Event(subscriptionId, event)
|
||||
} else {
|
||||
Unknown(jsonArray.toString())
|
||||
}
|
||||
}
|
||||
|
||||
"EOSE" -> {
|
||||
if (jsonArray.size() >= 2) {
|
||||
val subscriptionId = jsonArray[1].asString
|
||||
EndOfStoredEvents(subscriptionId)
|
||||
} else {
|
||||
Unknown(jsonArray.toString())
|
||||
}
|
||||
}
|
||||
|
||||
"OK" -> {
|
||||
if (jsonArray.size() >= 3) {
|
||||
val eventId = jsonArray[1].asString
|
||||
val accepted = jsonArray[2].asBoolean
|
||||
val message = if (jsonArray.size() >= 4) {
|
||||
jsonArray[3].asString
|
||||
} else null
|
||||
Ok(eventId, accepted, message)
|
||||
} else {
|
||||
Unknown(jsonArray.toString())
|
||||
}
|
||||
}
|
||||
|
||||
"NOTICE" -> {
|
||||
if (jsonArray.size() >= 2) {
|
||||
val message = jsonArray[1].asString
|
||||
Notice(message)
|
||||
} else {
|
||||
Unknown(jsonArray.toString())
|
||||
}
|
||||
}
|
||||
|
||||
else -> Unknown(jsonArray.toString())
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Unknown(jsonArray.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseEventFromJson(jsonObject: JsonObject): NostrEvent {
|
||||
return NostrEvent(
|
||||
id = jsonObject.get("id")?.asString ?: "",
|
||||
pubkey = jsonObject.get("pubkey")?.asString ?: "",
|
||||
createdAt = jsonObject.get("created_at")?.asInt ?: 0,
|
||||
kind = jsonObject.get("kind")?.asInt ?: 0,
|
||||
tags = parseTagsFromJson(jsonObject.get("tags")?.asJsonArray),
|
||||
content = jsonObject.get("content")?.asString ?: "",
|
||||
sig = jsonObject.get("sig")?.asString
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseTagsFromJson(tagsArray: JsonArray?): List<List<String>> {
|
||||
if (tagsArray == null) return emptyList()
|
||||
|
||||
return try {
|
||||
tagsArray.map { tagElement ->
|
||||
if (tagElement.isJsonArray) {
|
||||
val tagArray = tagElement.asJsonArray
|
||||
tagArray.map { it.asString }
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
/**
|
||||
* Test manager for Nostr functionality
|
||||
* Use this to verify the Nostr client works correctly
|
||||
*/
|
||||
class NostrTestManager(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NostrTestManager"
|
||||
}
|
||||
|
||||
private val testScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
private lateinit var nostrClient: NostrClient
|
||||
|
||||
/**
|
||||
* Run comprehensive Nostr tests
|
||||
*/
|
||||
fun runTests() {
|
||||
Log.i(TAG, "🧪 Starting Nostr functionality tests...")
|
||||
|
||||
testScope.launch {
|
||||
try {
|
||||
// Test 1: Initialize client
|
||||
testClientInitialization()
|
||||
|
||||
// Test 2: Test identity generation and storage
|
||||
testIdentityManagement()
|
||||
|
||||
// Test 3: Test relay connections
|
||||
testRelayConnections()
|
||||
|
||||
// Test 4: Test cryptography
|
||||
testCryptography()
|
||||
|
||||
// Test 5: Test Bech32 encoding
|
||||
testBech32()
|
||||
|
||||
// Test 6: Test message subscription (without sending)
|
||||
testMessageSubscription()
|
||||
|
||||
Log.i(TAG, "✅ All Nostr tests completed successfully!")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Nostr tests failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun testClientInitialization() {
|
||||
Log.d(TAG, "Testing client initialization...")
|
||||
|
||||
nostrClient = NostrClient.getInstance(context)
|
||||
nostrClient.initialize()
|
||||
|
||||
// Wait for initialization
|
||||
delay(2000)
|
||||
|
||||
val isInitialized = nostrClient.isInitialized.value ?: false
|
||||
require(isInitialized) { "Client failed to initialize" }
|
||||
|
||||
Log.d(TAG, "✅ Client initialization successful")
|
||||
}
|
||||
|
||||
private suspend fun testIdentityManagement() {
|
||||
Log.d(TAG, "Testing identity management...")
|
||||
|
||||
// Test current identity
|
||||
val identity = nostrClient.getCurrentIdentity()
|
||||
requireNotNull(identity) { "No current identity" }
|
||||
|
||||
Log.d(TAG, "Current identity npub: ${identity.getShortNpub()}")
|
||||
require(identity.npub.startsWith("npub1")) { "Invalid npub format" }
|
||||
require(identity.publicKeyHex.length == 64) { "Invalid public key length" }
|
||||
require(identity.privateKeyHex.length == 64) { "Invalid private key length" }
|
||||
|
||||
// Test geohash identity derivation
|
||||
val geohashIdentity = NostrIdentityBridge.deriveIdentity("u4pruydq", context)
|
||||
require(geohashIdentity.npub.startsWith("npub1")) { "Invalid geohash identity npub" }
|
||||
require(geohashIdentity.publicKeyHex != identity.publicKeyHex) { "Geohash identity should be different" }
|
||||
|
||||
Log.d(TAG, "Geohash identity npub: ${geohashIdentity.getShortNpub()}")
|
||||
Log.d(TAG, "✅ Identity management test successful")
|
||||
}
|
||||
|
||||
private suspend fun testRelayConnections() {
|
||||
Log.d(TAG, "Testing relay connections...")
|
||||
|
||||
// Wait for potential relay connections
|
||||
delay(3000)
|
||||
|
||||
val relayInfo = nostrClient.relayInfo.value ?: emptyList()
|
||||
require(relayInfo.isNotEmpty()) { "No relays configured" }
|
||||
|
||||
Log.d(TAG, "Configured relays: ${relayInfo.size}")
|
||||
relayInfo.forEach { relay ->
|
||||
Log.d(TAG, "Relay: ${relay.url} - Connected: ${relay.isConnected}")
|
||||
}
|
||||
|
||||
Log.d(TAG, "✅ Relay configuration test successful")
|
||||
}
|
||||
|
||||
private suspend fun testCryptography() {
|
||||
Log.d(TAG, "Testing cryptography functions...")
|
||||
|
||||
// Test key generation
|
||||
val (privateKey, publicKey) = NostrCrypto.generateKeyPair()
|
||||
require(privateKey.length == 64) { "Invalid private key length" }
|
||||
require(publicKey.length == 64) { "Invalid public key length" }
|
||||
require(NostrCrypto.isValidPrivateKey(privateKey)) { "Generated private key is invalid" }
|
||||
require(NostrCrypto.isValidPublicKey(publicKey)) { "Generated public key is invalid" }
|
||||
|
||||
// Test key derivation
|
||||
val derivedPublic = NostrCrypto.derivePublicKey(privateKey)
|
||||
require(derivedPublic == publicKey) { "Key derivation mismatch" }
|
||||
|
||||
// Test encryption/decryption
|
||||
val (recipientPrivate, recipientPublic) = NostrCrypto.generateKeyPair()
|
||||
val plaintext = "Hello, Nostr world! This is a test message."
|
||||
|
||||
val encrypted = NostrCrypto.encryptNIP44(plaintext, recipientPublic, privateKey)
|
||||
require(encrypted.isNotEmpty()) { "Encryption failed" }
|
||||
|
||||
val decrypted = NostrCrypto.decryptNIP44(encrypted, publicKey, recipientPrivate)
|
||||
require(decrypted == plaintext) { "Decryption failed: expected '$plaintext', got '$decrypted'" }
|
||||
|
||||
Log.d(TAG, "✅ Cryptography test successful")
|
||||
}
|
||||
|
||||
private suspend fun testBech32() {
|
||||
Log.d(TAG, "Testing Bech32 encoding...")
|
||||
|
||||
val testData = "hello world test data for bech32".toByteArray()
|
||||
val encoded = Bech32.encode("test", testData)
|
||||
require(encoded.startsWith("test1")) { "Invalid bech32 encoding" }
|
||||
|
||||
val (hrp, decoded) = Bech32.decode(encoded)
|
||||
require(hrp == "test") { "HRP mismatch" }
|
||||
require(decoded.contentEquals(testData)) { "Data mismatch after decode" }
|
||||
|
||||
// Test with actual public key
|
||||
val (_, publicKey) = NostrCrypto.generateKeyPair()
|
||||
val npub = Bech32.encode("npub", publicKey.hexToByteArray())
|
||||
require(npub.startsWith("npub1")) { "Invalid npub encoding" }
|
||||
|
||||
val (npubHrp, npubData) = Bech32.decode(npub)
|
||||
require(npubHrp == "npub") { "npub HRP mismatch" }
|
||||
require(npubData.toHexString() == publicKey) { "npub data mismatch" }
|
||||
|
||||
Log.d(TAG, "✅ Bech32 test successful")
|
||||
}
|
||||
|
||||
private suspend fun testMessageSubscription() {
|
||||
Log.d(TAG, "Testing message subscription...")
|
||||
|
||||
var messageReceived = false
|
||||
|
||||
// Subscribe to private messages (won't receive any in test, but tests the subscription mechanism)
|
||||
nostrClient.subscribeToPrivateMessages { content, senderNpub, timestamp ->
|
||||
Log.d(TAG, "📥 Received test private message from $senderNpub: $content")
|
||||
messageReceived = true
|
||||
}
|
||||
|
||||
// Subscribe to a test geohash
|
||||
nostrClient.subscribeToGeohash("u4pru") { content, senderPubkey, nickname, timestamp ->
|
||||
Log.d(TAG, "📥 Received test geohash message from ${senderPubkey.take(16)}...: $content")
|
||||
messageReceived = true
|
||||
}
|
||||
|
||||
// Wait a bit to see if any messages come through
|
||||
delay(2000)
|
||||
|
||||
Log.d(TAG, "✅ Message subscription test successful (no messages expected in test)")
|
||||
}
|
||||
|
||||
/**
|
||||
* Test sending a message to yourself (loopback test)
|
||||
*/
|
||||
fun testLoopbackMessage() {
|
||||
testScope.launch {
|
||||
try {
|
||||
val identity = nostrClient.getCurrentIdentity()
|
||||
requireNotNull(identity) { "No identity available for loopback test" }
|
||||
|
||||
Log.i(TAG, "🔄 Testing loopback private message...")
|
||||
|
||||
// Send message to ourselves
|
||||
nostrClient.sendPrivateMessage(
|
||||
content = "Test loopback message at ${System.currentTimeMillis()}",
|
||||
recipientNpub = identity.npub,
|
||||
onSuccess = {
|
||||
Log.i(TAG, "✅ Loopback message sent successfully")
|
||||
},
|
||||
onError = { error ->
|
||||
Log.e(TAG, "❌ Loopback message failed: $error")
|
||||
}
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Loopback test failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test sending a geohash message
|
||||
*/
|
||||
fun testGeohashMessage() {
|
||||
testScope.launch {
|
||||
try {
|
||||
Log.i(TAG, "🌍 Testing geohash message...")
|
||||
|
||||
nostrClient.sendGeohashMessage(
|
||||
content = "Test geohash message from Android at ${System.currentTimeMillis()}",
|
||||
geohash = "u4pru",
|
||||
nickname = "android-test",
|
||||
onSuccess = {
|
||||
Log.i(TAG, "✅ Geohash message sent successfully")
|
||||
},
|
||||
onError = { error ->
|
||||
Log.e(TAG, "❌ Geohash message failed: $error")
|
||||
}
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Geohash test failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get debug information about the Nostr client
|
||||
*/
|
||||
fun getDebugInfo(): String {
|
||||
return buildString {
|
||||
appendLine("=== Nostr Client Debug Info ===")
|
||||
|
||||
val identity = nostrClient.getCurrentIdentity()
|
||||
if (identity != null) {
|
||||
appendLine("Identity: ${identity.getShortNpub()}")
|
||||
appendLine("Public Key: ${identity.publicKeyHex.take(16)}...")
|
||||
appendLine("Created: ${java.util.Date(identity.createdAt)}")
|
||||
} else {
|
||||
appendLine("No identity loaded")
|
||||
}
|
||||
|
||||
val isInitialized = nostrClient.isInitialized.value ?: false
|
||||
appendLine("Initialized: $isInitialized")
|
||||
|
||||
val isConnected = nostrClient.relayConnectionStatus.value ?: false
|
||||
appendLine("Relay Connected: $isConnected")
|
||||
|
||||
val relays = nostrClient.relayInfo.value ?: emptyList()
|
||||
appendLine("Relays (${relays.size}):")
|
||||
relays.forEach { relay ->
|
||||
appendLine(" ${relay.url}: ${if (relay.isConnected) "✅" else "❌"} (sent: ${relay.messagesSent}, received: ${relay.messagesReceived})")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown test manager
|
||||
*/
|
||||
fun shutdown() {
|
||||
testScope.cancel()
|
||||
nostrClient.shutdown()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.bitchat.android.model.ReadReceipt
|
||||
import com.bitchat.android.model.NoisePayloadType
|
||||
import kotlinx.coroutines.*
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
|
||||
/**
|
||||
* Minimal Nostr transport for offline sending
|
||||
* Direct port from iOS NostrTransport for 100% compatibility
|
||||
*/
|
||||
class NostrTransport(
|
||||
private val context: Context,
|
||||
var senderPeerID: String = ""
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NostrTransport"
|
||||
private const val READ_ACK_INTERVAL = 350L // ~3 per second (0.35s interval like iOS)
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: NostrTransport? = null
|
||||
|
||||
fun getInstance(context: Context): NostrTransport {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: NostrTransport(context.applicationContext).also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Throttle READ receipts to avoid relay rate limits (like iOS)
|
||||
private data class QueuedRead(
|
||||
val receipt: ReadReceipt,
|
||||
val peerID: String
|
||||
)
|
||||
|
||||
private val readQueue = ConcurrentLinkedQueue<QueuedRead>()
|
||||
private var isSendingReadAcks = false
|
||||
private val transportScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
// MARK: - Transport Interface Methods
|
||||
|
||||
val myPeerID: String get() = senderPeerID
|
||||
|
||||
fun sendPrivateMessage(
|
||||
content: String,
|
||||
to: String,
|
||||
recipientNickname: String,
|
||||
messageID: String
|
||||
) {
|
||||
transportScope.launch {
|
||||
try {
|
||||
// Resolve favorite by full noise key or by short peerID fallback
|
||||
var recipientNostrPubkey: String? = null
|
||||
|
||||
// Try to resolve from favorites persistence service
|
||||
// This would need integration with the existing favorites system
|
||||
recipientNostrPubkey = resolveNostrPublicKey(to)
|
||||
|
||||
if (recipientNostrPubkey == null) {
|
||||
Log.w(TAG, "No Nostr public key found for peerID: $to")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val senderIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context)
|
||||
if (senderIdentity == null) {
|
||||
Log.e(TAG, "No Nostr identity available")
|
||||
return@launch
|
||||
}
|
||||
|
||||
Log.d(TAG, "NostrTransport: preparing PM to ${recipientNostrPubkey.take(16)}... for peerID ${to.take(8)}... id=${messageID.take(8)}...")
|
||||
|
||||
// Convert recipient npub -> hex (x-only)
|
||||
val recipientHex = try {
|
||||
val (hrp, data) = Bech32.decode(recipientNostrPubkey)
|
||||
if (hrp != "npub") {
|
||||
Log.e(TAG, "NostrTransport: recipient key not npub (hrp=$hrp)")
|
||||
return@launch
|
||||
}
|
||||
data.joinToString("") { "%02x".format(it) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "NostrTransport: failed to decode npub -> hex: $e")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val embedded = NostrEmbeddedBitChat.encodePMForNostr(
|
||||
content = content,
|
||||
messageID = messageID,
|
||||
recipientPeerID = to,
|
||||
senderPeerID = senderPeerID
|
||||
)
|
||||
|
||||
if (embedded == null) {
|
||||
Log.e(TAG, "NostrTransport: failed to embed PM packet")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val event = NostrProtocol.createPrivateMessage(
|
||||
content = embedded,
|
||||
recipientPubkey = recipientHex,
|
||||
senderIdentity = senderIdentity
|
||||
)
|
||||
|
||||
Log.d(TAG, "NostrTransport: sending PM giftWrap id=${event.id.take(16)}...")
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send private message via Nostr: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendReadReceipt(receipt: ReadReceipt, to: String) {
|
||||
// Enqueue and process with throttling to avoid relay rate limits
|
||||
readQueue.offer(QueuedRead(receipt, to))
|
||||
processReadQueueIfNeeded()
|
||||
}
|
||||
|
||||
private fun processReadQueueIfNeeded() {
|
||||
if (isSendingReadAcks) return
|
||||
if (readQueue.isEmpty()) return
|
||||
|
||||
isSendingReadAcks = true
|
||||
sendNextReadAck()
|
||||
}
|
||||
|
||||
private fun sendNextReadAck() {
|
||||
val item = readQueue.poll()
|
||||
if (item == null) {
|
||||
isSendingReadAcks = false
|
||||
return
|
||||
}
|
||||
|
||||
transportScope.launch {
|
||||
try {
|
||||
var recipientNostrPubkey: String? = null
|
||||
|
||||
// Try to resolve from favorites persistence service
|
||||
recipientNostrPubkey = resolveNostrPublicKey(item.peerID)
|
||||
|
||||
if (recipientNostrPubkey == null) {
|
||||
Log.w(TAG, "No Nostr public key found for read receipt to: ${item.peerID}")
|
||||
scheduleNextReadAck()
|
||||
return@launch
|
||||
}
|
||||
|
||||
val senderIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context)
|
||||
if (senderIdentity == null) {
|
||||
Log.e(TAG, "No Nostr identity available for read receipt")
|
||||
scheduleNextReadAck()
|
||||
return@launch
|
||||
}
|
||||
|
||||
Log.d(TAG, "NostrTransport: preparing READ ack for id=${item.receipt.originalMessageID.take(8)}... to ${recipientNostrPubkey.take(16)}...")
|
||||
|
||||
// Convert recipient npub -> hex
|
||||
val recipientHex = try {
|
||||
val (hrp, data) = Bech32.decode(recipientNostrPubkey)
|
||||
if (hrp != "npub") {
|
||||
scheduleNextReadAck()
|
||||
return@launch
|
||||
}
|
||||
data.joinToString("") { "%02x".format(it) }
|
||||
} catch (e: Exception) {
|
||||
scheduleNextReadAck()
|
||||
return@launch
|
||||
}
|
||||
|
||||
val ack = NostrEmbeddedBitChat.encodeAckForNostr(
|
||||
type = NoisePayloadType.READ_RECEIPT,
|
||||
messageID = item.receipt.originalMessageID,
|
||||
recipientPeerID = item.peerID,
|
||||
senderPeerID = senderPeerID
|
||||
)
|
||||
|
||||
if (ack == null) {
|
||||
Log.e(TAG, "NostrTransport: failed to embed READ ack")
|
||||
scheduleNextReadAck()
|
||||
return@launch
|
||||
}
|
||||
|
||||
val event = NostrProtocol.createPrivateMessage(
|
||||
content = ack,
|
||||
recipientPubkey = recipientHex,
|
||||
senderIdentity = senderIdentity
|
||||
)
|
||||
|
||||
Log.d(TAG, "NostrTransport: sending READ ack giftWrap id=${event.id.take(16)}...")
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
|
||||
scheduleNextReadAck()
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send read receipt via Nostr: ${e.message}")
|
||||
scheduleNextReadAck()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleNextReadAck() {
|
||||
transportScope.launch {
|
||||
delay(READ_ACK_INTERVAL)
|
||||
isSendingReadAcks = false
|
||||
processReadQueueIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
fun sendFavoriteNotification(to: String, isFavorite: Boolean) {
|
||||
transportScope.launch {
|
||||
try {
|
||||
var recipientNostrPubkey: String? = null
|
||||
|
||||
// Try to resolve from favorites persistence service
|
||||
recipientNostrPubkey = resolveNostrPublicKey(to)
|
||||
|
||||
if (recipientNostrPubkey == null) {
|
||||
Log.w(TAG, "No Nostr public key found for favorite notification to: $to")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val senderIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context)
|
||||
if (senderIdentity == null) {
|
||||
Log.e(TAG, "No Nostr identity available for favorite notification")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val content = if (isFavorite) {
|
||||
"[FAVORITED]:${senderIdentity.npub}"
|
||||
} else {
|
||||
"[UNFAVORITED]:${senderIdentity.npub}"
|
||||
}
|
||||
|
||||
Log.d(TAG, "NostrTransport: preparing FAVORITE($isFavorite) to ${recipientNostrPubkey.take(16)}...")
|
||||
|
||||
// Convert recipient npub -> hex
|
||||
val recipientHex = try {
|
||||
val (hrp, data) = Bech32.decode(recipientNostrPubkey)
|
||||
if (hrp != "npub") return@launch
|
||||
data.joinToString("") { "%02x".format(it) }
|
||||
} catch (e: Exception) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
val embedded = NostrEmbeddedBitChat.encodePMForNostr(
|
||||
content = content,
|
||||
messageID = UUID.randomUUID().toString(),
|
||||
recipientPeerID = to,
|
||||
senderPeerID = senderPeerID
|
||||
)
|
||||
|
||||
if (embedded == null) {
|
||||
Log.e(TAG, "NostrTransport: failed to embed favorite notification")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val event = NostrProtocol.createPrivateMessage(
|
||||
content = embedded,
|
||||
recipientPubkey = recipientHex,
|
||||
senderIdentity = senderIdentity
|
||||
)
|
||||
|
||||
Log.d(TAG, "NostrTransport: sending favorite giftWrap id=${event.id.take(16)}...")
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send favorite notification via Nostr: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendDeliveryAck(messageID: String, to: String) {
|
||||
transportScope.launch {
|
||||
try {
|
||||
var recipientNostrPubkey: String? = null
|
||||
|
||||
// Try to resolve from favorites persistence service
|
||||
recipientNostrPubkey = resolveNostrPublicKey(to)
|
||||
|
||||
if (recipientNostrPubkey == null) {
|
||||
Log.w(TAG, "No Nostr public key found for delivery ack to: $to")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val senderIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context)
|
||||
if (senderIdentity == null) {
|
||||
Log.e(TAG, "No Nostr identity available for delivery ack")
|
||||
return@launch
|
||||
}
|
||||
|
||||
Log.d(TAG, "NostrTransport: preparing DELIVERED ack for id=${messageID.take(8)}... to ${recipientNostrPubkey.take(16)}...")
|
||||
|
||||
val recipientHex = try {
|
||||
val (hrp, data) = Bech32.decode(recipientNostrPubkey)
|
||||
if (hrp != "npub") return@launch
|
||||
data.joinToString("") { "%02x".format(it) }
|
||||
} catch (e: Exception) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
val ack = NostrEmbeddedBitChat.encodeAckForNostr(
|
||||
type = NoisePayloadType.DELIVERED,
|
||||
messageID = messageID,
|
||||
recipientPeerID = to,
|
||||
senderPeerID = senderPeerID
|
||||
)
|
||||
|
||||
if (ack == null) {
|
||||
Log.e(TAG, "NostrTransport: failed to embed DELIVERED ack")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val event = NostrProtocol.createPrivateMessage(
|
||||
content = ack,
|
||||
recipientPubkey = recipientHex,
|
||||
senderIdentity = senderIdentity
|
||||
)
|
||||
|
||||
Log.d(TAG, "NostrTransport: sending DELIVERED ack giftWrap id=${event.id.take(16)}...")
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send delivery ack via Nostr: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Geohash ACK helpers (for per-geohash identity DMs)
|
||||
|
||||
fun sendDeliveryAckGeohash(
|
||||
messageID: String,
|
||||
toRecipientHex: String,
|
||||
fromIdentity: NostrIdentity
|
||||
) {
|
||||
transportScope.launch {
|
||||
try {
|
||||
Log.d(TAG, "GeoDM: send DELIVERED -> recip=${toRecipientHex.take(8)}... mid=${messageID.take(8)}... from=${fromIdentity.publicKeyHex.take(8)}...")
|
||||
|
||||
val embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(
|
||||
type = NoisePayloadType.DELIVERED,
|
||||
messageID = messageID,
|
||||
senderPeerID = senderPeerID
|
||||
)
|
||||
|
||||
if (embedded == null) return@launch
|
||||
|
||||
val event = NostrProtocol.createPrivateMessage(
|
||||
content = embedded,
|
||||
recipientPubkey = toRecipientHex,
|
||||
senderIdentity = fromIdentity
|
||||
)
|
||||
|
||||
// Register pending gift wrap for deduplication (like iOS)
|
||||
NostrRelayManager.registerPendingGiftWrap(event.id)
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send geohash delivery ack: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendReadReceiptGeohash(
|
||||
messageID: String,
|
||||
toRecipientHex: String,
|
||||
fromIdentity: NostrIdentity
|
||||
) {
|
||||
transportScope.launch {
|
||||
try {
|
||||
Log.d(TAG, "GeoDM: send READ -> recip=${toRecipientHex.take(8)}... mid=${messageID.take(8)}... from=${fromIdentity.publicKeyHex.take(8)}...")
|
||||
|
||||
val embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(
|
||||
type = NoisePayloadType.READ_RECEIPT,
|
||||
messageID = messageID,
|
||||
senderPeerID = senderPeerID
|
||||
)
|
||||
|
||||
if (embedded == null) return@launch
|
||||
|
||||
val event = NostrProtocol.createPrivateMessage(
|
||||
content = embedded,
|
||||
recipientPubkey = toRecipientHex,
|
||||
senderIdentity = fromIdentity
|
||||
)
|
||||
|
||||
// Register pending gift wrap for deduplication (like iOS)
|
||||
NostrRelayManager.registerPendingGiftWrap(event.id)
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send geohash read receipt: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Geohash DMs (per-geohash identity)
|
||||
|
||||
fun sendPrivateMessageGeohash(
|
||||
content: String,
|
||||
toRecipientHex: String,
|
||||
fromIdentity: NostrIdentity,
|
||||
messageID: String
|
||||
) {
|
||||
transportScope.launch {
|
||||
try {
|
||||
if (toRecipientHex.isEmpty()) return@launch
|
||||
|
||||
Log.d(TAG, "GeoDM: send PM -> recip=${toRecipientHex.take(8)}... mid=${messageID.take(8)}... from=${fromIdentity.publicKeyHex.take(8)}...")
|
||||
|
||||
// Build embedded BitChat packet without recipient peer ID
|
||||
val embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
|
||||
content = content,
|
||||
messageID = messageID,
|
||||
senderPeerID = senderPeerID
|
||||
)
|
||||
|
||||
if (embedded == null) {
|
||||
Log.e(TAG, "NostrTransport: failed to embed geohash PM packet")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val event = NostrProtocol.createPrivateMessage(
|
||||
content = embedded,
|
||||
recipientPubkey = toRecipientHex,
|
||||
senderIdentity = fromIdentity
|
||||
)
|
||||
|
||||
Log.d(TAG, "NostrTransport: sending geohash PM giftWrap id=${event.id.take(16)}...")
|
||||
|
||||
// Register pending gift wrap for deduplication (like iOS)
|
||||
NostrRelayManager.registerPendingGiftWrap(event.id)
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send geohash private message: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
/**
|
||||
* Resolve Nostr public key for a peer ID
|
||||
*/
|
||||
private fun resolveNostrPublicKey(peerID: String): String? {
|
||||
try {
|
||||
// Try to resolve from favorites persistence service
|
||||
val noiseKey = hexStringToByteArray(peerID)
|
||||
val favoriteStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
|
||||
if (favoriteStatus?.peerNostrPublicKey != null) {
|
||||
return favoriteStatus.peerNostrPublicKey
|
||||
}
|
||||
|
||||
// Fallback: try with 16-hex peerID lookup
|
||||
if (peerID.length == 16) {
|
||||
val fallbackStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
|
||||
return fallbackStatus?.peerNostrPublicKey
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to resolve Nostr public key for $peerID: ${e.message}")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex string to byte array (8 bytes)
|
||||
*/
|
||||
private fun hexStringToByteArray(hexString: String): ByteArray {
|
||||
if (hexString.length % 2 != 0) {
|
||||
return ByteArray(8) // Return 8-byte array filled with zeros
|
||||
}
|
||||
|
||||
val result = ByteArray(8) { 0 }
|
||||
var tempID = hexString
|
||||
var index = 0
|
||||
|
||||
while (tempID.length >= 2 && index < 8) {
|
||||
val hexByte = tempID.substring(0, 2)
|
||||
val byte = hexByte.toIntOrNull(16)?.toByte()
|
||||
if (byte != null) {
|
||||
result[index] = byte
|
||||
}
|
||||
tempID = tempID.substring(2)
|
||||
index++
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
fun cleanup() {
|
||||
transportScope.cancel()
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,7 @@ private fun LocationDisabledContent(
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "bitchat does NOT track your location or use GPS.\n\nLocation services are required by Android for Bluetooth scanning to work properly. This is an Android system requirement.",
|
||||
text = "bitchat does NOT track your location.\n\nLocation services are required for Bluetooth scanning and for the Geohash chat feature.",
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.8f)
|
||||
@@ -141,8 +141,8 @@ private fun LocationDisabledContent(
|
||||
Text(
|
||||
text = "• Bluetooth device scanning (Android requirement)\n" +
|
||||
"• Discovering nearby users on mesh network\n" +
|
||||
"• Creating connections without internet\n" +
|
||||
"• No GPS tracking or location collection",
|
||||
"• Geohash chat feature\n" +
|
||||
"• No tracking or location collection",
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.8f)
|
||||
|
||||
@@ -219,7 +219,7 @@ private fun PermissionCategoryCard(
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Text(
|
||||
text = "bitchat does NOT use GPS or track location",
|
||||
text = "bitchat does NOT track your location",
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Medium,
|
||||
|
||||
@@ -2,7 +2,9 @@ package com.bitchat.android.ui
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
@@ -92,6 +94,12 @@ fun NicknameEditor(
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val focusManager = LocalFocusManager.current
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
// Auto-scroll to end when text changes (simulates cursor following)
|
||||
LaunchedEffect(value) {
|
||||
scrollState.animateScrollTo(scrollState.maxValue)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -111,13 +119,16 @@ fun NicknameEditor(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
cursorBrush = SolidColor(colorScheme.primary),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
),
|
||||
modifier = Modifier.widthIn(max = 100.dp)
|
||||
modifier = Modifier
|
||||
.widthIn(max = 120.dp)
|
||||
.horizontalScroll(scrollState)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -129,11 +140,30 @@ fun PeerCounter(
|
||||
hasUnreadChannels: Map<String, Int>,
|
||||
hasUnreadPrivateMessages: Set<String>,
|
||||
isConnected: Boolean,
|
||||
selectedLocationChannel: com.bitchat.android.geohash.ChannelID?,
|
||||
geohashPeople: List<GeoPerson>,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
// Compute channel-aware people count and color (matches iOS logic exactly)
|
||||
val (peopleCount, countColor) = when (selectedLocationChannel) {
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> {
|
||||
// Geohash channel: show geohash participants
|
||||
val count = geohashPeople.size
|
||||
val green = Color(0xFF00C851) // Standard green
|
||||
Pair(count, if (count > 0) green else Color.Gray)
|
||||
}
|
||||
is com.bitchat.android.geohash.ChannelID.Mesh,
|
||||
null -> {
|
||||
// Mesh channel: show Bluetooth-connected peers (excluding self)
|
||||
val count = connectedPeers.size
|
||||
val meshBlue = Color(0xFF007AFF) // iOS-style blue for mesh
|
||||
Pair(count, if (isConnected && count > 0) meshBlue else Color.Gray)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier.clickable { onClick() }.padding(end = 8.dp) // Added right margin to match "bitchat" logo spacing
|
||||
@@ -167,15 +197,18 @@ fun PeerCounter(
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Default.Group,
|
||||
contentDescription = "Connected peers",
|
||||
contentDescription = when (selectedLocationChannel) {
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> "Geohash participants"
|
||||
else -> "Connected peers"
|
||||
},
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = if (isConnected) Color(0xFF00C851) else Color.Red
|
||||
tint = countColor
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "${connectedPeers.size}",
|
||||
text = "$peopleCount",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isConnected) Color(0xFF00C851) else Color.Red,
|
||||
color = countColor,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
@@ -201,7 +234,8 @@ fun ChatHeaderContent(
|
||||
onBackClick: () -> Unit,
|
||||
onSidebarClick: () -> Unit,
|
||||
onTripleClick: () -> Unit,
|
||||
onShowAppInfo: () -> Unit
|
||||
onShowAppInfo: () -> Unit,
|
||||
onLocationChannelsClick: () -> Unit
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
@@ -249,6 +283,7 @@ fun ChatHeaderContent(
|
||||
onTitleClick = onShowAppInfo,
|
||||
onTripleTitleClick = onTripleClick,
|
||||
onSidebarClick = onSidebarClick,
|
||||
onLocationChannelsClick = onLocationChannelsClick,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
@@ -408,6 +443,7 @@ private fun MainHeader(
|
||||
onTitleClick: () -> Unit,
|
||||
onTripleTitleClick: () -> Unit,
|
||||
onSidebarClick: () -> Unit,
|
||||
onLocationChannelsClick: () -> Unit,
|
||||
viewModel: ChatViewModel
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
@@ -416,6 +452,8 @@ private fun MainHeader(
|
||||
val hasUnreadChannels by viewModel.unreadChannelMessages.observeAsState(emptyMap())
|
||||
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
|
||||
val isConnected by viewModel.isConnected.observeAsState(false)
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
|
||||
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList())
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -444,13 +482,104 @@ private fun MainHeader(
|
||||
)
|
||||
}
|
||||
|
||||
// Right section with location channels button and peer counter
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
// Unread indicator (like iOS)
|
||||
if (hasUnreadPrivateMessages.isNotEmpty()) {
|
||||
Button(
|
||||
onClick = {
|
||||
// Open most relevant private chat (first unread)
|
||||
val firstUnread = hasUnreadPrivateMessages.firstOrNull()
|
||||
if (firstUnread != null) {
|
||||
viewModel.startPrivateChat(firstUnread)
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = Color(0xFFFF9500)
|
||||
),
|
||||
contentPadding = PaddingValues(4.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Email,
|
||||
contentDescription = "Open unread private chat",
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = Color(0xFFFF9500)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Location channels button (matching iOS implementation)
|
||||
LocationChannelsButton(
|
||||
viewModel = viewModel,
|
||||
onClick = onLocationChannelsClick
|
||||
)
|
||||
|
||||
PeerCounter(
|
||||
connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID },
|
||||
joinedChannels = joinedChannels,
|
||||
hasUnreadChannels = hasUnreadChannels,
|
||||
hasUnreadPrivateMessages = hasUnreadPrivateMessages,
|
||||
isConnected = isConnected,
|
||||
selectedLocationChannel = selectedLocationChannel,
|
||||
geohashPeople = geohashPeople,
|
||||
onClick = onSidebarClick
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LocationChannelsButton(
|
||||
viewModel: ChatViewModel,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
// Get current channel selection from location manager
|
||||
val selectedChannel by viewModel.selectedLocationChannel.observeAsState()
|
||||
val teleported by viewModel.isTeleported.observeAsState(false)
|
||||
|
||||
val (badgeText, badgeColor) = when (selectedChannel) {
|
||||
is com.bitchat.android.geohash.ChannelID.Mesh -> {
|
||||
"#mesh" to Color(0xFF007AFF) // iOS blue for mesh
|
||||
}
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> {
|
||||
val geohash = (selectedChannel as com.bitchat.android.geohash.ChannelID.Location).channel.geohash
|
||||
"#$geohash" to Color(0xFF00C851) // Green for location
|
||||
}
|
||||
null -> "#mesh" to Color(0xFF007AFF) // Default to mesh
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onClick,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = badgeColor
|
||||
),
|
||||
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 2.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = badgeText,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = badgeColor,
|
||||
maxLines = 1
|
||||
)
|
||||
|
||||
// Teleportation indicator (like iOS)
|
||||
if (teleported) {
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Text(
|
||||
text = "📍",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
var showPasswordPrompt by remember { mutableStateOf(false) }
|
||||
var showPasswordDialog by remember { mutableStateOf(false) }
|
||||
var passwordInput by remember { mutableStateOf("") }
|
||||
var showLocationChannelsSheet by remember { mutableStateOf(false) }
|
||||
|
||||
// Show password dialog when needed
|
||||
LaunchedEffect(showPasswordPrompt) {
|
||||
@@ -135,7 +136,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
colorScheme = colorScheme,
|
||||
onSidebarToggle = { viewModel.showSidebar() },
|
||||
onShowAppInfo = { viewModel.showAppInfo() },
|
||||
onPanicClear = { viewModel.panicClearAllData() }
|
||||
onPanicClear = { viewModel.panicClearAllData() },
|
||||
onLocationChannelsClick = { showLocationChannelsSheet = true }
|
||||
)
|
||||
|
||||
val alpha by animateFloatAsState(
|
||||
@@ -177,7 +179,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
// Dialogs
|
||||
// Dialogs and Sheets
|
||||
ChatDialogs(
|
||||
showPasswordDialog = showPasswordDialog,
|
||||
passwordPromptChannel = passwordPromptChannel,
|
||||
@@ -197,7 +199,10 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
passwordInput = ""
|
||||
},
|
||||
showAppInfo = showAppInfo,
|
||||
onAppInfoDismiss = { viewModel.hideAppInfo() }
|
||||
onAppInfoDismiss = { viewModel.hideAppInfo() },
|
||||
showLocationChannelsSheet = showLocationChannelsSheet,
|
||||
onLocationChannelsSheetDismiss = { showLocationChannelsSheet = false },
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
|
||||
@@ -270,7 +275,8 @@ private fun ChatFloatingHeader(
|
||||
colorScheme: ColorScheme,
|
||||
onSidebarToggle: () -> Unit,
|
||||
onShowAppInfo: () -> Unit,
|
||||
onPanicClear: () -> Unit
|
||||
onPanicClear: () -> Unit,
|
||||
onLocationChannelsClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
@@ -296,7 +302,8 @@ private fun ChatFloatingHeader(
|
||||
},
|
||||
onSidebarClick = onSidebarToggle,
|
||||
onTripleClick = onPanicClear,
|
||||
onShowAppInfo = onShowAppInfo
|
||||
onShowAppInfo = onShowAppInfo,
|
||||
onLocationChannelsClick = onLocationChannelsClick
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
@@ -324,7 +331,10 @@ private fun ChatDialogs(
|
||||
onPasswordConfirm: () -> Unit,
|
||||
onPasswordDismiss: () -> Unit,
|
||||
showAppInfo: Boolean,
|
||||
onAppInfoDismiss: () -> Unit
|
||||
onAppInfoDismiss: () -> Unit,
|
||||
showLocationChannelsSheet: Boolean,
|
||||
onLocationChannelsSheetDismiss: () -> Unit,
|
||||
viewModel: ChatViewModel
|
||||
) {
|
||||
// Password dialog
|
||||
PasswordPromptDialog(
|
||||
@@ -341,4 +351,13 @@ private fun ChatDialogs(
|
||||
show = showAppInfo,
|
||||
onDismiss = onAppInfoDismiss
|
||||
)
|
||||
|
||||
// Location channels sheet
|
||||
if (showLocationChannelsSheet) {
|
||||
LocationChannelsSheet(
|
||||
isPresented = showLocationChannelsSheet,
|
||||
onDismiss = onLocationChannelsSheetDismiss,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,24 @@ class ChatState {
|
||||
private val _showAppInfo = MutableLiveData<Boolean>(false)
|
||||
val showAppInfo: LiveData<Boolean> = _showAppInfo
|
||||
|
||||
// Location channels state (for Nostr geohash features)
|
||||
private val _selectedLocationChannel = MutableLiveData<com.bitchat.android.geohash.ChannelID?>(com.bitchat.android.geohash.ChannelID.Mesh)
|
||||
val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = _selectedLocationChannel
|
||||
|
||||
private val _isTeleported = MutableLiveData<Boolean>(false)
|
||||
val isTeleported: LiveData<Boolean> = _isTeleported
|
||||
|
||||
// Geohash people state (iOS-compatible)
|
||||
private val _geohashPeople = MutableLiveData<List<GeoPerson>>(emptyList())
|
||||
val geohashPeople: LiveData<List<GeoPerson>> = _geohashPeople
|
||||
|
||||
private val _teleportedGeo = MutableLiveData<Set<String>>(emptySet())
|
||||
val teleportedGeo: LiveData<Set<String>> = _teleportedGeo
|
||||
|
||||
// Geohash participant counts reactive state (for real-time location channel counts)
|
||||
private val _geohashParticipantCounts = MutableLiveData<Map<String, Int>>(emptyMap())
|
||||
val geohashParticipantCounts: LiveData<Map<String, Int>> = _geohashParticipantCounts
|
||||
|
||||
// Unread state computed properties
|
||||
val hasUnreadChannels: MediatorLiveData<Boolean> = MediatorLiveData<Boolean>()
|
||||
val hasUnreadPrivateMessages: MediatorLiveData<Boolean> = MediatorLiveData<Boolean>()
|
||||
@@ -148,6 +166,9 @@ class ChatState {
|
||||
fun getPeerSessionStatesValue() = _peerSessionStates.value ?: emptyMap()
|
||||
fun getPeerFingerprintsValue() = _peerFingerprints.value ?: emptyMap()
|
||||
fun getShowAppInfoValue() = _showAppInfo.value ?: false
|
||||
fun getGeohashPeopleValue() = _geohashPeople.value ?: emptyList()
|
||||
fun getTeleportedGeoValue() = _teleportedGeo.value ?: emptySet()
|
||||
fun getGeohashParticipantCountsValue() = _geohashParticipantCounts.value ?: emptyMap()
|
||||
|
||||
// Setters for state updates
|
||||
fun setMessages(messages: List<BitchatMessage>) {
|
||||
@@ -260,4 +281,24 @@ class ChatState {
|
||||
_showAppInfo.value = show
|
||||
}
|
||||
|
||||
fun setSelectedLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) {
|
||||
_selectedLocationChannel.value = channel
|
||||
}
|
||||
|
||||
fun setIsTeleported(teleported: Boolean) {
|
||||
_isTeleported.value = teleported
|
||||
}
|
||||
|
||||
fun setGeohashPeople(people: List<GeoPerson>) {
|
||||
_geohashPeople.value = people
|
||||
}
|
||||
|
||||
fun setTeleportedGeo(teleported: Set<String>) {
|
||||
_teleportedGeo.value = teleported
|
||||
}
|
||||
|
||||
fun setGeohashParticipantCounts(counts: Map<String, Int>) {
|
||||
_geohashParticipantCounts.value = counts
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@ fun getRSSIColor(rssi: Int): Color {
|
||||
}
|
||||
|
||||
/**
|
||||
* Format message as annotated string with proper styling
|
||||
* Format message as annotated string with iOS-style formatting
|
||||
* Timestamp at END, peer colors, hashtag suffix handling
|
||||
*/
|
||||
fun formatMessageAsAnnotatedString(
|
||||
message: BitchatMessage,
|
||||
@@ -41,112 +42,294 @@ fun formatMessageAsAnnotatedString(
|
||||
timeFormatter: SimpleDateFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||
): AnnotatedString {
|
||||
val builder = AnnotatedString.Builder()
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
|
||||
// Timestamp
|
||||
val timestampColor = if (message.sender == "system") Color.Gray else colorScheme.primary.copy(alpha = 0.7f)
|
||||
// Determine if this message was sent by self
|
||||
val isSelf = message.senderPeerID == meshService.myPeerID ||
|
||||
message.sender == currentUserNickname ||
|
||||
message.sender.startsWith("$currentUserNickname#")
|
||||
|
||||
if (message.sender != "system") {
|
||||
// Get base color for this peer (iOS-style color assignment)
|
||||
val baseColor = if (isSelf) {
|
||||
Color(0xFFFF9500) // Orange for self (iOS orange)
|
||||
} else {
|
||||
getPeerColor(message, isDark)
|
||||
}
|
||||
|
||||
// Split sender into base name and hashtag suffix
|
||||
val (baseName, suffix) = splitSuffix(message.sender)
|
||||
|
||||
// Sender prefix "<@"
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = timestampColor,
|
||||
fontSize = 12.sp
|
||||
color = baseColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
builder.append("<@")
|
||||
builder.pop()
|
||||
|
||||
// Base name
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
builder.append(baseName)
|
||||
builder.pop()
|
||||
|
||||
// Hashtag suffix in lighter color (iOS style)
|
||||
if (suffix.isNotEmpty()) {
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor.copy(alpha = 0.6f),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
builder.append(suffix)
|
||||
builder.pop()
|
||||
}
|
||||
|
||||
// Sender suffix "> "
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
|
||||
))
|
||||
builder.append("> ")
|
||||
builder.pop()
|
||||
|
||||
// Message content with iOS-style hashtag and mention highlighting
|
||||
appendIOSFormattedContent(builder, message.content, message.mentions, currentUserNickname, baseColor, isSelf, isDark)
|
||||
|
||||
// iOS-style timestamp at the END (smaller, grey)
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color.Gray.copy(alpha = 0.7f),
|
||||
fontSize = 10.sp
|
||||
))
|
||||
builder.append(" [${timeFormatter.format(message.timestamp)}]")
|
||||
builder.pop()
|
||||
|
||||
if (message.sender != "system") {
|
||||
// Sender
|
||||
val senderColor = when {
|
||||
message.senderPeerID == meshService.myPeerID -> colorScheme.primary
|
||||
else -> {
|
||||
val peerID = message.senderPeerID
|
||||
val rssi = peerID?.let { meshService.getPeerRSSI()[it] } ?: -60
|
||||
getRSSIColor(rssi)
|
||||
}
|
||||
}
|
||||
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = senderColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
))
|
||||
builder.append("<@${message.sender}> ")
|
||||
builder.pop()
|
||||
|
||||
// Message content with mentions and hashtags highlighted
|
||||
appendFormattedContent(builder, message.content, message.mentions, currentUserNickname, colorScheme)
|
||||
|
||||
} else {
|
||||
// System message
|
||||
// System message - iOS style
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color.Gray,
|
||||
fontSize = 12.sp,
|
||||
fontStyle = FontStyle.Italic
|
||||
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
|
||||
))
|
||||
builder.append("* ${message.content} *")
|
||||
builder.pop()
|
||||
|
||||
// Timestamp for system messages too
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color.Gray.copy(alpha = 0.5f),
|
||||
fontSize = 10.sp
|
||||
))
|
||||
builder.append(" [${timeFormatter.format(message.timestamp)}]")
|
||||
builder.pop()
|
||||
}
|
||||
|
||||
return builder.toAnnotatedString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Append formatted content with hashtag and mention highlighting
|
||||
* iOS-style peer color assignment using djb2 hash algorithm
|
||||
* Avoids orange (~30°) reserved for self messages
|
||||
*/
|
||||
private fun appendFormattedContent(
|
||||
fun getPeerColor(message: BitchatMessage, isDark: Boolean): Color {
|
||||
// Create seed from peer identifier (prioritizing stable keys)
|
||||
val seed = when {
|
||||
message.senderPeerID?.startsWith("nostr:") == true || message.senderPeerID?.startsWith("nostr_") == true -> {
|
||||
// For Nostr peers, use the full key if available, otherwise the peer ID
|
||||
"nostr:${message.senderPeerID.lowercase()}"
|
||||
}
|
||||
message.senderPeerID?.length == 16 -> {
|
||||
// For ephemeral peer IDs, try to get stable Noise key, fallback to peer ID
|
||||
"noise:${message.senderPeerID.lowercase()}"
|
||||
}
|
||||
message.senderPeerID?.length == 64 -> {
|
||||
// This is already a stable Noise key
|
||||
"noise:${message.senderPeerID.lowercase()}"
|
||||
}
|
||||
else -> {
|
||||
// Fallback to sender name
|
||||
message.sender.lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
return colorForPeerSeed(seed, isDark)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate consistent peer color using djb2 hash (matches iOS algorithm exactly)
|
||||
*/
|
||||
fun colorForPeerSeed(seed: String, isDark: Boolean): Color {
|
||||
// djb2 hash algorithm (matches iOS implementation)
|
||||
var hash = 5381UL
|
||||
for (byte in seed.toByteArray()) {
|
||||
hash = ((hash shl 5) + hash) + byte.toUByte().toULong()
|
||||
}
|
||||
|
||||
var hue = (hash % 360UL).toDouble() / 360.0
|
||||
|
||||
// Avoid orange (~30°) reserved for self (matches iOS logic)
|
||||
val orange = 30.0 / 360.0
|
||||
if (kotlin.math.abs(hue - orange) < 0.05) {
|
||||
hue = (hue + 0.12) % 1.0
|
||||
}
|
||||
|
||||
val saturation = if (isDark) 0.50 else 0.70
|
||||
val brightness = if (isDark) 0.95 else 0.45
|
||||
|
||||
return Color.hsv(
|
||||
hue = (hue * 360).toFloat(),
|
||||
saturation = saturation.toFloat(),
|
||||
value = brightness.toFloat()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a name into base and a '#abcd' suffix if present (matches iOS splitSuffix exactly)
|
||||
*/
|
||||
fun splitSuffix(name: String): Pair<String, String> {
|
||||
if (name.length < 5) return Pair(name, "")
|
||||
|
||||
val suffix = name.takeLast(5)
|
||||
if (suffix.startsWith("#") && suffix.drop(1).all {
|
||||
it.isDigit() || it.lowercaseChar() in 'a'..'f'
|
||||
}) {
|
||||
val base = name.dropLast(5)
|
||||
return Pair(base, suffix)
|
||||
}
|
||||
|
||||
return Pair(name, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS-style content formatting with proper hashtag and mention handling
|
||||
*/
|
||||
private fun appendIOSFormattedContent(
|
||||
builder: AnnotatedString.Builder,
|
||||
content: String,
|
||||
mentions: List<String>?,
|
||||
currentUserNickname: String,
|
||||
colorScheme: ColorScheme
|
||||
baseColor: Color,
|
||||
isSelf: Boolean,
|
||||
isDark: Boolean
|
||||
) {
|
||||
val isMentioned = mentions?.contains(currentUserNickname) == true
|
||||
|
||||
// Parse hashtags and mentions
|
||||
// iOS-style patterns: allow optional '#abcd' suffix in mentions
|
||||
val hashtagPattern = "#([a-zA-Z0-9_]+)".toRegex()
|
||||
val mentionPattern = "@([a-zA-Z0-9_]+)".toRegex()
|
||||
val mentionPattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)".toRegex()
|
||||
|
||||
val hashtagMatches = hashtagPattern.findAll(content).toList()
|
||||
val mentionMatches = mentionPattern.findAll(content).toList()
|
||||
|
||||
// Combine and sort all matches
|
||||
val allMatches = (hashtagMatches.map { it.range to "hashtag" } +
|
||||
mentionMatches.map { it.range to "mention" })
|
||||
.sortedBy { it.first.first }
|
||||
// Combine and sort matches, but exclude hashtags that overlap with mentions
|
||||
val mentionRanges = mentionMatches.map { it.range }
|
||||
fun overlapsMention(range: IntRange): Boolean {
|
||||
return mentionRanges.any { mentionRange ->
|
||||
range.first < mentionRange.last && range.last > mentionRange.first
|
||||
}
|
||||
}
|
||||
|
||||
val allMatches = mutableListOf<Pair<IntRange, String>>()
|
||||
|
||||
// Add hashtag matches that don't overlap with mentions
|
||||
for (match in hashtagMatches) {
|
||||
if (!overlapsMention(match.range)) {
|
||||
allMatches.add(match.range to "hashtag")
|
||||
}
|
||||
}
|
||||
|
||||
// Add all mention matches
|
||||
for (match in mentionMatches) {
|
||||
allMatches.add(match.range to "mention")
|
||||
}
|
||||
|
||||
allMatches.sortBy { it.first.first }
|
||||
|
||||
var lastEnd = 0
|
||||
val isMentioned = mentions?.contains(currentUserNickname) == true
|
||||
|
||||
for ((range, type) in allMatches) {
|
||||
// Add text before the match
|
||||
// Add text before match
|
||||
if (lastEnd < range.first) {
|
||||
val beforeText = content.substring(lastEnd, range.first)
|
||||
if (beforeText.isNotEmpty()) {
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = colorScheme.primary,
|
||||
color = baseColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isMentioned) FontWeight.Bold else FontWeight.Normal
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
|
||||
))
|
||||
if (isMentioned) {
|
||||
// Make entire message bold if user is mentioned
|
||||
builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
|
||||
builder.append(beforeText)
|
||||
builder.pop()
|
||||
} else {
|
||||
builder.append(beforeText)
|
||||
}
|
||||
builder.pop()
|
||||
}
|
||||
}
|
||||
|
||||
// Add the styled match
|
||||
// Add styled match
|
||||
val matchText = content.substring(range.first, range.last + 1)
|
||||
when (type) {
|
||||
"hashtag" -> {
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color(0xFF0080FF), // Blue
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline
|
||||
))
|
||||
}
|
||||
"mention" -> {
|
||||
// iOS-style mention with hashtag suffix support
|
||||
val mentionWithoutAt = matchText.removePrefix("@")
|
||||
val (mBase, mSuffix) = splitSuffix(mentionWithoutAt)
|
||||
|
||||
// Check if this mention targets current user
|
||||
val isMentionToMe = mBase == currentUserNickname
|
||||
val mentionColor = if (isMentionToMe) Color(0xFFFF9500) else baseColor
|
||||
|
||||
// "@" symbol
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = Color(0xFFFF9500), // Orange
|
||||
color = mentionColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold
|
||||
))
|
||||
builder.append("@")
|
||||
builder.pop()
|
||||
|
||||
// Base name
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = mentionColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold
|
||||
))
|
||||
builder.append(mBase)
|
||||
builder.pop()
|
||||
|
||||
// Hashtag suffix in lighter color
|
||||
if (mSuffix.isNotEmpty()) {
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = mentionColor.copy(alpha = 0.6f),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold
|
||||
))
|
||||
builder.append(mSuffix)
|
||||
builder.pop()
|
||||
}
|
||||
}
|
||||
"hashtag" -> {
|
||||
// iOS-style: render hashtags like normal content (no special styling)
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = baseColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
|
||||
))
|
||||
if (isMentioned) {
|
||||
builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
|
||||
builder.append(matchText)
|
||||
builder.pop()
|
||||
} else {
|
||||
builder.append(matchText)
|
||||
}
|
||||
builder.pop()
|
||||
}
|
||||
}
|
||||
|
||||
lastEnd = range.last + 1
|
||||
}
|
||||
@@ -155,11 +338,17 @@ private fun appendFormattedContent(
|
||||
if (lastEnd < content.length) {
|
||||
val remainingText = content.substring(lastEnd)
|
||||
builder.pushStyle(SpanStyle(
|
||||
color = colorScheme.primary,
|
||||
color = baseColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isMentioned) FontWeight.Bold else FontWeight.Normal
|
||||
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Normal
|
||||
))
|
||||
if (isMentioned) {
|
||||
builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
|
||||
builder.append(remainingText)
|
||||
builder.pop()
|
||||
} else {
|
||||
builder.append(remainingText)
|
||||
}
|
||||
builder.pop()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import com.bitchat.android.mesh.BluetoothMeshDelegate
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.nostr.NostrGeohashService
|
||||
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.delay
|
||||
import java.util.*
|
||||
@@ -46,7 +48,6 @@ class ChatViewModel(
|
||||
val privateChatManager = PrivateChatManager(state, messageManager, dataManager, noiseSessionDelegate)
|
||||
private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager)
|
||||
private val notificationManager = NotificationManager(application.applicationContext)
|
||||
|
||||
// Delegate handler for mesh callbacks
|
||||
private val meshDelegateHandler = MeshDelegateHandler(
|
||||
state = state,
|
||||
@@ -60,6 +61,16 @@ class ChatViewModel(
|
||||
getMeshService = { meshService }
|
||||
)
|
||||
|
||||
// Nostr and Geohash service - initialize singleton
|
||||
private val nostrGeohashService = NostrGeohashService.initialize(
|
||||
application = application,
|
||||
state = state,
|
||||
messageManager = messageManager,
|
||||
privateChatManager = privateChatManager,
|
||||
meshDelegateHandler = meshDelegateHandler,
|
||||
coroutineScope = viewModelScope
|
||||
)
|
||||
|
||||
// Expose state through LiveData (maintaining the same interface)
|
||||
val messages: LiveData<List<BitchatMessage>> = state.messages
|
||||
val connectedPeers: LiveData<List<String>> = state.connectedPeers
|
||||
@@ -88,6 +99,11 @@ class ChatViewModel(
|
||||
val peerNicknames: LiveData<Map<String, String>> = state.peerNicknames
|
||||
val peerRSSI: LiveData<Map<String, Int>> = state.peerRSSI
|
||||
val showAppInfo: LiveData<Boolean> = state.showAppInfo
|
||||
val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
|
||||
val isTeleported: LiveData<Boolean> = state.isTeleported
|
||||
val geohashPeople: LiveData<List<GeoPerson>> = state.geohashPeople
|
||||
val teleportedGeo: LiveData<Set<String>> = state.teleportedGeo
|
||||
val geohashParticipantCounts: LiveData<Map<String, Int>> = state.geohashParticipantCounts
|
||||
|
||||
init {
|
||||
// Note: Mesh service delegate is now set by MainActivity
|
||||
@@ -125,6 +141,15 @@ class ChatViewModel(
|
||||
// Initialize session state monitoring
|
||||
initializeSessionStateMonitoring()
|
||||
|
||||
// Initialize location channel state
|
||||
nostrGeohashService.initializeLocationChannelState()
|
||||
|
||||
// Initialize favorites persistence service
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication())
|
||||
|
||||
// Initialize Nostr integration
|
||||
nostrGeohashService.initializeNostrIntegration()
|
||||
|
||||
// Note: Mesh service is now started by MainActivity
|
||||
|
||||
// Show welcome message if no peers after delay
|
||||
@@ -195,21 +220,15 @@ class ChatViewModel(
|
||||
|
||||
// Check for commands
|
||||
if (content.startsWith("/")) {
|
||||
commandProcessor.processCommand(content, meshService, meshService.myPeerID) { messageContent, mentions, channel ->
|
||||
commandProcessor.processCommand(content, meshService, meshService.myPeerID, { messageContent, mentions, channel ->
|
||||
meshService.sendMessage(messageContent, mentions, channel)
|
||||
}
|
||||
}, this)
|
||||
return
|
||||
}
|
||||
|
||||
val mentions = messageManager.parseMentions(content, meshService.getPeerNicknames().values.toSet(), state.getNicknameValue())
|
||||
val channels = messageManager.parseChannels(content)
|
||||
|
||||
// Auto-join mentioned channels
|
||||
channels.forEach { channel ->
|
||||
if (!state.getJoinedChannelsValue().contains(channel)) {
|
||||
joinChannel(channel)
|
||||
}
|
||||
}
|
||||
// REMOVED: Auto-join mentioned channels feature that was incorrectly parsing hashtags from @mentions
|
||||
// This was causing messages like "test @jack#1234 test" to auto-join channel "#1234"
|
||||
|
||||
val selectedPeer = state.getSelectedPrivateChatPeerValue()
|
||||
val currentChannelValue = state.getCurrentChannelValue()
|
||||
@@ -227,7 +246,13 @@ class ChatViewModel(
|
||||
meshService.sendPrivateMessage(messageContent, peerID, recipientNicknameParam, messageId)
|
||||
}
|
||||
} else {
|
||||
// Send public/channel message
|
||||
// Check if we're in a location channel
|
||||
val selectedLocationChannel = state.selectedLocationChannel.value
|
||||
if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) {
|
||||
// Send to geohash channel via Nostr ephemeral event
|
||||
nostrGeohashService.sendGeohashMessage(content, selectedLocationChannel.channel, meshService.myPeerID, state.getNicknameValue())
|
||||
} else {
|
||||
// Send public/channel message via mesh
|
||||
val message = BitchatMessage(
|
||||
sender = state.getNicknameValue() ?: meshService.myPeerID,
|
||||
content = content,
|
||||
@@ -266,6 +291,9 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// MARK: - Utility Functions
|
||||
|
||||
@@ -323,6 +351,22 @@ class ChatViewModel(
|
||||
state.setPeerRSSI(rssiValues)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// MARK: - Debug and Troubleshooting
|
||||
|
||||
fun getDebugStatus(): String {
|
||||
@@ -360,7 +404,7 @@ class ChatViewModel(
|
||||
// MARK: - Mention Autocomplete
|
||||
|
||||
fun updateMentionSuggestions(input: String) {
|
||||
commandProcessor.updateMentionSuggestions(input, meshService)
|
||||
commandProcessor.updateMentionSuggestions(input, meshService, this)
|
||||
}
|
||||
|
||||
fun selectMentionSuggestion(nickname: String, currentText: String): String {
|
||||
@@ -423,6 +467,9 @@ class ChatViewModel(
|
||||
// Clear all notifications
|
||||
notificationManager.clearAllNotifications()
|
||||
|
||||
// Clear geohash message history
|
||||
nostrGeohashService.clearGeohashMessageHistory()
|
||||
|
||||
// Reset nickname
|
||||
val newNickname = "anon${Random.nextInt(1000, 9999)}"
|
||||
state.setNickname(newNickname)
|
||||
@@ -471,6 +518,79 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Get participant count for a specific geohash (5-minute activity window)
|
||||
*/
|
||||
fun geohashParticipantCount(geohash: String): Int {
|
||||
return nostrGeohashService.geohashParticipantCount(geohash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin sampling multiple geohashes for participant activity
|
||||
*/
|
||||
fun beginGeohashSampling(geohashes: List<String>) {
|
||||
nostrGeohashService.beginGeohashSampling(geohashes)
|
||||
}
|
||||
|
||||
/**
|
||||
* End geohash sampling
|
||||
*/
|
||||
fun endGeohashSampling() {
|
||||
nostrGeohashService.endGeohashSampling()
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Check if a geohash person is teleported (iOS-compatible)
|
||||
*/
|
||||
fun isPersonTeleported(pubkeyHex: String): Boolean {
|
||||
return nostrGeohashService.isPersonTeleported(pubkeyHex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start geohash DM with pubkey hex (iOS-compatible)
|
||||
*/
|
||||
fun startGeohashDM(pubkeyHex: String) {
|
||||
nostrGeohashService.startGeohashDM(pubkeyHex) { convKey ->
|
||||
startPrivateChat(convKey)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) {
|
||||
nostrGeohashService.selectLocationChannel(channel)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// MARK: - Navigation Management
|
||||
|
||||
fun showAppInfo() {
|
||||
@@ -525,4 +645,24 @@ class ChatViewModel(
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iOS-Compatible Color System
|
||||
|
||||
/**
|
||||
* Get consistent color for a mesh peer by ID (iOS-compatible)
|
||||
*/
|
||||
fun colorForMeshPeer(peerID: String, isDark: Boolean): androidx.compose.ui.graphics.Color {
|
||||
// Try to get stable Noise key, fallback to peer ID
|
||||
val seed = "noise:${peerID.lowercase()}"
|
||||
return colorForPeerSeed(seed, isDark).copy()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get consistent color for a Nostr pubkey (iOS-compatible)
|
||||
*/
|
||||
fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color {
|
||||
return nostrGeohashService.colorForNostrPubkey(pubkeyHex, isDark)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ class CommandProcessor(
|
||||
|
||||
// MARK: - Command Processing
|
||||
|
||||
fun processCommand(command: String, meshService: BluetoothMeshService, myPeerID: String, onSendMessage: (String, List<String>, String?) -> Unit): Boolean {
|
||||
fun processCommand(command: String, meshService: BluetoothMeshService, myPeerID: String, onSendMessage: (String, List<String>, String?) -> Unit, viewModel: ChatViewModel? = null): Boolean {
|
||||
if (!command.startsWith("/")) return false
|
||||
|
||||
val parts = command.split(" ")
|
||||
@@ -38,7 +38,7 @@ class CommandProcessor(
|
||||
when (cmd) {
|
||||
"/j", "/join" -> handleJoinCommand(parts, myPeerID)
|
||||
"/m", "/msg" -> handleMessageCommand(parts, meshService)
|
||||
"/w" -> handleWhoCommand(meshService)
|
||||
"/w" -> handleWhoCommand(meshService, viewModel)
|
||||
"/clear" -> handleClearCommand()
|
||||
"/pass" -> handlePassCommand(parts, myPeerID)
|
||||
"/block" -> handleBlockCommand(parts, meshService)
|
||||
@@ -130,19 +130,53 @@ class CommandProcessor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleWhoCommand(meshService: BluetoothMeshService) {
|
||||
private fun handleWhoCommand(meshService: BluetoothMeshService, viewModel: ChatViewModel? = null) {
|
||||
// Channel-aware who command (matches iOS behavior)
|
||||
val (peerList, contextDescription) = if (viewModel != null) {
|
||||
when (val selectedChannel = viewModel.selectedLocationChannel.value) {
|
||||
is com.bitchat.android.geohash.ChannelID.Mesh,
|
||||
null -> {
|
||||
// Mesh channel: show Bluetooth-connected peers
|
||||
val connectedPeers = state.getConnectedPeersValue()
|
||||
val peerList = connectedPeers.joinToString(", ") { peerID ->
|
||||
// Convert peerID to nickname using the mesh service
|
||||
getPeerNickname(peerID, meshService)
|
||||
}
|
||||
Pair(peerList, "online users")
|
||||
}
|
||||
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> {
|
||||
// Location channel: show geohash participants
|
||||
val geohashPeople = viewModel.geohashPeople.value ?: emptyList()
|
||||
val currentNickname = state.getNicknameValue()
|
||||
|
||||
val participantList = geohashPeople.mapNotNull { person ->
|
||||
val displayName = person.displayName
|
||||
// Exclude self from list
|
||||
if (displayName.startsWith("${currentNickname}#")) {
|
||||
null
|
||||
} else {
|
||||
displayName
|
||||
}
|
||||
}.joinToString(", ")
|
||||
|
||||
Pair(participantList, "participants in ${selectedChannel.channel.geohash}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback to mesh behavior
|
||||
val connectedPeers = state.getConnectedPeersValue()
|
||||
val peerList = connectedPeers.joinToString(", ") { peerID ->
|
||||
getPeerNickname(peerID, meshService)
|
||||
}
|
||||
Pair(peerList, "online users")
|
||||
}
|
||||
|
||||
val systemMessage = BitchatMessage(
|
||||
sender = "system",
|
||||
content = if (connectedPeers.isEmpty()) {
|
||||
"no one else is online right now."
|
||||
content = if (peerList.isEmpty()) {
|
||||
"no one else is around right now."
|
||||
} else {
|
||||
"online users: $peerList"
|
||||
"$contextDescription: $peerList"
|
||||
},
|
||||
timestamp = Date(),
|
||||
isRelay = false
|
||||
@@ -383,7 +417,7 @@ class CommandProcessor(
|
||||
|
||||
// MARK: - Mention Autocomplete
|
||||
|
||||
fun updateMentionSuggestions(input: String, meshService: BluetoothMeshService) {
|
||||
fun updateMentionSuggestions(input: String, meshService: BluetoothMeshService, viewModel: ChatViewModel? = null) {
|
||||
// Check if input contains @ and we're at the end of a word or at the end of input
|
||||
val atIndex = input.lastIndexOf('@')
|
||||
if (atIndex == -1) {
|
||||
@@ -402,11 +436,38 @@ class CommandProcessor(
|
||||
return
|
||||
}
|
||||
|
||||
// Get all connected peer nicknames - now using direct access instead of reflection
|
||||
val peerNicknames = meshService.getPeerNicknames().values.toList()
|
||||
// Get peer candidates based on active channel (matches iOS logic exactly)
|
||||
val peerCandidates: List<String> = if (viewModel != null) {
|
||||
when (val selectedChannel = viewModel.selectedLocationChannel.value) {
|
||||
is com.bitchat.android.geohash.ChannelID.Mesh,
|
||||
null -> {
|
||||
// Mesh channel: use Bluetooth mesh peer nicknames
|
||||
meshService.getPeerNicknames().values.filter { it != meshService.getPeerNicknames()[meshService.myPeerID] }
|
||||
}
|
||||
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> {
|
||||
// Location channel: use geohash participants with collision-resistant suffixes
|
||||
val geohashPeople = viewModel.geohashPeople.value ?: emptyList()
|
||||
val currentNickname = state.getNicknameValue()
|
||||
|
||||
geohashPeople.mapNotNull { person ->
|
||||
val displayName = person.displayName
|
||||
// Exclude self from suggestions
|
||||
if (displayName.startsWith("${currentNickname}#")) {
|
||||
null
|
||||
} else {
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback to mesh peers if no viewModel available
|
||||
meshService.getPeerNicknames().values.filter { it != meshService.getPeerNicknames()[meshService.myPeerID] }
|
||||
}
|
||||
|
||||
// Filter nicknames based on the text after @
|
||||
val filteredNicknames = peerNicknames.filter { nickname ->
|
||||
val filteredNicknames = peerCandidates.filter { nickname ->
|
||||
nickname.startsWith(textAfterAt, ignoreCase = true)
|
||||
}.sorted()
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* GeohashPeopleList - iOS-compatible component for displaying geohash participants
|
||||
* Shows peers discovered through Nostr ephemeral events instead of Bluetooth peers
|
||||
*/
|
||||
|
||||
/**
|
||||
* GeoPerson data class - matches iOS GeoPerson structure exactly
|
||||
*/
|
||||
data class GeoPerson(
|
||||
val id: String, // pubkey hex (lowercased) - matches iOS
|
||||
val displayName: String, // nickname with #suffix - matches iOS
|
||||
val lastSeen: Date // activity timestamp - matches iOS
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun GeohashPeopleList(
|
||||
viewModel: ChatViewModel,
|
||||
onTapPerson: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
// Observe geohash people from ChatViewModel
|
||||
val geohashPeople by viewModel.geohashPeople.observeAsState(emptyList())
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
|
||||
val isTeleported by viewModel.isTeleported.observeAsState(false)
|
||||
val nickname by viewModel.nickname.observeAsState("")
|
||||
val unreadPrivateMessages by viewModel.unreadPrivateMessages.observeAsState(emptySet())
|
||||
|
||||
Column {
|
||||
// Header matching iOS style
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.LocationOn,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "PEOPLE",
|
||||
style = MaterialTheme.typography.labelSmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
|
||||
if (geohashPeople.isEmpty()) {
|
||||
// Empty state - matches iOS "nobody around..."
|
||||
Text(
|
||||
text = "nobody around...",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp
|
||||
),
|
||||
color = colorScheme.onSurface.copy(alpha = 0.5f),
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
|
||||
)
|
||||
} else {
|
||||
// Get current geohash identity for "me" detection
|
||||
val myHex = remember(selectedLocationChannel) {
|
||||
when (val channel = selectedLocationChannel) {
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> {
|
||||
try {
|
||||
val identity = com.bitchat.android.nostr.NostrIdentityBridge.deriveIdentity(
|
||||
forGeohash = channel.channel.geohash,
|
||||
context = viewModel.getApplication()
|
||||
)
|
||||
identity.publicKeyHex.lowercase()
|
||||
} catch (e: Exception) {
|
||||
Log.e("GeohashPeopleList", "Failed to derive identity: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
// Sort people: me first, then by lastSeen (matches iOS exactly)
|
||||
val orderedPeople = remember(geohashPeople, myHex) {
|
||||
geohashPeople.sortedWith { a, b ->
|
||||
when {
|
||||
myHex != null && a.id == myHex && b.id != myHex -> -1
|
||||
myHex != null && b.id == myHex && a.id != myHex -> 1
|
||||
else -> b.lastSeen.compareTo(a.lastSeen) // Most recent first
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val firstID = orderedPeople.firstOrNull()?.id
|
||||
|
||||
orderedPeople.forEach { person ->
|
||||
GeohashPersonItem(
|
||||
person = person,
|
||||
isFirst = person.id == firstID,
|
||||
isMe = myHex != null && person.id == myHex,
|
||||
hasUnreadDM = unreadPrivateMessages.contains("nostr_${person.id.take(16)}"),
|
||||
isTeleported = person.id != myHex && viewModel.isPersonTeleported(person.id),
|
||||
isMyTeleported = person.id == myHex && isTeleported,
|
||||
nickname = nickname,
|
||||
colorScheme = colorScheme,
|
||||
viewModel = viewModel,
|
||||
onTap = {
|
||||
if (person.id != myHex) {
|
||||
// TODO: Re-enable when NIP-17 geohash DM issues are fixed
|
||||
// Start geohash DM (iOS-compatible)
|
||||
// viewModel.startGeohashDM(person.id)
|
||||
onTapPerson()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GeohashPersonItem(
|
||||
person: GeoPerson,
|
||||
isFirst: Boolean,
|
||||
isMe: Boolean,
|
||||
hasUnreadDM: Boolean,
|
||||
isTeleported: Boolean,
|
||||
isMyTeleported: Boolean,
|
||||
nickname: String,
|
||||
colorScheme: ColorScheme,
|
||||
viewModel: ChatViewModel,
|
||||
onTap: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onTap() }
|
||||
.padding(horizontal = 24.dp, vertical = 4.dp)
|
||||
.padding(top = if (isFirst) 10.dp else 0.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Icon logic matching iOS exactly
|
||||
if (hasUnreadDM) {
|
||||
// Unread DM indicator (orange envelope)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Email,
|
||||
contentDescription = "Unread message",
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = Color(0xFFFF9500) // iOS orange
|
||||
)
|
||||
} else {
|
||||
// Face icon with teleportation state
|
||||
val (iconName, iconColor) = when {
|
||||
isMe && isMyTeleported -> "face.dashed" to Color(0xFFFF9500) // Orange for teleported me
|
||||
isTeleported -> "face.dashed" to colorScheme.onSurface // Regular color for teleported others
|
||||
isMe -> "face.smiling" to Color(0xFFFF9500) // Orange for me
|
||||
else -> "face.smiling" to colorScheme.onSurface // Regular color for others
|
||||
}
|
||||
|
||||
// Use appropriate Material icon (closest match to iOS SF Symbols)
|
||||
val icon = when (iconName) {
|
||||
"face.dashed" -> Icons.Default.Face // Use regular face icon (no dashed variant in Material)
|
||||
else -> Icons.Default.Face
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = if (isTeleported || isMyTeleported) "Teleported user" else "User",
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = iconColor.copy(alpha = if (iconName == "face.dashed") 0.6f else 1.0f) // Make dashed faces slightly transparent
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// Display name with suffix handling (matches iOS splitSuffix logic)
|
||||
val (baseName, suffix) = com.bitchat.android.ui.splitSuffix(person.displayName)
|
||||
|
||||
// Get consistent peer color (matches iOS color assignment exactly)
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
val assignedColor = viewModel.colorForNostrPubkey(person.id, isDark)
|
||||
val baseColor = if (isMe) Color(0xFFFF9500) else assignedColor
|
||||
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Base name with peer-specific color
|
||||
Text(
|
||||
text = baseName,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Normal
|
||||
),
|
||||
color = baseColor
|
||||
)
|
||||
|
||||
// Suffix (collision-resistant #abcd) in lighter shade
|
||||
if (suffix.isNotEmpty()) {
|
||||
Text(
|
||||
text = suffix,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp
|
||||
),
|
||||
color = baseColor.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
|
||||
// "You" indicator for current user
|
||||
if (isMe) {
|
||||
Text(
|
||||
text = " (you)",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp
|
||||
),
|
||||
color = baseColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.geohash.ChannelID
|
||||
import com.bitchat.android.geohash.GeohashChannel
|
||||
import com.bitchat.android.geohash.GeohashChannelLevel
|
||||
import com.bitchat.android.geohash.LocationChannelManager
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Location Channels Sheet for selecting geohash-based location channels
|
||||
* Direct port from iOS LocationChannelsSheet for 100% compatibility
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LocationChannelsSheet(
|
||||
isPresented: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
viewModel: ChatViewModel,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val locationManager = LocationChannelManager.getInstance(context)
|
||||
|
||||
// Observe location manager state
|
||||
val permissionState by locationManager.permissionState.observeAsState()
|
||||
val availableChannels by locationManager.availableChannels.observeAsState(emptyList())
|
||||
val selectedChannel by locationManager.selectedChannel.observeAsState()
|
||||
val teleported by locationManager.teleported.observeAsState(false)
|
||||
val locationNames by locationManager.locationNames.observeAsState(emptyMap())
|
||||
|
||||
// CRITICAL FIX: Observe reactive participant counts for real-time updates
|
||||
val geohashParticipantCounts by viewModel.geohashParticipantCounts.observeAsState(emptyMap())
|
||||
|
||||
// UI state
|
||||
var customGeohash by remember { mutableStateOf("") }
|
||||
var customError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// iOS system colors (matches iOS exactly)
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) // iOS green
|
||||
val standardBlue = Color(0xFF007AFF) // iOS blue
|
||||
|
||||
if (isPresented) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
modifier = modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
// Header
|
||||
Text(
|
||||
text = "#location channels",
|
||||
fontSize = 18.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps.",
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
|
||||
// Permission handling
|
||||
when (permissionState) {
|
||||
LocationChannelManager.PermissionState.NOT_DETERMINED -> {
|
||||
Button(
|
||||
onClick = { locationManager.enableLocationChannels() },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = standardGreen.copy(alpha = 0.12f),
|
||||
contentColor = standardGreen
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "get location and my geohashes",
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LocationChannelManager.PermissionState.DENIED,
|
||||
LocationChannelManager.PermissionState.RESTRICTED -> {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "location permission denied. enable in settings to use location channels.",
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
|
||||
TextButton(
|
||||
onClick = {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", context.packageName, null)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = "open settings",
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LocationChannelManager.PermissionState.AUTHORIZED -> {
|
||||
// Authorized - show channels below
|
||||
}
|
||||
|
||||
null -> {
|
||||
// Loading state
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
// Channel list (iOS-style plain list)
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
// Mesh option first
|
||||
item {
|
||||
ChannelRow(
|
||||
title = meshTitleWithCount(viewModel),
|
||||
subtitle = "#bluetooth • ${bluetoothRangeString()}",
|
||||
isSelected = selectedChannel is ChannelID.Mesh,
|
||||
titleColor = standardBlue,
|
||||
titleBold = meshCount(viewModel) > 0,
|
||||
onClick = {
|
||||
locationManager.select(ChannelID.Mesh)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Nearby options
|
||||
if (availableChannels.isNotEmpty()) {
|
||||
items(availableChannels) { channel ->
|
||||
val coverage = coverageString(channel.geohash.length)
|
||||
val nameBase = locationNames[channel.level]
|
||||
val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it }
|
||||
val subtitlePrefix = "#${channel.geohash} • $coverage"
|
||||
// CRITICAL FIX: Use reactive participant count from LiveData
|
||||
val participantCount = geohashParticipantCounts[channel.geohash] ?: 0
|
||||
val highlight = participantCount > 0
|
||||
|
||||
ChannelRow(
|
||||
title = geohashTitleWithCount(channel, participantCount),
|
||||
subtitle = subtitlePrefix + (namePart?.let { " • $it" } ?: ""),
|
||||
isSelected = isChannelSelected(channel, selectedChannel),
|
||||
titleColor = standardGreen,
|
||||
titleBold = highlight,
|
||||
onClick = {
|
||||
// Selecting a suggested nearby channel is not a teleport
|
||||
locationManager.setTeleported(false)
|
||||
locationManager.select(ChannelID.Location(channel))
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
} else if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED) {
|
||||
item {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp))
|
||||
Text(
|
||||
text = "finding nearby channels…",
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom geohash teleport (iOS-style inline form)
|
||||
item {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
color = Color.Transparent
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(1.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "#",
|
||||
fontSize = 14.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
|
||||
BasicTextField(
|
||||
value = customGeohash,
|
||||
onValueChange = { newValue ->
|
||||
// iOS-style geohash validation (base32 characters only)
|
||||
val allowed = "0123456789bcdefghjkmnpqrstuvwxyz".toSet()
|
||||
val filtered = newValue
|
||||
.lowercase()
|
||||
.replace("#", "")
|
||||
.filter { it in allowed }
|
||||
.take(12)
|
||||
|
||||
customGeohash = filtered
|
||||
customError = null
|
||||
},
|
||||
textStyle = androidx.compose.ui.text.TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
decorationBox = { innerTextField ->
|
||||
if (customGeohash.isEmpty()) {
|
||||
Text(
|
||||
text = "geohash",
|
||||
fontSize = 14.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
)
|
||||
|
||||
val normalized = customGeohash.trim().lowercase().replace("#", "")
|
||||
val isValid = validateGeohash(normalized)
|
||||
|
||||
// iOS-style teleport button
|
||||
Button(
|
||||
onClick = {
|
||||
if (isValid) {
|
||||
val level = levelForLength(normalized.length)
|
||||
val channel = GeohashChannel(level = level, geohash = normalized)
|
||||
// Mark this selection as a manual teleport
|
||||
locationManager.setTeleported(true)
|
||||
locationManager.select(ChannelID.Location(channel))
|
||||
onDismiss()
|
||||
} else {
|
||||
customError = "invalid geohash"
|
||||
}
|
||||
},
|
||||
enabled = isValid,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
|
||||
contentColor = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = "teleport",
|
||||
fontSize = 14.sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
// iOS has a face.dashed icon, use closest Material equivalent
|
||||
Text(
|
||||
text = "📍",
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customError?.let { error ->
|
||||
Text(
|
||||
text = error,
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Footer action - remove location access
|
||||
if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED) {
|
||||
item {
|
||||
Button(
|
||||
onClick = {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", context.packageName, null)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.Red.copy(alpha = 0.08f),
|
||||
contentColor = Color(0xFFBF1A1A)
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = "remove location access",
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle management
|
||||
LaunchedEffect(isPresented) {
|
||||
if (isPresented) {
|
||||
// Refresh channels when opening
|
||||
if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED) {
|
||||
locationManager.refreshChannels()
|
||||
}
|
||||
// Begin periodic refresh while sheet is open
|
||||
locationManager.beginLiveRefresh()
|
||||
|
||||
// Begin multi-channel sampling for counts
|
||||
val geohashes = availableChannels.map { it.geohash }
|
||||
viewModel.beginGeohashSampling(geohashes)
|
||||
} else {
|
||||
locationManager.endLiveRefresh()
|
||||
viewModel.endGeohashSampling()
|
||||
}
|
||||
}
|
||||
|
||||
// React to permission changes
|
||||
LaunchedEffect(permissionState) {
|
||||
if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED) {
|
||||
locationManager.refreshChannels()
|
||||
}
|
||||
}
|
||||
|
||||
// React to available channels changes
|
||||
LaunchedEffect(availableChannels) {
|
||||
val geohashes = availableChannels.map { it.geohash }
|
||||
viewModel.beginGeohashSampling(geohashes)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
isSelected: Boolean,
|
||||
titleColor: Color? = null,
|
||||
titleBold: Boolean = false,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
// iOS-style list row (plain button, no card background)
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
color = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
|
||||
} else {
|
||||
Color.Transparent
|
||||
},
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
// Split title to handle count part with smaller font (iOS style)
|
||||
val (baseTitle, countSuffix) = splitTitleAndCount(title)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = baseTitle,
|
||||
fontSize = 14.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = if (titleBold) FontWeight.Bold else FontWeight.Normal,
|
||||
color = titleColor ?: MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
countSuffix?.let { count ->
|
||||
Text(
|
||||
text = count,
|
||||
fontSize = 11.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = subtitle,
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
Text(
|
||||
text = "✔︎",
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = Color(0xFF32D74B) // iOS green for checkmark
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Functions (matching iOS implementation)
|
||||
|
||||
private fun splitTitleAndCount(title: String): Pair<String, String?> {
|
||||
val lastBracketIndex = title.lastIndexOf('[')
|
||||
return if (lastBracketIndex != -1) {
|
||||
val prefix = title.substring(0, lastBracketIndex).trim()
|
||||
val suffix = title.substring(lastBracketIndex)
|
||||
Pair(prefix, suffix)
|
||||
} else {
|
||||
Pair(title, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun meshTitleWithCount(viewModel: ChatViewModel): String {
|
||||
val meshCount = meshCount(viewModel)
|
||||
val noun = if (meshCount == 1) "person" else "people"
|
||||
return "mesh [$meshCount $noun]"
|
||||
}
|
||||
|
||||
private fun meshCount(viewModel: ChatViewModel): Int {
|
||||
val myID = viewModel.meshService.myPeerID
|
||||
return viewModel.connectedPeers.value?.count { peerID ->
|
||||
peerID != myID
|
||||
} ?: 0
|
||||
}
|
||||
|
||||
private fun geohashTitleWithCount(channel: GeohashChannel, participantCount: Int): String {
|
||||
val noun = if (participantCount == 1) "person" else "people"
|
||||
return "${channel.level.displayName.lowercase()} [$participantCount $noun]"
|
||||
}
|
||||
|
||||
private fun isChannelSelected(channel: GeohashChannel, selectedChannel: ChannelID?): Boolean {
|
||||
return when (selectedChannel) {
|
||||
is ChannelID.Location -> selectedChannel.channel == channel
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateGeohash(geohash: String): Boolean {
|
||||
if (geohash.isEmpty() || geohash.length > 12) return false
|
||||
val allowed = "0123456789bcdefghjkmnpqrstuvwxyz".toSet()
|
||||
return geohash.all { it in allowed }
|
||||
}
|
||||
|
||||
private fun levelForLength(length: Int): GeohashChannelLevel {
|
||||
return when (length) {
|
||||
in 0..2 -> GeohashChannelLevel.COUNTRY
|
||||
in 3..4 -> GeohashChannelLevel.REGION
|
||||
5 -> GeohashChannelLevel.CITY
|
||||
6 -> GeohashChannelLevel.NEIGHBORHOOD
|
||||
7 -> GeohashChannelLevel.BLOCK
|
||||
else -> GeohashChannelLevel.BLOCK
|
||||
}
|
||||
}
|
||||
|
||||
private fun coverageString(precision: Int): String {
|
||||
// Approximate max cell dimension at equator for a given geohash length
|
||||
val maxMeters = when (precision) {
|
||||
2 -> 1_250_000.0
|
||||
3 -> 156_000.0
|
||||
4 -> 39_100.0
|
||||
5 -> 4_890.0
|
||||
6 -> 1_220.0
|
||||
7 -> 153.0
|
||||
8 -> 38.2
|
||||
9 -> 4.77
|
||||
10 -> 1.19
|
||||
else -> if (precision <= 1) 5_000_000.0 else 1.19 * Math.pow(0.25, (precision - 10).toDouble())
|
||||
}
|
||||
|
||||
// Use metric system for simplicity (could be made locale-aware)
|
||||
val km = maxMeters / 1000.0
|
||||
return "~${formatDistance(km)} km"
|
||||
}
|
||||
|
||||
private fun formatDistance(value: Double): String {
|
||||
return when {
|
||||
value >= 100 -> String.format("%.0f", value)
|
||||
value >= 10 -> String.format("%.1f", value)
|
||||
else -> String.format("%.1f", value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bluetoothRangeString(): String {
|
||||
// Approximate Bluetooth LE range for typical mobile devices
|
||||
return "~10–50 m"
|
||||
}
|
||||
|
||||
private fun formattedNamePrefix(level: GeohashChannelLevel): String {
|
||||
return when (level) {
|
||||
GeohashChannelLevel.COUNTRY -> ""
|
||||
else -> "~"
|
||||
}
|
||||
}
|
||||
@@ -106,8 +106,20 @@ fun SidebarOverlay(
|
||||
}
|
||||
}
|
||||
|
||||
// People section
|
||||
// People section - switch between mesh and geohash lists (iOS-compatible)
|
||||
item {
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
|
||||
|
||||
when (selectedLocationChannel) {
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> {
|
||||
// Show geohash people list when in location channel
|
||||
GeohashPeopleList(
|
||||
viewModel = viewModel,
|
||||
onTapPerson = onDismiss
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
// Show mesh peer list when in mesh channel (default)
|
||||
PeopleSection(
|
||||
connectedPeers = connectedPeers,
|
||||
peerNicknames = peerNicknames,
|
||||
@@ -127,6 +139,8 @@ fun SidebarOverlay(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SidebarHeader() {
|
||||
@@ -307,6 +321,7 @@ fun PeopleSection(
|
||||
isFavorite = isFavorite,
|
||||
hasUnreadDM = hasUnreadPrivateMessages.contains(peerID),
|
||||
colorScheme = colorScheme,
|
||||
viewModel = viewModel,
|
||||
onItemClick = { onPrivateChatStart(peerID) },
|
||||
onToggleFavorite = {
|
||||
Log.d("SidebarComponents", "Sidebar toggle favorite: peerID=$peerID, currentFavorite=$isFavorite")
|
||||
@@ -331,10 +346,20 @@ private fun PeerItem(
|
||||
isFavorite: Boolean,
|
||||
hasUnreadDM: Boolean,
|
||||
colorScheme: ColorScheme,
|
||||
viewModel: ChatViewModel,
|
||||
onItemClick: () -> Unit,
|
||||
onToggleFavorite: () -> Unit,
|
||||
unreadCount: Int = 0
|
||||
) {
|
||||
// Split display name for hashtag suffix support (iOS-compatible)
|
||||
val (baseName, suffix) = com.bitchat.android.ui.splitSuffix(displayName)
|
||||
val isMe = displayName == "You" || peerID == viewModel.nickname.value
|
||||
|
||||
// Get consistent peer color (iOS-compatible)
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
val assignedColor = viewModel.colorForMeshPeer(peerID, isDark)
|
||||
val baseColor = if (isMe) Color(0xFFFF9500) else assignedColor
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -348,9 +373,12 @@ private fun PeerItem(
|
||||
) {
|
||||
// Show unread badge or signal strength
|
||||
if (hasUnreadDM) {
|
||||
UnreadBadge(
|
||||
count = unreadCount,
|
||||
colorScheme = colorScheme
|
||||
// Show mail icon for unread DMs (iOS orange)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Email,
|
||||
contentDescription = "Unread message",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color(0xFFFF9500) // iOS orange
|
||||
)
|
||||
} else {
|
||||
// Signal strength indicators
|
||||
@@ -362,14 +390,35 @@ private fun PeerItem(
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// Display name with iOS-style color and hashtag suffix support
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Base name with peer-specific color
|
||||
Text(
|
||||
text = displayName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isSelected) colorScheme.primary else colorScheme.onSurface,
|
||||
fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal,
|
||||
modifier = Modifier.weight(1f)
|
||||
text = baseName,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Normal
|
||||
),
|
||||
color = baseColor
|
||||
)
|
||||
|
||||
// Hashtag suffix in lighter shade (iOS-style)
|
||||
if (suffix.isNotEmpty()) {
|
||||
Text(
|
||||
text = suffix,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 14.sp
|
||||
),
|
||||
color = baseColor.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Favorite star with proper filled/outlined states
|
||||
IconButton(
|
||||
onClick = onToggleFavorite,
|
||||
@@ -379,12 +428,14 @@ private fun PeerItem(
|
||||
imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star,
|
||||
contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = if (isFavorite) Color(0xFFFFD700) else Color(0x87878700)
|
||||
tint = if (isFavorite) Color(0xFFFFD700) else Color(0xFF4CAF50)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Composable
|
||||
private fun SignalStrengthIndicator(
|
||||
signalStrength: Int,
|
||||
|
||||
@@ -34,7 +34,11 @@ kotlinx-coroutines = "1.10.2"
|
||||
# Bluetooth
|
||||
nordic-ble = "2.6.1"
|
||||
|
||||
# WebSocket
|
||||
okhttp = "4.12.0"
|
||||
|
||||
# Google Play Services
|
||||
gms-location = "21.3.0"
|
||||
|
||||
# Security
|
||||
security-crypto = "1.1.0-beta01"
|
||||
@@ -84,7 +88,11 @@ kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutine
|
||||
# Bluetooth
|
||||
nordic-ble = { module = "no.nordicsemi.android:ble", version.ref = "nordic-ble" }
|
||||
|
||||
# WebSocket
|
||||
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
||||
|
||||
# Google Play Services
|
||||
gms-location = { module = "com.google.android.gms:play-services-location", version.ref = "gms-location" }
|
||||
|
||||
# Security
|
||||
androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "security-crypto" }
|
||||
|
||||
Reference in New Issue
Block a user