mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
Merge 411541faf5aa79f21073aea580acbb8a9a49656a into c8f45408b1984753a4df7f05aff03ceaa17bbba6
This commit is contained in:
commit
c94eb9ed80
@ -145,6 +145,9 @@ dependencies {
|
||||
// EXIF orientation handling for images
|
||||
implementation("androidx.exifinterface:exifinterface:1.3.7")
|
||||
|
||||
// Async image loading from URLs (Coil)
|
||||
implementation(libs.coil.compose)
|
||||
|
||||
// Testing
|
||||
testImplementation(libs.bundles.testing)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
|
||||
@ -785,6 +785,18 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast a pre-built BitchatPacket (signs it before sending).
|
||||
* Used by NostrProfileManager for mesh fallback when relays are unavailable.
|
||||
*/
|
||||
fun broadcastPacket(packet: BitchatPacket) {
|
||||
serviceScope.launch {
|
||||
val signedPacket = signPacketBeforeBroadcast(packet)
|
||||
connectionManager.broadcastPacket(RoutedPacket(signedPacket))
|
||||
try { gossipSyncManager.onPublicPacketSeen(signedPacket) } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a file over mesh as a broadcast MESSAGE (public mesh timeline/channels).
|
||||
*/
|
||||
|
||||
@ -388,6 +388,18 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
val packet = routed.packet
|
||||
val peerID = routed.peerID ?: "unknown"
|
||||
|
||||
// Check if this is a Nostr relay request packet (should be forwarded to relays, not displayed)
|
||||
if (packet.payload.isNotEmpty()) {
|
||||
val header = packet.payload[0]
|
||||
if (header == com.bitchat.android.nostr.NostrMeshSerializer.TYPE_NOSTR_RELAY_REQUEST ||
|
||||
header == com.bitchat.android.nostr.NostrMeshSerializer.TYPE_NOSTR_PLAINTEXT) {
|
||||
Log.d(TAG, "📡 Received Nostr relay request via mesh from ${peerID.take(8)}, routing to gateway")
|
||||
// Route to NostrMeshGateway for relay publishing (if we have internet)
|
||||
com.bitchat.android.nostr.NostrMeshGateway.meshPacketReceived(appContext, packet.payload, null)
|
||||
return // Don't display Nostr relay packets as chat messages
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce: only accept public messages from verified peers we know
|
||||
val peerInfo = delegate?.getPeerInfo(peerID)
|
||||
if (peerInfo == null || !peerInfo.isVerifiedNickname) {
|
||||
|
||||
@ -100,6 +100,17 @@ data class NostrFilter(
|
||||
fun forEvents(ids: List<String>): NostrFilter {
|
||||
return NostrFilter(ids = ids)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create filter for user metadata (kind 0) - profile information
|
||||
*/
|
||||
fun profileMetadata(pubkey: String): NostrFilter {
|
||||
return NostrFilter(
|
||||
kinds = listOf(NostrKind.METADATA),
|
||||
authors = listOf(pubkey),
|
||||
limit = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -3,6 +3,10 @@ package com.bitchat.android.nostr
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
|
||||
@ -87,6 +91,25 @@ data class NostrIdentity(
|
||||
npub
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the nsec (bech32-encoded private key) for backup/export
|
||||
*/
|
||||
fun getNsec(): String {
|
||||
return Bech32.encode("nsec", privateKeyHex.hexToByteArrayLocal())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get short display format for nsec
|
||||
*/
|
||||
fun getShortNsec(): String {
|
||||
val nsec = getNsec()
|
||||
return if (nsec.length > 16) {
|
||||
"${nsec.take(8)}...${nsec.takeLast(8)}"
|
||||
} else {
|
||||
nsec
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -101,6 +124,15 @@ object NostrIdentityBridge {
|
||||
// Cache for derived geohash identities to avoid repeated crypto operations
|
||||
private val geohashIdentityCache = mutableMapOf<String, NostrIdentity>()
|
||||
|
||||
// Cache for resolved profile names (pubkeyHex -> displayName)
|
||||
private val profileNameCache = mutableMapOf<String, String>()
|
||||
|
||||
// Pending profile resolutions to avoid duplicate requests
|
||||
private val pendingResolutions = mutableSetOf<String>()
|
||||
|
||||
// Listeners for profile name updates (messageId -> callback)
|
||||
private val profileUpdateListeners = mutableMapOf<String, () -> Unit>()
|
||||
|
||||
/**
|
||||
* Get or create the current Nostr identity
|
||||
*/
|
||||
@ -224,6 +256,416 @@ object NostrIdentityBridge {
|
||||
Log.e(TAG, "Failed to clear Nostr data: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a Nostr identity from an nsec (bech32-encoded private key)
|
||||
* Returns the imported identity on success, or null on failure
|
||||
*/
|
||||
fun importFromNsec(nsec: String, context: Context): NostrIdentity? {
|
||||
return try {
|
||||
val trimmed = nsec.trim().lowercase()
|
||||
val (hrp, data) = Bech32.decode(trimmed)
|
||||
|
||||
require(hrp == "nsec") { "Invalid nsec prefix, got: $hrp" }
|
||||
|
||||
val privateKeyHex = data.toHexStringLocal()
|
||||
require(NostrCrypto.isValidPrivateKey(privateKeyHex)) { "Invalid private key" }
|
||||
|
||||
val identity = NostrIdentity.fromPrivateKey(privateKeyHex)
|
||||
|
||||
// Save to secure storage
|
||||
val stateManager = SecureIdentityStateManager(context)
|
||||
saveNostrPrivateKey(stateManager, privateKeyHex)
|
||||
|
||||
// Clear derived identity cache since main identity changed
|
||||
geohashIdentityCache.clear()
|
||||
|
||||
Log.i(TAG, "Successfully imported Nostr identity: ${identity.getShortNpub()}")
|
||||
identity
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to import nsec: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the current identity's nsec for backup
|
||||
* Returns null if no identity exists
|
||||
*/
|
||||
fun exportNsec(context: Context): String? {
|
||||
return try {
|
||||
val identity = getCurrentNostrIdentity(context)
|
||||
identity?.getNsec()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to export nsec: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an nsec string is valid (for UI validation)
|
||||
*/
|
||||
fun isValidNsec(nsec: String): Boolean {
|
||||
return try {
|
||||
val trimmed = nsec.trim().lowercase()
|
||||
val (hrp, data) = Bech32.decode(trimmed)
|
||||
hrp == "nsec" && NostrCrypto.isValidPrivateKey(data.toHexStringLocal())
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// -- Public-only NPUB storage and helpers --
|
||||
private const val NOSTR_PUBLIC_ONLY = "nostr_public_only"
|
||||
|
||||
fun setPublicOnlyNpub(context: Context, npub: String?) {
|
||||
try {
|
||||
val stateManager = SecureIdentityStateManager(context)
|
||||
if (npub == null) {
|
||||
stateManager.clearSecureValues(NOSTR_PUBLIC_ONLY)
|
||||
Log.d(TAG, "Cleared public-only npub")
|
||||
} else {
|
||||
stateManager.storeSecureValue(NOSTR_PUBLIC_ONLY, npub)
|
||||
Log.d(TAG, "Stored public-only npub: ${npub.take(16)}...")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to set public-only npub: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun getPublicOnlyNpub(context: Context): String? {
|
||||
return try {
|
||||
val stateManager = SecureIdentityStateManager(context)
|
||||
stateManager.getSecureValue(NOSTR_PUBLIC_ONLY)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun isValidNpub(npub: String): Boolean {
|
||||
return try {
|
||||
val trimmed = npub.trim()
|
||||
val (hrp, data) = Bech32.decode(trimmed)
|
||||
hrp == "npub" && data.size == 32
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// Note: pub-derivation helpers are provided on the bridge for reuse across UI/logic.
|
||||
|
||||
/**
|
||||
* Return pubkey hex derived from an npub, or null if invalid
|
||||
*/
|
||||
fun getPubHexFromNpub(npub: String): String? {
|
||||
return try {
|
||||
val trimmed = npub.trim()
|
||||
val (hrp, data) = Bech32.decode(trimmed)
|
||||
if (hrp != "npub" || data.size != 32) return null
|
||||
data.toHexStringLocal()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the effective public key hex to use for subscriptions/presence.
|
||||
* Priority: private key -> public-only npub -> null
|
||||
*/
|
||||
fun getEffectivePublicKeyHex(context: Context): String? {
|
||||
return try {
|
||||
val stateManager = SecureIdentityStateManager(context)
|
||||
val private = stateManager.getSecureValue(NOSTR_PRIVATE_KEY)
|
||||
if (!private.isNullOrEmpty()) {
|
||||
return NostrCrypto.derivePublicKey(private)
|
||||
}
|
||||
|
||||
val pubNpub = stateManager.getSecureValue(NOSTR_PUBLIC_ONLY)
|
||||
if (!pubNpub.isNullOrEmpty()) {
|
||||
val (_, data) = Bech32.decode(pubNpub.trim())
|
||||
return data.toHexStringLocal()
|
||||
}
|
||||
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to get effective public key: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove stored private key while preserving public-only npub if present
|
||||
*/
|
||||
fun clearPrivateKey(context: Context) {
|
||||
try {
|
||||
val stateManager = SecureIdentityStateManager(context)
|
||||
stateManager.clearSecureValues(NOSTR_PRIVATE_KEY)
|
||||
// Also clear any cached identities derived from private key
|
||||
geohashIdentityCache.clear()
|
||||
Log.i(TAG, "Cleared stored Nostr private key")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to clear private key: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Nostr content and replace nostr: URIs with human-readable text.
|
||||
* Uses cached names if available, otherwise shows truncated version.
|
||||
* Call resolveNostrReferences() to fetch names asynchronously.
|
||||
*/
|
||||
fun parseNostrContent(content: String): String {
|
||||
// Pattern to match nostr: URIs (bech32 can include digits and lowercase letters)
|
||||
val nostrUriPattern = "nostr:([a-z]+1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)".toRegex(RegexOption.IGNORE_CASE)
|
||||
|
||||
return nostrUriPattern.replace(content) { matchResult ->
|
||||
val bech32 = matchResult.groupValues[1].lowercase()
|
||||
try {
|
||||
when {
|
||||
bech32.startsWith("npub1") -> {
|
||||
// Check cache first
|
||||
val pubkeyHex = try {
|
||||
val (_, data) = Bech32.decode(bech32)
|
||||
data.toHexStringLocal()
|
||||
} catch (e: Exception) { null }
|
||||
|
||||
val cachedName = pubkeyHex?.let { profileNameCache[it] }
|
||||
if (cachedName != null) {
|
||||
"@$cachedName"
|
||||
} else {
|
||||
"@${bech32.take(10)}…${bech32.takeLast(4)}"
|
||||
}
|
||||
}
|
||||
bech32.startsWith("note1") -> {
|
||||
"📝${bech32.take(9)}…"
|
||||
}
|
||||
bech32.startsWith("nevent1") -> {
|
||||
"🔗event…"
|
||||
}
|
||||
bech32.startsWith("nprofile1") -> {
|
||||
// Try to extract pubkey from TLV and check cache
|
||||
val pubkeyHex = extractPubkeyFromNprofile(bech32)
|
||||
val cachedName = pubkeyHex?.let { profileNameCache[it] }
|
||||
if (cachedName != null) {
|
||||
"👤@$cachedName"
|
||||
} else {
|
||||
"👤profile…"
|
||||
}
|
||||
}
|
||||
bech32.startsWith("naddr1") -> {
|
||||
"📄addr…"
|
||||
}
|
||||
bech32.startsWith("nrelay1") -> {
|
||||
"🌐relay…"
|
||||
}
|
||||
else -> {
|
||||
"nostr:${bech32.take(12)}…"
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
"nostr:${bech32.take(12)}…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all npub/nprofile references from content and resolve them asynchronously.
|
||||
* When resolved, calls onUpdate so the UI can refresh.
|
||||
*/
|
||||
fun resolveNostrReferences(content: String, onUpdate: () -> Unit) {
|
||||
val nostrUriPattern = "nostr:([a-z]+1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]+)".toRegex(RegexOption.IGNORE_CASE)
|
||||
val matches = nostrUriPattern.findAll(content)
|
||||
|
||||
val pubkeysToResolve = mutableListOf<String>()
|
||||
|
||||
for (match in matches) {
|
||||
val bech32 = match.groupValues[1].lowercase()
|
||||
try {
|
||||
val pubkeyHex: String? = when {
|
||||
bech32.startsWith("npub1") -> {
|
||||
val (_, data) = Bech32.decode(bech32)
|
||||
data.toHexStringLocal()
|
||||
}
|
||||
bech32.startsWith("nprofile1") -> {
|
||||
extractPubkeyFromNprofile(bech32)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (pubkeyHex != null &&
|
||||
!profileNameCache.containsKey(pubkeyHex) &&
|
||||
!pendingResolutions.contains(pubkeyHex)) {
|
||||
pubkeysToResolve.add(pubkeyHex)
|
||||
pendingResolutions.add(pubkeyHex)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to extract pubkey from $bech32: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve each pubkey asynchronously
|
||||
for (pubkeyHex in pubkeysToResolve) {
|
||||
fetchProfileName(pubkeyHex) { name ->
|
||||
if (name != null) {
|
||||
profileNameCache[pubkeyHex] = name
|
||||
onUpdate()
|
||||
}
|
||||
pendingResolutions.remove(pubkeyHex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch profile name for a pubkey from relays
|
||||
*/
|
||||
private fun fetchProfileName(pubkeyHex: String, onResult: (String?) -> Unit) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val relayManager = NostrRelayManager.shared
|
||||
val filter = NostrFilter.profileMetadata(pubkeyHex)
|
||||
val subscriptionId = "name-${pubkeyHex.take(8)}-${System.currentTimeMillis()}"
|
||||
|
||||
var resolved = false
|
||||
|
||||
relayManager.subscribe(
|
||||
filter = filter,
|
||||
id = subscriptionId,
|
||||
handler = { event ->
|
||||
if (!resolved && event.kind == NostrKind.METADATA && event.pubkey == pubkeyHex) {
|
||||
try {
|
||||
val profileJson = com.google.gson.JsonParser.parseString(event.content).asJsonObject
|
||||
val name = profileJson.get("name")?.asString?.takeIf { it.isNotBlank() }
|
||||
?: profileJson.get("display_name")?.asString?.takeIf { it.isNotBlank() }
|
||||
|
||||
resolved = true
|
||||
relayManager.unsubscribe(subscriptionId)
|
||||
|
||||
GlobalScope.launch(Dispatchers.Main) {
|
||||
onResult(name)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to parse profile for $pubkeyHex: ${e.message}")
|
||||
// Don't mark as resolved - let timeout or next event retry
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Timeout after 5 seconds
|
||||
delay(5000)
|
||||
if (!resolved) {
|
||||
relayManager.unsubscribe(subscriptionId)
|
||||
onResult(null)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to fetch profile name: ${e.message}")
|
||||
onResult(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to extract pubkey hex from nprofile1 TLV encoding.
|
||||
* nprofile uses: 0x00 = pubkey (32 bytes), 0x01 = relay
|
||||
*/
|
||||
private fun extractPubkeyFromNprofile(nprofile: String): String? {
|
||||
return try {
|
||||
val (_, data) = Bech32.decode(nprofile)
|
||||
// TLV: first byte is type (0x00 for pubkey), second is length
|
||||
if (data.size >= 34 && data[0] == 0.toByte() && data[1] == 32.toByte()) {
|
||||
data.sliceArray(2..33).toHexStringLocal()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached profile name for a pubkey, if available
|
||||
*/
|
||||
fun getCachedProfileName(pubkeyHex: String): String? {
|
||||
return profileNameCache[pubkeyHex]
|
||||
}
|
||||
|
||||
// Popular relays known for good profile/metadata coverage
|
||||
private val PROFILE_RELAYS = listOf(
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.primal.net",
|
||||
"wss://relay.nostr.band",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.snort.social",
|
||||
"wss://purplepag.es",
|
||||
"wss://nostr.wine",
|
||||
"wss://relay.nostr.info"
|
||||
)
|
||||
|
||||
/**
|
||||
* Fetch profile metadata (kind 0) for a pubkey from Nostr relays.
|
||||
* Returns a callback when profile is found with name, displayName, about, picture, nip05.
|
||||
* This is an async operation - the callback may be called after some delay or not at all if no profile is found.
|
||||
*/
|
||||
fun fetchProfileFromRelays(
|
||||
pubkeyHex: String,
|
||||
onProfileFetched: (name: String?, displayName: String?, about: String?, picture: String?, nip05: String?) -> Unit
|
||||
) {
|
||||
try {
|
||||
val relayManager = NostrRelayManager.shared
|
||||
val filter = NostrFilter.profileMetadata(pubkeyHex)
|
||||
val subscriptionId = "profile-${pubkeyHex.take(8)}-${System.currentTimeMillis()}"
|
||||
|
||||
Log.d(TAG, "Fetching profile for pubkey: ${pubkeyHex.take(16)}... using ${PROFILE_RELAYS.size} profile relays")
|
||||
|
||||
// First ensure we're connected to profile-focused relays
|
||||
relayManager.connectToAdditionalRelays(PROFILE_RELAYS)
|
||||
|
||||
// Small delay to allow connections to be established, then subscribe
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
delay(500) // Give relays time to connect
|
||||
|
||||
relayManager.subscribe(
|
||||
filter = filter,
|
||||
id = subscriptionId,
|
||||
targetRelayUrls = PROFILE_RELAYS,
|
||||
handler = { event ->
|
||||
if (event.kind == NostrKind.METADATA && event.pubkey == pubkeyHex) {
|
||||
try {
|
||||
// Parse the content as JSON to extract profile fields
|
||||
val profileJson = com.google.gson.JsonParser.parseString(event.content).asJsonObject
|
||||
|
||||
val name = profileJson.get("name")?.asString
|
||||
val displayName = profileJson.get("display_name")?.asString
|
||||
val about = profileJson.get("about")?.asString
|
||||
val picture = profileJson.get("picture")?.asString
|
||||
val nip05 = profileJson.get("nip05")?.asString
|
||||
|
||||
Log.i(TAG, "Found profile for ${pubkeyHex.take(16)}: name=$name, displayName=$displayName")
|
||||
|
||||
// Unsubscribe after receiving profile
|
||||
relayManager.unsubscribe(subscriptionId)
|
||||
|
||||
// Call the callback with profile data
|
||||
onProfileFetched(name, displayName, about, picture, nip05)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to parse profile content: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Set a timeout to unsubscribe if no profile is found (10 seconds)
|
||||
delay(10000)
|
||||
relayManager.unsubscribe(subscriptionId)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to fetch profile: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// Note: pub-derivation helpers are provided on the bridge for reuse across UI/logic.
|
||||
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
|
||||
197
app/src/main/java/com/bitchat/android/nostr/NostrMeshGateway.kt
Normal file
197
app/src/main/java/com/bitchat/android/nostr/NostrMeshGateway.kt
Normal file
@ -0,0 +1,197 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Nostr Mesh Gateway helpers
|
||||
* - publishToMeshOrRelay(event): decide ruta según conectividad
|
||||
* - MeshListener snippet: detecta TYPE_NOSTR_RELAY_REQUEST paquetes y publica en relays
|
||||
* - Deduplication: prevents multiple mesh peers from publishing the same event
|
||||
*/
|
||||
object NostrMeshGateway {
|
||||
private const val TAG = "NostrMeshGateway"
|
||||
|
||||
// Deduplication cache: stores event IDs that we've already published to relays
|
||||
// This prevents multiple online devices from publishing the same event
|
||||
private const val MAX_PUBLISHED_CACHE_SIZE = 500
|
||||
private const val PUBLISHED_CACHE_TTL_MS = 5 * 60 * 1000L // 5 minutes
|
||||
private val publishedEventCache = LinkedHashMap<String, Long>(MAX_PUBLISHED_CACHE_SIZE, 0.75f, true)
|
||||
private val cacheLock = Any()
|
||||
|
||||
/**
|
||||
* Check if we've already published this event recently
|
||||
*/
|
||||
private fun hasRecentlyPublished(eventId: String): Boolean {
|
||||
synchronized(cacheLock) {
|
||||
val publishedAt = publishedEventCache[eventId] ?: return false
|
||||
val age = System.currentTimeMillis() - publishedAt
|
||||
if (age > PUBLISHED_CACHE_TTL_MS) {
|
||||
publishedEventCache.remove(eventId)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an event as published (add to dedup cache)
|
||||
*/
|
||||
private fun markAsPublished(eventId: String) {
|
||||
synchronized(cacheLock) {
|
||||
// Evict old entries if cache is full
|
||||
if (publishedEventCache.size >= MAX_PUBLISHED_CACHE_SIZE) {
|
||||
val now = System.currentTimeMillis()
|
||||
publishedEventCache.entries.removeIf { now - it.value > PUBLISHED_CACHE_TTL_MS }
|
||||
// If still full, remove oldest
|
||||
if (publishedEventCache.size >= MAX_PUBLISHED_CACHE_SIZE) {
|
||||
val oldest = publishedEventCache.keys.firstOrNull()
|
||||
if (oldest != null) publishedEventCache.remove(oldest)
|
||||
}
|
||||
}
|
||||
publishedEventCache[eventId] = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide publicar directamente a relays si hay conectividad, o enviar por Mesh si no
|
||||
*/
|
||||
fun publishToMeshOrRelay(context: Context, event: NostrEvent, meshSender: (ByteArray) -> Unit) {
|
||||
if (hasInternetConnection(context)) {
|
||||
Log.d(TAG, "Device online - publishing event to relays directly")
|
||||
// Ensure event meets PoW requirement before sending
|
||||
val minDifficulty = com.bitchat.android.nostr.NostrProofOfWork.estimateWork(0) // placeholder: use PoW settings
|
||||
|
||||
// Send immediately via NostrRelayManager
|
||||
try {
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send event to relays: ${e.message}")
|
||||
// Fallback: send over mesh
|
||||
try {
|
||||
val payload = NostrMeshSerializer.serializeEventForMesh(event)
|
||||
meshSender(payload)
|
||||
} catch (ex: Exception) {
|
||||
Log.e(TAG, "Failed to serialize event for mesh fallback: ${ex.message}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "No internet - sending event over mesh")
|
||||
// When offline: ensure PoW is present (NIP-13). Mining should be done before calling this ideally.
|
||||
// If not mined, we could kick off mining synchronously (dangerous for battery) or reject.
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
try {
|
||||
// If event lacks nonce, attempt a light PoW using user-preferred difficulty
|
||||
val prefDifficulty = NostrProofOfWork.estimateWork(8).toIntOrNullSafe() ?: 8
|
||||
} catch (ignored: Exception) {}
|
||||
}
|
||||
|
||||
try {
|
||||
val payload = NostrMeshSerializer.serializeEventForMesh(event)
|
||||
meshSender(payload)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to serialize event for mesh: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple connectivity check helper
|
||||
*/
|
||||
fun hasInternetConnection(context: Context): Boolean {
|
||||
try {
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val nw = cm.activeNetwork ?: return false
|
||||
val actNw = cm.getNetworkCapabilities(nw) ?: return false
|
||||
return actNw.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
} catch (e: Exception) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mesh listener snippet to be integrated in the mesh receive path.
|
||||
* When a packet with header TYPE_NOSTR_RELAY_REQUEST is received and device has internet,
|
||||
* it will attempt to deserialize, validate signature & PoW, then publish to relays.
|
||||
*/
|
||||
fun meshPacketReceived(context: Context, packet: ByteArray, ackSender: ((ByteArray) -> Unit)? = null) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
// Quick header check
|
||||
if (packet.isEmpty()) return@launch
|
||||
val header = packet[0]
|
||||
if (header != NostrMeshSerializer.TYPE_NOSTR_RELAY_REQUEST && header != NostrMeshSerializer.TYPE_NOSTR_PLAINTEXT) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (!hasInternetConnection(context)) {
|
||||
Log.d(TAG, "Received Nostr mesh packet but device is offline - skipping relay publish")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val jsonString = NostrMeshSerializer.deserializeEventFromMesh(packet) ?: run {
|
||||
Log.w(TAG, "Failed to deserialize Nostr event from mesh packet")
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Parse event
|
||||
val event = NostrEvent.fromJsonString(jsonString)
|
||||
if (event == null) {
|
||||
Log.w(TAG, "Failed to parse NostrEvent JSON")
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Validate signature (NIP-01)
|
||||
if (!event.isValidSignature()) {
|
||||
Log.w(TAG, "Invalid Nostr signature - discarding event id=${event.id.take(16)}...")
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Validate PoW (NIP-13) - require at least low difficulty (8 bits)
|
||||
val requiredDifficulty = 8
|
||||
if (!NostrProofOfWork.validateDifficulty(event, requiredDifficulty)) {
|
||||
Log.w(TAG, "Event failed PoW validation - discarding id=${event.id.take(16)}...")
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Deduplication: check if we (or another peer) already published this event
|
||||
if (hasRecentlyPublished(event.id)) {
|
||||
Log.d(TAG, "Event already published recently, skipping duplicate id=${event.id.take(16)}...")
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Publish to relays via NostrRelayManager
|
||||
try {
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
markAsPublished(event.id) // Mark as published AFTER successful send
|
||||
Log.i(TAG, "Published mesh-origin event to relays id=${event.id.take(16)}...")
|
||||
|
||||
// Optionally send acknowledgement back over mesh
|
||||
ackSender?.let { sender ->
|
||||
try {
|
||||
val ack = "DELIVERED:${event.id}".toByteArray(Charsets.UTF_8)
|
||||
sender(ack)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to send ack over mesh: ${e.message}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to publish event to relays: ${e.message}")
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error handling mesh packet: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// helper extension to attempt convert Long to Int safely
|
||||
private fun Long.toIntOrNullSafe(): Int? {
|
||||
return if (this in Int.MIN_VALUE..Int.MAX_VALUE) this.toInt() else null
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,209 @@
|
||||
package com.bitchat.android.nostr
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import com.bitchat.android.util.AppConstants
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Manager for publishing and subscribing to the user's profile notes (#me) via Nostr
|
||||
* - Publishes kind=1 text note with tag ["bitchat","1"] for compatibility and filtering
|
||||
* - Subscribes to author's kind=1 notes filtered by the tag above
|
||||
* - Uses NostrRelayManager when online, falls back to mesh via NostrMeshGateway when offline
|
||||
*/
|
||||
object NostrProfileManager {
|
||||
private const val TAG = "NostrProfileManager"
|
||||
|
||||
// Minimum PoW difficulty required for mesh relay (must match NostrMeshGateway)
|
||||
private const val MESH_REQUIRED_POW_DIFFICULTY = 8
|
||||
|
||||
// Popular relays known for good profile/user content coverage
|
||||
private val PROFILE_RELAYS = listOf(
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.primal.net",
|
||||
"wss://relay.nostr.band",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.snort.social",
|
||||
"wss://purplepag.es",
|
||||
"wss://nostr.wine"
|
||||
)
|
||||
|
||||
private var context: Context? = null
|
||||
private var meshService: com.bitchat.android.mesh.BluetoothMeshService? = null
|
||||
private var subscribeFunc: ((NostrFilter, String, (NostrEvent) -> Unit) -> String)? = null
|
||||
private var unsubscribeFunc: ((String) -> Unit)? = null
|
||||
private var sendEventFunc: ((NostrEvent, List<String>?) -> Unit)? = null
|
||||
private var currentSubId: String? = null
|
||||
|
||||
/**
|
||||
* Result of publishing a profile message
|
||||
*/
|
||||
sealed class PublishResult {
|
||||
object Success : PublishResult()
|
||||
object NotInitialized : PublishResult()
|
||||
object NoIdentity : PublishResult()
|
||||
object PowRequired : PublishResult() // PoW is required but not enabled
|
||||
object PowMiningFailed : PublishResult()
|
||||
data class Error(val message: String) : PublishResult()
|
||||
}
|
||||
|
||||
fun initialize(
|
||||
context: Context,
|
||||
meshService: com.bitchat.android.mesh.BluetoothMeshService,
|
||||
subscribe: (NostrFilter, String, (NostrEvent) -> Unit) -> String,
|
||||
unsubscribe: (String) -> Unit,
|
||||
sendEvent: (NostrEvent, List<String>?) -> Unit
|
||||
) {
|
||||
this.context = context.applicationContext
|
||||
this.meshService = meshService
|
||||
this.subscribeFunc = subscribe
|
||||
this.unsubscribeFunc = unsubscribe
|
||||
this.sendEventFunc = sendEvent
|
||||
Log.d(TAG, "Initialized NostrProfileManager")
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a profile message (kind=1) signed with current identity.
|
||||
* Publishes to Nostr relays (no bitchat tag for normal posts).
|
||||
*
|
||||
* @return PublishResult indicating success or type of failure
|
||||
*/
|
||||
suspend fun publishProfileMessage(content: String, nickname: String?): PublishResult = withContext(Dispatchers.IO) {
|
||||
Log.d(TAG, "publishProfileMessage called with content: ${content.take(50)}...")
|
||||
val ctx = context ?: run {
|
||||
Log.w(TAG, "Not initialized - context is null")
|
||||
return@withContext PublishResult.NotInitialized
|
||||
}
|
||||
val mesh = meshService
|
||||
val sendEvent = sendEventFunc
|
||||
|
||||
val identity = try { NostrIdentityBridge.getCurrentNostrIdentity(ctx) } catch (e: Exception) { null }
|
||||
if (identity == null) {
|
||||
Log.e(TAG, "No Nostr identity available for profile publish")
|
||||
return@withContext PublishResult.NoIdentity
|
||||
}
|
||||
|
||||
// Check if we're offline - if so, we need PoW for mesh relay
|
||||
val isOffline = !NostrMeshGateway.hasInternetConnection(ctx)
|
||||
|
||||
val event: NostrEvent
|
||||
if (isOffline) {
|
||||
Log.d(TAG, "Device is offline - checking PoW requirements for mesh relay")
|
||||
|
||||
if (!PoWPreferenceManager.isPowEnabled()) {
|
||||
Log.w(TAG, "PoW is required for offline #me messages but is not enabled")
|
||||
return@withContext PublishResult.PowRequired
|
||||
}
|
||||
|
||||
// Create unsigned event first (we'll sign after mining)
|
||||
val unsignedEvent = NostrEvent(
|
||||
pubkey = identity.publicKeyHex,
|
||||
createdAt = (System.currentTimeMillis() / 1000).toInt(),
|
||||
kind = NostrKind.TEXT_NOTE,
|
||||
tags = emptyList(),
|
||||
content = content
|
||||
)
|
||||
|
||||
// Mine the event with required difficulty
|
||||
val difficulty = maxOf(PoWPreferenceManager.getPowDifficulty(), MESH_REQUIRED_POW_DIFFICULTY)
|
||||
Log.d(TAG, "Mining PoW for offline message with difficulty $difficulty...")
|
||||
|
||||
PoWPreferenceManager.startMining()
|
||||
try {
|
||||
val minedEvent = NostrProofOfWork.mineEvent(unsignedEvent, difficulty)
|
||||
if (minedEvent == null) {
|
||||
Log.e(TAG, "PoW mining failed")
|
||||
return@withContext PublishResult.PowMiningFailed
|
||||
}
|
||||
// Sign the mined event (with nonce tag already added)
|
||||
event = minedEvent.sign(identity.privateKeyHex)
|
||||
Log.d(TAG, "PoW mining successful, signed event id=${event.id.take(16)}...")
|
||||
} finally {
|
||||
PoWPreferenceManager.stopMining()
|
||||
}
|
||||
} else {
|
||||
// Online: create and sign normally (no PoW needed)
|
||||
event = NostrEvent.createTextNote(content, identity.publicKeyHex, identity.privateKeyHex, tags = emptyList())
|
||||
}
|
||||
|
||||
try {
|
||||
// Ensure we're connected to profile-focused relays for better delivery
|
||||
NostrRelayManager.shared.connectToAdditionalRelays(PROFILE_RELAYS)
|
||||
|
||||
// If we have a direct sendEvent function (relays), prefer that via NostrRelayManager; else fallback to mesh
|
||||
val meshSender: (ByteArray) -> Unit = { bytes ->
|
||||
try {
|
||||
val svc = mesh
|
||||
if (svc != null) {
|
||||
val pkt = BitchatPacket(
|
||||
type = MessageType.MESSAGE.value,
|
||||
ttl = AppConstants.SYNC_TTL_HOPS,
|
||||
senderID = svc.myPeerID,
|
||||
payload = bytes
|
||||
)
|
||||
svc.broadcastPacket(pkt)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "meshSender failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
NostrMeshGateway.publishToMeshOrRelay(ctx, event, meshSender)
|
||||
|
||||
Log.d(TAG, "Published profile message id=${event.id.take(16)}...")
|
||||
return@withContext PublishResult.Success
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to publish profile message: ${e.message}")
|
||||
return@withContext PublishResult.Error(e.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to our profile notes and deliver incoming events to handler
|
||||
*/
|
||||
fun subscribeMyProfile(handler: (NostrEvent) -> Unit) {
|
||||
val ctx = context ?: run { Log.w(TAG, "Not initialized"); return }
|
||||
val sub = subscribeFunc ?: run { Log.w(TAG, "subscribeFunc not set"); return }
|
||||
|
||||
val identity = try { NostrIdentityBridge.getCurrentNostrIdentity(ctx) } catch (e: Exception) { null }
|
||||
if (identity == null) {
|
||||
Log.w(TAG, "No identity to subscribe for")
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure we're connected to profile-focused relays for better coverage
|
||||
NostrRelayManager.shared.connectToAdditionalRelays(PROFILE_RELAYS)
|
||||
|
||||
// Build filter: author=self, kind=1 (all text notes, not just bitchat-tagged)
|
||||
val filter = NostrFilter.Builder()
|
||||
.authors(identity.publicKeyHex)
|
||||
.kinds(NostrKind.TEXT_NOTE)
|
||||
.limit(100)
|
||||
.build()
|
||||
|
||||
val id = "profile-sub-${System.currentTimeMillis()}"
|
||||
try {
|
||||
currentSubId = sub(filter, id) { event -> handler(event) }
|
||||
Log.d(TAG, "Subscribed to my profile notes with subId=$currentSubId, pubkey=${identity.publicKeyHex.take(16)}...")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to subscribe to profile: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun unsubscribeProfile() {
|
||||
val unsub = unsubscribeFunc ?: return
|
||||
val id = currentSubId ?: return
|
||||
try {
|
||||
unsub(id)
|
||||
currentSubId = null
|
||||
Log.d(TAG, "Unsubscribed profile sub $id")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to unsubscribe profile: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -256,6 +256,15 @@ class NostrRelayManager private constructor() {
|
||||
startSubscriptionValidation()
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to additional relays (e.g., for profile lookups).
|
||||
* These are added to the relay list and connections are established if not already connected.
|
||||
*/
|
||||
fun connectToAdditionalRelays(relayUrls: List<String>) {
|
||||
Log.d(TAG, "📡 Connecting to ${relayUrls.size} additional relays for profile lookup")
|
||||
ensureConnectionsFor(relayUrls.toSet())
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from all relays
|
||||
*/
|
||||
|
||||
@ -145,7 +145,8 @@ object AppStateStore {
|
||||
seenMessageIds.add(msg.id)
|
||||
val map = _channelMessages.value.toMutableMap()
|
||||
val list = (map[channel] ?: emptyList()) + msg
|
||||
map[channel] = list
|
||||
// Only sort #me channel (Nostr posts can arrive out of order from relays)
|
||||
map[channel] = if (channel == "#me") list.sortedBy { it.timestamp } else list
|
||||
_channelMessages.value = map
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) }
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
|
||||
@ -16,6 +16,9 @@ import com.bitchat.android.service.MeshServiceHolder
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import com.bitchat.android.nostr.NostrIdentityBridge
|
||||
import com.bitchat.android.nostr.NostrProfileManager
|
||||
import com.bitchat.android.nostr.NostrKind
|
||||
import com.bitchat.android.nostr.NostrRelayManager
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
|
||||
|
||||
@ -321,6 +324,17 @@ class ChatViewModel(
|
||||
|
||||
// Note: Mesh service is now started by MainActivity
|
||||
|
||||
// Initialize NostrProfileManager with relay/mesh hooks
|
||||
try {
|
||||
NostrProfileManager.initialize(
|
||||
context = getApplication(),
|
||||
meshService = meshService,
|
||||
subscribe = { filter, id, handler -> NostrRelayManager.getInstance(getApplication()).subscribe(filter, id, handler) },
|
||||
unsubscribe = { id -> NostrRelayManager.getInstance(getApplication()).unsubscribe(id) },
|
||||
sendEvent = { event, relays -> NostrRelayManager.getInstance(getApplication()).sendEvent(event, relays) }
|
||||
)
|
||||
} catch (_: Exception) { }
|
||||
|
||||
// BLE receives are inserted by MessageHandler path; no VoiceNoteBus for Tor in this branch.
|
||||
}
|
||||
|
||||
@ -384,7 +398,54 @@ class ChatViewModel(
|
||||
}
|
||||
|
||||
fun switchToChannel(channel: String?) {
|
||||
// Manage subscription lifecycle for our Nostr-profile channel (#me)
|
||||
val prev = state.getCurrentChannelValue()
|
||||
if (prev == "#me" && channel != "#me") {
|
||||
try { NostrProfileManager.unsubscribeProfile() } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
channelManager.switchToChannel(channel)
|
||||
|
||||
if (channel == "#me") {
|
||||
try {
|
||||
NostrProfileManager.subscribeMyProfile { event ->
|
||||
try {
|
||||
// Accept all valid kind=1 text notes from our profile
|
||||
if (event.kind == NostrKind.TEXT_NOTE && event.isValidSignature()) {
|
||||
val meNick = state.getNicknameValue() ?: meshService.myPeerID
|
||||
val originalContent = event.content
|
||||
val eventId = event.id
|
||||
|
||||
// Parse nostr: URIs to human-readable format (uses cache)
|
||||
val parsedContent = NostrIdentityBridge.parseNostrContent(originalContent)
|
||||
val msg = BitchatMessage(
|
||||
id = eventId,
|
||||
sender = meNick,
|
||||
content = parsedContent,
|
||||
timestamp = Date(event.createdAt.toLong() * 1000L),
|
||||
isRelay = true,
|
||||
senderPeerID = meshService.myPeerID,
|
||||
channel = "#me"
|
||||
)
|
||||
channelManager.addChannelMessage("#me", msg, meshService.myPeerID)
|
||||
|
||||
// Resolve references asynchronously and update message when done
|
||||
NostrIdentityBridge.resolveNostrReferences(originalContent) {
|
||||
// Re-parse with updated cache and update the message
|
||||
val updatedContent = NostrIdentityBridge.parseNostrContent(originalContent)
|
||||
if (updatedContent != parsedContent) {
|
||||
channelManager.updateChannelMessageContent("#me", eventId, updatedContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to process profile event: ${e.message}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to subscribe to #me profile: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun leaveChannel(channel: String) {
|
||||
@ -554,6 +615,57 @@ class ChatViewModel(
|
||||
// Send to geohash channel via Nostr ephemeral event
|
||||
geohashViewModel.sendGeohashMessage(content, selectedLocationChannel.channel, mesh.myPeerID, state.getNicknameValue())
|
||||
} else {
|
||||
// If we're in the special profile channel #me, publish via NostrProfileManager
|
||||
Log.d(TAG, "sendMessage: currentChannelValue=$currentChannelValue")
|
||||
if (currentChannelValue == "#me") {
|
||||
val nick = state.getNicknameValue()
|
||||
Log.d(TAG, "Publishing to #me profile channel")
|
||||
// Local echo
|
||||
val echo = BitchatMessage(
|
||||
sender = nick ?: meshService.myPeerID,
|
||||
content = content,
|
||||
timestamp = Date(),
|
||||
isRelay = false,
|
||||
senderPeerID = meshService.myPeerID,
|
||||
mentions = if (mentions.isNotEmpty()) mentions else null,
|
||||
channel = "#me"
|
||||
)
|
||||
channelManager.addChannelMessage("#me", echo, meshService.myPeerID)
|
||||
|
||||
// Publish to Nostr (async with result handling)
|
||||
viewModelScope.launch {
|
||||
val result = NostrProfileManager.publishProfileMessage(content, nick)
|
||||
when (result) {
|
||||
is NostrProfileManager.PublishResult.PowRequired -> {
|
||||
// Show system message explaining PoW is needed
|
||||
val systemMessage = BitchatMessage(
|
||||
sender = "system",
|
||||
content = "⚠️ Para enviar mensajes en #me sin conexión, debes activar Proof of Work en Configuración → Nostr → PoW",
|
||||
timestamp = Date(),
|
||||
isRelay = false,
|
||||
channel = "#me"
|
||||
)
|
||||
channelManager.addChannelMessage("#me", systemMessage, null)
|
||||
}
|
||||
is NostrProfileManager.PublishResult.PowMiningFailed -> {
|
||||
val systemMessage = BitchatMessage(
|
||||
sender = "system",
|
||||
content = "❌ Error: El minado de PoW falló. Intenta con una dificultad menor.",
|
||||
timestamp = Date(),
|
||||
isRelay = false,
|
||||
channel = "#me"
|
||||
)
|
||||
channelManager.addChannelMessage("#me", systemMessage, null)
|
||||
}
|
||||
is NostrProfileManager.PublishResult.Error -> {
|
||||
Log.w(TAG, "Failed to publish profile message: ${result.message}")
|
||||
}
|
||||
else -> { /* Success or other cases */ }
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Send public/channel message via mesh
|
||||
val message = BitchatMessage(
|
||||
sender = state.getNicknameValue() ?: mesh.myPeerID,
|
||||
|
||||
@ -13,6 +13,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bookmark
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Map
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.PinDrop
|
||||
import androidx.compose.material.icons.outlined.BookmarkBorder
|
||||
import androidx.compose.material3.*
|
||||
@ -73,6 +74,9 @@ fun LocationChannelsSheet(
|
||||
|
||||
// Observe reactive participant counts
|
||||
val geohashParticipantCounts by viewModel.geohashParticipantCounts.collectAsStateWithLifecycle()
|
||||
|
||||
// Observe current channel for #me selection state
|
||||
val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle()
|
||||
|
||||
// UI state
|
||||
var customGeohash by remember { mutableStateOf("") }
|
||||
@ -187,7 +191,31 @@ fun LocationChannelsSheet(
|
||||
}
|
||||
}
|
||||
|
||||
// Mesh option first
|
||||
// #me - Personal Nostr profile channel (first option)
|
||||
item(key = "me") {
|
||||
val isMeSelected = currentChannel == "#me"
|
||||
ChannelRow(
|
||||
title = "#me",
|
||||
subtitle = stringResource(R.string.me_channel_description),
|
||||
isSelected = isMeSelected,
|
||||
titleColor = Color(0xFFAF52DE), // iOS purple
|
||||
titleBold = isMeSelected,
|
||||
trailingContent = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Person,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFFAF52DE).copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
viewModel.switchToChannel("#me")
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Mesh option
|
||||
item(key = "mesh") {
|
||||
ChannelRow(
|
||||
title = meshTitleWithCount(viewModel),
|
||||
|
||||
@ -366,6 +366,11 @@ fun MessageItem(
|
||||
)
|
||||
} else {
|
||||
// Normal message display
|
||||
// Check for image URLs in the message content
|
||||
val imageUrls = remember(message.content) {
|
||||
MessageSpecialParser.extractImageUrls(message.content)
|
||||
}
|
||||
|
||||
val annotatedText = formatMessageAsAnnotatedString(
|
||||
message = message,
|
||||
currentUserNickname = currentUserNickname,
|
||||
@ -382,85 +387,184 @@ fun MessageItem(
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val context = LocalContext.current
|
||||
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
Text(
|
||||
text = annotatedText,
|
||||
modifier = modifier.pointerInput(message) {
|
||||
detectTapGestures(
|
||||
onTap = { position ->
|
||||
val layout = textLayoutResult ?: return@detectTapGestures
|
||||
val offset = layout.getOffsetForPosition(position)
|
||||
// Nickname click only when not self
|
||||
if (!isSelf && onNicknameClick != null) {
|
||||
val nicknameAnnotations = annotatedText.getStringAnnotations(
|
||||
tag = "nickname_click",
|
||||
|
||||
// If there are image URLs, wrap text + images in a Column
|
||||
if (imageUrls.isNotEmpty()) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = annotatedText,
|
||||
modifier = Modifier.pointerInput(message) {
|
||||
detectTapGestures(
|
||||
onTap = { position ->
|
||||
val layout = textLayoutResult ?: return@detectTapGestures
|
||||
val offset = layout.getOffsetForPosition(position)
|
||||
// Nickname click only when not self
|
||||
if (!isSelf && onNicknameClick != null) {
|
||||
val nicknameAnnotations = annotatedText.getStringAnnotations(
|
||||
tag = "nickname_click",
|
||||
start = offset,
|
||||
end = offset
|
||||
)
|
||||
if (nicknameAnnotations.isNotEmpty()) {
|
||||
val nickname = nicknameAnnotations.first().item
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
onNicknameClick.invoke(nickname)
|
||||
return@detectTapGestures
|
||||
}
|
||||
}
|
||||
// Geohash teleport (all messages)
|
||||
val geohashAnnotations = annotatedText.getStringAnnotations(
|
||||
tag = "geohash_click",
|
||||
start = offset,
|
||||
end = offset
|
||||
)
|
||||
if (geohashAnnotations.isNotEmpty()) {
|
||||
val geohash = geohashAnnotations.first().item
|
||||
try {
|
||||
val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(
|
||||
context
|
||||
)
|
||||
val level = when (geohash.length) {
|
||||
in 0..2 -> com.bitchat.android.geohash.GeohashChannelLevel.REGION
|
||||
in 3..4 -> com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE
|
||||
5 -> com.bitchat.android.geohash.GeohashChannelLevel.CITY
|
||||
6 -> com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD
|
||||
else -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK
|
||||
}
|
||||
val channel = com.bitchat.android.geohash.GeohashChannel(level, geohash.lowercase())
|
||||
locationManager.setTeleported(true)
|
||||
locationManager.select(com.bitchat.android.geohash.ChannelID.Location(channel))
|
||||
} catch (_: Exception) { }
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
return@detectTapGestures
|
||||
}
|
||||
// URL open (all messages) - skip if it's an image URL (already shown below)
|
||||
val urlAnnotations = annotatedText.getStringAnnotations(
|
||||
tag = "url_click",
|
||||
start = offset,
|
||||
end = offset
|
||||
)
|
||||
if (urlAnnotations.isNotEmpty()) {
|
||||
val raw = urlAnnotations.first().item
|
||||
val resolved = if (raw.startsWith("http://", ignoreCase = true) || raw.startsWith("https://", ignoreCase = true)) raw else "https://$raw"
|
||||
// Check if this URL is an image URL - if so, don't open browser
|
||||
if (resolved !in imageUrls) {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(resolved))
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
return@detectTapGestures
|
||||
}
|
||||
},
|
||||
onLongPress = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onMessageLongPress?.invoke(message)
|
||||
}
|
||||
)
|
||||
},
|
||||
fontFamily = FontFamily.Monospace,
|
||||
softWrap = true,
|
||||
overflow = TextOverflow.Visible,
|
||||
style = androidx.compose.ui.text.TextStyle(
|
||||
color = colorScheme.onSurface
|
||||
),
|
||||
onTextLayout = { result -> textLayoutResult = result }
|
||||
)
|
||||
|
||||
// Render image previews below the text
|
||||
com.bitchat.android.ui.media.UrlImagesColumn(
|
||||
urls = imageUrls,
|
||||
onImageClick = onImageClick
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// No image URLs - render just the text as before
|
||||
Text(
|
||||
text = annotatedText,
|
||||
modifier = modifier.pointerInput(message) {
|
||||
detectTapGestures(
|
||||
onTap = { position ->
|
||||
val layout = textLayoutResult ?: return@detectTapGestures
|
||||
val offset = layout.getOffsetForPosition(position)
|
||||
// Nickname click only when not self
|
||||
if (!isSelf && onNicknameClick != null) {
|
||||
val nicknameAnnotations = annotatedText.getStringAnnotations(
|
||||
tag = "nickname_click",
|
||||
start = offset,
|
||||
end = offset
|
||||
)
|
||||
if (nicknameAnnotations.isNotEmpty()) {
|
||||
val nickname = nicknameAnnotations.first().item
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
onNicknameClick.invoke(nickname)
|
||||
return@detectTapGestures
|
||||
}
|
||||
}
|
||||
// Geohash teleport (all messages)
|
||||
val geohashAnnotations = annotatedText.getStringAnnotations(
|
||||
tag = "geohash_click",
|
||||
start = offset,
|
||||
end = offset
|
||||
)
|
||||
if (nicknameAnnotations.isNotEmpty()) {
|
||||
val nickname = nicknameAnnotations.first().item
|
||||
if (geohashAnnotations.isNotEmpty()) {
|
||||
val geohash = geohashAnnotations.first().item
|
||||
try {
|
||||
val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(
|
||||
context
|
||||
)
|
||||
val level = when (geohash.length) {
|
||||
in 0..2 -> com.bitchat.android.geohash.GeohashChannelLevel.REGION
|
||||
in 3..4 -> com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE
|
||||
5 -> com.bitchat.android.geohash.GeohashChannelLevel.CITY
|
||||
6 -> com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD
|
||||
else -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK
|
||||
}
|
||||
val channel = com.bitchat.android.geohash.GeohashChannel(level, geohash.lowercase())
|
||||
locationManager.setTeleported(true)
|
||||
locationManager.select(com.bitchat.android.geohash.ChannelID.Location(channel))
|
||||
} catch (_: Exception) { }
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
onNicknameClick.invoke(nickname)
|
||||
return@detectTapGestures
|
||||
}
|
||||
// URL open (all messages)
|
||||
val urlAnnotations = annotatedText.getStringAnnotations(
|
||||
tag = "url_click",
|
||||
start = offset,
|
||||
end = offset
|
||||
)
|
||||
if (urlAnnotations.isNotEmpty()) {
|
||||
val raw = urlAnnotations.first().item
|
||||
val resolved = if (raw.startsWith("http://", ignoreCase = true) || raw.startsWith("https://", ignoreCase = true)) raw else "https://$raw"
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(resolved))
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) { }
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
return@detectTapGestures
|
||||
}
|
||||
},
|
||||
onLongPress = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onMessageLongPress?.invoke(message)
|
||||
}
|
||||
// Geohash teleport (all messages)
|
||||
val geohashAnnotations = annotatedText.getStringAnnotations(
|
||||
tag = "geohash_click",
|
||||
start = offset,
|
||||
end = offset
|
||||
)
|
||||
if (geohashAnnotations.isNotEmpty()) {
|
||||
val geohash = geohashAnnotations.first().item
|
||||
try {
|
||||
val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(
|
||||
context
|
||||
)
|
||||
val level = when (geohash.length) {
|
||||
in 0..2 -> com.bitchat.android.geohash.GeohashChannelLevel.REGION
|
||||
in 3..4 -> com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE
|
||||
5 -> com.bitchat.android.geohash.GeohashChannelLevel.CITY
|
||||
6 -> com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD
|
||||
else -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK
|
||||
}
|
||||
val channel = com.bitchat.android.geohash.GeohashChannel(level, geohash.lowercase())
|
||||
locationManager.setTeleported(true)
|
||||
locationManager.select(com.bitchat.android.geohash.ChannelID.Location(channel))
|
||||
} catch (_: Exception) { }
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
return@detectTapGestures
|
||||
}
|
||||
// URL open (all messages)
|
||||
val urlAnnotations = annotatedText.getStringAnnotations(
|
||||
tag = "url_click",
|
||||
start = offset,
|
||||
end = offset
|
||||
)
|
||||
if (urlAnnotations.isNotEmpty()) {
|
||||
val raw = urlAnnotations.first().item
|
||||
val resolved = if (raw.startsWith("http://", ignoreCase = true) || raw.startsWith("https://", ignoreCase = true)) raw else "https://$raw"
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(resolved))
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) { }
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
return@detectTapGestures
|
||||
}
|
||||
},
|
||||
onLongPress = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onMessageLongPress?.invoke(message)
|
||||
}
|
||||
)
|
||||
},
|
||||
fontFamily = FontFamily.Monospace,
|
||||
softWrap = true,
|
||||
overflow = TextOverflow.Visible,
|
||||
style = androidx.compose.ui.text.TextStyle(
|
||||
color = colorScheme.onSurface
|
||||
),
|
||||
onTextLayout = { result -> textLayoutResult = result }
|
||||
)
|
||||
)
|
||||
},
|
||||
fontFamily = FontFamily.Monospace,
|
||||
softWrap = true,
|
||||
overflow = TextOverflow.Visible,
|
||||
style = androidx.compose.ui.text.TextStyle(
|
||||
color = colorScheme.onSurface
|
||||
),
|
||||
onTextLayout = { result -> textLayoutResult = result }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -11,8 +11,47 @@ object MessageSpecialParser {
|
||||
// Geohash alphabet is base32: 0123456789bcdefghjkmnpqrstuvwxyz
|
||||
private val standaloneGeohashRegex = Regex("(^|[^A-Za-z0-9_#])#([0-9bcdefghjkmnpqrstuvwxyz]{2,})($|[^A-Za-z0-9_])", RegexOption.IGNORE_CASE)
|
||||
|
||||
// Image URL extensions (case insensitive)
|
||||
private val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "webp", "avif", "bmp", "svg")
|
||||
|
||||
data class GeohashMatch(val start: Int, val endExclusive: Int, val geohash: String)
|
||||
data class UrlMatch(val start: Int, val endExclusive: Int, val url: String)
|
||||
data class ImageUrlMatch(val url: String, val start: Int, val endExclusive: Int)
|
||||
|
||||
/**
|
||||
* Finds image URLs in text. Returns URLs ending with common image extensions.
|
||||
* Also handles URLs with query params after the extension (e.g., image.jpg?size=large)
|
||||
*/
|
||||
fun findImageUrls(text: String): List<ImageUrlMatch> {
|
||||
val urls = findUrls(text)
|
||||
return urls.mapNotNull { urlMatch ->
|
||||
val url = urlMatch.url
|
||||
// Normalize URL for checking (add https:// if missing)
|
||||
val normalizedUrl = if (url.startsWith("http://", ignoreCase = true) ||
|
||||
url.startsWith("https://", ignoreCase = true)) {
|
||||
url
|
||||
} else {
|
||||
"https://$url"
|
||||
}
|
||||
|
||||
// Extract path without query params
|
||||
val pathPart = normalizedUrl.substringBefore('?').substringBefore('#')
|
||||
val extension = pathPart.substringAfterLast('.', "").lowercase()
|
||||
|
||||
if (extension in IMAGE_EXTENSIONS) {
|
||||
ImageUrlMatch(normalizedUrl, urlMatch.start, urlMatch.endExclusive)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts image URLs from message content, returning list of full URLs.
|
||||
*/
|
||||
fun extractImageUrls(content: String): List<String> {
|
||||
return findImageUrls(content).map { it.url }
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds standalone geohashes within [text]. A match is returned only when
|
||||
|
||||
669
app/src/main/java/com/bitchat/android/ui/NostrAccountSheet.kt
Normal file
669
app/src/main/java/com/bitchat/android/ui/NostrAccountSheet.kt
Normal file
@ -0,0 +1,669 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Key
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.core.ui.component.button.CloseButton
|
||||
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
|
||||
import com.bitchat.android.nostr.NostrIdentity
|
||||
import com.bitchat.android.nostr.NostrIdentityBridge
|
||||
|
||||
/**
|
||||
* Row component for displaying key information
|
||||
*/
|
||||
@Composable
|
||||
private fun KeyDisplayRow(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
value: String,
|
||||
showValue: Boolean,
|
||||
onToggleVisibility: () -> Unit,
|
||||
onCopy: () -> Unit,
|
||||
isSecret: Boolean = false
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = if (isSecret) colorScheme.error else colorScheme.primary,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
if (isSecret) {
|
||||
IconButton(onClick = onToggleVisibility, modifier = Modifier.size(32.dp)) {
|
||||
Icon(
|
||||
imageVector = if (showValue) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
|
||||
contentDescription = if (showValue) "Hide" else "Show",
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onCopy, modifier = Modifier.size(32.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ContentCopy,
|
||||
contentDescription = "Copy",
|
||||
tint = colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (showValue || !isSecret) value else value.take(8) + "..." + value.takeLast(4),
|
||||
style = TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp
|
||||
),
|
||||
color = colorScheme.onSurface.copy(alpha = 0.8f),
|
||||
modifier = Modifier.padding(12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nostr Account Management Sheet
|
||||
* Allows users to view, export, and import their Nostr identity
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NostrAccountSheet(
|
||||
isPresented: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onIdentityChanged: (() -> Unit)? = null,
|
||||
onNostrNameFound: ((String) -> Unit)? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
// State
|
||||
var currentIdentity by remember { mutableStateOf<NostrIdentity?>(null) }
|
||||
var showNsec by remember { mutableStateOf(false) }
|
||||
var showImportDialog by remember { mutableStateOf(false) }
|
||||
var importNsecText by remember { mutableStateOf("") }
|
||||
var importError by remember { mutableStateOf<String?>(null) }
|
||||
var showImportConfirmation by remember { mutableStateOf(false) }
|
||||
|
||||
// State for profile name confirmation
|
||||
var showNameConfirmation by remember { mutableStateOf(false) }
|
||||
var foundNostrName by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Load identity on display
|
||||
LaunchedEffect(isPresented) {
|
||||
if (isPresented) {
|
||||
currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context)
|
||||
showNsec = false
|
||||
}
|
||||
}
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
val isScrolled by remember {
|
||||
derivedStateOf {
|
||||
lazyListState.firstVisibleItemIndex > 0 || lazyListState.firstVisibleItemScrollOffset > 0
|
||||
}
|
||||
}
|
||||
val topBarAlpha by animateFloatAsState(
|
||||
targetValue = if (isScrolled) 0.98f else 0f,
|
||||
label = "topBarAlpha"
|
||||
)
|
||||
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||
|
||||
if (isPresented) {
|
||||
BitchatBottomSheet(
|
||||
modifier = modifier,
|
||||
onDismissRequest = onDismiss,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(top = 80.dp, bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||
) {
|
||||
// Header
|
||||
item(key = "header") {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Nostr Account",
|
||||
style = TextStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 24.sp,
|
||||
letterSpacing = 1.sp
|
||||
),
|
||||
color = colorScheme.onBackground
|
||||
)
|
||||
Text(
|
||||
text = "Your decentralized identity",
|
||||
fontSize = 13.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Current Identity Display
|
||||
item(key = "identity") {
|
||||
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
|
||||
Text(
|
||||
text = "YOUR KEYS",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.5f),
|
||||
letterSpacing = 0.5.sp,
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp)
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = colorScheme.surface,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column {
|
||||
currentIdentity?.let { identity ->
|
||||
// Public Key (npub)
|
||||
KeyDisplayRow(
|
||||
icon = Icons.Filled.Person,
|
||||
label = "Public Key (npub)",
|
||||
value = identity.npub,
|
||||
showValue = true,
|
||||
onToggleVisibility = {},
|
||||
onCopy = {
|
||||
clipboardManager.setText(AnnotatedString(identity.npub))
|
||||
Toast.makeText(context, "Public key copied", Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
isSecret = false
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(start = 56.dp),
|
||||
color = colorScheme.outline.copy(alpha = 0.12f)
|
||||
)
|
||||
|
||||
// Private Key (nsec)
|
||||
KeyDisplayRow(
|
||||
icon = Icons.Filled.Key,
|
||||
label = "Private Key (nsec)",
|
||||
value = identity.getNsec(),
|
||||
showValue = showNsec,
|
||||
onToggleVisibility = { showNsec = !showNsec },
|
||||
onCopy = {
|
||||
if (showNsec) {
|
||||
clipboardManager.setText(AnnotatedString(identity.getNsec()))
|
||||
Toast.makeText(context, "Private key copied - keep it safe!", Toast.LENGTH_LONG).show()
|
||||
} else {
|
||||
Toast.makeText(context, "Reveal the key first to copy", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
isSecret = true
|
||||
)
|
||||
} ?: run {
|
||||
Text(
|
||||
text = "No identity found",
|
||||
modifier = Modifier.padding(16.dp),
|
||||
color = colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Security Warning
|
||||
item(key = "warning") {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 20.dp)
|
||||
.fillMaxWidth(),
|
||||
color = colorScheme.error.copy(alpha = 0.1f),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
tint = colorScheme.error,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = "Keep your nsec private",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = colorScheme.error
|
||||
)
|
||||
Text(
|
||||
text = "Your private key (nsec) gives full access to your Nostr identity. Never share it with anyone. Back it up securely.",
|
||||
fontSize = 13.sp,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import Key Section
|
||||
item(key = "import") {
|
||||
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
|
||||
Text(
|
||||
text = "IMPORT PRIVATE KEY",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.5f),
|
||||
letterSpacing = 0.5.sp,
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp)
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = colorScheme.surface,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "Have an existing Nostr account? Import your private key (nsec) to sign and publish messages.",
|
||||
fontSize = 13.sp,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = { showImportDialog = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Key,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Import Private Key (nsec)",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear Private Key Section
|
||||
item(key = "clear_private") {
|
||||
Column(modifier = Modifier.padding(horizontal = 20.dp)) {
|
||||
Text(
|
||||
text = "PRIVATE KEY",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = colorScheme.onBackground.copy(alpha = 0.5f),
|
||||
letterSpacing = 0.5.sp,
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp)
|
||||
)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = colorScheme.surface,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "You can remove your private key from this device while keeping the public-only npub for subscriptions.",
|
||||
fontSize = 13.sp,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
modifier = Modifier.padding(bottom = 12.dp)
|
||||
)
|
||||
Button(onClick = {
|
||||
NostrIdentityBridge.clearPrivateKey(context)
|
||||
currentIdentity = NostrIdentityBridge.getCurrentNostrIdentity(context)
|
||||
Toast.makeText(context, "Private key removed from device", Toast.LENGTH_LONG).show()
|
||||
}, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(containerColor = colorScheme.error), shape = RoundedCornerShape(12.dp)) {
|
||||
Text("Remove Private Key", color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
item(key = "footer") {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Your keys are stored securely on this device",
|
||||
fontSize = 12.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.4f)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TopBar
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.height(64.dp)
|
||||
.background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha))
|
||||
) {
|
||||
CloseButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import Dialog
|
||||
if (showImportDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
showImportDialog = false
|
||||
importNsecText = ""
|
||||
importError = null
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = "Import nsec",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(
|
||||
text = "Paste your nsec below. This will replace your current identity.",
|
||||
fontSize = 14.sp,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = importNsecText,
|
||||
onValueChange = {
|
||||
importNsecText = it
|
||||
importError = null
|
||||
},
|
||||
label = { Text("nsec1...") },
|
||||
placeholder = { Text("nsec1...") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = { keyboardController?.hide() }
|
||||
),
|
||||
isError = importError != null,
|
||||
supportingText = importError?.let { { Text(it, color = colorScheme.error) } }
|
||||
)
|
||||
|
||||
Surface(
|
||||
color = colorScheme.error.copy(alpha = 0.1f),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Warning,
|
||||
contentDescription = null,
|
||||
tint = colorScheme.error,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Text(
|
||||
text = "This will permanently replace your current key!",
|
||||
fontSize = 12.sp,
|
||||
color = colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (importNsecText.isBlank()) {
|
||||
importError = "Please enter an nsec"
|
||||
return@TextButton
|
||||
}
|
||||
|
||||
if (!NostrIdentityBridge.isValidNsec(importNsecText)) {
|
||||
importError = "Invalid nsec format"
|
||||
return@TextButton
|
||||
}
|
||||
|
||||
showImportConfirmation = true
|
||||
}
|
||||
) {
|
||||
Text("Import", color = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showImportDialog = false
|
||||
importNsecText = ""
|
||||
importError = null
|
||||
}
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Confirmation Dialog
|
||||
if (showImportConfirmation) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showImportConfirmation = false },
|
||||
title = {
|
||||
Text(
|
||||
text = "Confirm Import",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = "Are you sure you want to replace your current Nostr identity? This action cannot be undone. Make sure you have backed up your current nsec if needed.",
|
||||
fontSize = 14.sp
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val newIdentity = NostrIdentityBridge.importFromNsec(importNsecText, context)
|
||||
if (newIdentity != null) {
|
||||
currentIdentity = newIdentity
|
||||
showImportConfirmation = false
|
||||
showImportDialog = false
|
||||
importNsecText = ""
|
||||
importError = null
|
||||
showNsec = false
|
||||
onIdentityChanged?.invoke()
|
||||
Toast.makeText(context, "Identity imported successfully!", Toast.LENGTH_LONG).show()
|
||||
|
||||
// Fetch profile from Nostr relays to get the user's name
|
||||
NostrIdentityBridge.fetchProfileFromRelays(newIdentity.publicKeyHex) { name, displayName, _, _, _ ->
|
||||
// Prefer name (username like @avillagran), fallback to display_name
|
||||
val nostrName = name?.takeIf { it.isNotBlank() } ?: displayName?.takeIf { it.isNotBlank() }
|
||||
if (nostrName != null) {
|
||||
foundNostrName = nostrName
|
||||
showNameConfirmation = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
showImportConfirmation = false
|
||||
importError = "Failed to import key"
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text("Replace", color = colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showImportConfirmation = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Name confirmation dialog - shown when a Nostr profile with a name is found
|
||||
if (showNameConfirmation && foundNostrName != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
showNameConfirmation = false
|
||||
foundNostrName = null
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = "Use Nostr Name?",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "We found your Nostr profile name:",
|
||||
fontSize = 14.sp
|
||||
)
|
||||
Surface(
|
||||
color = colorScheme.primaryContainer,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = foundNostrName!!,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
color = colorScheme.onPrimaryContainer
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "Would you like to use this as your BitChat username?",
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
foundNostrName?.let { name ->
|
||||
onNostrNameFound?.invoke(name)
|
||||
Toast.makeText(context, "Username updated to: $name", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
showNameConfirmation = false
|
||||
foundNostrName = null
|
||||
}
|
||||
) {
|
||||
Text("Yes, use this name", color = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showNameConfirmation = false
|
||||
foundNostrName = null
|
||||
}
|
||||
) {
|
||||
Text("No, keep current")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,7 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
@ -20,6 +21,8 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@ -33,8 +36,22 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.bitchat.android.R
|
||||
import java.io.File
|
||||
import java.net.URL
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Check if a path is a URL (http/https)
|
||||
*/
|
||||
private fun isUrl(path: String): Boolean {
|
||||
return path.startsWith("http://", ignoreCase = true) ||
|
||||
path.startsWith("https://", ignoreCase = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fullscreen image viewer with swipe navigation between multiple images
|
||||
@ -72,18 +89,57 @@ fun FullScreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onClo
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) { page ->
|
||||
val currentPath = imagePaths[page]
|
||||
val bmp = remember(currentPath) { try { android.graphics.BitmapFactory.decodeFile(currentPath) } catch (_: Exception) { null } }
|
||||
|
||||
bmp?.let {
|
||||
androidx.compose.foundation.Image(
|
||||
bitmap = it.asImageBitmap(),
|
||||
contentDescription = stringResource(R.string.cd_image_index_of, page + 1, imagePaths.size),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
} ?: run {
|
||||
|
||||
if (isUrl(currentPath)) {
|
||||
// Load from URL using Coil
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var isError by remember { mutableStateOf(false) }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(text = stringResource(R.string.image_unavailable), color = Color.White)
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context)
|
||||
.data(currentPath)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = stringResource(R.string.cd_image_index_of, page + 1, imagePaths.size),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Fit,
|
||||
onLoading = { isLoading = true; isError = false },
|
||||
onSuccess = { isLoading = false; isError = false },
|
||||
onError = { isLoading = false; isError = true }
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(48.dp),
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
Text(
|
||||
text = stringResource(R.string.image_load_failed),
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Load from local file
|
||||
val bmp = remember(currentPath) {
|
||||
try { android.graphics.BitmapFactory.decodeFile(currentPath) } catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
bmp?.let {
|
||||
androidx.compose.foundation.Image(
|
||||
bitmap = it.asImageBitmap(),
|
||||
contentDescription = stringResource(R.string.cd_image_index_of, page + 1, imagePaths.size),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
} ?: run {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(text = stringResource(R.string.image_unavailable), color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -139,34 +195,66 @@ fun FullScreenImageViewer(imagePaths: List<String>, initialIndex: Int = 0, onClo
|
||||
}
|
||||
|
||||
private fun saveToDownloads(context: android.content.Context, path: String) {
|
||||
runCatching {
|
||||
val name = File(path).name
|
||||
val mime = when {
|
||||
name.endsWith(".png", true) -> "image/png"
|
||||
name.endsWith(".webp", true) -> "image/webp"
|
||||
else -> "image/jpeg"
|
||||
}
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.Downloads.DISPLAY_NAME, name)
|
||||
put(MediaStore.Downloads.MIME_TYPE, mime)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
put(MediaStore.Downloads.IS_PENDING, 1)
|
||||
// Launch in background thread for URL downloads
|
||||
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
|
||||
runCatching {
|
||||
val isUrlPath = isUrl(path)
|
||||
|
||||
// Determine filename and mime type
|
||||
val name = if (isUrlPath) {
|
||||
// Extract filename from URL, fallback to timestamp-based name
|
||||
val urlPath = path.substringBefore('?').substringBefore('#')
|
||||
val urlName = urlPath.substringAfterLast('/')
|
||||
if (urlName.isNotEmpty() && urlName.contains('.')) {
|
||||
urlName
|
||||
} else {
|
||||
"image_${System.currentTimeMillis()}.jpg"
|
||||
}
|
||||
} else {
|
||||
File(path).name
|
||||
}
|
||||
|
||||
val mime = when {
|
||||
name.endsWith(".png", true) -> "image/png"
|
||||
name.endsWith(".webp", true) -> "image/webp"
|
||||
name.endsWith(".gif", true) -> "image/gif"
|
||||
name.endsWith(".avif", true) -> "image/avif"
|
||||
else -> "image/jpeg"
|
||||
}
|
||||
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.Downloads.DISPLAY_NAME, name)
|
||||
put(MediaStore.Downloads.MIME_TYPE, mime)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
put(MediaStore.Downloads.IS_PENDING, 1)
|
||||
}
|
||||
}
|
||||
|
||||
val uri = context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
|
||||
if (uri != null) {
|
||||
context.contentResolver.openOutputStream(uri)?.use { out ->
|
||||
if (isUrlPath) {
|
||||
// Download from URL
|
||||
URL(path).openStream().use { it.copyTo(out) }
|
||||
} else {
|
||||
// Copy from local file
|
||||
File(path).inputStream().use { it.copyTo(out) }
|
||||
}
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val v2 = ContentValues().apply { put(MediaStore.Downloads.IS_PENDING, 0) }
|
||||
context.contentResolver.update(uri, v2, null, null)
|
||||
}
|
||||
// Show toast message indicating the image has been saved (on main thread)
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(context, context.getString(R.string.toast_image_saved), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
// Show error toast on main thread
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(context, context.getString(R.string.toast_failed_to_save_image), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
val uri = context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
|
||||
if (uri != null) {
|
||||
context.contentResolver.openOutputStream(uri)?.use { out ->
|
||||
File(path).inputStream().use { it.copyTo(out) }
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val v2 = ContentValues().apply { put(MediaStore.Downloads.IS_PENDING, 0) }
|
||||
context.contentResolver.update(uri, v2, null, null)
|
||||
}
|
||||
// Show toast message indicating the image has been saved
|
||||
Toast.makeText(context, context.getString(R.string.toast_image_saved), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}.onFailure {
|
||||
// Optionally handle failure case (e.g., show error toast)
|
||||
Toast.makeText(context, context.getString(R.string.toast_failed_to_save_image), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@ -149,3 +149,97 @@ fun ImageMessageItem(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays an image loaded from a URL using Coil.
|
||||
* Used for inline image previews in text messages containing image URLs.
|
||||
*/
|
||||
@Composable
|
||||
fun UrlImageItem(
|
||||
url: String,
|
||||
onImageClick: ((String) -> Unit)? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
var isError by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.widthIn(max = 300.dp)
|
||||
.heightIn(max = 300.dp)
|
||||
.clip(androidx.compose.foundation.shape.RoundedCornerShape(10.dp))
|
||||
) {
|
||||
coil.compose.AsyncImage(
|
||||
model = coil.request.ImageRequest.Builder(LocalContext.current)
|
||||
.data(url)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = "Image",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onImageClick?.invoke(url) },
|
||||
contentScale = ContentScale.Fit,
|
||||
onLoading = { isLoading = true; isError = false },
|
||||
onSuccess = { isLoading = false; isError = false },
|
||||
onError = { isLoading = false; isError = true }
|
||||
)
|
||||
|
||||
// Loading indicator
|
||||
if (isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Gray.copy(alpha = 0.3f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
androidx.compose.material3.CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Error state - show placeholder
|
||||
if (isError) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(100.dp)
|
||||
.background(Color.Gray.copy(alpha = 0.2f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Failed to load image",
|
||||
color = Color.Gray,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays multiple URL images in a vertical column.
|
||||
* Used when a message contains multiple image URLs.
|
||||
*/
|
||||
@Composable
|
||||
fun UrlImagesColumn(
|
||||
urls: List<String>,
|
||||
onImageClick: ((String, List<String>, Int) -> Unit)? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
urls.forEachIndexed { index, url ->
|
||||
UrlImageItem(
|
||||
url = url,
|
||||
onImageClick = { clickedUrl ->
|
||||
onImageClick?.invoke(clickedUrl, urls, index)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -185,6 +185,7 @@
|
||||
|
||||
<string name="image_page_of">Image %1$d of %2$d</string>
|
||||
<string name="image_unavailable">Image unavailable</string>
|
||||
<string name="image_load_failed">Failed to load image</string>
|
||||
<string name="image_saved_to_downloads">Image saved to Downloads</string>
|
||||
<string name="image_save_failed">Failed to save image</string>
|
||||
<string name="pick_file">Pick file</string>
|
||||
@ -222,6 +223,7 @@
|
||||
<string name="disable_location_services">disable location services</string>
|
||||
<string name="enable_location_services">enable location services</string>
|
||||
<string name="mesh_label">mesh</string>
|
||||
<string name="me_channel_description">Your personal Nostr feed synced across devices</string>
|
||||
<string name="location_level_block">block</string>
|
||||
<string name="location_level_neighborhood">neighborhood</string>
|
||||
<string name="location_level_city">city</string>
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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" }
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user