diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 30ff742e..cdcf871a 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -42,6 +42,7 @@ import com.bitchat.android.ui.ChatViewModel import com.bitchat.android.ui.theme.BitchatTheme import com.bitchat.android.ui.theme.ThemePreference import com.bitchat.android.ui.theme.ThemePreferenceManager +import com.bitchat.android.nostr.PoWPreferenceManager import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -591,6 +592,10 @@ class MainActivity : ComponentActivity() { Log.d("MainActivity", "Permissions verified, initializing chat system") + // Initialize PoW preferences early in the initialization process + PoWPreferenceManager.init(this@MainActivity) + Log.d("MainActivity", "PoW preferences initialized") + // Ensure all permissions are still granted (user might have revoked in settings) if (!permissionManager.areAllPermissionsGranted()) { val missing = permissionManager.getMissingPermissions() diff --git a/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt b/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt index c3681720..5bce45a9 100644 --- a/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt +++ b/app/src/main/java/com/bitchat/android/model/BitchatMessage.kt @@ -59,7 +59,8 @@ data class BitchatMessage( val channel: String? = null, val encryptedContent: ByteArray? = null, val isEncrypted: Boolean = false, - val deliveryStatus: DeliveryStatus? = null + val deliveryStatus: DeliveryStatus? = null, + val powDifficulty: Int? = null ) : Parcelable { /** diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt b/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt index b95f15d7..9e34c34c 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrClient.kt @@ -169,7 +169,7 @@ class NostrClient private constructor(private val context: Context) { // Derive geohash-specific identity val geohashIdentity = NostrIdentityBridge.deriveIdentity(geohash, context) - // Create ephemeral event + // Create ephemeral event (with PoW if enabled) val event = NostrProtocol.createEphemeralGeohashEvent( content = content, geohash = geohash, @@ -281,6 +281,16 @@ class NostrClient private constructor(private val context: Context) { handler: (content: String, senderPubkey: String, nickname: String?, timestamp: Int) -> Unit ) { try { + // Check Proof of Work validation for incoming geohash events + val powSettings = PoWPreferenceManager.getCurrentSettings() + if (powSettings.enabled && powSettings.difficulty > 0) { + if (!NostrProofOfWork.validateDifficulty(event, powSettings.difficulty)) { + Log.w(TAG, "🚫 Rejecting geohash event ${event.id.take(8)}... due to insufficient PoW (required: ${powSettings.difficulty})") + return + } + Log.v(TAG, "✅ PoW validation passed for geohash event ${event.id.take(8)}...") + } + // Extract nickname from tags val nickname = event.tags.find { it.size >= 2 && it[0] == "n" }?.get(1) diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt b/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt index 687f79bc..17462712 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.util.* +import kotlin.random.Random /** * Service responsible for all Nostr and Geohash business logic extracted from ChatViewModel @@ -242,6 +243,37 @@ class NostrGeohashService( fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) { coroutineScope.launch { try { + // Generate a temporary message ID for tracking animation + val tempMessageId = "temp_${System.currentTimeMillis()}_${Random.nextInt(1000)}" + + // Add local echo message IMMEDIATELY (with temporary ID) + val powSettingsLocal = PoWPreferenceManager.getCurrentSettings() + val localMessage = BitchatMessage( + id = tempMessageId, + sender = nickname ?: myPeerID, + content = content, + timestamp = Date(), + isRelay = false, + senderPeerID = "geohash:${channel.geohash}", + channel = "#${channel.geohash}", + powDifficulty = if (powSettingsLocal.enabled) powSettingsLocal.difficulty else null + ) + + // Store and display the message immediately + storeGeohashMessage(channel.geohash, localMessage) + messageManager.addMessage(localMessage) + + Log.d(TAG, "📝 Added message immediately with temp ID: $tempMessageId") + + // Check if PoW is enabled before starting animation + val powSettings = PoWPreferenceManager.getCurrentSettings() + if (powSettings.enabled && powSettings.difficulty > 0) { + // Start matrix animation for this message + com.bitchat.android.ui.PoWMiningTracker.startMiningMessage(tempMessageId) + Log.d(TAG, "🎭 Started matrix animation for message: $tempMessageId") + } + + // Now begin the async PoW process val identity = NostrIdentityBridge.deriveIdentity( forGeohash = channel.geohash, context = application @@ -257,6 +289,12 @@ class NostrGeohashService( teleported = teleported ) + // Stop animation when PoW completes + if (powSettings.enabled && powSettings.difficulty > 0) { + com.bitchat.android.ui.PoWMiningTracker.stopMiningMessage(tempMessageId) + Log.d(TAG, "🎭 Stopped matrix animation for message: $tempMessageId") + } + val nostrRelayManager = NostrRelayManager.getInstance(application) nostrRelayManager.sendEventToGeohash( event = event, @@ -267,22 +305,10 @@ class NostrGeohashService( Log.i(TAG, "📤 Sent geohash message to ${channel.geohash}: ${content.take(50)}") - // Add local echo message - val localMessage = BitchatMessage( - sender = nickname ?: myPeerID, - content = content, - timestamp = Date(), - isRelay = false, - senderPeerID = "geohash:${channel.geohash}", - channel = "#${channel.geohash}" - ) - - // Store our own message in geohash history - storeGeohashMessage(channel.geohash, localMessage) - messageManager.addMessage(localMessage) - } catch (e: Exception) { Log.e(TAG, "Failed to send geohash message: ${e.message}") + // Make sure to stop animation even if there's an error + com.bitchat.android.ui.PoWMiningTracker.stopMiningMessage("temp_${System.currentTimeMillis()}") } } } @@ -1156,6 +1182,16 @@ class NostrGeohashService( return@launch } + // Check Proof of Work validation BEFORE other processing + val powSettings = PoWPreferenceManager.getCurrentSettings() + if (powSettings.enabled && powSettings.difficulty > 0) { + if (!NostrProofOfWork.validateDifficulty(event, powSettings.difficulty)) { + Log.w(TAG, "🚫 Rejecting geohash event ${event.id.take(8)}... due to insufficient PoW (required: ${powSettings.difficulty})") + return@launch + } + Log.v(TAG, "✅ PoW validation passed for event ${event.id.take(8)}...") + } + // Check if this user is blocked in geohash channels BEFORE any processing if (isGeohashUserBlocked(event.pubkey)) { Log.v(TAG, "🚫 Skipping event from blocked geohash user: ${event.pubkey.take(8)}...") @@ -1238,6 +1274,9 @@ class NostrGeohashService( // Note: mentions parsing needs peer nicknames parameter // val mentions = messageManager.parseMentions(content, peerNicknames, nickname) + // Calculate actual PoW difficulty from the finalized event ID so we can show it for incoming messages too + val actualPow = try { NostrProofOfWork.calculateDifficulty(event.id) } catch (e: Exception) { 0 } + val message = BitchatMessage( id = event.id, sender = senderName, @@ -1247,7 +1286,8 @@ class NostrGeohashService( originalSender = senderHandle, senderPeerID = "nostr:${event.pubkey.take(8)}", mentions = null, // mentions need to be passed from outside - channel = "#$geohash" + channel = "#$geohash", + powDifficulty = actualPow.takeIf { it > 0 } ) // Store in geohash history for persistence across channel switches diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrProofOfWork.kt b/app/src/main/java/com/bitchat/android/nostr/NostrProofOfWork.kt new file mode 100644 index 00000000..d713422a --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrProofOfWork.kt @@ -0,0 +1,216 @@ +package com.bitchat.android.nostr + +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.security.MessageDigest +import kotlin.random.Random + +/** + * Nostr Proof of Work (PoW) implementation following NIP-13 + * + * This implements the Proof of Work system for Nostr events to provide spam deterrence. + * The difficulty is defined as the number of leading zero bits in the event ID. + * + * Reference: https://github.com/nostr-protocol/nips/blob/master/13.md + */ +object NostrProofOfWork { + + private const val TAG = "NostrProofOfWork" + + /** + * Calculate the difficulty (number of leading zero bits) of an event ID + * @param eventIdHex The hexadecimal event ID + * @return The number of leading zero bits + */ + fun calculateDifficulty(eventIdHex: String): Int { + var count = 0 + + for (i in eventIdHex.indices) { + val nibble = eventIdHex[i].toString().toInt(16) + if (nibble == 0) { + count += 4 + } else { + // Count leading zeros in the nibble + count += when (nibble) { + 1 -> 3 // 0001 + 2, 3 -> 2 // 001x + 4, 5, 6, 7 -> 1 // 01xx + else -> 0 // 1xxx + } + break + } + } + + return count + } + + /** + * Validate that an event meets the minimum difficulty requirement + * @param event The Nostr event to validate + * @param minimumDifficulty The minimum required difficulty + * @return true if the event meets the difficulty requirement + */ + fun validateDifficulty(event: NostrEvent, minimumDifficulty: Int): Boolean { + if (minimumDifficulty <= 0) return true + + val actualDifficulty = calculateDifficulty(event.id) + val committedDifficulty = getCommittedDifficulty(event) + + Log.d(TAG, "Validating PoW: actual=$actualDifficulty, required=$minimumDifficulty, committed=$committedDifficulty") + + // Check if actual difficulty meets requirement + if (actualDifficulty < minimumDifficulty) { + Log.w(TAG, "Event ${event.id.take(16)}... has insufficient difficulty: $actualDifficulty < $minimumDifficulty") + return false + } + + // If there's a committed difficulty, it should match or exceed the minimum + if (committedDifficulty != null && committedDifficulty < minimumDifficulty) { + Log.w(TAG, "Event ${event.id.take(16)}... has committed difficulty $committedDifficulty but achieved $actualDifficulty (possible spam)") + return false + } + + return true + } + + /** + * Mine a Nostr event to achieve the target difficulty + * @param event The event to mine (will be modified with nonce tag) + * @param targetDifficulty The target difficulty to achieve + * @param maxIterations Maximum number of iterations before giving up (default: 1,000,000) + * @return The mined event with nonce tag, or null if mining failed + */ + suspend fun mineEvent( + event: NostrEvent, + targetDifficulty: Int, + maxIterations: Int = 1_000_000 + ): NostrEvent? = withContext(Dispatchers.Default) { + if (targetDifficulty <= 0) return@withContext event + + Log.d(TAG, "Starting PoW mining for difficulty $targetDifficulty...") + val startTime = System.currentTimeMillis() + + var nonce = Random.nextLong(0, 1_000_000).toString() + var iterations = 0 + + while (iterations < maxIterations) { + // Create a copy of the event with the nonce tag + val eventWithNonce = addNonceTag(event, nonce, targetDifficulty) + + // Calculate the event ID + val eventId = eventWithNonce.computeEventIdHex() + val actualDifficulty = calculateDifficulty(eventId) + + if (actualDifficulty >= targetDifficulty) { + val timeElapsed = System.currentTimeMillis() - startTime + Log.i(TAG, "✅ PoW mining successful! Difficulty: $actualDifficulty, iterations: $iterations, time: ${timeElapsed}ms") + + // Return the event with the computed ID + return@withContext eventWithNonce.copy(id = eventId) + } + + // Increment nonce and try again + nonce = (nonce.toLongOrNull()?.plus(1) ?: Random.nextLong()).toString() + iterations++ + + // Log progress every 100,000 iterations + if (iterations % 100_000 == 0) { + val timeElapsed = System.currentTimeMillis() - startTime + Log.d(TAG, "PoW mining progress: $iterations iterations, ${timeElapsed}ms elapsed") + } + } + + val timeElapsed = System.currentTimeMillis() - startTime + Log.w(TAG, "❌ PoW mining failed after $maxIterations iterations (${timeElapsed}ms)") + return@withContext null + } + + /** + * Add or update the nonce tag in an event + * @param event The original event + * @param nonce The nonce value + * @param targetDifficulty The target difficulty being attempted + * @return A new event with the nonce tag added/updated + */ + private fun addNonceTag(event: NostrEvent, nonce: String, targetDifficulty: Int): NostrEvent { + val newTags = event.tags.toMutableList() + + // Remove existing nonce tag if present + newTags.removeAll { tag -> tag.isNotEmpty() && tag[0] == "nonce" } + + // Add new nonce tag with format: ["nonce", nonce_value, target_difficulty] + newTags.add(listOf("nonce", nonce, targetDifficulty.toString())) + + // Update created_at as recommended by NIP-13 + val updatedCreatedAt = (System.currentTimeMillis() / 1000).toInt() + + return event.copy( + tags = newTags, + createdAt = updatedCreatedAt + ) + } + + /** + * Get the committed difficulty from an event's nonce tag + * @param event The event to check + * @return The committed difficulty, or null if not present + */ + private fun getCommittedDifficulty(event: NostrEvent): Int? { + val nonceTag = event.tags.find { tag -> + tag.isNotEmpty() && tag[0] == "nonce" && tag.size >= 3 + } + + return nonceTag?.get(2)?.toIntOrNull() + } + + /** + * Check if an event has a nonce tag (indicating it was mined) + * @param event The event to check + * @return true if the event has a nonce tag + */ + fun hasNonce(event: NostrEvent): Boolean { + return event.tags.any { tag -> tag.isNotEmpty() && tag[0] == "nonce" } + } + + /** + * Get the nonce value from an event + * @param event The event to check + * @return The nonce value, or null if not present + */ + fun getNonce(event: NostrEvent): String? { + val nonceTag = event.tags.find { tag -> + tag.isNotEmpty() && tag[0] == "nonce" && tag.size >= 2 + } + + return nonceTag?.get(1) + } + + /** + * Estimate the computational work required for a given difficulty + * @param difficulty The target difficulty + * @return Estimated number of hash operations required + */ + fun estimateWork(difficulty: Int): Long { + return if (difficulty <= 0) 1L else 1L shl difficulty + } + + /** + * Get a human-readable description of the estimated mining time + * @param difficulty The target difficulty + * @param hashesPerSecond Estimated hashes per second (default: 100,000) + * @return Human-readable time estimate + */ + fun estimateMiningTime(difficulty: Int, hashesPerSecond: Int = 100_000): String { + val estimatedHashes = estimateWork(difficulty) + val estimatedSeconds = estimatedHashes / hashesPerSecond + + return when { + estimatedSeconds < 1 -> "< 1 second" + estimatedSeconds < 60 -> "${estimatedSeconds}s" + estimatedSeconds < 3600 -> "${estimatedSeconds / 60}m ${estimatedSeconds % 60}s" + estimatedSeconds < 86400 -> "${estimatedSeconds / 3600}h ${(estimatedSeconds % 3600) / 60}m" + else -> "${estimatedSeconds / 86400}d ${(estimatedSeconds % 86400) / 3600}h" + } + } +} diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt index 5b646088..26f3c241 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt @@ -3,6 +3,8 @@ package com.bitchat.android.nostr import android.util.Log import com.google.gson.Gson import com.google.gson.JsonParser +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext /** * NIP-17 Protocol Implementation for Private Direct Messages @@ -90,14 +92,15 @@ object NostrProtocol { /** * Create a geohash-scoped ephemeral public message (kind 20000) + * Includes Proof of Work mining if enabled in settings */ - fun createEphemeralGeohashEvent( + suspend fun createEphemeralGeohashEvent( content: String, geohash: String, senderIdentity: NostrIdentity, nickname: String? = null, teleported: Boolean = false - ): NostrEvent { + ): NostrEvent = withContext(Dispatchers.Default) { val tags = mutableListOf>() tags.add(listOf("g", geohash)) @@ -110,7 +113,7 @@ object NostrProtocol { tags.add(listOf("t", "teleport")) } - val event = NostrEvent( + var event = NostrEvent( pubkey = senderIdentity.publicKeyHex, createdAt = (System.currentTimeMillis() / 1000).toInt(), kind = NostrKind.EPHEMERAL_EVENT, @@ -118,7 +121,36 @@ object NostrProtocol { content = content ) - return senderIdentity.signEvent(event) + // Check if Proof of Work is enabled + val powSettings = PoWPreferenceManager.getCurrentSettings() + if (powSettings.enabled && powSettings.difficulty > 0) { + Log.d(TAG, "PoW enabled for geohash event: difficulty=${powSettings.difficulty}") + + try { + // Start mining state for animated indicators + PoWPreferenceManager.startMining() + + // Mine the event before signing + val minedEvent = NostrProofOfWork.mineEvent( + event = event, + targetDifficulty = powSettings.difficulty, + maxIterations = 2_000_000 // Allow up to 2M iterations for reasonable mining time + ) + + if (minedEvent != null) { + event = minedEvent + val actualDifficulty = NostrProofOfWork.calculateDifficulty(event.id) + Log.d(TAG, "✅ PoW mining successful: target=${powSettings.difficulty}, actual=$actualDifficulty, nonce=${NostrProofOfWork.getNonce(event)}") + } else { + Log.w(TAG, "❌ PoW mining failed, proceeding without PoW") + } + } finally { + // Always stop mining state when done (success or failure) + PoWPreferenceManager.stopMining() + } + } + + return@withContext senderIdentity.signEvent(event) } // MARK: - Private Methods diff --git a/app/src/main/java/com/bitchat/android/nostr/PoWPreferenceManager.kt b/app/src/main/java/com/bitchat/android/nostr/PoWPreferenceManager.kt new file mode 100644 index 00000000..a5300033 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/PoWPreferenceManager.kt @@ -0,0 +1,149 @@ +package com.bitchat.android.nostr + +import android.content.Context +import android.content.SharedPreferences +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Manages Proof of Work preferences for Nostr events + */ +object PoWPreferenceManager { + + private const val PREFS_NAME = "pow_preferences" + private const val KEY_POW_ENABLED = "pow_enabled" + private const val KEY_POW_DIFFICULTY = "pow_difficulty" + + // Default values + private const val DEFAULT_POW_ENABLED = true + private const val DEFAULT_POW_DIFFICULTY = 10 // Reasonable default for geohash spam prevention + + // State flows for reactive UI + private val _powEnabled = MutableStateFlow(DEFAULT_POW_ENABLED) + val powEnabled: StateFlow = _powEnabled.asStateFlow() + + private val _powDifficulty = MutableStateFlow(DEFAULT_POW_DIFFICULTY) + val powDifficulty: StateFlow = _powDifficulty.asStateFlow() + + // Mining state for animated indicators + private val _isMining = MutableStateFlow(false) + val isMining: StateFlow = _isMining.asStateFlow() + + private lateinit var sharedPrefs: SharedPreferences + private var isInitialized = false + + /** + * Initialize the preference manager with application context + * Should be called once during app startup + */ + fun init(context: Context) { + if (isInitialized) return + + sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + // Load current values + _powEnabled.value = sharedPrefs.getBoolean(KEY_POW_ENABLED, DEFAULT_POW_ENABLED) + _powDifficulty.value = sharedPrefs.getInt(KEY_POW_DIFFICULTY, DEFAULT_POW_DIFFICULTY) + + isInitialized = true + } + + /** + * Get current PoW enabled state + */ + fun isPowEnabled(): Boolean { + return _powEnabled.value + } + + /** + * Set PoW enabled state + */ + fun setPowEnabled(enabled: Boolean) { + _powEnabled.value = enabled + if (::sharedPrefs.isInitialized) { + sharedPrefs.edit().putBoolean(KEY_POW_ENABLED, enabled).apply() + } + } + + /** + * Get current PoW difficulty setting + */ + fun getPowDifficulty(): Int { + return _powDifficulty.value + } + + /** + * Set PoW difficulty (clamped between 0 and 32) + */ + fun setPowDifficulty(difficulty: Int) { + val clampedDifficulty = difficulty.coerceIn(0, 32) + _powDifficulty.value = clampedDifficulty + if (::sharedPrefs.isInitialized) { + sharedPrefs.edit().putInt(KEY_POW_DIFFICULTY, clampedDifficulty).apply() + } + } + + /** + * Get current settings as a data class + */ + data class PoWSettings( + val enabled: Boolean, + val difficulty: Int + ) + + /** + * Get current settings + */ + fun getCurrentSettings(): PoWSettings { + return PoWSettings( + enabled = _powEnabled.value, + difficulty = _powDifficulty.value + ) + } + + /** + * Reset to default settings + */ + fun resetToDefaults() { + setPowEnabled(DEFAULT_POW_ENABLED) + setPowDifficulty(DEFAULT_POW_DIFFICULTY) + } + + /** + * Get difficulty levels with descriptions for UI + */ + fun getDifficultyLevels(): List> { + return listOf( + 0 to "Disabled (no PoW)", + 8 to "Very Low (instant)", + 12 to "Low (~0.1s)", + 16 to "Medium (~2s)", + 20 to "High (~30s)", + 24 to "Very High (~8m)", + 28 to "Extreme (~2h)", + 32 to "Maximum (~8h)" + ) + } + + /** + * Get current mining state + */ + fun isMining(): Boolean { + return _isMining.value + } + + /** + * Start mining state - triggers animated indicators + */ + fun startMining() { + _isMining.value = true + } + + /** + * Stop mining state - stops animated indicators + */ + fun stopMining() { + _isMining.value = false + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index 83edea61..70590273 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -7,6 +7,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bluetooth import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Security import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.* import androidx.compose.runtime.* @@ -20,6 +21,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.BaselineShift import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.bitchat.android.nostr.NostrProofOfWork +import com.bitchat.android.nostr.PoWPreferenceManager /** * About Sheet for bitchat app information @@ -168,6 +171,128 @@ fun AboutSheet( } } + // Proof of Work section + item { + val context = LocalContext.current + + // Initialize PoW preferences if not already done + LaunchedEffect(Unit) { + PoWPreferenceManager.init(context) + } + + val powEnabled by PoWPreferenceManager.powEnabled.collectAsState() + val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState() + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "proof of work", + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + color = colorScheme.onSurface.copy(alpha = 0.8f) + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + FilterChip( + selected = !powEnabled, + onClick = { PoWPreferenceManager.setPowEnabled(false) }, + label = { Text("pow off", fontFamily = FontFamily.Monospace) } + ) + FilterChip( + selected = powEnabled, + onClick = { PoWPreferenceManager.setPowEnabled(true) }, + label = { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("pow on", fontFamily = FontFamily.Monospace) + // Show current difficulty + if (powEnabled) { + Surface( + color = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), + shape = RoundedCornerShape(50) + ) { Box(Modifier.size(8.dp)) } + } + } + } + ) + } + + Text( + text = "add proof of work to geohash messages for spam deterrence.", + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + color = colorScheme.onSurface.copy(alpha = 0.6f) + ) + + // Show difficulty slider when enabled + if (powEnabled) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "difficulty: $powDifficulty bits (~${NostrProofOfWork.estimateMiningTime(powDifficulty)})", + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = colorScheme.onSurface.copy(alpha = 0.7f) + ) + + Slider( + value = powDifficulty.toFloat(), + onValueChange = { PoWPreferenceManager.setPowDifficulty(it.toInt()) }, + valueRange = 0f..20f, + steps = 21, // 20 discrete values (0-20) + colors = SliderDefaults.colors( + thumbColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), + activeTrackColor = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) + ) + ) + + // Show difficulty description + Surface( + modifier = Modifier.fillMaxWidth(), + color = colorScheme.surfaceVariant.copy(alpha = 0.25f), + shape = RoundedCornerShape(8.dp) + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = "difficulty $powDifficulty requires ~${NostrProofOfWork.estimateWork(powDifficulty)} hash attempts", + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + color = colorScheme.onSurface.copy(alpha = 0.7f) + ) + Text( + text = when { + powDifficulty == 0 -> "no proof of work required" + powDifficulty <= 8 -> "very low - minimal spam protection" + powDifficulty <= 12 -> "low - basic spam protection" + powDifficulty <= 16 -> "medium - good spam protection" + powDifficulty <= 20 -> "high - strong spam protection" + powDifficulty <= 24 -> "very high - may cause delays" + else -> "extreme - significant computation required" + }, + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + color = colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + } + } + } + } + } + // Network (Tor) section item { val ctx = LocalContext.current diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index 3f7f59f8..bb161953 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -571,6 +571,12 @@ private fun MainHeader( // Tor status cable icon when Tor is enabled TorStatusIcon(modifier = Modifier.size(14.dp)) + + // PoW status indicator + PoWStatusIndicator( + modifier = Modifier, + style = PoWIndicatorStyle.COMPACT + ) PeerCounter( connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID }, diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt index bdb2fa1f..858f8962 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -117,11 +117,18 @@ 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) builder.pushStyle(SpanStyle( color = Color.Gray.copy(alpha = 0.7f), fontSize = (BASE_FONT_SIZE - 4).sp )) builder.append(" [${timeFormatter.format(message.timestamp)}]") + // If message has valid PoW difficulty, append bits immediately after timestamp with minimal spacing + //message.powDifficulty?.let { bits -> + // if (bits > 0) { + // builder.append(" ${bits}b") + // } + //} builder.pop() } else { diff --git a/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt new file mode 100644 index 00000000..97f5df24 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/MatrixEncryptionAnimation.kt @@ -0,0 +1,270 @@ +package com.bitchat.android.ui + +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlin.random.Random + +/** + * Animation state for individual characters + */ +private enum class CharacterAnimationState { + ENCRYPTED, // Showing random encrypted characters + DECRYPTING, // Transitioning to final character + FINAL // Showing final decrypted character +} + +/** + * Check if a message should be animated based on its mining state + */ +@Composable +fun shouldAnimateMessage(messageId: String): Boolean { + val miningMessages by PoWMiningTracker.miningMessages.collectAsState() + return miningMessages.contains(messageId) +} + +/** + * Tracks which messages are currently being mined for PoW + * Provides reactive state for UI animations + */ +object PoWMiningTracker { + private val _miningMessages = MutableStateFlow>(emptySet()) + val miningMessages: StateFlow> = _miningMessages.asStateFlow() + + /** + * Start tracking a message as mining + */ + fun startMiningMessage(messageId: String) { + _miningMessages.value = _miningMessages.value + messageId + } + + /** + * Stop tracking a message as mining + */ + fun stopMiningMessage(messageId: String) { + _miningMessages.value = _miningMessages.value - messageId + } + + /** + * Check if a message is currently mining + */ + fun isMiningMessage(messageId: String): Boolean { + return _miningMessages.value.contains(messageId) + } + + /** + * Clear all mining messages (for cleanup) + */ + fun clearAllMining() { + _miningMessages.value = emptySet() + } +} + +/** + * Enhanced message display that shows matrix animation during PoW mining + * Formats message like a normal message but animates only the content portion + */ +@Composable +fun MessageWithMatrixAnimation( + message: com.bitchat.android.model.BitchatMessage, + currentUserNickname: String, + meshService: com.bitchat.android.mesh.BluetoothMeshService, + colorScheme: androidx.compose.material3.ColorScheme, + timeFormatter: java.text.SimpleDateFormat, + onNicknameClick: ((String) -> Unit)?, + onMessageLongPress: ((com.bitchat.android.model.BitchatMessage) -> Unit)?, + modifier: Modifier = Modifier +) { + val isAnimating = shouldAnimateMessage(message.id) + + if (isAnimating) { + // During animation: Show formatted message with animated content + AnimatedMessageDisplay( + message = message, + currentUserNickname = currentUserNickname, + meshService = meshService, + colorScheme = colorScheme, + timeFormatter = timeFormatter, + modifier = modifier + ) + } else { + // After animation: Show complete normal message using existing formatter + val annotatedText = formatMessageAsAnnotatedString( + message = message, + currentUserNickname = currentUserNickname, + meshService = meshService, + colorScheme = colorScheme, + timeFormatter = timeFormatter + ) + + Text( + text = annotatedText, + modifier = modifier, + fontFamily = FontFamily.Monospace, + softWrap = true + ) + } +} + +/** + * Display message with proper formatting but animated content + * Uses IDENTICAL layout structure as normal message for pixel-perfect alignment + */ +@Composable +private fun AnimatedMessageDisplay( + message: com.bitchat.android.model.BitchatMessage, + currentUserNickname: String, + meshService: com.bitchat.android.mesh.BluetoothMeshService, + colorScheme: androidx.compose.material3.ColorScheme, + timeFormatter: java.text.SimpleDateFormat, + modifier: Modifier = Modifier +) { + // Get the animated content text + var animatedContent by remember(message.content) { mutableStateOf(message.content) } + val isAnimating = shouldAnimateMessage(message.id) + + // Character-by-character animation state like the JavaScript version + var characterStates by remember(message.content) { + mutableStateOf(message.content.map { char -> + if (char == ' ') CharacterAnimationState.FINAL else CharacterAnimationState.ENCRYPTED + }) + } + + // Update animated content when animation state changes + LaunchedEffect(isAnimating, message.content) { + if (isAnimating && message.content.isNotEmpty()) { + val encryptedChars = "!@$%^&*()_+-=[]{}|;:,<>?".toCharArray() + + // Start character animations with staggered delays (like JS version) + message.content.forEachIndexed { index, targetChar -> + if (targetChar != ' ') { // Skip spaces + launch { + delay(index * 50L) // Stagger start like JS version + + // Animate this character indefinitely in a loop + while (true) { + // Animate with random characters + while (characterStates.getOrNull(index) == CharacterAnimationState.ENCRYPTED) { + // Generate random encrypted character for this position + val newContent = animatedContent.toCharArray() + if (index < newContent.size) { + newContent[index] = encryptedChars[Random.nextInt(encryptedChars.size)] + animatedContent = String(newContent) + } + + delay(100L) // Change character every 100ms like JS + + // Random chance to reveal (10% like JS version) + if (Random.nextFloat() < 0.1f) { + // Reveal the final character + val finalContent = animatedContent.toCharArray() + if (index < finalContent.size) { + finalContent[index] = targetChar + animatedContent = String(finalContent) + } + + // Mark as revealed + val finalStates = characterStates.toMutableList() + finalStates[index] = CharacterAnimationState.FINAL + characterStates = finalStates + break + } + } + + // Keep revealed for 2 seconds, then fade back to encrypted (like JS) + delay(2000L) + + // Reset back to encrypted for next cycle + val resetStates = characterStates.toMutableList() + resetStates[index] = CharacterAnimationState.ENCRYPTED + characterStates = resetStates + } + } + } + } + } else { + // Not animating, show final content + animatedContent = message.content + characterStates = message.content.map { CharacterAnimationState.FINAL } + } + } + + // Create a temporary message with animated content for formatting + val animatedMessage = message.copy(content = animatedContent) + + // Use formatting function without timestamp during animation + val annotatedText = if (isAnimating) { + formatMessageAsAnnotatedStringWithoutTimestamp( + message = animatedMessage, + currentUserNickname = currentUserNickname, + meshService = meshService, + colorScheme = colorScheme + ) + } else { + formatMessageAsAnnotatedString( + message = animatedMessage, + currentUserNickname = currentUserNickname, + meshService = meshService, + colorScheme = colorScheme, + timeFormatter = timeFormatter + ) + } + + // Use IDENTICAL Text composable structure as normal message + Text( + text = annotatedText, + modifier = modifier, + fontFamily = FontFamily.Monospace, + softWrap = true, + overflow = androidx.compose.ui.text.style.TextOverflow.Visible, + style = androidx.compose.ui.text.TextStyle( + color = colorScheme.onSurface + ) + ) +} + + +/** + * Format message without timestamp and PoW badge for animation phase + * Identical to formatMessageAsAnnotatedString but excludes timestamp and PoW badge + */ +private fun formatMessageAsAnnotatedStringWithoutTimestamp( + message: com.bitchat.android.model.BitchatMessage, + currentUserNickname: String, + meshService: com.bitchat.android.mesh.BluetoothMeshService, + colorScheme: androidx.compose.material3.ColorScheme +): AnnotatedString { + // Get the full formatted text first + val timeFormatter = java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.getDefault()) + val fullText = formatMessageAsAnnotatedString( + message = message, + currentUserNickname = currentUserNickname, + meshService = meshService, + colorScheme = colorScheme, + timeFormatter = timeFormatter + ) + + // Find and remove the timestamp and PoW badge at the end + val text = fullText.text + val timestampPattern = """ \[\d{2}:\d{2}:\d{2}].*$""".toRegex() // Matches " [HH:mm:ss] 12b" or just " [HH:mm:ss]" + val match = timestampPattern.find(text) + + return if (match != null) { + // Remove timestamp and PoW portion + val endIndex = match.range.first + AnnotatedString( + text = text.substring(0, endIndex), + spanStyles = fullText.spanStyles.filter { it.end <= endIndex }, + paragraphStyles = fullText.paragraphStyles.filter { it.end <= endIndex } + ) + } else { + fullText + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index a27bbdad..c8452c3b 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -166,101 +166,120 @@ private fun MessageTextWithClickableNicknames( onMessageLongPress: ((BitchatMessage) -> Unit)?, modifier: Modifier = Modifier ) { - val annotatedText = formatMessageAsAnnotatedString( - message = message, - currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter - ) + // Check if this message should be animated during PoW mining + val shouldAnimate = shouldAnimateMessage(message.id) - // Check if this message was sent by self to avoid click interactions on own nickname - val isSelf = message.senderPeerID == meshService.myPeerID || - message.sender == currentUserNickname || - message.sender.startsWith("$currentUserNickname#") - - val haptic = LocalHapticFeedback.current - val context = LocalContext.current - var textLayoutResult by remember { mutableStateOf(null) } - Text( - text = annotatedText, - modifier = modifier.pointerInput(message) { - detectTapGestures( - onTap = { position -> - val layout = textLayoutResult ?: return@detectTapGestures - val offset = layout.getOffsetForPosition(position) - // Nickname click only when not self - if (!isSelf && onNicknameClick != null) { - val nicknameAnnotations = annotatedText.getStringAnnotations( - tag = "nickname_click", + // If animation is needed, use the matrix animation component for content only + if (shouldAnimate) { + // Display message with matrix animation for content + MessageWithMatrixAnimation( + message = message, + currentUserNickname = currentUserNickname, + meshService = meshService, + colorScheme = colorScheme, + timeFormatter = timeFormatter, + onNicknameClick = onNicknameClick, + onMessageLongPress = onMessageLongPress, + modifier = modifier + ) + } else { + // Normal message display + val annotatedText = formatMessageAsAnnotatedString( + message = message, + currentUserNickname = currentUserNickname, + meshService = meshService, + colorScheme = colorScheme, + timeFormatter = timeFormatter + ) + + // Check if this message was sent by self to avoid click interactions on own nickname + val isSelf = message.senderPeerID == meshService.myPeerID || + message.sender == currentUserNickname || + message.sender.startsWith("$currentUserNickname#") + + val haptic = LocalHapticFeedback.current + val context = LocalContext.current + var textLayoutResult by remember { mutableStateOf(null) } + Text( + text = annotatedText, + modifier = modifier.pointerInput(message) { + detectTapGestures( + onTap = { position -> + val layout = textLayoutResult ?: return@detectTapGestures + val offset = layout.getOffsetForPosition(position) + // Nickname click only when not self + if (!isSelf && onNicknameClick != null) { + val nicknameAnnotations = annotatedText.getStringAnnotations( + tag = "nickname_click", + 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 } + ) + } } @Composable diff --git a/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt b/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt new file mode 100644 index 00000000..59aaed06 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt @@ -0,0 +1,194 @@ +package com.bitchat.android.ui + +import androidx.compose.animation.core.* +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Security +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.graphicsLayer +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.bitchat.android.nostr.NostrProofOfWork +import com.bitchat.android.nostr.PoWPreferenceManager + +/** + * Shows the current Proof of Work status and settings + */ +@Composable +fun PoWStatusIndicator( + modifier: Modifier = Modifier, + style: PoWIndicatorStyle = PoWIndicatorStyle.COMPACT +) { + val powEnabled by PoWPreferenceManager.powEnabled.collectAsState() + val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState() + val isMining by PoWPreferenceManager.isMining.collectAsState() + val colorScheme = MaterialTheme.colorScheme + val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f + + if (!powEnabled) return + + when (style) { + PoWIndicatorStyle.COMPACT -> { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // PoW icon with animation if mining + if (isMining) { + val rotation by rememberInfiniteTransition(label = "pow-rotation").animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "pow-icon-rotation" + ) + + Icon( + imageVector = Icons.Filled.Security, + contentDescription = "Mining PoW", + tint = Color(0xFFFF9500), // Orange for mining + modifier = Modifier + .size(12.dp) + .graphicsLayer { rotationZ = rotation } + ) + } else { + Icon( + imageVector = Icons.Filled.Security, + contentDescription = "PoW Enabled", + tint = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D), // Green when ready + modifier = Modifier.size(12.dp) + ) + } + } + } + + PoWIndicatorStyle.DETAILED -> { + Surface( + modifier = modifier, + color = colorScheme.surfaceVariant.copy(alpha = 0.3f), + shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp) + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // PoW icon + Icon( + imageVector = Icons.Filled.Security, + contentDescription = "Proof of Work", + tint = if (isMining) Color(0xFFFF9500) else { + if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) + }, + modifier = Modifier.size(14.dp) + ) + + // Status text + Text( + text = if (isMining) { + "mining..." + } else { + "pow: ${powDifficulty}bit" + }, + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = if (isMining) Color(0xFFFF9500) else { + colorScheme.onSurface.copy(alpha = 0.7f) + } + ) + + // Time estimate + if (!isMining && powDifficulty > 0) { + Text( + text = "(~${NostrProofOfWork.estimateMiningTime(powDifficulty)})", + fontSize = 9.sp, + fontFamily = FontFamily.Monospace, + color = colorScheme.onSurface.copy(alpha = 0.5f) + ) + } + } + } + } + } +} + +/** + * Style options for the PoW status indicator + */ +enum class PoWIndicatorStyle { + COMPACT, // Small icon + difficulty number + DETAILED // Icon + status text + time estimate +} + +/** + * Shows mining progress with animated indicator + */ +@Composable +fun PoWMiningIndicator( + modifier: Modifier = Modifier, + difficulty: Int, + iterations: Int? = null +) { + val colorScheme = MaterialTheme.colorScheme + + Surface( + modifier = modifier, + color = Color(0xFFFF9500).copy(alpha = 0.1f), + shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp) + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Animated security icon + val rotation by rememberInfiniteTransition(label = "mining-rotation").animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(1000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "mining-icon-rotation" + ) + + Icon( + imageVector = Icons.Filled.Security, + contentDescription = "Mining Proof of Work", + tint = Color(0xFFFF9500), + modifier = Modifier + .size(16.dp) + .graphicsLayer { rotationZ = rotation } + ) + + Column( + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = "mining proof of work...", + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + color = Color(0xFFFF9500) + ) + + Text( + text = "difficulty: ${difficulty}bit (~${NostrProofOfWork.estimateMiningTime(difficulty)})" + + if (iterations != null) " • ${iterations} attempts" else "", + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + color = colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + } + } +}