From 2a7c004db2ea0842f305f9e32f8a79e7d76c61da Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sat, 30 Aug 2025 14:47:32 +0200 Subject: [PATCH] add pow --- .../java/com/bitchat/android/MainActivity.kt | 5 + .../com/bitchat/android/nostr/NostrClient.kt | 12 +- .../android/nostr/NostrGeohashService.kt | 10 + .../bitchat/android/nostr/NostrProofOfWork.kt | 216 ++++++++++++++++++ .../bitchat/android/nostr/NostrProtocol.kt | 32 ++- .../android/nostr/PoWPreferenceManager.kt | 124 ++++++++++ .../java/com/bitchat/android/ui/AboutSheet.kt | 133 +++++++++++ .../java/com/bitchat/android/ui/ChatHeader.kt | 6 + .../bitchat/android/ui/PoWStatusIndicator.kt | 205 +++++++++++++++++ 9 files changed, 738 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/nostr/NostrProofOfWork.kt create mode 100644 app/src/main/java/com/bitchat/android/nostr/PoWPreferenceManager.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt 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/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..1ef98ce6 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrGeohashService.kt @@ -1156,6 +1156,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)}...") 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..d5f26a60 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,28 @@ 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}") + + // 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") + } + } + + 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..53992a3f --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/PoWPreferenceManager.kt @@ -0,0 +1,124 @@ +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 = false + private const val DEFAULT_POW_DIFFICULTY = 16 // 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() + + 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)" + ) + } +} 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..889980dc 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,136 @@ 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) + ) { + Text( + text = "$powDifficulty", + fontSize = 9.sp, + fontFamily = FontFamily.Monospace, + color = Color.White, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } + } + } + } + ) + } + + Text( + text = "add computational 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..32f, + steps = 31, // 32 discrete values (0-32) + 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/PoWStatusIndicator.kt b/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt new file mode 100644 index 00000000..9f3e1d1f --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/PoWStatusIndicator.kt @@ -0,0 +1,205 @@ +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, + isMining: Boolean = false, + style: PoWIndicatorStyle = PoWIndicatorStyle.COMPACT +) { + val powEnabled by PoWPreferenceManager.powEnabled.collectAsState() + val powDifficulty by PoWPreferenceManager.powDifficulty.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) + ) + } + + // Difficulty indicator + Text( + text = powDifficulty.toString(), + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + color = if (isMining) Color(0xFFFF9500) else { + if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D) + } + ) + } + } + + 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) + ) + } + } + } +}