mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 02:25:21 +00:00
Nip13 pow (#357)
* add pow * animation * matrix style * animation better * improve animation * improve animation * works * fix jump * difficulty indicator * 10 is default * animation runs forever * pow in message timestamp * pow works * default on * adjust animation * no printing
This commit is contained in:
@@ -42,6 +42,7 @@ import com.bitchat.android.ui.ChatViewModel
|
|||||||
import com.bitchat.android.ui.theme.BitchatTheme
|
import com.bitchat.android.ui.theme.BitchatTheme
|
||||||
import com.bitchat.android.ui.theme.ThemePreference
|
import com.bitchat.android.ui.theme.ThemePreference
|
||||||
import com.bitchat.android.ui.theme.ThemePreferenceManager
|
import com.bitchat.android.ui.theme.ThemePreferenceManager
|
||||||
|
import com.bitchat.android.nostr.PoWPreferenceManager
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -591,6 +592,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
Log.d("MainActivity", "Permissions verified, initializing chat system")
|
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)
|
// Ensure all permissions are still granted (user might have revoked in settings)
|
||||||
if (!permissionManager.areAllPermissionsGranted()) {
|
if (!permissionManager.areAllPermissionsGranted()) {
|
||||||
val missing = permissionManager.getMissingPermissions()
|
val missing = permissionManager.getMissingPermissions()
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ data class BitchatMessage(
|
|||||||
val channel: String? = null,
|
val channel: String? = null,
|
||||||
val encryptedContent: ByteArray? = null,
|
val encryptedContent: ByteArray? = null,
|
||||||
val isEncrypted: Boolean = false,
|
val isEncrypted: Boolean = false,
|
||||||
val deliveryStatus: DeliveryStatus? = null
|
val deliveryStatus: DeliveryStatus? = null,
|
||||||
|
val powDifficulty: Int? = null
|
||||||
) : Parcelable {
|
) : Parcelable {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ class NostrClient private constructor(private val context: Context) {
|
|||||||
// Derive geohash-specific identity
|
// Derive geohash-specific identity
|
||||||
val geohashIdentity = NostrIdentityBridge.deriveIdentity(geohash, context)
|
val geohashIdentity = NostrIdentityBridge.deriveIdentity(geohash, context)
|
||||||
|
|
||||||
// Create ephemeral event
|
// Create ephemeral event (with PoW if enabled)
|
||||||
val event = NostrProtocol.createEphemeralGeohashEvent(
|
val event = NostrProtocol.createEphemeralGeohashEvent(
|
||||||
content = content,
|
content = content,
|
||||||
geohash = geohash,
|
geohash = geohash,
|
||||||
@@ -281,6 +281,16 @@ class NostrClient private constructor(private val context: Context) {
|
|||||||
handler: (content: String, senderPubkey: String, nickname: String?, timestamp: Int) -> Unit
|
handler: (content: String, senderPubkey: String, nickname: String?, timestamp: Int) -> Unit
|
||||||
) {
|
) {
|
||||||
try {
|
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
|
// Extract nickname from tags
|
||||||
val nickname = event.tags.find { it.size >= 2 && it[0] == "n" }?.get(1)
|
val nickname = event.tags.find { it.size >= 2 && it[0] == "n" }?.get(1)
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service responsible for all Nostr and Geohash business logic extracted from ChatViewModel
|
* 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?) {
|
fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) {
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
try {
|
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(
|
val identity = NostrIdentityBridge.deriveIdentity(
|
||||||
forGeohash = channel.geohash,
|
forGeohash = channel.geohash,
|
||||||
context = application
|
context = application
|
||||||
@@ -257,6 +289,12 @@ class NostrGeohashService(
|
|||||||
teleported = teleported
|
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)
|
val nostrRelayManager = NostrRelayManager.getInstance(application)
|
||||||
nostrRelayManager.sendEventToGeohash(
|
nostrRelayManager.sendEventToGeohash(
|
||||||
event = event,
|
event = event,
|
||||||
@@ -267,22 +305,10 @@ class NostrGeohashService(
|
|||||||
|
|
||||||
Log.i(TAG, "📤 Sent geohash message to ${channel.geohash}: ${content.take(50)}")
|
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) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to send geohash message: ${e.message}")
|
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
|
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
|
// Check if this user is blocked in geohash channels BEFORE any processing
|
||||||
if (isGeohashUserBlocked(event.pubkey)) {
|
if (isGeohashUserBlocked(event.pubkey)) {
|
||||||
Log.v(TAG, "🚫 Skipping event from blocked geohash user: ${event.pubkey.take(8)}...")
|
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
|
// Note: mentions parsing needs peer nicknames parameter
|
||||||
// val mentions = messageManager.parseMentions(content, peerNicknames, nickname)
|
// 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(
|
val message = BitchatMessage(
|
||||||
id = event.id,
|
id = event.id,
|
||||||
sender = senderName,
|
sender = senderName,
|
||||||
@@ -1247,7 +1286,8 @@ class NostrGeohashService(
|
|||||||
originalSender = senderHandle,
|
originalSender = senderHandle,
|
||||||
senderPeerID = "nostr:${event.pubkey.take(8)}",
|
senderPeerID = "nostr:${event.pubkey.take(8)}",
|
||||||
mentions = null, // mentions need to be passed from outside
|
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
|
// Store in geohash history for persistence across channel switches
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ package com.bitchat.android.nostr
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.google.gson.JsonParser
|
import com.google.gson.JsonParser
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NIP-17 Protocol Implementation for Private Direct Messages
|
* NIP-17 Protocol Implementation for Private Direct Messages
|
||||||
@@ -90,14 +92,15 @@ object NostrProtocol {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a geohash-scoped ephemeral public message (kind 20000)
|
* 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,
|
content: String,
|
||||||
geohash: String,
|
geohash: String,
|
||||||
senderIdentity: NostrIdentity,
|
senderIdentity: NostrIdentity,
|
||||||
nickname: String? = null,
|
nickname: String? = null,
|
||||||
teleported: Boolean = false
|
teleported: Boolean = false
|
||||||
): NostrEvent {
|
): NostrEvent = withContext(Dispatchers.Default) {
|
||||||
val tags = mutableListOf<List<String>>()
|
val tags = mutableListOf<List<String>>()
|
||||||
tags.add(listOf("g", geohash))
|
tags.add(listOf("g", geohash))
|
||||||
|
|
||||||
@@ -110,7 +113,7 @@ object NostrProtocol {
|
|||||||
tags.add(listOf("t", "teleport"))
|
tags.add(listOf("t", "teleport"))
|
||||||
}
|
}
|
||||||
|
|
||||||
val event = NostrEvent(
|
var event = NostrEvent(
|
||||||
pubkey = senderIdentity.publicKeyHex,
|
pubkey = senderIdentity.publicKeyHex,
|
||||||
createdAt = (System.currentTimeMillis() / 1000).toInt(),
|
createdAt = (System.currentTimeMillis() / 1000).toInt(),
|
||||||
kind = NostrKind.EPHEMERAL_EVENT,
|
kind = NostrKind.EPHEMERAL_EVENT,
|
||||||
@@ -118,7 +121,36 @@ object NostrProtocol {
|
|||||||
content = content
|
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
|
// MARK: - Private Methods
|
||||||
|
|||||||
@@ -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<Boolean> = _powEnabled.asStateFlow()
|
||||||
|
|
||||||
|
private val _powDifficulty = MutableStateFlow(DEFAULT_POW_DIFFICULTY)
|
||||||
|
val powDifficulty: StateFlow<Int> = _powDifficulty.asStateFlow()
|
||||||
|
|
||||||
|
// Mining state for animated indicators
|
||||||
|
private val _isMining = MutableStateFlow(false)
|
||||||
|
val isMining: StateFlow<Boolean> = _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<Pair<Int, String>> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.filled.Bluetooth
|
import androidx.compose.material.icons.filled.Bluetooth
|
||||||
import androidx.compose.material.icons.filled.Lock
|
import androidx.compose.material.icons.filled.Lock
|
||||||
import androidx.compose.material.icons.filled.Public
|
import androidx.compose.material.icons.filled.Public
|
||||||
|
import androidx.compose.material.icons.filled.Security
|
||||||
import androidx.compose.material.icons.filled.Warning
|
import androidx.compose.material.icons.filled.Warning
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
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.text.style.BaselineShift
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.bitchat.android.nostr.NostrProofOfWork
|
||||||
|
import com.bitchat.android.nostr.PoWPreferenceManager
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* About Sheet for bitchat app information
|
* 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
|
// Network (Tor) section
|
||||||
item {
|
item {
|
||||||
val ctx = LocalContext.current
|
val ctx = LocalContext.current
|
||||||
|
|||||||
@@ -572,6 +572,12 @@ private fun MainHeader(
|
|||||||
// Tor status cable icon when Tor is enabled
|
// Tor status cable icon when Tor is enabled
|
||||||
TorStatusIcon(modifier = Modifier.size(14.dp))
|
TorStatusIcon(modifier = Modifier.size(14.dp))
|
||||||
|
|
||||||
|
// PoW status indicator
|
||||||
|
PoWStatusIndicator(
|
||||||
|
modifier = Modifier,
|
||||||
|
style = PoWIndicatorStyle.COMPACT
|
||||||
|
)
|
||||||
|
|
||||||
PeerCounter(
|
PeerCounter(
|
||||||
connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID },
|
connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID },
|
||||||
joinedChannels = joinedChannels,
|
joinedChannels = joinedChannels,
|
||||||
|
|||||||
@@ -117,11 +117,18 @@ fun formatMessageAsAnnotatedString(
|
|||||||
appendIOSFormattedContent(builder, message.content, message.mentions, currentUserNickname, baseColor, isSelf, isDark)
|
appendIOSFormattedContent(builder, message.content, message.mentions, currentUserNickname, baseColor, isSelf, isDark)
|
||||||
|
|
||||||
// iOS-style timestamp at the END (smaller, grey)
|
// iOS-style timestamp at the END (smaller, grey)
|
||||||
|
// Timestamp (and optional PoW badge)
|
||||||
builder.pushStyle(SpanStyle(
|
builder.pushStyle(SpanStyle(
|
||||||
color = Color.Gray.copy(alpha = 0.7f),
|
color = Color.Gray.copy(alpha = 0.7f),
|
||||||
fontSize = (BASE_FONT_SIZE - 4).sp
|
fontSize = (BASE_FONT_SIZE - 4).sp
|
||||||
))
|
))
|
||||||
builder.append(" [${timeFormatter.format(message.timestamp)}]")
|
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()
|
builder.pop()
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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<Set<String>>(emptySet())
|
||||||
|
val miningMessages: StateFlow<Set<String>> = _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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -166,6 +166,24 @@ private fun MessageTextWithClickableNicknames(
|
|||||||
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
onMessageLongPress: ((BitchatMessage) -> Unit)?,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
|
// Check if this message should be animated during PoW mining
|
||||||
|
val shouldAnimate = shouldAnimateMessage(message.id)
|
||||||
|
|
||||||
|
// 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(
|
val annotatedText = formatMessageAsAnnotatedString(
|
||||||
message = message,
|
message = message,
|
||||||
currentUserNickname = currentUserNickname,
|
currentUserNickname = currentUserNickname,
|
||||||
@@ -262,6 +280,7 @@ private fun MessageTextWithClickableNicknames(
|
|||||||
onTextLayout = { result -> textLayoutResult = result }
|
onTextLayout = { result -> textLayoutResult = result }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DeliveryStatusIcon(status: DeliveryStatus) {
|
fun DeliveryStatusIcon(status: DeliveryStatus) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user