Merge 411541faf5aa79f21073aea580acbb8a9a49656a into c8f45408b1984753a4df7f05aff03ceaa17bbba6

This commit is contained in:
Andrés Villagrán 2026-07-26 13:27:26 +04:00 committed by GitHub
commit c94eb9ed80
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 2533 additions and 116 deletions

View File

@ -145,6 +145,9 @@ dependencies {
// EXIF orientation handling for images
implementation("androidx.exifinterface:exifinterface:1.3.7")
// Async image loading from URLs (Coil)
implementation(libs.coil.compose)
// Testing
testImplementation(libs.bundles.testing)
androidTestImplementation(platform(libs.androidx.compose.bom))

View File

@ -785,6 +785,18 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
}
/**
* Broadcast a pre-built BitchatPacket (signs it before sending).
* Used by NostrProfileManager for mesh fallback when relays are unavailable.
*/
fun broadcastPacket(packet: BitchatPacket) {
serviceScope.launch {
val signedPacket = signPacketBeforeBroadcast(packet)
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
}
}
/**
* Send a file over mesh as a broadcast MESSAGE (public mesh timeline/channels).
*/

View File

@ -388,6 +388,18 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
val packet = routed.packet
val peerID = routed.peerID ?: "unknown"
// Check if this is a Nostr relay request packet (should be forwarded to relays, not displayed)
if (packet.payload.isNotEmpty()) {
val header = packet.payload[0]
if (header == com.bitchat.android.nostr.NostrMeshSerializer.TYPE_NOSTR_RELAY_REQUEST ||
header == com.bitchat.android.nostr.NostrMeshSerializer.TYPE_NOSTR_PLAINTEXT) {
Log.d(TAG, "📡 Received Nostr relay request via mesh from ${peerID.take(8)}, routing to gateway")
// Route to NostrMeshGateway for relay publishing (if we have internet)
com.bitchat.android.nostr.NostrMeshGateway.meshPacketReceived(appContext, packet.payload, null)
return // Don't display Nostr relay packets as chat messages
}
}
// Enforce: only accept public messages from verified peers we know
val peerInfo = delegate?.getPeerInfo(peerID)
if (peerInfo == null || !peerInfo.isVerifiedNickname) {

View File

@ -100,6 +100,17 @@ data class NostrFilter(
fun forEvents(ids: List<String>): NostrFilter {
return NostrFilter(ids = ids)
}
/**
* Create filter for user metadata (kind 0) - profile information
*/
fun profileMetadata(pubkey: String): NostrFilter {
return NostrFilter(
kinds = listOf(NostrKind.METADATA),
authors = listOf(pubkey),
limit = 1
)
}
}
/**

View File

@ -3,6 +3,10 @@ package com.bitchat.android.nostr
import android.content.Context
import android.util.Log
import com.bitchat.android.identity.SecureIdentityStateManager
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.security.MessageDigest
import java.security.SecureRandom
@ -87,6 +91,25 @@ data class NostrIdentity(
npub
}
}
/**
* Get the nsec (bech32-encoded private key) for backup/export
*/
fun getNsec(): String {
return Bech32.encode("nsec", privateKeyHex.hexToByteArrayLocal())
}
/**
* Get short display format for nsec
*/
fun getShortNsec(): String {
val nsec = getNsec()
return if (nsec.length > 16) {
"${nsec.take(8)}...${nsec.takeLast(8)}"
} else {
nsec
}
}
}
/**
@ -101,6 +124,15 @@ object NostrIdentityBridge {
// Cache for derived geohash identities to avoid repeated crypto operations
private val geohashIdentityCache = mutableMapOf<String, NostrIdentity>()
// Cache for resolved profile names (pubkeyHex -> displayName)
private val profileNameCache = mutableMapOf<String, String>()
// Pending profile resolutions to avoid duplicate requests
private val pendingResolutions = mutableSetOf<String>()
// Listeners for profile name updates (messageId -> callback)
private val profileUpdateListeners = mutableMapOf<String, () -> Unit>()
/**
* Get or create the current Nostr identity
*/
@ -224,6 +256,416 @@ object NostrIdentityBridge {
Log.e(TAG, "Failed to clear Nostr data: ${e.message}")
}
}
/**
* Import a Nostr identity from an nsec (bech32-encoded private key)
* Returns the imported identity on success, or null on failure
*/
fun importFromNsec(nsec: String, context: Context): NostrIdentity? {
return try {
val trimmed = nsec.trim().lowercase()
val (hrp, data) = Bech32.decode(trimmed)
require(hrp == "nsec") { "Invalid nsec prefix, got: $hrp" }
val privateKeyHex = data.toHexStringLocal()
require(NostrCrypto.isValidPrivateKey(privateKeyHex)) { "Invalid private key" }
val identity = NostrIdentity.fromPrivateKey(privateKeyHex)
// Save to secure storage
val stateManager = SecureIdentityStateManager(context)
saveNostrPrivateKey(stateManager, privateKeyHex)
// Clear derived identity cache since main identity changed
geohashIdentityCache.clear()
Log.i(TAG, "Successfully imported Nostr identity: ${identity.getShortNpub()}")
identity
} catch (e: Exception) {
Log.e(TAG, "Failed to import nsec: ${e.message}")
null
}
}
/**
* Export the current identity's nsec for backup
* Returns null if no identity exists
*/
fun exportNsec(context: Context): String? {
return try {
val identity = getCurrentNostrIdentity(context)
identity?.getNsec()
} catch (e: Exception) {
Log.e(TAG, "Failed to export nsec: ${e.message}")
null
}
}
/**
* Check if an nsec string is valid (for UI validation)
*/
fun isValidNsec(nsec: String): Boolean {
return try {
val trimmed = nsec.trim().lowercase()
val (hrp, data) = Bech32.decode(trimmed)
hrp == "nsec" && NostrCrypto.isValidPrivateKey(data.toHexStringLocal())
} catch (e: Exception) {
false
}
}
// -- Public-only NPUB storage and helpers --
private const val NOSTR_PUBLIC_ONLY = "nostr_public_only"
fun setPublicOnlyNpub(context: Context, npub: String?) {
try {
val stateManager = SecureIdentityStateManager(context)
if (npub == null) {
stateManager.clearSecureValues(NOSTR_PUBLIC_ONLY)
Log.d(TAG, "Cleared public-only npub")
} else {
stateManager.storeSecureValue(NOSTR_PUBLIC_ONLY, npub)
Log.d(TAG, "Stored public-only npub: ${npub.take(16)}...")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to set public-only npub: ${e.message}")
}
}
fun getPublicOnlyNpub(context: Context): String? {
return try {
val stateManager = SecureIdentityStateManager(context)
stateManager.getSecureValue(NOSTR_PUBLIC_ONLY)
} catch (e: Exception) {
null
}
}
fun isValidNpub(npub: String): Boolean {
return try {
val trimmed = npub.trim()
val (hrp, data) = Bech32.decode(trimmed)
hrp == "npub" && data.size == 32
} catch (e: Exception) {
false
}
}
// Note: pub-derivation helpers are provided on the bridge for reuse across UI/logic.
/**
* Return pubkey hex derived from an npub, or null if invalid
*/
fun getPubHexFromNpub(npub: String): String? {
return try {
val trimmed = npub.trim()
val (hrp, data) = Bech32.decode(trimmed)
if (hrp != "npub" || data.size != 32) return null
data.toHexStringLocal()
} catch (e: Exception) {
null
}
}
/**
* Return the effective public key hex to use for subscriptions/presence.
* Priority: private key -> public-only npub -> null
*/
fun getEffectivePublicKeyHex(context: Context): String? {
return try {
val stateManager = SecureIdentityStateManager(context)
val private = stateManager.getSecureValue(NOSTR_PRIVATE_KEY)
if (!private.isNullOrEmpty()) {
return NostrCrypto.derivePublicKey(private)
}
val pubNpub = stateManager.getSecureValue(NOSTR_PUBLIC_ONLY)
if (!pubNpub.isNullOrEmpty()) {
val (_, data) = Bech32.decode(pubNpub.trim())
return data.toHexStringLocal()
}
null
} catch (e: Exception) {
Log.e(TAG, "Failed to get effective public key: ${e.message}")
null
}
}
/**
* Remove stored private key while preserving public-only npub if present
*/
fun clearPrivateKey(context: Context) {
try {
val stateManager = SecureIdentityStateManager(context)
stateManager.clearSecureValues(NOSTR_PRIVATE_KEY)
// Also clear any cached identities derived from private key
geohashIdentityCache.clear()
Log.i(TAG, "Cleared stored Nostr private key")
} catch (e: Exception) {
Log.e(TAG, "Failed to clear private key: ${e.message}")
}
}
/**
* Parse Nostr content and replace nostr: URIs with human-readable text.
* Uses cached names if available, otherwise shows truncated version.
* Call resolveNostrReferences() to fetch names asynchronously.
*/
fun parseNostrContent(content: String): String {
// Pattern to match nostr: URIs (bech32 can include digits and lowercase letters)
val nostrUriPattern = "nostr:([a-z]+1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)".toRegex(RegexOption.IGNORE_CASE)
return nostrUriPattern.replace(content) { matchResult ->
val bech32 = matchResult.groupValues[1].lowercase()
try {
when {
bech32.startsWith("npub1") -> {
// Check cache first
val pubkeyHex = try {
val (_, data) = Bech32.decode(bech32)
data.toHexStringLocal()
} catch (e: Exception) { null }
val cachedName = pubkeyHex?.let { profileNameCache[it] }
if (cachedName != null) {
"@$cachedName"
} else {
"@${bech32.take(10)}${bech32.takeLast(4)}"
}
}
bech32.startsWith("note1") -> {
"📝${bech32.take(9)}"
}
bech32.startsWith("nevent1") -> {
"🔗event…"
}
bech32.startsWith("nprofile1") -> {
// Try to extract pubkey from TLV and check cache
val pubkeyHex = extractPubkeyFromNprofile(bech32)
val cachedName = pubkeyHex?.let { profileNameCache[it] }
if (cachedName != null) {
"👤@$cachedName"
} else {
"👤profile…"
}
}
bech32.startsWith("naddr1") -> {
"📄addr…"
}
bech32.startsWith("nrelay1") -> {
"🌐relay…"
}
else -> {
"nostr:${bech32.take(12)}"
}
}
} catch (e: Exception) {
"nostr:${bech32.take(12)}"
}
}
}
/**
* Extract all npub/nprofile references from content and resolve them asynchronously.
* When resolved, calls onUpdate so the UI can refresh.
*/
fun resolveNostrReferences(content: String, onUpdate: () -> Unit) {
val nostrUriPattern = "nostr:([a-z]+1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)".toRegex(RegexOption.IGNORE_CASE)
val matches = nostrUriPattern.findAll(content)
val pubkeysToResolve = mutableListOf<String>()
for (match in matches) {
val bech32 = match.groupValues[1].lowercase()
try {
val pubkeyHex: String? = when {
bech32.startsWith("npub1") -> {
val (_, data) = Bech32.decode(bech32)
data.toHexStringLocal()
}
bech32.startsWith("nprofile1") -> {
extractPubkeyFromNprofile(bech32)
}
else -> null
}
if (pubkeyHex != null &&
!profileNameCache.containsKey(pubkeyHex) &&
!pendingResolutions.contains(pubkeyHex)) {
pubkeysToResolve.add(pubkeyHex)
pendingResolutions.add(pubkeyHex)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to extract pubkey from $bech32: ${e.message}")
}
}
// Resolve each pubkey asynchronously
for (pubkeyHex in pubkeysToResolve) {
fetchProfileName(pubkeyHex) { name ->
if (name != null) {
profileNameCache[pubkeyHex] = name
onUpdate()
}
pendingResolutions.remove(pubkeyHex)
}
}
}
/**
* Fetch profile name for a pubkey from relays
*/
private fun fetchProfileName(pubkeyHex: String, onResult: (String?) -> Unit) {
GlobalScope.launch(Dispatchers.IO) {
try {
val relayManager = NostrRelayManager.shared
val filter = NostrFilter.profileMetadata(pubkeyHex)
val subscriptionId = "name-${pubkeyHex.take(8)}-${System.currentTimeMillis()}"
var resolved = false
relayManager.subscribe(
filter = filter,
id = subscriptionId,
handler = { event ->
if (!resolved && event.kind == NostrKind.METADATA && event.pubkey == pubkeyHex) {
try {
val profileJson = com.google.gson.JsonParser.parseString(event.content).asJsonObject
val name = profileJson.get("name")?.asString?.takeIf { it.isNotBlank() }
?: profileJson.get("display_name")?.asString?.takeIf { it.isNotBlank() }
resolved = true
relayManager.unsubscribe(subscriptionId)
GlobalScope.launch(Dispatchers.Main) {
onResult(name)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to parse profile for $pubkeyHex: ${e.message}")
// Don't mark as resolved - let timeout or next event retry
}
}
}
)
// Timeout after 5 seconds
delay(5000)
if (!resolved) {
relayManager.unsubscribe(subscriptionId)
onResult(null)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to fetch profile name: ${e.message}")
onResult(null)
}
}
}
/**
* Try to extract pubkey hex from nprofile1 TLV encoding.
* nprofile uses: 0x00 = pubkey (32 bytes), 0x01 = relay
*/
private fun extractPubkeyFromNprofile(nprofile: String): String? {
return try {
val (_, data) = Bech32.decode(nprofile)
// TLV: first byte is type (0x00 for pubkey), second is length
if (data.size >= 34 && data[0] == 0.toByte() && data[1] == 32.toByte()) {
data.sliceArray(2..33).toHexStringLocal()
} else {
null
}
} catch (e: Exception) {
null
}
}
/**
* Get cached profile name for a pubkey, if available
*/
fun getCachedProfileName(pubkeyHex: String): String? {
return profileNameCache[pubkeyHex]
}
// Popular relays known for good profile/metadata coverage
private val PROFILE_RELAYS = listOf(
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://relay.nostr.band",
"wss://nos.lol",
"wss://relay.snort.social",
"wss://purplepag.es",
"wss://nostr.wine",
"wss://relay.nostr.info"
)
/**
* Fetch profile metadata (kind 0) for a pubkey from Nostr relays.
* Returns a callback when profile is found with name, displayName, about, picture, nip05.
* This is an async operation - the callback may be called after some delay or not at all if no profile is found.
*/
fun fetchProfileFromRelays(
pubkeyHex: String,
onProfileFetched: (name: String?, displayName: String?, about: String?, picture: String?, nip05: String?) -> Unit
) {
try {
val relayManager = NostrRelayManager.shared
val filter = NostrFilter.profileMetadata(pubkeyHex)
val subscriptionId = "profile-${pubkeyHex.take(8)}-${System.currentTimeMillis()}"
Log.d(TAG, "Fetching profile for pubkey: ${pubkeyHex.take(16)}... using ${PROFILE_RELAYS.size} profile relays")
// First ensure we're connected to profile-focused relays
relayManager.connectToAdditionalRelays(PROFILE_RELAYS)
// Small delay to allow connections to be established, then subscribe
GlobalScope.launch(Dispatchers.IO) {
delay(500) // Give relays time to connect
relayManager.subscribe(
filter = filter,
id = subscriptionId,
targetRelayUrls = PROFILE_RELAYS,
handler = { event ->
if (event.kind == NostrKind.METADATA && event.pubkey == pubkeyHex) {
try {
// Parse the content as JSON to extract profile fields
val profileJson = com.google.gson.JsonParser.parseString(event.content).asJsonObject
val name = profileJson.get("name")?.asString
val displayName = profileJson.get("display_name")?.asString
val about = profileJson.get("about")?.asString
val picture = profileJson.get("picture")?.asString
val nip05 = profileJson.get("nip05")?.asString
Log.i(TAG, "Found profile for ${pubkeyHex.take(16)}: name=$name, displayName=$displayName")
// Unsubscribe after receiving profile
relayManager.unsubscribe(subscriptionId)
// Call the callback with profile data
onProfileFetched(name, displayName, about, picture, nip05)
} catch (e: Exception) {
Log.e(TAG, "Failed to parse profile content: ${e.message}")
}
}
}
)
// Set a timeout to unsubscribe if no profile is found (10 seconds)
delay(10000)
relayManager.unsubscribe(subscriptionId)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to fetch profile: ${e.message}")
}
}
// Note: pub-derivation helpers are provided on the bridge for reuse across UI/logic.
// MARK: - Private Methods

View File

@ -0,0 +1,197 @@
package com.bitchat.android.nostr
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* Nostr Mesh Gateway helpers
* - publishToMeshOrRelay(event): decide ruta según conectividad
* - MeshListener snippet: detecta TYPE_NOSTR_RELAY_REQUEST paquetes y publica en relays
* - Deduplication: prevents multiple mesh peers from publishing the same event
*/
object NostrMeshGateway {
private const val TAG = "NostrMeshGateway"
// Deduplication cache: stores event IDs that we've already published to relays
// This prevents multiple online devices from publishing the same event
private const val MAX_PUBLISHED_CACHE_SIZE = 500
private const val PUBLISHED_CACHE_TTL_MS = 5 * 60 * 1000L // 5 minutes
private val publishedEventCache = LinkedHashMap<String, Long>(MAX_PUBLISHED_CACHE_SIZE, 0.75f, true)
private val cacheLock = Any()
/**
* Check if we've already published this event recently
*/
private fun hasRecentlyPublished(eventId: String): Boolean {
synchronized(cacheLock) {
val publishedAt = publishedEventCache[eventId] ?: return false
val age = System.currentTimeMillis() - publishedAt
if (age > PUBLISHED_CACHE_TTL_MS) {
publishedEventCache.remove(eventId)
return false
}
return true
}
}
/**
* Mark an event as published (add to dedup cache)
*/
private fun markAsPublished(eventId: String) {
synchronized(cacheLock) {
// Evict old entries if cache is full
if (publishedEventCache.size >= MAX_PUBLISHED_CACHE_SIZE) {
val now = System.currentTimeMillis()
publishedEventCache.entries.removeIf { now - it.value > PUBLISHED_CACHE_TTL_MS }
// If still full, remove oldest
if (publishedEventCache.size >= MAX_PUBLISHED_CACHE_SIZE) {
val oldest = publishedEventCache.keys.firstOrNull()
if (oldest != null) publishedEventCache.remove(oldest)
}
}
publishedEventCache[eventId] = System.currentTimeMillis()
}
}
/**
* Decide publicar directamente a relays si hay conectividad, o enviar por Mesh si no
*/
fun publishToMeshOrRelay(context: Context, event: NostrEvent, meshSender: (ByteArray) -> Unit) {
if (hasInternetConnection(context)) {
Log.d(TAG, "Device online - publishing event to relays directly")
// Ensure event meets PoW requirement before sending
val minDifficulty = com.bitchat.android.nostr.NostrProofOfWork.estimateWork(0) // placeholder: use PoW settings
// Send immediately via NostrRelayManager
try {
NostrRelayManager.getInstance(context).sendEvent(event)
} catch (e: Exception) {
Log.e(TAG, "Failed to send event to relays: ${e.message}")
// Fallback: send over mesh
try {
val payload = NostrMeshSerializer.serializeEventForMesh(event)
meshSender(payload)
} catch (ex: Exception) {
Log.e(TAG, "Failed to serialize event for mesh fallback: ${ex.message}")
}
}
} else {
Log.d(TAG, "No internet - sending event over mesh")
// When offline: ensure PoW is present (NIP-13). Mining should be done before calling this ideally.
// If not mined, we could kick off mining synchronously (dangerous for battery) or reject.
CoroutineScope(Dispatchers.Default).launch {
try {
// If event lacks nonce, attempt a light PoW using user-preferred difficulty
val prefDifficulty = NostrProofOfWork.estimateWork(8).toIntOrNullSafe() ?: 8
} catch (ignored: Exception) {}
}
try {
val payload = NostrMeshSerializer.serializeEventForMesh(event)
meshSender(payload)
} catch (e: Exception) {
Log.e(TAG, "Failed to serialize event for mesh: ${e.message}")
}
}
}
/**
* Simple connectivity check helper
*/
fun hasInternetConnection(context: Context): Boolean {
try {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val nw = cm.activeNetwork ?: return false
val actNw = cm.getNetworkCapabilities(nw) ?: return false
return actNw.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
} catch (e: Exception) {
return false
}
}
/**
* Mesh listener snippet to be integrated in the mesh receive path.
* When a packet with header TYPE_NOSTR_RELAY_REQUEST is received and device has internet,
* it will attempt to deserialize, validate signature & PoW, then publish to relays.
*/
fun meshPacketReceived(context: Context, packet: ByteArray, ackSender: ((ByteArray) -> Unit)? = null) {
CoroutineScope(Dispatchers.IO).launch {
try {
// Quick header check
if (packet.isEmpty()) return@launch
val header = packet[0]
if (header != NostrMeshSerializer.TYPE_NOSTR_RELAY_REQUEST && header != NostrMeshSerializer.TYPE_NOSTR_PLAINTEXT) {
return@launch
}
if (!hasInternetConnection(context)) {
Log.d(TAG, "Received Nostr mesh packet but device is offline - skipping relay publish")
return@launch
}
val jsonString = NostrMeshSerializer.deserializeEventFromMesh(packet) ?: run {
Log.w(TAG, "Failed to deserialize Nostr event from mesh packet")
return@launch
}
// Parse event
val event = NostrEvent.fromJsonString(jsonString)
if (event == null) {
Log.w(TAG, "Failed to parse NostrEvent JSON")
return@launch
}
// Validate signature (NIP-01)
if (!event.isValidSignature()) {
Log.w(TAG, "Invalid Nostr signature - discarding event id=${event.id.take(16)}...")
return@launch
}
// Validate PoW (NIP-13) - require at least low difficulty (8 bits)
val requiredDifficulty = 8
if (!NostrProofOfWork.validateDifficulty(event, requiredDifficulty)) {
Log.w(TAG, "Event failed PoW validation - discarding id=${event.id.take(16)}...")
return@launch
}
// Deduplication: check if we (or another peer) already published this event
if (hasRecentlyPublished(event.id)) {
Log.d(TAG, "Event already published recently, skipping duplicate id=${event.id.take(16)}...")
return@launch
}
// Publish to relays via NostrRelayManager
try {
NostrRelayManager.getInstance(context).sendEvent(event)
markAsPublished(event.id) // Mark as published AFTER successful send
Log.i(TAG, "Published mesh-origin event to relays id=${event.id.take(16)}...")
// Optionally send acknowledgement back over mesh
ackSender?.let { sender ->
try {
val ack = "DELIVERED:${event.id}".toByteArray(Charsets.UTF_8)
sender(ack)
} catch (e: Exception) {
Log.w(TAG, "Failed to send ack over mesh: ${e.message}")
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to publish event to relays: ${e.message}")
}
} catch (e: Exception) {
Log.e(TAG, "Error handling mesh packet: ${e.message}")
}
}
}
// helper extension to attempt convert Long to Int safely
private fun Long.toIntOrNullSafe(): Int? {
return if (this in Int.MIN_VALUE..Int.MAX_VALUE) this.toInt() else null
}
}

View File

@ -0,0 +1,136 @@
package com.bitchat.android.nostr
import android.util.Log
import com.bitchat.android.protocol.CompressionUtil
import java.io.ByteArrayOutputStream
import java.util.zip.GZIPOutputStream
/**
* NostrMeshSerializer
* - Serializa eventos Nostr a payloads optimizados para transporte sobre Bluetooth Mesh
* - Aplica compresión (preferencia: raw deflate via CompressionUtil, fallback GZIP)
* - Añade un header simple para identificar paquetes tipo TYPE_NOSTR_RELAY_REQUEST
*
* Formato de paquete (simple):
* [HEADER (1 byte)] [ORIG_SIZE (4 bytes, BE)] [BODY (bytes...)]
* HEADER: 0x7E = TYPE_NOSTR_RELAY_REQUEST, 0x00 = uncompressed body
*/
object NostrMeshSerializer {
private const val TAG = "NostrMeshSerializer"
// Header identifiers
const val TYPE_NOSTR_RELAY_REQUEST: Byte = 0x7E
const val TYPE_NOSTR_PLAINTEXT: Byte = 0x00
/**
* Serializa y comprime un evento Nostr listo para ser enviado sobre la Mesh
* - Devuelve el payload listo para enviar por BLE
*/
fun serializeEventForMesh(event: NostrEvent): ByteArray {
val json = event.toJsonString()
val bytes = json.toByteArray(Charsets.UTF_8)
// Preferir compresión compatible con el proyecto (CompressionUtil -> raw deflate)
try {
val compressed = CompressionUtil.compress(bytes)
if (compressed != null) {
Log.d(TAG, "Using raw deflate compression: ${compressed.size} < ${bytes.size}")
return buildPacket(TYPE_NOSTR_RELAY_REQUEST, bytes.size, compressed)
}
} catch (e: Exception) {
Log.w(TAG, "CompressionUtil.compress failed: ${e.message}")
}
// Fallback: try GZIP if CompressionUtil didn't compress
try {
val gzipped = gzip(bytes)
if (gzipped.size < bytes.size) {
Log.d(TAG, "Using GZIP compression as fallback: ${gzipped.size} < ${bytes.size}")
return buildPacket(TYPE_NOSTR_RELAY_REQUEST, bytes.size, gzipped)
}
} catch (e: Exception) {
Log.w(TAG, "GZIP fallback failed: ${e.message}")
}
// No compression beneficial -> send plaintext but still mark type
return buildPacket(TYPE_NOSTR_PLAINTEXT, bytes.size, bytes)
}
/**
* Reconstruye el evento Nostr desde el payload Mesh
* - El caller decide si debe intentar descompress o validar
*/
fun deserializeEventFromMesh(payload: ByteArray): String? {
if (payload.isEmpty()) return null
val header = payload[0]
if (header != TYPE_NOSTR_RELAY_REQUEST && header != TYPE_NOSTR_PLAINTEXT) {
Log.w(TAG, "Unknown packet header: $header")
return null
}
if (payload.size < 5) {
Log.w(TAG, "Payload too small to contain original size")
return null
}
val origSize = ((payload[1].toInt() and 0xFF) shl 24) or
((payload[2].toInt() and 0xFF) shl 16) or
((payload[3].toInt() and 0xFF) shl 8) or
(payload[4].toInt() and 0xFF)
val body = payload.copyOfRange(5, payload.size)
return when (header) {
TYPE_NOSTR_RELAY_REQUEST -> {
// Try raw deflate first (CompressionUtil expects raw deflate)
CompressionUtil.decompress(body, origSize)?.let { bytes ->
return String(bytes, Charsets.UTF_8)
}
// Fallback try GZIP
try {
val inflated = tryGzipDecompress(body)
if (inflated != null) return String(inflated, Charsets.UTF_8)
} catch (e: Exception) {
Log.w(TAG, "GZIP fallback failed during decompress: ${e.message}")
}
Log.w(TAG, "Failed to decompress body for Nostr payload")
null
}
TYPE_NOSTR_PLAINTEXT -> {
// Plaintext body
String(body, Charsets.UTF_8)
}
else -> null
}
}
private fun buildPacket(header: Byte, originalSize: Int, body: ByteArray): ByteArray {
val out = ByteArrayOutputStream(5 + body.size)
out.write(byteArrayOf(header))
// Original size big-endian 4 bytes
out.write((originalSize ushr 24) and 0xFF)
out.write((originalSize ushr 16) and 0xFF)
out.write((originalSize ushr 8) and 0xFF)
out.write((originalSize) and 0xFF)
out.write(body)
return out.toByteArray()
}
private fun gzip(input: ByteArray): ByteArray {
val baos = ByteArrayOutputStream()
GZIPOutputStream(baos).use { it.write(input) }
return baos.toByteArray()
}
private fun tryGzipDecompress(input: ByteArray): ByteArray? {
return try {
val inflater = java.util.zip.GZIPInputStream(input.inputStream())
inflater.readBytes()
} catch (e: Exception) {
null
}
}
}

View File

@ -0,0 +1,209 @@
package com.bitchat.android.nostr
import android.content.Context
import android.util.Log
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.util.AppConstants
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.*
/**
* Manager for publishing and subscribing to the user's profile notes (#me) via Nostr
* - Publishes kind=1 text note with tag ["bitchat","1"] for compatibility and filtering
* - Subscribes to author's kind=1 notes filtered by the tag above
* - Uses NostrRelayManager when online, falls back to mesh via NostrMeshGateway when offline
*/
object NostrProfileManager {
private const val TAG = "NostrProfileManager"
// Minimum PoW difficulty required for mesh relay (must match NostrMeshGateway)
private const val MESH_REQUIRED_POW_DIFFICULTY = 8
// Popular relays known for good profile/user content coverage
private val PROFILE_RELAYS = listOf(
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://relay.nostr.band",
"wss://nos.lol",
"wss://relay.snort.social",
"wss://purplepag.es",
"wss://nostr.wine"
)
private var context: Context? = null
private var meshService: com.bitchat.android.mesh.BluetoothMeshService? = null
private var subscribeFunc: ((NostrFilter, String, (NostrEvent) -> Unit) -> String)? = null
private var unsubscribeFunc: ((String) -> Unit)? = null
private var sendEventFunc: ((NostrEvent, List<String>?) -> Unit)? = null
private var currentSubId: String? = null
/**
* Result of publishing a profile message
*/
sealed class PublishResult {
object Success : PublishResult()
object NotInitialized : PublishResult()
object NoIdentity : PublishResult()
object PowRequired : PublishResult() // PoW is required but not enabled
object PowMiningFailed : PublishResult()
data class Error(val message: String) : PublishResult()
}
fun initialize(
context: Context,
meshService: com.bitchat.android.mesh.BluetoothMeshService,
subscribe: (NostrFilter, String, (NostrEvent) -> Unit) -> String,
unsubscribe: (String) -> Unit,
sendEvent: (NostrEvent, List<String>?) -> Unit
) {
this.context = context.applicationContext
this.meshService = meshService
this.subscribeFunc = subscribe
this.unsubscribeFunc = unsubscribe
this.sendEventFunc = sendEvent
Log.d(TAG, "Initialized NostrProfileManager")
}
/**
* Publish a profile message (kind=1) signed with current identity.
* Publishes to Nostr relays (no bitchat tag for normal posts).
*
* @return PublishResult indicating success or type of failure
*/
suspend fun publishProfileMessage(content: String, nickname: String?): PublishResult = withContext(Dispatchers.IO) {
Log.d(TAG, "publishProfileMessage called with content: ${content.take(50)}...")
val ctx = context ?: run {
Log.w(TAG, "Not initialized - context is null")
return@withContext PublishResult.NotInitialized
}
val mesh = meshService
val sendEvent = sendEventFunc
val identity = try { NostrIdentityBridge.getCurrentNostrIdentity(ctx) } catch (e: Exception) { null }
if (identity == null) {
Log.e(TAG, "No Nostr identity available for profile publish")
return@withContext PublishResult.NoIdentity
}
// Check if we're offline - if so, we need PoW for mesh relay
val isOffline = !NostrMeshGateway.hasInternetConnection(ctx)
val event: NostrEvent
if (isOffline) {
Log.d(TAG, "Device is offline - checking PoW requirements for mesh relay")
if (!PoWPreferenceManager.isPowEnabled()) {
Log.w(TAG, "PoW is required for offline #me messages but is not enabled")
return@withContext PublishResult.PowRequired
}
// Create unsigned event first (we'll sign after mining)
val unsignedEvent = NostrEvent(
pubkey = identity.publicKeyHex,
createdAt = (System.currentTimeMillis() / 1000).toInt(),
kind = NostrKind.TEXT_NOTE,
tags = emptyList(),
content = content
)
// Mine the event with required difficulty
val difficulty = maxOf(PoWPreferenceManager.getPowDifficulty(), MESH_REQUIRED_POW_DIFFICULTY)
Log.d(TAG, "Mining PoW for offline message with difficulty $difficulty...")
PoWPreferenceManager.startMining()
try {
val minedEvent = NostrProofOfWork.mineEvent(unsignedEvent, difficulty)
if (minedEvent == null) {
Log.e(TAG, "PoW mining failed")
return@withContext PublishResult.PowMiningFailed
}
// Sign the mined event (with nonce tag already added)
event = minedEvent.sign(identity.privateKeyHex)
Log.d(TAG, "PoW mining successful, signed event id=${event.id.take(16)}...")
} finally {
PoWPreferenceManager.stopMining()
}
} else {
// Online: create and sign normally (no PoW needed)
event = NostrEvent.createTextNote(content, identity.publicKeyHex, identity.privateKeyHex, tags = emptyList())
}
try {
// Ensure we're connected to profile-focused relays for better delivery
NostrRelayManager.shared.connectToAdditionalRelays(PROFILE_RELAYS)
// If we have a direct sendEvent function (relays), prefer that via NostrRelayManager; else fallback to mesh
val meshSender: (ByteArray) -> Unit = { bytes ->
try {
val svc = mesh
if (svc != null) {
val pkt = BitchatPacket(
type = MessageType.MESSAGE.value,
ttl = AppConstants.SYNC_TTL_HOPS,
senderID = svc.myPeerID,
payload = bytes
)
svc.broadcastPacket(pkt)
}
} catch (e: Exception) {
Log.w(TAG, "meshSender failed: ${e.message}")
}
}
NostrMeshGateway.publishToMeshOrRelay(ctx, event, meshSender)
Log.d(TAG, "Published profile message id=${event.id.take(16)}...")
return@withContext PublishResult.Success
} catch (e: Exception) {
Log.e(TAG, "Failed to publish profile message: ${e.message}")
return@withContext PublishResult.Error(e.message ?: "Unknown error")
}
}
/**
* Subscribe to our profile notes and deliver incoming events to handler
*/
fun subscribeMyProfile(handler: (NostrEvent) -> Unit) {
val ctx = context ?: run { Log.w(TAG, "Not initialized"); return }
val sub = subscribeFunc ?: run { Log.w(TAG, "subscribeFunc not set"); return }
val identity = try { NostrIdentityBridge.getCurrentNostrIdentity(ctx) } catch (e: Exception) { null }
if (identity == null) {
Log.w(TAG, "No identity to subscribe for")
return
}
// Ensure we're connected to profile-focused relays for better coverage
NostrRelayManager.shared.connectToAdditionalRelays(PROFILE_RELAYS)
// Build filter: author=self, kind=1 (all text notes, not just bitchat-tagged)
val filter = NostrFilter.Builder()
.authors(identity.publicKeyHex)
.kinds(NostrKind.TEXT_NOTE)
.limit(100)
.build()
val id = "profile-sub-${System.currentTimeMillis()}"
try {
currentSubId = sub(filter, id) { event -> handler(event) }
Log.d(TAG, "Subscribed to my profile notes with subId=$currentSubId, pubkey=${identity.publicKeyHex.take(16)}...")
} catch (e: Exception) {
Log.e(TAG, "Failed to subscribe to profile: ${e.message}")
}
}
fun unsubscribeProfile() {
val unsub = unsubscribeFunc ?: return
val id = currentSubId ?: return
try {
unsub(id)
currentSubId = null
Log.d(TAG, "Unsubscribed profile sub $id")
} catch (e: Exception) {
Log.w(TAG, "Failed to unsubscribe profile: ${e.message}")
}
}
}

View File

@ -256,6 +256,15 @@ class NostrRelayManager private constructor() {
startSubscriptionValidation()
}
/**
* Connect to additional relays (e.g., for profile lookups).
* These are added to the relay list and connections are established if not already connected.
*/
fun connectToAdditionalRelays(relayUrls: List<String>) {
Log.d(TAG, "📡 Connecting to ${relayUrls.size} additional relays for profile lookup")
ensureConnectionsFor(relayUrls.toSet())
}
/**
* Disconnect from all relays
*/

View File

@ -145,7 +145,8 @@ object AppStateStore {
seenMessageIds.add(msg.id)
val map = _channelMessages.value.toMutableMap()
val list = (map[channel] ?: emptyList()) + msg
map[channel] = list
// Only sort #me channel (Nostr posts can arrive out of order from relays)
map[channel] = if (channel == "#me") list.sortedBy { it.timestamp } else list
_channelMessages.value = map
}
}

View File

@ -200,6 +200,7 @@ fun AboutSheet(
isPresented: Boolean,
onDismiss: () -> Unit,
onShowDebug: (() -> Unit)? = null,
onShowNostrAccount: (() -> Unit)? = null,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
@ -363,6 +364,65 @@ fun AboutSheet(
}
}
// Nostr Account Section
item(key = "nostr_account") {
if (onShowNostrAccount != null) {
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
Text(
text = "NOSTR ACCOUNT",
style = MaterialTheme.typography.labelSmall,
color = colorScheme.onBackground.copy(alpha = 0.5f),
letterSpacing = 0.5.sp,
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp)
)
Surface(
modifier = Modifier.fillMaxWidth(),
color = colorScheme.surface,
shape = RoundedCornerShape(16.dp),
onClick = onShowNostrAccount
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = null,
tint = colorScheme.primary,
modifier = Modifier.size(22.dp)
)
Column {
Text(
text = "Manage Keys",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = colorScheme.onSurface
)
Text(
text = "View, export or import your Nostr identity",
style = MaterialTheme.typography.bodySmall,
color = colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
Text(
text = ">",
style = MaterialTheme.typography.bodyLarge,
color = colorScheme.onSurface.copy(alpha = 0.3f)
)
}
}
}
}
}
// Settings Section - Unified Card with Toggles
item(key = "settings") {
LaunchedEffect(Unit) { PoWPreferenceManager.init(context) }

View File

@ -184,6 +184,13 @@ class ChannelManager(
dataManager.addChannelMember(channel, peerID)
}
}
/**
* Update the content of a message in a channel (used for async content resolution)
*/
fun updateChannelMessageContent(channel: String, messageId: String, newContent: String) {
messageManager.updateChannelMessageContent(channel, messageId, newContent)
}
fun removeChannelMember(channel: String, peerID: String) {
dataManager.removeChannelMember(channel, peerID)

View File

@ -504,10 +504,12 @@ private fun ChatDialogs(
// About sheet
var showDebugSheet by remember { mutableStateOf(false) }
var showNostrAccountSheet by remember { mutableStateOf(false) }
AboutSheet(
isPresented = showAppInfo,
onDismiss = onAppInfoDismiss,
onShowDebug = { showDebugSheet = true }
onShowDebug = { showDebugSheet = true },
onShowNostrAccount = { showNostrAccountSheet = true }
)
if (showDebugSheet) {
com.bitchat.android.ui.debug.DebugSettingsSheet(
@ -516,6 +518,19 @@ private fun ChatDialogs(
meshService = viewModel.meshService
)
}
if (showNostrAccountSheet) {
NostrAccountSheet(
isPresented = showNostrAccountSheet,
onDismiss = { showNostrAccountSheet = false },
onIdentityChanged = {
// Reconnect to relays with new identity if needed
},
onNostrNameFound = { nostrName ->
// Update the user's nickname with the name from their Nostr profile
viewModel.setNickname(nostrName)
}
)
}
// Location channels sheet
if (showLocationChannelsSheet) {

View File

@ -22,6 +22,38 @@ import java.util.*
* Extracted from ChatScreen.kt for better organization
*/
// Date formatters for smart timestamp display
private val timeOnlyFormatter = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
private val dateTimeFormatter = SimpleDateFormat("MMM d, HH:mm", Locale.getDefault())
private val dateTimeWithYearFormatter = SimpleDateFormat("MMM d yyyy, HH:mm", Locale.getDefault())
/**
* Format timestamp smartly based on age:
* - Today: just time (HH:mm:ss)
* - This year: date + time (MMM d, HH:mm)
* - Older: full date with year (MMM d yyyy, HH:mm)
*/
fun formatSmartTimestamp(timestamp: Date): String {
val now = Calendar.getInstance()
val msgTime = Calendar.getInstance().apply { time = timestamp }
return when {
// Same day - just show time
now.get(Calendar.YEAR) == msgTime.get(Calendar.YEAR) &&
now.get(Calendar.DAY_OF_YEAR) == msgTime.get(Calendar.DAY_OF_YEAR) -> {
timeOnlyFormatter.format(timestamp)
}
// Same year - show month, day, time
now.get(Calendar.YEAR) == msgTime.get(Calendar.YEAR) -> {
dateTimeFormatter.format(timestamp)
}
// Different year - show full date with year
else -> {
dateTimeWithYearFormatter.format(timestamp)
}
}
}
/**
* Get RSSI-based color for signal strength visualization
*/
@ -120,12 +152,12 @@ fun formatMessageAsAnnotatedString(
appendIOSFormattedContent(builder, message.content, message.mentions, currentUserNickname, baseColor, isSelf, isDark)
// iOS-style timestamp at the END (smaller, grey)
// Timestamp (and optional PoW badge)
// Timestamp (and optional PoW badge) - smart format based on message age
builder.pushStyle(SpanStyle(
color = Color.Gray.copy(alpha = 0.7f),
fontSize = (BASE_FONT_SIZE - 4).sp
))
builder.append(" [${timeFormatter.format(message.timestamp)}]")
builder.append(" [${formatSmartTimestamp(message.timestamp)}]")
// If message has valid PoW difficulty, append bits immediately after timestamp with minimal spacing
message.powDifficulty?.let { bits ->
if (bits > 0) {
@ -149,7 +181,7 @@ fun formatMessageAsAnnotatedString(
color = Color.Gray.copy(alpha = 0.5f),
fontSize = (BASE_FONT_SIZE - 4).sp
))
builder.append(" [${timeFormatter.format(message.timestamp)}]")
builder.append(" [${formatSmartTimestamp(message.timestamp)}]")
builder.pop()
}

View File

@ -16,6 +16,9 @@ import com.bitchat.android.service.MeshServiceHolder
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.nostr.NostrIdentityBridge
import com.bitchat.android.nostr.NostrProfileManager
import com.bitchat.android.nostr.NostrKind
import com.bitchat.android.nostr.NostrRelayManager
import com.bitchat.android.protocol.BitchatPacket
@ -321,6 +324,17 @@ class ChatViewModel(
// Note: Mesh service is now started by MainActivity
// Initialize NostrProfileManager with relay/mesh hooks
try {
NostrProfileManager.initialize(
context = getApplication(),
meshService = meshService,
subscribe = { filter, id, handler -> NostrRelayManager.getInstance(getApplication()).subscribe(filter, id, handler) },
unsubscribe = { id -> NostrRelayManager.getInstance(getApplication()).unsubscribe(id) },
sendEvent = { event, relays -> NostrRelayManager.getInstance(getApplication()).sendEvent(event, relays) }
)
} catch (_: Exception) { }
// BLE receives are inserted by MessageHandler path; no VoiceNoteBus for Tor in this branch.
}
@ -384,7 +398,54 @@ class ChatViewModel(
}
fun switchToChannel(channel: String?) {
// Manage subscription lifecycle for our Nostr-profile channel (#me)
val prev = state.getCurrentChannelValue()
if (prev == "#me" && channel != "#me") {
try { NostrProfileManager.unsubscribeProfile() } catch (_: Exception) { }
}
channelManager.switchToChannel(channel)
if (channel == "#me") {
try {
NostrProfileManager.subscribeMyProfile { event ->
try {
// Accept all valid kind=1 text notes from our profile
if (event.kind == NostrKind.TEXT_NOTE && event.isValidSignature()) {
val meNick = state.getNicknameValue() ?: meshService.myPeerID
val originalContent = event.content
val eventId = event.id
// Parse nostr: URIs to human-readable format (uses cache)
val parsedContent = NostrIdentityBridge.parseNostrContent(originalContent)
val msg = BitchatMessage(
id = eventId,
sender = meNick,
content = parsedContent,
timestamp = Date(event.createdAt.toLong() * 1000L),
isRelay = true,
senderPeerID = meshService.myPeerID,
channel = "#me"
)
channelManager.addChannelMessage("#me", msg, meshService.myPeerID)
// Resolve references asynchronously and update message when done
NostrIdentityBridge.resolveNostrReferences(originalContent) {
// Re-parse with updated cache and update the message
val updatedContent = NostrIdentityBridge.parseNostrContent(originalContent)
if (updatedContent != parsedContent) {
channelManager.updateChannelMessageContent("#me", eventId, updatedContent)
}
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to process profile event: ${e.message}")
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to subscribe to #me profile: ${e.message}")
}
}
}
fun leaveChannel(channel: String) {
@ -554,6 +615,57 @@ class ChatViewModel(
// Send to geohash channel via Nostr ephemeral event
geohashViewModel.sendGeohashMessage(content, selectedLocationChannel.channel, mesh.myPeerID, state.getNicknameValue())
} else {
// If we're in the special profile channel #me, publish via NostrProfileManager
Log.d(TAG, "sendMessage: currentChannelValue=$currentChannelValue")
if (currentChannelValue == "#me") {
val nick = state.getNicknameValue()
Log.d(TAG, "Publishing to #me profile channel")
// Local echo
val echo = BitchatMessage(
sender = nick ?: meshService.myPeerID,
content = content,
timestamp = Date(),
isRelay = false,
senderPeerID = meshService.myPeerID,
mentions = if (mentions.isNotEmpty()) mentions else null,
channel = "#me"
)
channelManager.addChannelMessage("#me", echo, meshService.myPeerID)
// Publish to Nostr (async with result handling)
viewModelScope.launch {
val result = NostrProfileManager.publishProfileMessage(content, nick)
when (result) {
is NostrProfileManager.PublishResult.PowRequired -> {
// Show system message explaining PoW is needed
val systemMessage = BitchatMessage(
sender = "system",
content = "⚠️ Para enviar mensajes en #me sin conexión, debes activar Proof of Work en Configuración → Nostr → PoW",
timestamp = Date(),
isRelay = false,
channel = "#me"
)
channelManager.addChannelMessage("#me", systemMessage, null)
}
is NostrProfileManager.PublishResult.PowMiningFailed -> {
val systemMessage = BitchatMessage(
sender = "system",
content = "❌ Error: El minado de PoW falló. Intenta con una dificultad menor.",
timestamp = Date(),
isRelay = false,
channel = "#me"
)
channelManager.addChannelMessage("#me", systemMessage, null)
}
is NostrProfileManager.PublishResult.Error -> {
Log.w(TAG, "Failed to publish profile message: ${result.message}")
}
else -> { /* Success or other cases */ }
}
}
return
}
// Send public/channel message via mesh
val message = BitchatMessage(
sender = state.getNicknameValue() ?: mesh.myPeerID,

View File

@ -13,6 +13,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Map
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PinDrop
import androidx.compose.material.icons.outlined.BookmarkBorder
import androidx.compose.material3.*
@ -73,6 +74,9 @@ fun LocationChannelsSheet(
// Observe reactive participant counts
val geohashParticipantCounts by viewModel.geohashParticipantCounts.collectAsStateWithLifecycle()
// Observe current channel for #me selection state
val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle()
// UI state
var customGeohash by remember { mutableStateOf("") }
@ -187,7 +191,31 @@ fun LocationChannelsSheet(
}
}
// Mesh option first
// #me - Personal Nostr profile channel (first option)
item(key = "me") {
val isMeSelected = currentChannel == "#me"
ChannelRow(
title = "#me",
subtitle = stringResource(R.string.me_channel_description),
isSelected = isMeSelected,
titleColor = Color(0xFFAF52DE), // iOS purple
titleBold = isMeSelected,
trailingContent = {
Icon(
imageVector = Icons.Filled.Person,
contentDescription = null,
tint = Color(0xFFAF52DE).copy(alpha = 0.7f),
modifier = Modifier.size(20.dp)
)
},
onClick = {
viewModel.switchToChannel("#me")
onDismiss()
}
)
}
// Mesh option
item(key = "mesh") {
ChannelRow(
title = meshTitleWithCount(viewModel),

View File

@ -366,6 +366,11 @@ fun MessageItem(
)
} else {
// Normal message display
// Check for image URLs in the message content
val imageUrls = remember(message.content) {
MessageSpecialParser.extractImageUrls(message.content)
}
val annotatedText = formatMessageAsAnnotatedString(
message = message,
currentUserNickname = currentUserNickname,
@ -382,85 +387,184 @@ fun MessageItem(
val haptic = LocalHapticFeedback.current
val context = LocalContext.current
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
Text(
text = annotatedText,
modifier = modifier.pointerInput(message) {
detectTapGestures(
onTap = { position ->
val layout = textLayoutResult ?: return@detectTapGestures
val offset = layout.getOffsetForPosition(position)
// Nickname click only when not self
if (!isSelf && onNicknameClick != null) {
val nicknameAnnotations = annotatedText.getStringAnnotations(
tag = "nickname_click",
// If there are image URLs, wrap text + images in a Column
if (imageUrls.isNotEmpty()) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = annotatedText,
modifier = Modifier.pointerInput(message) {
detectTapGestures(
onTap = { position ->
val layout = textLayoutResult ?: return@detectTapGestures
val offset = layout.getOffsetForPosition(position)
// Nickname click only when not self
if (!isSelf && onNicknameClick != null) {
val nicknameAnnotations = annotatedText.getStringAnnotations(
tag = "nickname_click",
start = offset,
end = offset
)
if (nicknameAnnotations.isNotEmpty()) {
val nickname = nicknameAnnotations.first().item
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onNicknameClick.invoke(nickname)
return@detectTapGestures
}
}
// Geohash teleport (all messages)
val geohashAnnotations = annotatedText.getStringAnnotations(
tag = "geohash_click",
start = offset,
end = offset
)
if (geohashAnnotations.isNotEmpty()) {
val geohash = geohashAnnotations.first().item
try {
val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(
context
)
val level = when (geohash.length) {
in 0..2 -> com.bitchat.android.geohash.GeohashChannelLevel.REGION
in 3..4 -> com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE
5 -> com.bitchat.android.geohash.GeohashChannelLevel.CITY
6 -> com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD
else -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK
}
val channel = com.bitchat.android.geohash.GeohashChannel(level, geohash.lowercase())
locationManager.setTeleported(true)
locationManager.select(com.bitchat.android.geohash.ChannelID.Location(channel))
} catch (_: Exception) { }
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
return@detectTapGestures
}
// URL open (all messages) - skip if it's an image URL (already shown below)
val urlAnnotations = annotatedText.getStringAnnotations(
tag = "url_click",
start = offset,
end = offset
)
if (urlAnnotations.isNotEmpty()) {
val raw = urlAnnotations.first().item
val resolved = if (raw.startsWith("http://", ignoreCase = true) || raw.startsWith("https://", ignoreCase = true)) raw else "https://$raw"
// Check if this URL is an image URL - if so, don't open browser
if (resolved !in imageUrls) {
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(resolved))
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
} catch (_: Exception) { }
}
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
return@detectTapGestures
}
},
onLongPress = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onMessageLongPress?.invoke(message)
}
)
},
fontFamily = FontFamily.Monospace,
softWrap = true,
overflow = TextOverflow.Visible,
style = androidx.compose.ui.text.TextStyle(
color = colorScheme.onSurface
),
onTextLayout = { result -> textLayoutResult = result }
)
// Render image previews below the text
com.bitchat.android.ui.media.UrlImagesColumn(
urls = imageUrls,
onImageClick = onImageClick
)
}
} else {
// No image URLs - render just the text as before
Text(
text = annotatedText,
modifier = modifier.pointerInput(message) {
detectTapGestures(
onTap = { position ->
val layout = textLayoutResult ?: return@detectTapGestures
val offset = layout.getOffsetForPosition(position)
// Nickname click only when not self
if (!isSelf && onNicknameClick != null) {
val nicknameAnnotations = annotatedText.getStringAnnotations(
tag = "nickname_click",
start = offset,
end = offset
)
if (nicknameAnnotations.isNotEmpty()) {
val nickname = nicknameAnnotations.first().item
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onNicknameClick.invoke(nickname)
return@detectTapGestures
}
}
// Geohash teleport (all messages)
val geohashAnnotations = annotatedText.getStringAnnotations(
tag = "geohash_click",
start = offset,
end = offset
)
if (nicknameAnnotations.isNotEmpty()) {
val nickname = nicknameAnnotations.first().item
if (geohashAnnotations.isNotEmpty()) {
val geohash = geohashAnnotations.first().item
try {
val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(
context
)
val level = when (geohash.length) {
in 0..2 -> com.bitchat.android.geohash.GeohashChannelLevel.REGION
in 3..4 -> com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE
5 -> com.bitchat.android.geohash.GeohashChannelLevel.CITY
6 -> com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD
else -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK
}
val channel = com.bitchat.android.geohash.GeohashChannel(level, geohash.lowercase())
locationManager.setTeleported(true)
locationManager.select(com.bitchat.android.geohash.ChannelID.Location(channel))
} catch (_: Exception) { }
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
onNicknameClick.invoke(nickname)
return@detectTapGestures
}
// URL open (all messages)
val urlAnnotations = annotatedText.getStringAnnotations(
tag = "url_click",
start = offset,
end = offset
)
if (urlAnnotations.isNotEmpty()) {
val raw = urlAnnotations.first().item
val resolved = if (raw.startsWith("http://", ignoreCase = true) || raw.startsWith("https://", ignoreCase = true)) raw else "https://$raw"
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(resolved))
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
} catch (_: Exception) { }
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
return@detectTapGestures
}
},
onLongPress = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onMessageLongPress?.invoke(message)
}
// Geohash teleport (all messages)
val geohashAnnotations = annotatedText.getStringAnnotations(
tag = "geohash_click",
start = offset,
end = offset
)
if (geohashAnnotations.isNotEmpty()) {
val geohash = geohashAnnotations.first().item
try {
val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(
context
)
val level = when (geohash.length) {
in 0..2 -> com.bitchat.android.geohash.GeohashChannelLevel.REGION
in 3..4 -> com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE
5 -> com.bitchat.android.geohash.GeohashChannelLevel.CITY
6 -> com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD
else -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK
}
val channel = com.bitchat.android.geohash.GeohashChannel(level, geohash.lowercase())
locationManager.setTeleported(true)
locationManager.select(com.bitchat.android.geohash.ChannelID.Location(channel))
} catch (_: Exception) { }
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
return@detectTapGestures
}
// URL open (all messages)
val urlAnnotations = annotatedText.getStringAnnotations(
tag = "url_click",
start = offset,
end = offset
)
if (urlAnnotations.isNotEmpty()) {
val raw = urlAnnotations.first().item
val resolved = if (raw.startsWith("http://", ignoreCase = true) || raw.startsWith("https://", ignoreCase = true)) raw else "https://$raw"
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(resolved))
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
} catch (_: Exception) { }
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
return@detectTapGestures
}
},
onLongPress = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onMessageLongPress?.invoke(message)
}
)
},
fontFamily = FontFamily.Monospace,
softWrap = true,
overflow = TextOverflow.Visible,
style = androidx.compose.ui.text.TextStyle(
color = colorScheme.onSurface
),
onTextLayout = { result -> textLayoutResult = result }
)
)
},
fontFamily = FontFamily.Monospace,
softWrap = true,
overflow = TextOverflow.Visible,
style = androidx.compose.ui.text.TextStyle(
color = colorScheme.onSurface
),
onTextLayout = { result -> textLayoutResult = result }
)
}
}
}

View File

@ -51,7 +51,17 @@ class MessageManager(private val state: ChatState) {
}
val channelMessageList = currentChannelMessages[channel]?.toMutableList() ?: mutableListOf()
// Deduplicate by message ID to prevent duplicates from multiple relay responses
if (channelMessageList.any { it.id == message.id }) {
return // Already have this message
}
channelMessageList.add(message)
// Sort by timestamp (oldest first) for chat-style display
channelMessageList.sortBy { it.timestamp }
currentChannelMessages[channel] = channelMessageList
state.setChannelMessages(currentChannelMessages)
// Reflect into process-wide store
@ -96,6 +106,22 @@ class MessageManager(private val state: ChatState) {
currentUnread.remove(channel)
state.setUnreadChannelMessages(currentUnread)
}
/**
* Update the content of a specific message in a channel by its ID.
* Used for async content resolution (e.g., resolving nostr: URIs to usernames).
*/
fun updateChannelMessageContent(channel: String, messageId: String, newContent: String) {
val channelMessages = state.getChannelMessagesValue().toMutableMap()
val messages = channelMessages[channel]?.toMutableList() ?: return
val index = messages.indexOfFirst { it.id == messageId }
if (index >= 0) {
messages[index] = messages[index].copy(content = newContent)
channelMessages[channel] = messages
state.setChannelMessages(channelMessages)
}
}
// MARK: - Private Message Management

View File

@ -11,8 +11,47 @@ object MessageSpecialParser {
// Geohash alphabet is base32: 0123456789bcdefghjkmnpqrstuvwxyz
private val standaloneGeohashRegex = Regex("(^|[^A-Za-z0-9_#])#([0-9bcdefghjkmnpqrstuvwxyz]{2,})($|[^A-Za-z0-9_])", RegexOption.IGNORE_CASE)
// Image URL extensions (case insensitive)
private val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "webp", "avif", "bmp", "svg")
data class GeohashMatch(val start: Int, val endExclusive: Int, val geohash: String)
data class UrlMatch(val start: Int, val endExclusive: Int, val url: String)
data class ImageUrlMatch(val url: String, val start: Int, val endExclusive: Int)
/**
* Finds image URLs in text. Returns URLs ending with common image extensions.
* Also handles URLs with query params after the extension (e.g., image.jpg?size=large)
*/
fun findImageUrls(text: String): List<ImageUrlMatch> {
val urls = findUrls(text)
return urls.mapNotNull { urlMatch ->
val url = urlMatch.url
// Normalize URL for checking (add https:// if missing)
val normalizedUrl = if (url.startsWith("http://", ignoreCase = true) ||
url.startsWith("https://", ignoreCase = true)) {
url
} else {
"https://$url"
}
// Extract path without query params
val pathPart = normalizedUrl.substringBefore('?').substringBefore('#')
val extension = pathPart.substringAfterLast('.', "").lowercase()
if (extension in IMAGE_EXTENSIONS) {
ImageUrlMatch(normalizedUrl, urlMatch.start, urlMatch.endExclusive)
} else {
null
}
}
}
/**
* Extracts image URLs from message content, returning list of full URLs.
*/
fun extractImageUrls(content: String): List<String> {
return findImageUrls(content).map { it.url }
}
/**
* Finds standalone geohashes within [text]. A match is returned only when

View File

@ -0,0 +1,669 @@
package com.bitchat.android.ui
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.core.ui.component.button.CloseButton
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
import com.bitchat.android.nostr.NostrIdentity
import com.bitchat.android.nostr.NostrIdentityBridge
/**
* Row component for displaying key information
*/
@Composable
private fun KeyDisplayRow(
icon: ImageVector,
label: String,
value: String,
showValue: Boolean,
onToggleVisibility: () -> Unit,
onCopy: () -> Unit,
isSecret: Boolean = false
) {
val colorScheme = MaterialTheme.colorScheme
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = if (isSecret) colorScheme.error else colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = colorScheme.onSurface
)
}
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
if (isSecret) {
IconButton(onClick = onToggleVisibility, modifier = Modifier.size(32.dp)) {
Icon(
imageVector = if (showValue) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
contentDescription = if (showValue) "Hide" else "Show",
tint = colorScheme.onSurface.copy(alpha = 0.6f),
modifier = Modifier.size(18.dp)
)
}
}
IconButton(onClick = onCopy, modifier = Modifier.size(32.dp)) {
Icon(
imageVector = Icons.Filled.ContentCopy,
contentDescription = "Copy",
tint = colorScheme.onSurface.copy(alpha = 0.6f),
modifier = Modifier.size(18.dp)
)
}
}
}
Surface(
modifier = Modifier.fillMaxWidth(),
color = colorScheme.surfaceVariant.copy(alpha = 0.5f),
shape = RoundedCornerShape(8.dp)
) {
Text(
text = if (showValue || !isSecret) value else value.take(8) + "..." + value.takeLast(4),
style = TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
),
color = colorScheme.onSurface.copy(alpha = 0.8f),
modifier = Modifier.padding(12.dp)
)
}
}
}
/**
* Nostr Account Management Sheet
* Allows users to view, export, and import their Nostr identity
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NostrAccountSheet(
isPresented: Boolean,
onDismiss: () -> Unit,
onIdentityChanged: (() -> Unit)? = null,
onNostrNameFound: ((String) -> Unit)? = null,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
val keyboardController = LocalSoftwareKeyboardController.current
// State
var currentIdentity by remember { mutableStateOf<NostrIdentity?>(null) }
var showNsec by remember { mutableStateOf(false) }
var showImportDialog by remember { mutableStateOf(false) }
var importNsecText by remember { mutableStateOf("") }
var importError by remember { mutableStateOf<String?>(null) }
var showImportConfirmation by remember { mutableStateOf(false) }
// State for profile name confirmation
var showNameConfirmation by remember { mutableStateOf(false) }
var foundNostrName by remember { mutableStateOf<String?>(null) }
// Load identity on display
LaunchedEffect(isPresented) {
if (isPresented) {
currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context)
showNsec = false
}
}
val lazyListState = rememberLazyListState()
val isScrolled by remember {
derivedStateOf {
lazyListState.firstVisibleItemIndex > 0 || lazyListState.firstVisibleItemScrollOffset > 0
}
}
val topBarAlpha by animateFloatAsState(
targetValue = if (isScrolled) 0.98f else 0f,
label = "topBarAlpha"
)
val colorScheme = MaterialTheme.colorScheme
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
if (isPresented) {
BitchatBottomSheet(
modifier = modifier,
onDismissRequest = onDismiss,
) {
Box(modifier = Modifier.fillMaxWidth()) {
LazyColumn(
state = lazyListState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(top = 80.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
// Header
item(key = "header") {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "Nostr Account",
style = TextStyle(
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
letterSpacing = 1.sp
),
color = colorScheme.onBackground
)
Text(
text = "Your decentralized identity",
fontSize = 13.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onBackground.copy(alpha = 0.6f)
)
}
}
// Current Identity Display
item(key = "identity") {
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
Text(
text = "YOUR KEYS",
style = MaterialTheme.typography.labelSmall,
color = colorScheme.onBackground.copy(alpha = 0.5f),
letterSpacing = 0.5.sp,
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp)
)
Surface(
modifier = Modifier.fillMaxWidth(),
color = colorScheme.surface,
shape = RoundedCornerShape(16.dp)
) {
Column {
currentIdentity?.let { identity ->
// Public Key (npub)
KeyDisplayRow(
icon = Icons.Filled.Person,
label = "Public Key (npub)",
value = identity.npub,
showValue = true,
onToggleVisibility = {},
onCopy = {
clipboardManager.setText(AnnotatedString(identity.npub))
Toast.makeText(context, "Public key copied", Toast.LENGTH_SHORT).show()
},
isSecret = false
)
HorizontalDivider(
modifier = Modifier.padding(start = 56.dp),
color = colorScheme.outline.copy(alpha = 0.12f)
)
// Private Key (nsec)
KeyDisplayRow(
icon = Icons.Filled.Key,
label = "Private Key (nsec)",
value = identity.getNsec(),
showValue = showNsec,
onToggleVisibility = { showNsec = !showNsec },
onCopy = {
if (showNsec) {
clipboardManager.setText(AnnotatedString(identity.getNsec()))
Toast.makeText(context, "Private key copied - keep it safe!", Toast.LENGTH_LONG).show()
} else {
Toast.makeText(context, "Reveal the key first to copy", Toast.LENGTH_SHORT).show()
}
},
isSecret = true
)
} ?: run {
Text(
text = "No identity found",
modifier = Modifier.padding(16.dp),
color = colorScheme.onSurface.copy(alpha = 0.6f)
)
}
}
}
}
}
// Security Warning
item(key = "warning") {
Surface(
modifier = Modifier
.padding(horizontal = 20.dp)
.fillMaxWidth(),
color = colorScheme.error.copy(alpha = 0.1f),
shape = RoundedCornerShape(16.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top
) {
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = null,
tint = colorScheme.error,
modifier = Modifier.size(20.dp)
)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = "Keep your nsec private",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = colorScheme.error
)
Text(
text = "Your private key (nsec) gives full access to your Nostr identity. Never share it with anyone. Back it up securely.",
fontSize = 13.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f)
)
}
}
}
}
// Import Key Section
item(key = "import") {
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
Text(
text = "IMPORT PRIVATE KEY",
style = MaterialTheme.typography.labelSmall,
color = colorScheme.onBackground.copy(alpha = 0.5f),
letterSpacing = 0.5.sp,
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp)
)
Surface(
modifier = Modifier.fillMaxWidth(),
color = colorScheme.surface,
shape = RoundedCornerShape(16.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Have an existing Nostr account? Import your private key (nsec) to sign and publish messages.",
fontSize = 13.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f),
modifier = Modifier.padding(bottom = 12.dp)
)
Button(
onClick = { showImportDialog = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
),
shape = RoundedCornerShape(12.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Filled.Key,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Text(
text = "Import Private Key (nsec)",
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Medium
)
}
}
}
}
}
}
// Clear Private Key Section
item(key = "clear_private") {
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
Text(
text = "PRIVATE KEY",
style = MaterialTheme.typography.labelSmall,
color = colorScheme.onBackground.copy(alpha = 0.5f),
letterSpacing = 0.5.sp,
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp)
)
Surface(
modifier = Modifier.fillMaxWidth(),
color = colorScheme.surface,
shape = RoundedCornerShape(16.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "You can remove your private key from this device while keeping the public-only npub for subscriptions.",
fontSize = 13.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f),
modifier = Modifier.padding(bottom = 12.dp)
)
Button(onClick = {
NostrIdentityBridge.clearPrivateKey(context)
currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context)
Toast.makeText(context, "Private key removed from device", Toast.LENGTH_LONG).show()
}, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(containerColor = colorScheme.error), shape = RoundedCornerShape(12.dp)) {
Text("Remove Private Key", color = Color.White)
}
}
}
}
}
// Footer
item(key = "footer") {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Your keys are stored securely on this device",
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = colorScheme.onSurface.copy(alpha = 0.4f)
)
Spacer(modifier = Modifier.height(20.dp))
}
}
}
// TopBar
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.height(64.dp)
.background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha))
) {
CloseButton(
onClick = onDismiss,
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(horizontal = 16.dp),
)
}
}
}
// Import Dialog
if (showImportDialog) {
AlertDialog(
onDismissRequest = {
showImportDialog = false
importNsecText = ""
importError = null
},
title = {
Text(
text = "Import nsec",
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = "Paste your nsec below. This will replace your current identity.",
fontSize = 14.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f)
)
OutlinedTextField(
value = importNsecText,
onValueChange = {
importNsecText = it
importError = null
},
label = { Text("nsec1...") },
placeholder = { Text("nsec1...") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = { keyboardController?.hide() }
),
isError = importError != null,
supportingText = importError?.let { { Text(it, color = colorScheme.error) } }
)
Surface(
color = colorScheme.error.copy(alpha = 0.1f),
shape = RoundedCornerShape(8.dp)
) {
Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = null,
tint = colorScheme.error,
modifier = Modifier.size(16.dp)
)
Text(
text = "This will permanently replace your current key!",
fontSize = 12.sp,
color = colorScheme.error
)
}
}
}
},
confirmButton = {
TextButton(
onClick = {
if (importNsecText.isBlank()) {
importError = "Please enter an nsec"
return@TextButton
}
if (!NostrIdentityBridge.isValidNsec(importNsecText)) {
importError = "Invalid nsec format"
return@TextButton
}
showImportConfirmation = true
}
) {
Text("Import", color = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D))
}
},
dismissButton = {
TextButton(
onClick = {
showImportDialog = false
importNsecText = ""
importError = null
}
) {
Text("Cancel")
}
}
)
}
// Confirmation Dialog
if (showImportConfirmation) {
AlertDialog(
onDismissRequest = { showImportConfirmation = false },
title = {
Text(
text = "Confirm Import",
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
)
},
text = {
Text(
text = "Are you sure you want to replace your current Nostr identity? This action cannot be undone. Make sure you have backed up your current nsec if needed.",
fontSize = 14.sp
)
},
confirmButton = {
TextButton(
onClick = {
val newIdentity = NostrIdentityBridge.importFromNsec(importNsecText, context)
if (newIdentity != null) {
currentIdentity = newIdentity
showImportConfirmation = false
showImportDialog = false
importNsecText = ""
importError = null
showNsec = false
onIdentityChanged?.invoke()
Toast.makeText(context, "Identity imported successfully!", Toast.LENGTH_LONG).show()
// Fetch profile from Nostr relays to get the user's name
NostrIdentityBridge.fetchProfileFromRelays(newIdentity.publicKeyHex) { name, displayName, _, _, _ ->
// Prefer name (username like @avillagran), fallback to display_name
val nostrName = name?.takeIf { it.isNotBlank() } ?: displayName?.takeIf { it.isNotBlank() }
if (nostrName != null) {
foundNostrName = nostrName
showNameConfirmation = true
}
}
} else {
showImportConfirmation = false
importError = "Failed to import key"
}
}
) {
Text("Replace", color = colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showImportConfirmation = false }) {
Text("Cancel")
}
}
)
}
// Name confirmation dialog - shown when a Nostr profile with a name is found
if (showNameConfirmation && foundNostrName != null) {
AlertDialog(
onDismissRequest = {
showNameConfirmation = false
foundNostrName = null
},
title = {
Text(
text = "Use Nostr Name?",
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "We found your Nostr profile name:",
fontSize = 14.sp
)
Surface(
color = colorScheme.primaryContainer,
shape = RoundedCornerShape(8.dp)
) {
Text(
text = foundNostrName!!,
modifier = Modifier.padding(12.dp),
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = colorScheme.onPrimaryContainer
)
}
Text(
text = "Would you like to use this as your BitChat username?",
fontSize = 14.sp
)
}
},
confirmButton = {
TextButton(
onClick = {
foundNostrName?.let { name ->
onNostrNameFound?.invoke(name)
Toast.makeText(context, "Username updated to: $name", Toast.LENGTH_SHORT).show()
}
showNameConfirmation = false
foundNostrName = null
}
) {
Text("Yes, use this name", color = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D))
}
},
dismissButton = {
TextButton(
onClick = {
showNameConfirmation = false
foundNostrName = null
}
) {
Text("No, keep current")
}
}
)
}
}
}

View File

@ -13,6 +13,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Download
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@ -20,6 +21,8 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@ -33,8 +36,22 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.res.stringResource
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.bitchat.android.R
import java.io.File
import java.net.URL
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Check if a path is a URL (http/https)
*/
private fun isUrl(path: String): Boolean {
return path.startsWith("http://", ignoreCase = true) ||
path.startsWith("https://", ignoreCase = true)
}
/**
* Fullscreen image viewer with swipe navigation between multiple images
@ -72,18 +89,57 @@ fun FullScreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onClo
modifier = Modifier.fillMaxSize()
) { page ->
val currentPath = imagePaths[page]
val bmp = remember(currentPath) { try { android.graphics.BitmapFactory.decodeFile(currentPath) } catch (_: Exception) { null } }
bmp?.let {
androidx.compose.foundation.Image(
bitmap = it.asImageBitmap(),
contentDescription = stringResource(R.string.cd_image_index_of, page + 1, imagePaths.size),
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit
)
} ?: run {
if (isUrl(currentPath)) {
// Load from URL using Coil
var isLoading by remember { mutableStateOf(true) }
var isError by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = stringResource(R.string.image_unavailable), color = Color.White)
AsyncImage(
model = ImageRequest.Builder(context)
.data(currentPath)
.crossfade(true)
.build(),
contentDescription = stringResource(R.string.cd_image_index_of, page + 1, imagePaths.size),
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit,
onLoading = { isLoading = true; isError = false },
onSuccess = { isLoading = false; isError = false },
onError = { isLoading = false; isError = true }
)
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(48.dp),
color = Color.White
)
}
if (isError) {
Text(
text = stringResource(R.string.image_load_failed),
color = Color.White
)
}
}
} else {
// Load from local file
val bmp = remember(currentPath) {
try { android.graphics.BitmapFactory.decodeFile(currentPath) } catch (_: Exception) { null }
}
bmp?.let {
androidx.compose.foundation.Image(
bitmap = it.asImageBitmap(),
contentDescription = stringResource(R.string.cd_image_index_of, page + 1, imagePaths.size),
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit
)
} ?: run {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = stringResource(R.string.image_unavailable), color = Color.White)
}
}
}
}
@ -139,34 +195,66 @@ fun FullScreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onClo
}
private fun saveToDownloads(context: android.content.Context, path: String) {
runCatching {
val name = File(path).name
val mime = when {
name.endsWith(".png", true) -> "image/png"
name.endsWith(".webp", true) -> "image/webp"
else -> "image/jpeg"
}
val values = ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, name)
put(MediaStore.Downloads.MIME_TYPE, mime)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.Downloads.IS_PENDING, 1)
// Launch in background thread for URL downloads
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
runCatching {
val isUrlPath = isUrl(path)
// Determine filename and mime type
val name = if (isUrlPath) {
// Extract filename from URL, fallback to timestamp-based name
val urlPath = path.substringBefore('?').substringBefore('#')
val urlName = urlPath.substringAfterLast('/')
if (urlName.isNotEmpty() && urlName.contains('.')) {
urlName
} else {
"image_${System.currentTimeMillis()}.jpg"
}
} else {
File(path).name
}
val mime = when {
name.endsWith(".png", true) -> "image/png"
name.endsWith(".webp", true) -> "image/webp"
name.endsWith(".gif", true) -> "image/gif"
name.endsWith(".avif", true) -> "image/avif"
else -> "image/jpeg"
}
val values = ContentValues().apply {
put(MediaStore.Downloads.DISPLAY_NAME, name)
put(MediaStore.Downloads.MIME_TYPE, mime)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.Downloads.IS_PENDING, 1)
}
}
val uri = context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
if (uri != null) {
context.contentResolver.openOutputStream(uri)?.use { out ->
if (isUrlPath) {
// Download from URL
URL(path).openStream().use { it.copyTo(out) }
} else {
// Copy from local file
File(path).inputStream().use { it.copyTo(out) }
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val v2 = ContentValues().apply { put(MediaStore.Downloads.IS_PENDING, 0) }
context.contentResolver.update(uri, v2, null, null)
}
// Show toast message indicating the image has been saved (on main thread)
withContext(Dispatchers.Main) {
Toast.makeText(context, context.getString(R.string.toast_image_saved), Toast.LENGTH_SHORT).show()
}
}
}.onFailure {
// Show error toast on main thread
withContext(Dispatchers.Main) {
Toast.makeText(context, context.getString(R.string.toast_failed_to_save_image), Toast.LENGTH_SHORT).show()
}
}
val uri = context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
if (uri != null) {
context.contentResolver.openOutputStream(uri)?.use { out ->
File(path).inputStream().use { it.copyTo(out) }
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val v2 = ContentValues().apply { put(MediaStore.Downloads.IS_PENDING, 0) }
context.contentResolver.update(uri, v2, null, null)
}
// Show toast message indicating the image has been saved
Toast.makeText(context, context.getString(R.string.toast_image_saved), Toast.LENGTH_SHORT).show()
}
}.onFailure {
// Optionally handle failure case (e.g., show error toast)
Toast.makeText(context, context.getString(R.string.toast_failed_to_save_image), Toast.LENGTH_SHORT).show()
}
}

View File

@ -149,3 +149,97 @@ fun ImageMessageItem(
}
}
}
/**
* Displays an image loaded from a URL using Coil.
* Used for inline image previews in text messages containing image URLs.
*/
@Composable
fun UrlImageItem(
url: String,
onImageClick: ((String) -> Unit)? = null,
modifier: Modifier = Modifier
) {
var isLoading by remember { mutableStateOf(true) }
var isError by remember { mutableStateOf(false) }
Box(
modifier = modifier
.widthIn(max = 300.dp)
.heightIn(max = 300.dp)
.clip(androidx.compose.foundation.shape.RoundedCornerShape(10.dp))
) {
coil.compose.AsyncImage(
model = coil.request.ImageRequest.Builder(LocalContext.current)
.data(url)
.crossfade(true)
.build(),
contentDescription = "Image",
modifier = Modifier
.fillMaxWidth()
.clickable { onImageClick?.invoke(url) },
contentScale = ContentScale.Fit,
onLoading = { isLoading = true; isError = false },
onSuccess = { isLoading = false; isError = false },
onError = { isLoading = false; isError = true }
)
// Loading indicator
if (isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Gray.copy(alpha = 0.3f)),
contentAlignment = Alignment.Center
) {
androidx.compose.material3.CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
color = Color.White
)
}
}
// Error state - show placeholder
if (isError) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(100.dp)
.background(Color.Gray.copy(alpha = 0.2f)),
contentAlignment = Alignment.Center
) {
Text(
text = "Failed to load image",
color = Color.Gray,
fontFamily = FontFamily.Monospace
)
}
}
}
}
/**
* Displays multiple URL images in a vertical column.
* Used when a message contains multiple image URLs.
*/
@Composable
fun UrlImagesColumn(
urls: List<String>,
onImageClick: ((String, List<String>, Int) -> Unit)? = null,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
urls.forEachIndexed { index, url ->
UrlImageItem(
url = url,
onImageClick = { clickedUrl ->
onImageClick?.invoke(clickedUrl, urls, index)
}
)
}
}
}

View File

@ -185,6 +185,7 @@
<string name="image_page_of">Image %1$d of %2$d</string>
<string name="image_unavailable">Image unavailable</string>
<string name="image_load_failed">Failed to load image</string>
<string name="image_saved_to_downloads">Image saved to Downloads</string>
<string name="image_save_failed">Failed to save image</string>
<string name="pick_file">Pick file</string>
@ -222,6 +223,7 @@
<string name="disable_location_services">disable location services</string>
<string name="enable_location_services">enable location services</string>
<string name="mesh_label">mesh</string>
<string name="me_channel_description">Your personal Nostr feed synced across devices</string>
<string name="location_level_block">block</string>
<string name="location_level_neighborhood">neighborhood</string>
<string name="location_level_city">city</string>

View File

@ -0,0 +1,103 @@
package com.bitchat.android.nostr
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
/**
* Tests for NostrMeshGateway deduplication logic
*/
class NostrMeshGatewayTest {
@Before
fun setUp() {
// Reset any state between tests
}
@Test
fun `NostrMeshSerializer TYPE_NOSTR_RELAY_REQUEST header is correct`() {
assertEquals(0x7E.toByte(), NostrMeshSerializer.TYPE_NOSTR_RELAY_REQUEST)
}
@Test
fun `NostrMeshSerializer TYPE_NOSTR_PLAINTEXT header is correct`() {
assertEquals(0x00.toByte(), NostrMeshSerializer.TYPE_NOSTR_PLAINTEXT)
}
@Test
fun `serializeEventForMesh produces valid packet with header`() {
val event = NostrEvent(
id = "abc123",
pubkey = "pubkey123",
createdAt = 1234567890,
kind = 1,
tags = emptyList(),
content = "Hello World",
sig = "sig123"
)
val serialized = NostrMeshSerializer.serializeEventForMesh(event)
// First byte should be header (either 0x7E for compressed or 0x00 for plaintext)
assertTrue(
serialized[0] == NostrMeshSerializer.TYPE_NOSTR_RELAY_REQUEST ||
serialized[0] == NostrMeshSerializer.TYPE_NOSTR_PLAINTEXT
)
// Should have at least 5 bytes (1 header + 4 size)
assertTrue(serialized.size >= 5)
}
@Test
fun `serializeEventForMesh and deserializeEventFromMesh are inverse operations`() {
val event = NostrEvent(
id = "testid123456789",
pubkey = "testpubkey",
createdAt = 1700000000,
kind = 1,
tags = listOf(listOf("nonce", "12345", "8")),
content = "Test content for serialization",
sig = "testsignature"
)
val serialized = NostrMeshSerializer.serializeEventForMesh(event)
val deserialized = NostrMeshSerializer.deserializeEventFromMesh(serialized)
assertNotNull(deserialized)
// Parse back to event and verify
val parsedEvent = NostrEvent.fromJsonString(deserialized!!)
assertNotNull(parsedEvent)
assertEquals(event.id, parsedEvent!!.id)
assertEquals(event.pubkey, parsedEvent.pubkey)
assertEquals(event.content, parsedEvent.content)
assertEquals(event.kind, parsedEvent.kind)
}
@Test
fun `deserializeEventFromMesh returns null for invalid header`() {
val invalidPacket = byteArrayOf(0x99.toByte(), 0x00, 0x00, 0x00, 0x05, 0x48, 0x65, 0x6C, 0x6C, 0x6F)
val result = NostrMeshSerializer.deserializeEventFromMesh(invalidPacket)
assertNull(result)
}
@Test
fun `deserializeEventFromMesh returns null for empty packet`() {
val emptyPacket = byteArrayOf()
val result = NostrMeshSerializer.deserializeEventFromMesh(emptyPacket)
assertNull(result)
}
@Test
fun `deserializeEventFromMesh returns null for packet too small`() {
val tooSmall = byteArrayOf(0x7E, 0x00, 0x00) // Only 3 bytes, need at least 5
val result = NostrMeshSerializer.deserializeEventFromMesh(tooSmall)
assertNull(result)
}
}

View File

@ -45,6 +45,9 @@ gms-location = "21.3.0"
# Security
security-crypto = "1.1.0-beta01"
# Image loading
coil = "2.5.0"
# QR
zxing-core = "3.5.4"
@ -114,6 +117,9 @@ gms-location = { module = "com.google.android.gms:play-services-location", versi
# Security
androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "security-crypto" }
# Image loading
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
# QR
zxing-core = { module = "com.google.zxing:core", version.ref = "zxing-core" }