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:
callebtc
2025-08-22 19:09:18 +02:00
committed by GitHub
parent 848cffee07
commit 7243d841a3
35 changed files with 7859 additions and 171 deletions
+6
View File
@@ -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)
+4
View File
@@ -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(
)
}
PeerCounter(
connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID },
joinedChannels = joinedChannels,
hasUnreadChannels = hasUnreadChannels,
hasUnreadPrivateMessages = hasUnreadPrivateMessages,
isConnected = isConnected,
onClick = onSidebarClick
)
// 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>) {
@@ -259,5 +280,25 @@ class ChatState {
fun setShowAppInfo(show: Boolean) {
_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)
builder.pushStyle(SpanStyle(
color = timestampColor,
fontSize = 12.sp
))
builder.append("[${timeFormatter.format(message.timestamp)}] ")
builder.pop()
// 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") {
// 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)
}
// 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 = senderColor,
color = baseColor,
fontSize = 14.sp,
fontWeight = FontWeight.Medium
fontWeight = if (isSelf) FontWeight.Bold else FontWeight.Medium
))
builder.append("<@${message.sender}> ")
builder.append("<@")
builder.pop()
// Message content with mentions and hashtags highlighted
appendFormattedContent(builder, message.content, message.mentions, currentUserNickname, colorScheme)
// 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()
} 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)
builder.pushStyle(SpanStyle(
color = colorScheme.primary,
fontSize = 14.sp,
fontWeight = if (isMentioned) FontWeight.Bold else FontWeight.Normal
))
builder.append(beforeText)
builder.pop()
if (beforeText.isNotEmpty()) {
builder.pushStyle(SpanStyle(
color = baseColor,
fontSize = 14.sp,
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()
}
}
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
))
builder.append(remainingText)
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,46 +246,55 @@ class ChatViewModel(
meshService.sendPrivateMessage(messageContent, peerID, recipientNicknameParam, messageId)
}
} else {
// Send public/channel message
val message = BitchatMessage(
sender = state.getNicknameValue() ?: meshService.myPeerID,
content = content,
timestamp = Date(),
isRelay = false,
senderPeerID = meshService.myPeerID,
mentions = if (mentions.isNotEmpty()) mentions else null,
channel = currentChannelValue
)
if (currentChannelValue != null) {
channelManager.addChannelMessage(currentChannelValue, message, meshService.myPeerID)
// Check if encrypted channel
if (channelManager.hasChannelKey(currentChannelValue)) {
channelManager.sendEncryptedChannelMessage(
content,
mentions,
currentChannelValue,
state.getNicknameValue(),
meshService.myPeerID,
onEncryptedPayload = { encryptedData ->
// This would need proper mesh service integration
meshService.sendMessage(content, mentions, currentChannelValue)
},
onFallback = {
meshService.sendMessage(content, mentions, currentChannelValue)
}
)
} else {
meshService.sendMessage(content, mentions, currentChannelValue)
}
// 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 {
messageManager.addMessage(message)
meshService.sendMessage(content, mentions, null)
// Send public/channel message via mesh
val message = BitchatMessage(
sender = state.getNicknameValue() ?: meshService.myPeerID,
content = content,
timestamp = Date(),
isRelay = false,
senderPeerID = meshService.myPeerID,
mentions = if (mentions.isNotEmpty()) mentions else null,
channel = currentChannelValue
)
if (currentChannelValue != null) {
channelManager.addChannelMessage(currentChannelValue, message, meshService.myPeerID)
// Check if encrypted channel
if (channelManager.hasChannelKey(currentChannelValue)) {
channelManager.sendEncryptedChannelMessage(
content,
mentions,
currentChannelValue,
state.getNicknameValue(),
meshService.myPeerID,
onEncryptedPayload = { encryptedData ->
// This would need proper mesh service integration
meshService.sendMessage(content, mentions, currentChannelValue)
},
onFallback = {
meshService.sendMessage(content, mentions, currentChannelValue)
}
)
} else {
meshService.sendMessage(content, mentions, currentChannelValue)
}
} else {
messageManager.addMessage(message)
meshService.sendMessage(content, mentions, null)
}
}
}
}
// MARK: - Utility Functions
fun getPeerIDForNickname(nickname: String): String? {
@@ -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) {
val connectedPeers = state.getConnectedPeersValue()
val peerList = connectedPeers.joinToString(", ") { peerID ->
// Convert peerID to nickname using the mesh service
getPeerNickname(peerID, meshService)
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 ->
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 "~1050 m"
}
private fun formattedNamePrefix(level: GeohashChannelLevel): String {
return when (level) {
GeohashChannelLevel.COUNTRY -> ""
else -> "~"
}
}
@@ -106,21 +106,35 @@ fun SidebarOverlay(
}
}
// People section
// People section - switch between mesh and geohash lists (iOS-compatible)
item {
PeopleSection(
connectedPeers = connectedPeers,
peerNicknames = peerNicknames,
peerRSSI = peerRSSI,
nickname = nickname,
colorScheme = colorScheme,
selectedPrivatePeer = selectedPrivatePeer,
viewModel = viewModel,
onPrivateChatStart = { peerID ->
viewModel.startPrivateChat(peerID)
onDismiss()
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,
peerRSSI = peerRSSI,
nickname = nickname,
colorScheme = colorScheme,
selectedPrivatePeer = selectedPrivatePeer,
viewModel = viewModel,
onPrivateChatStart = { peerID ->
viewModel.startPrivateChat(peerID)
onDismiss()
}
)
}
}
}
}
}
@@ -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()
@@ -346,11 +371,14 @@ private fun PeerItem(
.padding(horizontal = 24.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Show unread badge or signal strength
// 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,13 +390,34 @@ private fun PeerItem(
Spacer(modifier = Modifier.width(8.dp))
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)
)
// 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 = 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(
@@ -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,
+8
View File
@@ -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" }