From 260040fea205e0fb0c90c58dcb864707af45b86c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Villagr=C3=A1n?= Date: Wed, 14 Jan 2026 22:07:43 -0300 Subject: [PATCH 1/3] feat(nostr): add mesh serializer + gateway listener helpers (initial) --- .../bitchat/android/nostr/NostrMeshGateway.kt | 148 ++++++++++++++++++ .../android/nostr/NostrMeshSerializer.kt | 136 ++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 app/src/main/java/com/bitchat/android/nostr/NostrMeshGateway.kt create mode 100644 app/src/main/java/com/bitchat/android/nostr/NostrMeshSerializer.kt diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrMeshGateway.kt b/app/src/main/java/com/bitchat/android/nostr/NostrMeshGateway.kt new file mode 100644 index 00000000..7679c050 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrMeshGateway.kt @@ -0,0 +1,148 @@ +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 + */ +object NostrMeshGateway { + private const val TAG = "NostrMeshGateway" + + /** + * 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 + } + + // Publish to relays via NostrRelayManager + try { + NostrRelayManager.getInstance(context).sendEvent(event) + 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 + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrMeshSerializer.kt b/app/src/main/java/com/bitchat/android/nostr/NostrMeshSerializer.kt new file mode 100644 index 00000000..6ba18283 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrMeshSerializer.kt @@ -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 + } + } +} From 7afab46dc04790f402953e0fd41c2fe92885bb6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Villagr=C3=A1n?= Date: Thu, 15 Jan 2026 03:34:09 -0300 Subject: [PATCH 2/3] feat(nostr): Add mesh-to-relay gateway for #me messages Core changes: - Filter Nostr packets in MessageHandler to route to gateway instead of displaying in public chat - Add event ID deduplication cache to prevent multiple online devices publishing same event - Add PoW requirement notification for offline #me messages - Fix signature invalidation by signing AFTER PoW mining completes New files: - NostrProfileManager: Manages #me channel message publishing with PublishResult sealed class - NostrAccountSheet: UI for Nostr account management - NostrMeshGatewayTest: Unit tests for serializer UI/UX improvements: - Enhanced image viewer with pinch-to-zoom and pan gestures - Improved message components styling - Location channels sheet updates - About sheet enhancements --- app/build.gradle.kts | 3 + .../android/mesh/BluetoothMeshService.kt | 12 + .../bitchat/android/mesh/MessageHandler.kt | 12 + .../com/bitchat/android/nostr/NostrFilter.kt | 11 + .../bitchat/android/nostr/NostrIdentity.kt | 441 ++++++++++++ .../bitchat/android/nostr/NostrMeshGateway.kt | 49 ++ .../android/nostr/NostrProfileManager.kt | 209 ++++++ .../android/nostr/NostrRelayManager.kt | 9 + .../bitchat/android/services/AppStateStore.kt | 3 +- .../java/com/bitchat/android/ui/AboutSheet.kt | 60 ++ .../com/bitchat/android/ui/ChannelManager.kt | 7 + .../java/com/bitchat/android/ui/ChatScreen.kt | 17 +- .../com/bitchat/android/ui/ChatUIUtils.kt | 38 +- .../com/bitchat/android/ui/ChatViewModel.kt | 112 +++ .../android/ui/LocationChannelsSheet.kt | 30 +- .../bitchat/android/ui/MessageComponents.kt | 248 +++++-- .../com/bitchat/android/ui/MessageManager.kt | 26 + .../android/ui/MessageSpecialParser.kt | 39 + .../bitchat/android/ui/NostrAccountSheet.kt | 669 ++++++++++++++++++ .../android/ui/media/FullScreenImageViewer.kt | 164 ++++- .../android/ui/media/ImageMessageItem.kt | 94 +++ app/src/main/res/values/strings.xml | 2 + .../android/nostr/NostrMeshGatewayTest.kt | 103 +++ gradle/libs.versions.toml | 6 + 24 files changed, 2248 insertions(+), 116 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/nostr/NostrProfileManager.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/NostrAccountSheet.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/nostr/NostrMeshGatewayTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bb5852b7..e6b93410 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -138,6 +138,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)) diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 4e8b3a43..fb9bb703 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -714,6 +714,18 @@ class BluetoothMeshService(private val context: Context) { } } + /** + * 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). */ diff --git a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt index 63081d74..7c37152d 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt @@ -383,6 +383,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) { diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt b/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt index 247162a0..890a30d0 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrFilter.kt @@ -73,6 +73,17 @@ data class NostrFilter( fun forEvents(ids: List): 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 + ) + } } /** diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt b/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt index 01583dde..b0f2e65f 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt @@ -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() + // Cache for resolved profile names (pubkeyHex -> displayName) + private val profileNameCache = mutableMapOf() + + // Pending profile resolutions to avoid duplicate requests + private val pendingResolutions = mutableSetOf() + + // Listeners for profile name updates (messageId -> callback) + private val profileUpdateListeners = mutableMapOf Unit>() + /** * Get or create the current Nostr identity */ @@ -224,6 +256,415 @@ 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() + + 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) { + resolved = true + 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() } + + relayManager.unsubscribe(subscriptionId) + + GlobalScope.launch(Dispatchers.Main) { + onResult(name) + } + } catch (e: Exception) { + Log.w(TAG, "Failed to parse profile for $pubkeyHex: ${e.message}") + } + } + } + ) + + // 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 diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrMeshGateway.kt b/app/src/main/java/com/bitchat/android/nostr/NostrMeshGateway.kt index 7679c050..c5ceb6b6 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrMeshGateway.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrMeshGateway.kt @@ -12,9 +12,51 @@ 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(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 @@ -116,10 +158,17 @@ object NostrMeshGateway { 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 diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrProfileManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrProfileManager.kt new file mode 100644 index 00000000..f891a3f3 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrProfileManager.kt @@ -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?) -> 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?) -> 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}") + } + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt index d44e6e0b..403cefa7 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt @@ -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) { + Log.d(TAG, "📡 Connecting to ${relayUrls.size} additional relays for profile lookup") + ensureConnectionsFor(relayUrls.toSet()) + } + /** * Disconnect from all relays */ diff --git a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt index 07f146bd..18e76355 100644 --- a/app/src/main/java/com/bitchat/android/services/AppStateStore.kt +++ b/app/src/main/java/com/bitchat/android/services/AppStateStore.kt @@ -91,7 +91,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 } } diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index f137ac63..259c110b 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -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) } diff --git a/app/src/main/java/com/bitchat/android/ui/ChannelManager.kt b/app/src/main/java/com/bitchat/android/ui/ChannelManager.kt index 5cc1375c..47534090 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChannelManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChannelManager.kt @@ -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) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 0d22a0d5..9c66a202 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -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) { diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt index 82db6b64..7a3754f0 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -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() } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 9fccff3c..74a05bb2 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -14,6 +14,9 @@ import com.bitchat.android.mesh.BluetoothMeshService 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 @@ -299,6 +302,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. } @@ -362,7 +376,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) { @@ -533,6 +594,57 @@ class ChatViewModel( // Send to geohash channel via Nostr ephemeral event geohashViewModel.sendGeohashMessage(content, selectedLocationChannel.channel, meshService.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() ?: meshService.myPeerID, diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index d3835a29..3ee322f7 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -14,6 +14,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.* @@ -71,6 +72,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("") } @@ -230,7 +234,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), diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 986c8c63..5293ec5b 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -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(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 } + ) + } } } diff --git a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt index 40b7eb98..6a2e5104 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageManager.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/ui/MessageSpecialParser.kt b/app/src/main/java/com/bitchat/android/ui/MessageSpecialParser.kt index 8e8dec96..8a314739 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageSpecialParser.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageSpecialParser.kt @@ -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 { + 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 { + return findImageUrls(content).map { it.url } + } /** * Finds standalone geohashes within [text]. A match is returned only when diff --git a/app/src/main/java/com/bitchat/android/ui/NostrAccountSheet.kt b/app/src/main/java/com/bitchat/android/ui/NostrAccountSheet.kt new file mode 100644 index 00000000..e6d9c202 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/NostrAccountSheet.kt @@ -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(null) } + var showNsec by remember { mutableStateOf(false) } + var showImportDialog by remember { mutableStateOf(false) } + var importNsecText by remember { mutableStateOf("") } + var importError by remember { mutableStateOf(null) } + var showImportConfirmation by remember { mutableStateOf(false) } + + // State for profile name confirmation + var showNameConfirmation by remember { mutableStateOf(false) } + var foundNostrName by remember { mutableStateOf(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") + } + } + ) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt b/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt index 2964f9c0..b278577f 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/FullScreenImageViewer.kt @@ -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, 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, 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() } } diff --git a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt index 2310cd5b..c488ce3b 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/ImageMessageItem.kt @@ -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, + onImageClick: ((String, List, 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) + } + ) + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9845fd85..d605c278 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -185,6 +185,7 @@ Image %1$d of %2$d Image unavailable + Failed to load image Image saved to Downloads Failed to save image Pick file @@ -219,6 +220,7 @@ disable location services enable location services mesh + Your personal Nostr feed synced across devices block neighborhood city diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrMeshGatewayTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrMeshGatewayTest.kt new file mode 100644 index 00000000..8cc7c6bc --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrMeshGatewayTest.kt @@ -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) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b6a5411a..0b520d34 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } From 411541faf5aa79f21073aea580acbb8a9a49656a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Villagr=C3=A1n?= Date: Thu, 15 Jan 2026 04:14:48 -0300 Subject: [PATCH 3/3] fix(nostr): prevent subscription leak on JSON parse failure Move resolved=true after successful parse so timeout cleanup still runs if metadata content is invalid JSON. Fixes dangling subscriptions and permanently pending pubkeys. --- app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt b/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt index b0f2e65f..c864862c 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt @@ -531,12 +531,12 @@ object NostrIdentityBridge { id = subscriptionId, handler = { event -> if (!resolved && event.kind == NostrKind.METADATA && event.pubkey == pubkeyHex) { - resolved = true 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) { @@ -544,6 +544,7 @@ object NostrIdentityBridge { } } 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 } } }