mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Merge remote-tracking branch 'origin/main' into codex/nostr-double-ratchet
This commit is contained in:
commit
40fe3723ea
@ -183,7 +183,6 @@ object BinaryProtocol {
|
|||||||
private const val SENDER_ID_SIZE = 8
|
private const val SENDER_ID_SIZE = 8
|
||||||
private const val RECIPIENT_ID_SIZE = 8
|
private const val RECIPIENT_ID_SIZE = 8
|
||||||
private const val SIGNATURE_SIZE = 64
|
private const val SIGNATURE_SIZE = 64
|
||||||
|
|
||||||
object Flags {
|
object Flags {
|
||||||
const val HAS_RECIPIENT: UByte = 0x01u
|
const val HAS_RECIPIENT: UByte = 0x01u
|
||||||
const val HAS_SIGNATURE: UByte = 0x02u
|
const val HAS_SIGNATURE: UByte = 0x02u
|
||||||
@ -200,6 +199,15 @@ object BinaryProtocol {
|
|||||||
|
|
||||||
fun encode(packet: BitchatPacket, padding: Boolean = true): ByteArray? {
|
fun encode(packet: BitchatPacket, padding: Boolean = true): ByteArray? {
|
||||||
try {
|
try {
|
||||||
|
val maxPayloadLength = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
|
||||||
|
if (packet.payload.size > maxPayloadLength) {
|
||||||
|
Log.w(
|
||||||
|
"BinaryProtocol",
|
||||||
|
"Cannot encode payload ${packet.payload.size} above receiver limit $maxPayloadLength"
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
// Try to compress payload if beneficial
|
// Try to compress payload if beneficial
|
||||||
var payload = packet.payload
|
var payload = packet.payload
|
||||||
var originalPayloadSize: Int? = null
|
var originalPayloadSize: Int? = null
|
||||||
@ -324,21 +332,36 @@ object BinaryProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun decode(data: ByteArray): BitchatPacket? {
|
fun decode(data: ByteArray): BitchatPacket? =
|
||||||
|
decode(data, CompressionUtil::decompressWithResourcesReserved)
|
||||||
|
|
||||||
|
/** Test seam used to prove rejected expansion sizes never reach inflation. */
|
||||||
|
internal fun decodeForTesting(
|
||||||
|
data: ByteArray,
|
||||||
|
decompress: (ByteArray, Int) -> ByteArray?
|
||||||
|
): BitchatPacket? = decode(data, decompress)
|
||||||
|
|
||||||
|
private fun decode(
|
||||||
|
data: ByteArray,
|
||||||
|
decompress: (ByteArray, Int) -> ByteArray?
|
||||||
|
): BitchatPacket? {
|
||||||
// Try decode as-is first (robust when padding wasn't applied) - iOS fix
|
// Try decode as-is first (robust when padding wasn't applied) - iOS fix
|
||||||
decodeCore(data)?.let { return it }
|
decodeCore(data, decompress)?.let { return it }
|
||||||
|
|
||||||
// If that fails, try after removing padding
|
// If that fails, try after removing padding
|
||||||
val unpadded = MessagePadding.unpad(data)
|
val unpadded = MessagePadding.unpad(data)
|
||||||
if (unpadded.contentEquals(data)) return null // No padding was removed, already failed
|
if (unpadded.contentEquals(data)) return null // No padding was removed, already failed
|
||||||
|
|
||||||
return decodeCore(unpadded)
|
return decodeCore(unpadded, decompress)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Core decoding implementation used by decode() with and without padding removal - iOS fix
|
* Core decoding implementation used by decode() with and without padding removal - iOS fix
|
||||||
*/
|
*/
|
||||||
private fun decodeCore(raw: ByteArray): BitchatPacket? {
|
private fun decodeCore(
|
||||||
|
raw: ByteArray,
|
||||||
|
decompress: (ByteArray, Int) -> ByteArray?
|
||||||
|
): BitchatPacket? {
|
||||||
try {
|
try {
|
||||||
if (raw.size < HEADER_SIZE_V1 + SENDER_ID_SIZE) return null
|
if (raw.size < HEADER_SIZE_V1 + SENDER_ID_SIZE) return null
|
||||||
|
|
||||||
@ -435,23 +458,45 @@ object BinaryProtocol {
|
|||||||
} else {
|
} else {
|
||||||
buffer.getShort().toUShort().toInt()
|
buffer.getShort().toUShort().toInt()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val maxExpandedSize = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
|
||||||
|
if (originalSize <= 0 || originalSize > maxExpandedSize) {
|
||||||
|
Log.w(
|
||||||
|
"BinaryProtocol",
|
||||||
|
"Expanded payload size $originalSize is outside the allowed range 1..$maxExpandedSize"
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
// Compressed payload
|
// Compressed payload
|
||||||
val compressedSize = payloadLength.toInt() - lengthFieldBytes
|
val compressedSize = payloadLength.toInt() - lengthFieldBytes
|
||||||
val compressedPayload = ByteArray(compressedSize)
|
if (compressedSize == 0) {
|
||||||
buffer.get(compressedPayload)
|
Log.w("BinaryProtocol", "Compressed payload has no deflate bytes")
|
||||||
|
return null
|
||||||
|
}
|
||||||
// Security check: Compression bomb protection
|
// Security check: Compression bomb protection
|
||||||
if (compressedSize > 0) {
|
val ratio = originalSize.toDouble() / compressedSize.toDouble()
|
||||||
val ratio = originalSize.toDouble() / compressedSize.toDouble()
|
if (ratio > 50_000.0) {
|
||||||
if (ratio > 50_000.0) {
|
Log.w("BinaryProtocol", "🚫 Suspicious compression ratio: ${ratio}:1")
|
||||||
Log.w("BinaryProtocol", "🚫 Suspicious compression ratio: ${ratio}:1")
|
return null
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decompress
|
// Reserve the compressed copy plus expanded output before either allocation.
|
||||||
CompressionUtil.decompress(compressedPayload, originalSize) ?: return null
|
// Small packets share the memory pool; packets wait only while its budget is full.
|
||||||
|
val resourceBytes = compressedSize.toLong() + originalSize.toLong()
|
||||||
|
val expandedPayload = CompressionUtil.withDecompressionResources(resourceBytes) {
|
||||||
|
val compressedPayload = ByteArray(compressedSize)
|
||||||
|
buffer.get(compressedPayload)
|
||||||
|
decompress(compressedPayload, originalSize)
|
||||||
|
} ?: return null
|
||||||
|
if (expandedPayload.size != originalSize) {
|
||||||
|
Log.w(
|
||||||
|
"BinaryProtocol",
|
||||||
|
"Expanded payload size ${expandedPayload.size} did not match declared size $originalSize"
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
expandedPayload
|
||||||
} else {
|
} else {
|
||||||
val payloadBytes = ByteArray(payloadLength.toInt())
|
val payloadBytes = ByteArray(payloadLength.toInt())
|
||||||
buffer.get(payloadBytes)
|
buffer.get(payloadBytes)
|
||||||
@ -477,7 +522,7 @@ object BinaryProtocol {
|
|||||||
route = route
|
route = route
|
||||||
)
|
)
|
||||||
|
|
||||||
} catch (e: Throwable) {
|
} catch (e: Exception) {
|
||||||
Log.e("BinaryProtocol", "Error decoding packet: ${e.message}")
|
Log.e("BinaryProtocol", "Error decoding packet: ${e.message}")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.bitchat.android.protocol
|
|||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.util.zip.DataFormatException
|
||||||
import java.util.zip.Deflater
|
import java.util.zip.Deflater
|
||||||
import java.util.zip.Inflater
|
import java.util.zip.Inflater
|
||||||
|
|
||||||
@ -11,6 +12,8 @@ import java.util.zip.Inflater
|
|||||||
*/
|
*/
|
||||||
object CompressionUtil {
|
object CompressionUtil {
|
||||||
private const val COMPRESSION_THRESHOLD = com.bitchat.android.util.AppConstants.Protocol.COMPRESSION_THRESHOLD_BYTES // bytes - same as iOS
|
private const val COMPRESSION_THRESHOLD = com.bitchat.android.util.AppConstants.Protocol.COMPRESSION_THRESHOLD_BYTES // bytes - same as iOS
|
||||||
|
|
||||||
|
private val decompressionPool = DecompressionResourcePool.forRuntime()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to check if compression is worth it - exact same logic as iOS
|
* Helper to check if compression is worth it - exact same logic as iOS
|
||||||
@ -73,47 +76,124 @@ object CompressionUtil {
|
|||||||
* iOS COMPRESSION_ZLIB produces raw deflate data (no headers)
|
* iOS COMPRESSION_ZLIB produces raw deflate data (no headers)
|
||||||
*/
|
*/
|
||||||
fun decompress(compressedData: ByteArray, originalSize: Int): ByteArray? {
|
fun decompress(compressedData: ByteArray, originalSize: Int): ByteArray? {
|
||||||
// iOS COMPRESSION_ZLIB produces raw deflate format (no headers)
|
if (!isValidRequest(compressedData, originalSize)) return null
|
||||||
try {
|
return withDecompressionResources(originalSize.toLong()) {
|
||||||
val inflater = Inflater(true) // true = raw deflate, no headers
|
decompressWithResourcesReserved(compressedData, originalSize)
|
||||||
inflater.setInput(compressedData)
|
}
|
||||||
|
}
|
||||||
val decompressedBuffer = ByteArray(originalSize)
|
|
||||||
val actualSize = inflater.inflate(decompressedBuffer)
|
internal fun <T> withDecompressionResources(bytes: Long, block: () -> T): T? =
|
||||||
inflater.end()
|
decompressionPool.withReservation(bytes, block)
|
||||||
|
|
||||||
// Verify decompressed size matches expected (same validation as iOS)
|
/**
|
||||||
return if (actualSize == originalSize) {
|
* Inflate after the caller has reserved all packet-specific allocations.
|
||||||
decompressedBuffer
|
* This avoids nested acquisition when BinaryProtocol reserves both its input copy and output.
|
||||||
} else if (actualSize > 0) {
|
*/
|
||||||
// Handle case where actual size is different
|
internal fun decompressWithResourcesReserved(
|
||||||
decompressedBuffer.copyOfRange(0, actualSize)
|
compressedData: ByteArray,
|
||||||
} else {
|
originalSize: Int
|
||||||
|
): ByteArray? {
|
||||||
|
if (!isValidRequest(compressedData, originalSize)) return null
|
||||||
|
return decompressExact(compressedData, originalSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isValidRequest(compressedData: ByteArray, originalSize: Int): Boolean {
|
||||||
|
val maxExpandedSize = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
|
||||||
|
if (compressedData.isEmpty()) {
|
||||||
|
Log.w("CompressionUtil", "Refusing an empty compressed payload")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (originalSize <= 0 || originalSize > maxExpandedSize) {
|
||||||
|
Log.w(
|
||||||
|
"CompressionUtil",
|
||||||
|
"Refusing expanded payload size $originalSize outside 1..$maxExpandedSize"
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decompressExact(compressedData: ByteArray, originalSize: Int): ByteArray? {
|
||||||
|
return if (looksLikeZlib(compressedData)) {
|
||||||
|
// A raw stream can coincidentally begin with a valid-looking zlib header. The
|
||||||
|
// header therefore only determines which format to try first; any non-exact zlib
|
||||||
|
// result must still fall back to raw under the same size/completion bounds.
|
||||||
|
val zlibResult = try {
|
||||||
|
inflateExact(compressedData, originalSize, nowrap = false)
|
||||||
|
} catch (zlibException: DataFormatException) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
if (zlibResult != null) {
|
||||||
Log.d("CompressionUtil", "Raw deflate decompression failed: ${e.message}, trying with zlib headers...")
|
zlibResult
|
||||||
|
} else {
|
||||||
// Fallback: try with zlib headers in case of mixed usage
|
try {
|
||||||
try {
|
inflateExact(compressedData, originalSize, nowrap = true)
|
||||||
val inflater = Inflater(false) // false = expect zlib headers
|
} catch (rawException: DataFormatException) {
|
||||||
inflater.setInput(compressedData)
|
Log.d("CompressionUtil", "Invalid zlib/raw deflate stream")
|
||||||
|
|
||||||
val decompressedBuffer = ByteArray(originalSize)
|
|
||||||
val actualSize = inflater.inflate(decompressedBuffer)
|
|
||||||
inflater.end()
|
|
||||||
|
|
||||||
return if (actualSize == originalSize) {
|
|
||||||
decompressedBuffer
|
|
||||||
} else if (actualSize > 0) {
|
|
||||||
decompressedBuffer.copyOfRange(0, actualSize)
|
|
||||||
} else {
|
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
} catch (fallbackException: Exception) {
|
|
||||||
Log.e("CompressionUtil", "Both raw deflate and zlib decompression failed: ${fallbackException.message}")
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
inflateExact(compressedData, originalSize, nowrap = true)
|
||||||
|
} catch (rawException: DataFormatException) {
|
||||||
|
Log.d("CompressionUtil", "Invalid raw deflate stream")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RFC 1950 header check used to avoid speculative double inflation. */
|
||||||
|
private fun looksLikeZlib(data: ByteArray): Boolean {
|
||||||
|
if (data.size < 2) return false
|
||||||
|
val cmf = data[0].toInt() and 0xFF
|
||||||
|
val flg = data[1].toInt() and 0xFF
|
||||||
|
return (cmf and 0x0F) == 8 &&
|
||||||
|
(cmf ushr 4) <= 7 &&
|
||||||
|
((cmf shl 8) or flg) % 31 == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inflate one complete stream into exactly [originalSize] bytes.
|
||||||
|
*
|
||||||
|
* A full output buffer alone is not success: an attacker can under-declare a larger stream so
|
||||||
|
* the first inflate call fills the buffer while [Inflater.finished] remains false. Conversely,
|
||||||
|
* a truncated or over-declared stream can produce a non-empty prefix. Both forms are rejected,
|
||||||
|
* as are trailing bytes after the compressed stream.
|
||||||
|
*
|
||||||
|
* [DataFormatException] is deliberately allowed to escape so the caller can try the legacy
|
||||||
|
* zlib-wrapped format. Size/completion mismatches return null; the fallback must then prove the
|
||||||
|
* same bytes are a complete, exact-sized zlib stream before they can be accepted.
|
||||||
|
*/
|
||||||
|
@Throws(DataFormatException::class)
|
||||||
|
private fun inflateExact(
|
||||||
|
compressedData: ByteArray,
|
||||||
|
originalSize: Int,
|
||||||
|
nowrap: Boolean
|
||||||
|
): ByteArray? {
|
||||||
|
val inflater = Inflater(nowrap)
|
||||||
|
return try {
|
||||||
|
inflater.setInput(compressedData)
|
||||||
|
val output = ByteArray(originalSize)
|
||||||
|
var written = 0
|
||||||
|
|
||||||
|
while (written < originalSize) {
|
||||||
|
val count = inflater.inflate(output, written, originalSize - written)
|
||||||
|
if (count == 0) break
|
||||||
|
written += count
|
||||||
|
}
|
||||||
|
|
||||||
|
if (written != originalSize) return null
|
||||||
|
|
||||||
|
// Give Inflater one byte of room to consume the end marker. Any produced byte proves
|
||||||
|
// the declared size was smaller than the actual expansion.
|
||||||
|
val overflowProbe = ByteArray(1)
|
||||||
|
if (inflater.inflate(overflowProbe) != 0) return null
|
||||||
|
|
||||||
|
if (!inflater.finished() || inflater.remaining != 0) return null
|
||||||
|
output
|
||||||
|
} finally {
|
||||||
|
inflater.end()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,73 @@
|
|||||||
|
package com.bitchat.android.protocol
|
||||||
|
|
||||||
|
import java.util.concurrent.Semaphore
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.math.ceil
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fair, weighted admission control for decompression allocations.
|
||||||
|
*
|
||||||
|
* Permits represent memory rather than workers: small packets can proceed concurrently while
|
||||||
|
* near-limit packets consume most of the budget. Callers must reserve before allocating any
|
||||||
|
* packet-specific compressed copy or expanded output.
|
||||||
|
*/
|
||||||
|
internal class DecompressionResourcePool(
|
||||||
|
budgetBytes: Long,
|
||||||
|
private val unitBytes: Int,
|
||||||
|
private val waitTimeoutMs: Long
|
||||||
|
) {
|
||||||
|
private val totalPermits = (budgetBytes / unitBytes).toInt().coerceAtLeast(1)
|
||||||
|
private val permits = Semaphore(totalPermits, true)
|
||||||
|
|
||||||
|
fun <T> withReservation(bytes: Long, block: () -> T): T? {
|
||||||
|
val requiredPermits = permitsFor(bytes)
|
||||||
|
val acquired = try {
|
||||||
|
permits.tryAcquire(requiredPermits, waitTimeoutMs, TimeUnit.MILLISECONDS)
|
||||||
|
} catch (_: InterruptedException) {
|
||||||
|
Thread.currentThread().interrupt()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
if (!acquired) return null
|
||||||
|
|
||||||
|
return try {
|
||||||
|
block()
|
||||||
|
} finally {
|
||||||
|
permits.release(requiredPermits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun permitsFor(bytes: Long): Int =
|
||||||
|
ceil(bytes.coerceAtLeast(1).toDouble() / unitBytes.toDouble())
|
||||||
|
.toInt()
|
||||||
|
.coerceAtMost(totalPermits)
|
||||||
|
|
||||||
|
internal val availablePermits: Int
|
||||||
|
get() = permits.availablePermits()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val DEFAULT_UNIT_BYTES = 256 * 1024
|
||||||
|
private const val DEFAULT_WAIT_TIMEOUT_MS = 1_000L
|
||||||
|
private const val HEAP_BUDGET_DIVISOR = 8L
|
||||||
|
private const val MAX_BUDGET_BYTES = 64L * 1024 * 1024
|
||||||
|
|
||||||
|
fun forRuntime(
|
||||||
|
maxHeapBytes: Long = Runtime.getRuntime().maxMemory(),
|
||||||
|
maxPacketResourceBytes: Long =
|
||||||
|
2L * com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
|
||||||
|
): DecompressionResourcePool {
|
||||||
|
val budget = recommendedBudgetBytes(maxHeapBytes, maxPacketResourceBytes)
|
||||||
|
return DecompressionResourcePool(
|
||||||
|
budgetBytes = budget,
|
||||||
|
unitBytes = DEFAULT_UNIT_BYTES,
|
||||||
|
waitTimeoutMs = DEFAULT_WAIT_TIMEOUT_MS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun recommendedBudgetBytes(
|
||||||
|
maxHeapBytes: Long,
|
||||||
|
maxPacketResourceBytes: Long
|
||||||
|
): Long = (maxHeapBytes / HEAP_BUDGET_DIVISOR)
|
||||||
|
.coerceAtLeast(maxPacketResourceBytes)
|
||||||
|
.coerceAtMost(MAX_BUDGET_BYTES.coerceAtLeast(maxPacketResourceBytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,9 +7,14 @@ import androidx.lifecycle.AndroidViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.bitchat.android.favorites.FavoritesChangeListener
|
import com.bitchat.android.favorites.FavoritesChangeListener
|
||||||
import com.bitchat.android.favorites.FavoritesPersistenceService
|
import com.bitchat.android.favorites.FavoritesPersistenceService
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import com.bitchat.android.mesh.BluetoothMeshDelegate
|
import com.bitchat.android.mesh.BluetoothMeshDelegate
|
||||||
import com.bitchat.android.mesh.BluetoothMeshService
|
import com.bitchat.android.mesh.BluetoothMeshService
|
||||||
import com.bitchat.android.mesh.MeshService
|
import com.bitchat.android.mesh.MeshService
|
||||||
@ -31,10 +36,12 @@ import com.bitchat.android.nostr.NdrOutOfBandPayload
|
|||||||
import com.bitchat.android.nostr.NdrOutOfBandRoutePolicy
|
import com.bitchat.android.nostr.NdrOutOfBandRoutePolicy
|
||||||
import com.bitchat.android.nostr.NostrEvent
|
import com.bitchat.android.nostr.NostrEvent
|
||||||
import com.bitchat.android.nostr.NostrIdentityBridge
|
import com.bitchat.android.nostr.NostrIdentityBridge
|
||||||
|
import com.bitchat.android.nostr.GeohashConversationRegistry
|
||||||
import com.bitchat.android.protocol.BitchatPacket
|
import com.bitchat.android.protocol.BitchatPacket
|
||||||
|
|
||||||
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import com.bitchat.android.util.NotificationIntervalManager
|
import com.bitchat.android.util.NotificationIntervalManager
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
@ -133,8 +140,12 @@ class ChatViewModel(
|
|||||||
messageManager,
|
messageManager,
|
||||||
dataManager,
|
dataManager,
|
||||||
noiseSessionDelegate,
|
noiseSessionDelegate,
|
||||||
hasReadReceiptBeenSent = seenMessageStore::hasReadReceiptBeenSent,
|
hasReadReceiptBeenSent = { messageID ->
|
||||||
markMessageReadLocally = seenMessageStore::markReadLocally
|
seenMessageStore.hasReadReceiptBeenSent(messageID)
|
||||||
|
},
|
||||||
|
markMessageReadLocally = { messageID ->
|
||||||
|
seenMessageStore.markReadLocally(messageID)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager)
|
private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager)
|
||||||
private val notificationManager = NotificationManager(
|
private val notificationManager = NotificationManager(
|
||||||
@ -173,7 +184,9 @@ class ChatViewModel(
|
|||||||
onHapticFeedback = { ChatViewModelUtils.triggerHapticFeedback(application.applicationContext) },
|
onHapticFeedback = { ChatViewModelUtils.triggerHapticFeedback(application.applicationContext) },
|
||||||
getMyPeerID = { mesh.myPeerID },
|
getMyPeerID = { mesh.myPeerID },
|
||||||
getMeshService = { mesh },
|
getMeshService = { mesh },
|
||||||
markMessageReadLocally = seenMessageStore::markReadLocally
|
markMessageReadLocally = { messageID ->
|
||||||
|
seenMessageStore.markReadLocally(messageID)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// New Geohash architecture ViewModel (replaces God object service usage in UI path)
|
// New Geohash architecture ViewModel (replaces God object service usage in UI path)
|
||||||
@ -261,6 +274,57 @@ class ChatViewModel(
|
|||||||
val privateChats: StateFlow<Map<String, List<BitchatMessage>>> = state.privateChats
|
val privateChats: StateFlow<Map<String, List<BitchatMessage>>> = state.privateChats
|
||||||
val selectedPrivateChatPeer: StateFlow<String?> = state.selectedPrivateChatPeer
|
val selectedPrivateChatPeer: StateFlow<String?> = state.selectedPrivateChatPeer
|
||||||
val unreadPrivateMessages: StateFlow<Set<String>> = state.unreadPrivateMessages
|
val unreadPrivateMessages: StateFlow<Set<String>> = state.unreadPrivateMessages
|
||||||
|
internal val unreadConversations: StateFlow<List<UnreadConversationSummary>> = combine(
|
||||||
|
state.unreadPrivateMessages,
|
||||||
|
state.privateChats,
|
||||||
|
state.nickname,
|
||||||
|
state.connectedPeers
|
||||||
|
) { unreadConversationIDs, chats, currentNickname, connectedPeerIDs ->
|
||||||
|
val seenStore = seenMessageStore
|
||||||
|
val connectedPeerIDSet = connectedPeerIDs.mapTo(mutableSetOf()) { it.lowercase() }
|
||||||
|
buildUnreadConversationSummaries(
|
||||||
|
unreadConversationIDs = unreadConversationIDs,
|
||||||
|
privateChats = chats,
|
||||||
|
currentUserIdentifiers = setOf(currentNickname, mesh.myPeerID),
|
||||||
|
canonicalize = ContactDirectory::canonicalConversationId,
|
||||||
|
isMessageRead = { message -> seenStore.hasBeenReadLocally(message.id) }
|
||||||
|
).map { summary ->
|
||||||
|
val resolution = ContactDirectory.resolve(summary.conversationID)
|
||||||
|
val resolvedNostrPubkey = summary.nostrPubkey
|
||||||
|
?: resolution.nostrPubkey?.let(ContactIdentityResolver::nostrPubkeyHex)
|
||||||
|
val aliases = buildSet {
|
||||||
|
addAll(summary.identityAliases)
|
||||||
|
add(summary.conversationID)
|
||||||
|
add(resolution.conversationID)
|
||||||
|
resolution.meshPeerID?.let(::add)
|
||||||
|
resolution.noiseKeyHex?.let(::add)
|
||||||
|
resolvedNostrPubkey
|
||||||
|
?.let(ContactIdentityResolver::nostrAliasForPubkey)
|
||||||
|
?.let(::add)
|
||||||
|
}.mapTo(mutableSetOf()) { it.lowercase() }
|
||||||
|
|
||||||
|
summary.copy(
|
||||||
|
displayName = resolution.displayName
|
||||||
|
?.takeUnless {
|
||||||
|
it.isBlank() || it.equals("Unknown", ignoreCase = true)
|
||||||
|
}
|
||||||
|
?: summary.displayName,
|
||||||
|
nostrPubkey = resolvedNostrPubkey,
|
||||||
|
identityAliases = aliases,
|
||||||
|
isConnected = aliases.any(connectedPeerIDSet::contains),
|
||||||
|
sourceGeohash = aliases
|
||||||
|
.asSequence()
|
||||||
|
.mapNotNull(GeohashConversationRegistry::get)
|
||||||
|
.firstOrNull()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.flowOn(Dispatchers.IO)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.Eagerly,
|
||||||
|
initialValue = emptyList()
|
||||||
|
)
|
||||||
val joinedChannels: StateFlow<Set<String>> = state.joinedChannels
|
val joinedChannels: StateFlow<Set<String>> = state.joinedChannels
|
||||||
val currentChannel: StateFlow<String?> = state.currentChannel
|
val currentChannel: StateFlow<String?> = state.currentChannel
|
||||||
val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = state.channelMessages
|
val channelMessages: StateFlow<Map<String, List<BitchatMessage>>> = state.channelMessages
|
||||||
@ -326,24 +390,27 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try { com.bitchat.android.services.AppStateStore.privateMessages.collect { byPeer ->
|
try { com.bitchat.android.services.AppStateStore.privateMessages.collect { byPeer ->
|
||||||
val canonicalChats = ContactDirectory.canonicalizePrivateChats(byPeer)
|
val (canonicalChats, unreadConversationIDs) = withContext(Dispatchers.IO) {
|
||||||
|
val canonical = ContactDirectory.canonicalizePrivateChats(byPeer)
|
||||||
|
val unread = try {
|
||||||
|
val myNick = state.getNicknameValue().ifBlank { mesh.myPeerID }
|
||||||
|
canonical
|
||||||
|
.filterValues { messages ->
|
||||||
|
messages.any { message ->
|
||||||
|
message.sender != myNick &&
|
||||||
|
message.sender != "system" &&
|
||||||
|
!seenMessageStore.hasBeenReadLocally(message.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.keys
|
||||||
|
} catch (_: Exception) {
|
||||||
|
state.getUnreadPrivateMessagesValue()
|
||||||
|
}
|
||||||
|
canonical to unread
|
||||||
|
}
|
||||||
state.setPrivateChats(canonicalChats)
|
state.setPrivateChats(canonicalChats)
|
||||||
// Recompute unread set using SeenMessageStore for robustness across Activity recreation
|
// Recompute unread set using SeenMessageStore for robustness across Activity recreation
|
||||||
try {
|
state.setUnreadPrivateMessages(unreadConversationIDs)
|
||||||
val myNick = state.getNicknameValue() ?: mesh.myPeerID
|
|
||||||
val unread = mutableSetOf<String>()
|
|
||||||
canonicalChats.forEach { (peer, list) ->
|
|
||||||
if (list.any { msg ->
|
|
||||||
msg.sender != myNick &&
|
|
||||||
msg.sender != "system" &&
|
|
||||||
!seenMessageStore.hasBeenReadLocally(msg.id)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
unread.add(peer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
state.setUnreadPrivateMessages(unread)
|
|
||||||
} catch (_: Exception) { }
|
|
||||||
} } catch (_: Exception) { }
|
} } catch (_: Exception) { }
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@ -493,15 +560,26 @@ class ChatViewModel(
|
|||||||
|
|
||||||
// MARK: - Private Chat Management (delegated)
|
// MARK: - Private Chat Management (delegated)
|
||||||
|
|
||||||
fun startPrivateChat(peerID: String) {
|
suspend fun startPrivateChat(peerID: String) {
|
||||||
// For geohash conversation keys, ensure DM subscription is active
|
// For geohash conversation keys, ensure DM subscription is active
|
||||||
if (peerID.startsWith("nostr_")) {
|
if (peerID.startsWith("nostr_")) {
|
||||||
ensureGeohashDMSubscriptionIfNeeded(peerID)
|
ensureGeohashDMSubscriptionIfNeeded(peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
val success = privateChatManager.startPrivateChat(peerID, mesh)
|
val (conversationID, success) = withContext(Dispatchers.IO) {
|
||||||
|
val canonicalID = ContactDirectory.canonicalConversationId(peerID)
|
||||||
|
val unreadAliases = matchingUnreadAliases(
|
||||||
|
unreadConversationIDs = state.getUnreadPrivateMessagesValue(),
|
||||||
|
canonicalConversationID = canonicalID,
|
||||||
|
canonicalize = ContactDirectory::canonicalConversationId
|
||||||
|
)
|
||||||
|
canonicalID to privateChatManager.startPrivateChat(
|
||||||
|
peerID = canonicalID,
|
||||||
|
meshService = mesh,
|
||||||
|
unreadAliases = unreadAliases
|
||||||
|
)
|
||||||
|
}
|
||||||
if (success) {
|
if (success) {
|
||||||
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
|
||||||
// Notify notification manager about current private chat
|
// Notify notification manager about current private chat
|
||||||
setCurrentPrivateChatPeer(conversationID)
|
setCurrentPrivateChatPeer(conversationID)
|
||||||
// Clear notifications for this sender since user is now viewing the chat
|
// Clear notifications for this sender since user is now viewing the chat
|
||||||
|
|||||||
@ -36,7 +36,8 @@ data class GeoPerson(
|
|||||||
fun GeohashPeopleList(
|
fun GeohashPeopleList(
|
||||||
viewModel: ChatViewModel,
|
viewModel: ChatViewModel,
|
||||||
onTapPerson: () -> Unit,
|
onTapPerson: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
|
excludedIdentityAliases: Set<String> = emptySet()
|
||||||
) {
|
) {
|
||||||
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
||||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||||
@ -77,9 +78,15 @@ fun GeohashPeopleList(
|
|||||||
geohashPeople
|
geohashPeople
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val sections = remember(peopleIncludingSelf, myHex, isTeleported, teleportedGeo) {
|
val visiblePeople = remember(peopleIncludingSelf, excludedIdentityAliases) {
|
||||||
|
peopleIncludingSelf.filterNot { person ->
|
||||||
|
val alias = "nostr_${person.id.take(16)}".lowercase()
|
||||||
|
alias in excludedIdentityAliases
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val sections = remember(visiblePeople, myHex, isTeleported, teleportedGeo) {
|
||||||
sectionGeohashPeople(
|
sectionGeohashPeople(
|
||||||
people = peopleIncludingSelf,
|
people = visiblePeople,
|
||||||
myId = myHex,
|
myId = myHex,
|
||||||
selfIsTeleported = isTeleported,
|
selfIsTeleported = isTeleported,
|
||||||
teleportedIds = teleportedGeo
|
teleportedIds = teleportedGeo
|
||||||
|
|||||||
@ -43,7 +43,7 @@ class MediaSendingManager(
|
|||||||
get() = getMeshService()
|
get() = getMeshService()
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "MediaSendingManager"
|
private const val TAG = "MediaSendingManager"
|
||||||
private const val MAX_FILE_SIZE = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES // 50MB limit
|
private const val MAX_FILE_SIZE = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES
|
||||||
private const val PENDING_PRIVATE_MEDIA_TIMEOUT_MS = 15_000L
|
private const val PENDING_PRIVATE_MEDIA_TIMEOUT_MS = 15_000L
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,6 +81,46 @@ class MediaSendingManager(
|
|||||||
private var automaticRetryRequestedFor: String? = null
|
private var automaticRetryRequestedFor: String? = null
|
||||||
private var pendingAutomaticTimeoutRequestId: String? = null
|
private var pendingAutomaticTimeoutRequestId: String? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforce the send-size cap with a user-visible failure posted to the
|
||||||
|
* conversation the user is sending from. Returns true if the file is
|
||||||
|
* oversized and the send was aborted.
|
||||||
|
*/
|
||||||
|
private fun rejectIfOversized(
|
||||||
|
file: java.io.File,
|
||||||
|
toPeerIDOrNull: String?,
|
||||||
|
channelOrNull: String?
|
||||||
|
): Boolean {
|
||||||
|
val size = file.length()
|
||||||
|
if (size <= MAX_FILE_SIZE) return false
|
||||||
|
Log.e(TAG, "❌ File too large: $size bytes (max: $MAX_FILE_SIZE)")
|
||||||
|
val sizeMb = size / (1024 * 1024)
|
||||||
|
val maxMb = MAX_FILE_SIZE / (1024 * 1024)
|
||||||
|
val text = "cannot send ${file.name}: file is too large (${sizeMb} MB, max $maxMb MB)"
|
||||||
|
when {
|
||||||
|
toPeerIDOrNull != null -> {
|
||||||
|
val sys = BitchatMessage(
|
||||||
|
sender = "system",
|
||||||
|
content = text,
|
||||||
|
timestamp = Date(),
|
||||||
|
isRelay = false
|
||||||
|
)
|
||||||
|
messageManager.addPrivateMessageNoUnread(toPeerIDOrNull, sys)
|
||||||
|
}
|
||||||
|
channelOrNull != null -> {
|
||||||
|
val sys = BitchatMessage(
|
||||||
|
sender = "system",
|
||||||
|
content = text,
|
||||||
|
timestamp = Date(),
|
||||||
|
isRelay = false
|
||||||
|
)
|
||||||
|
messageManager.addChannelMessage(channelOrNull, sys)
|
||||||
|
}
|
||||||
|
else -> messageManager.addSystemMessage(text)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a voice note (audio file)
|
* Send a voice note (audio file)
|
||||||
*/
|
*/
|
||||||
@ -103,8 +143,7 @@ class MediaSendingManager(
|
|||||||
return@withContext null
|
return@withContext null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.length() > MAX_FILE_SIZE) {
|
if (rejectIfOversized(file, toPeerIDOrNull, channelOrNull)) {
|
||||||
Log.e(TAG, "File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
|
|
||||||
return@withContext null
|
return@withContext null
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -148,8 +187,7 @@ class MediaSendingManager(
|
|||||||
return@withContext null
|
return@withContext null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.length() > MAX_FILE_SIZE) {
|
if (rejectIfOversized(file, toPeerIDOrNull, channelOrNull)) {
|
||||||
Log.e(TAG, "File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
|
|
||||||
return@withContext null
|
return@withContext null
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -193,8 +231,7 @@ class MediaSendingManager(
|
|||||||
return@withContext null
|
return@withContext null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.length() > MAX_FILE_SIZE) {
|
if (rejectIfOversized(file, toPeerIDOrNull, channelOrNull)) {
|
||||||
Log.e(TAG, "File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
|
|
||||||
return@withContext null
|
return@withContext null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -88,9 +88,17 @@ fun MeshPeerListSheet(
|
|||||||
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
|
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
|
||||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||||
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
val geohashPeople by viewModel.geohashPeople.collectAsStateWithLifecycle()
|
||||||
|
val unreadConversations by viewModel.unreadConversations.collectAsStateWithLifecycle()
|
||||||
val geohashPeopleCount = geohashPeople.size
|
val geohashPeopleCount = geohashPeople.size
|
||||||
val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle()
|
val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle()
|
||||||
val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() }
|
val wifiAwarePeerIDs = remember(wifiAwareConnected) { wifiAwareConnected.keys.toSet() }
|
||||||
|
val unreadIdentityAliases = remember(unreadConversations) {
|
||||||
|
unreadConversations
|
||||||
|
.flatMapTo(mutableSetOf()) { it.identityAliases }
|
||||||
|
}
|
||||||
|
val visibleConnectedPeers = connectedPeers.filterNot { peerID ->
|
||||||
|
peerID.lowercase() in unreadIdentityAliases
|
||||||
|
}
|
||||||
|
|
||||||
// Bottom sheet state
|
// Bottom sheet state
|
||||||
val sheetState = rememberModalBottomSheetState(
|
val sheetState = rememberModalBottomSheetState(
|
||||||
@ -123,7 +131,21 @@ fun MeshPeerListSheet(
|
|||||||
) {
|
) {
|
||||||
val peopleCount = when (selectedLocationChannel) {
|
val peopleCount = when (selectedLocationChannel) {
|
||||||
is ChannelID.Location -> geohashPeopleCount
|
is ChannelID.Location -> geohashPeopleCount
|
||||||
else -> connectedPeers.count { it != viewModel.myPeerID }
|
else -> visibleConnectedPeers.count { it != viewModel.myPeerID }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unreadConversations.isNotEmpty()) {
|
||||||
|
item(key = "unread_private_messages_section") {
|
||||||
|
UnreadDirectMessagesSection(
|
||||||
|
conversations = unreadConversations,
|
||||||
|
viewModel = viewModel,
|
||||||
|
onPrivateChatStart = { conversationID ->
|
||||||
|
viewModel.showPrivateChatSheet(conversationID)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(top = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Channels section
|
// Channels section
|
||||||
@ -133,7 +155,9 @@ fun MeshPeerListSheet(
|
|||||||
SheetIconSectionHeader(
|
SheetIconSectionHeader(
|
||||||
iconRes = R.drawable.ic_spec_chat_bubbles,
|
iconRes = R.drawable.ic_spec_chat_bubbles,
|
||||||
title = stringResource(R.string.channels),
|
title = stringResource(R.string.channels),
|
||||||
modifier = Modifier.padding(top = 8.dp)
|
modifier = Modifier.padding(
|
||||||
|
top = if (unreadConversations.isNotEmpty()) 20.dp else 8.dp
|
||||||
|
)
|
||||||
)
|
)
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@ -185,8 +209,12 @@ fun MeshPeerListSheet(
|
|||||||
GeohashPeopleList(
|
GeohashPeopleList(
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onTapPerson = onDismiss,
|
onTapPerson = onDismiss,
|
||||||
|
excludedIdentityAliases = unreadIdentityAliases,
|
||||||
modifier = Modifier.padding(
|
modifier = Modifier.padding(
|
||||||
top = if (joinedChannels.isNotEmpty()) 20.dp else 8.dp
|
top = if (
|
||||||
|
joinedChannels.isNotEmpty() ||
|
||||||
|
unreadConversations.isNotEmpty()
|
||||||
|
) 20.dp else 8.dp
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -194,9 +222,12 @@ fun MeshPeerListSheet(
|
|||||||
else -> {
|
else -> {
|
||||||
PeopleSection(
|
PeopleSection(
|
||||||
modifier = Modifier.padding(
|
modifier = Modifier.padding(
|
||||||
top = if (joinedChannels.isNotEmpty()) 20.dp else 8.dp
|
top = if (
|
||||||
|
joinedChannels.isNotEmpty() ||
|
||||||
|
unreadConversations.isNotEmpty()
|
||||||
|
) 20.dp else 8.dp
|
||||||
),
|
),
|
||||||
connectedPeers = connectedPeers,
|
connectedPeers = visibleConnectedPeers,
|
||||||
peerNicknames = peerNicknames,
|
peerNicknames = peerNicknames,
|
||||||
peerRSSI = peerRSSI,
|
peerRSSI = peerRSSI,
|
||||||
nickname = nickname,
|
nickname = nickname,
|
||||||
@ -204,6 +235,7 @@ fun MeshPeerListSheet(
|
|||||||
selectedPrivatePeer = selectedPrivatePeer,
|
selectedPrivatePeer = selectedPrivatePeer,
|
||||||
wifiAwarePeerIDs = wifiAwarePeerIDs,
|
wifiAwarePeerIDs = wifiAwarePeerIDs,
|
||||||
peopleCount = peopleCount,
|
peopleCount = peopleCount,
|
||||||
|
excludedIdentityAliases = unreadIdentityAliases,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onPrivateChatStart = { peerID ->
|
onPrivateChatStart = { peerID ->
|
||||||
viewModel.showPrivateChatSheet(peerID)
|
viewModel.showPrivateChatSheet(peerID)
|
||||||
@ -318,6 +350,7 @@ fun PeopleSection(
|
|||||||
selectedPrivatePeer: String?,
|
selectedPrivatePeer: String?,
|
||||||
wifiAwarePeerIDs: Set<String> = emptySet(),
|
wifiAwarePeerIDs: Set<String> = emptySet(),
|
||||||
peopleCount: Int = 0,
|
peopleCount: Int = 0,
|
||||||
|
excludedIdentityAliases: Set<String> = emptySet(),
|
||||||
viewModel: ChatViewModel,
|
viewModel: ChatViewModel,
|
||||||
onPrivateChatStart: (String) -> Unit
|
onPrivateChatStart: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
@ -433,8 +466,6 @@ fun PeopleSection(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Build a map of base name counts across all people shown in the list (connected + offline + nostr)
|
// Build a map of base name counts across all people shown in the list (connected + offline + nostr)
|
||||||
val hex64Regex = Regex("^[0-9a-fA-F]{64}$")
|
|
||||||
|
|
||||||
// Helper to compute display name used for a given key
|
// Helper to compute display name used for a given key
|
||||||
fun computeDisplayNameForPeerId(key: String): String {
|
fun computeDisplayNameForPeerId(key: String): String {
|
||||||
return if (key == nickname) "You" else (peerNicknames[key] ?: (privateChats[key]?.lastOrNull()?.sender ?: key.take(12)))
|
return if (key == nickname) "You" else (peerNicknames[key] ?: (privateChats[key]?.lastOrNull()?.sender ?: key.take(12)))
|
||||||
@ -453,33 +484,28 @@ fun PeopleSection(
|
|||||||
val offlineFavorites = FavoritesPersistenceService.shared.getOurFavorites()
|
val offlineFavorites = FavoritesPersistenceService.shared.getOurFavorites()
|
||||||
offlineFavorites.forEach { fav ->
|
offlineFavorites.forEach { fav ->
|
||||||
val favPeerID = ContactIdentityResolver.noiseKeyHex(fav.peerNoisePublicKey)
|
val favPeerID = ContactIdentityResolver.noiseKeyHex(fav.peerNoisePublicKey)
|
||||||
if (!isFavoriteMappedToConnected(fav)) {
|
if (
|
||||||
|
favPeerID.lowercase() !in excludedIdentityAliases &&
|
||||||
|
!isFavoriteMappedToConnected(fav)
|
||||||
|
) {
|
||||||
val dn = peerNicknames[favPeerID] ?: fav.peerNickname
|
val dn = peerNicknames[favPeerID] ?: fav.peerNickname
|
||||||
val (b, _) = splitSuffix(dn)
|
val (b, _) = splitSuffix(dn)
|
||||||
if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1
|
if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nostr-only conversations
|
|
||||||
val connectedIds = sortedPeers.toSet()
|
|
||||||
privateChats.keys
|
|
||||||
.filter { key ->
|
|
||||||
(key.startsWith("nostr_") || hex64Regex.matches(key)) &&
|
|
||||||
!connectedIds.contains(key) &&
|
|
||||||
!connectedNoiseHexes.contains(key.lowercase())
|
|
||||||
}
|
|
||||||
.forEach { convKey ->
|
|
||||||
val dn = peerNicknames[convKey] ?: (privateChats[convKey]?.lastOrNull()?.sender ?: convKey.take(12))
|
|
||||||
val (b, _) = splitSuffix(dn)
|
|
||||||
if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every row this card will show, in final order, so the animated list can key on identity
|
// Every row this card will show, in final order, so the animated list can key on identity
|
||||||
// and animate reordering. Offline favourites are appended after the connected peers.
|
// and animate reordering. Offline favourites are appended after the connected peers.
|
||||||
// Collected once for the whole card rather than once per row.
|
// Collected once for the whole card rather than once per row.
|
||||||
val directMap by viewModel.peerDirect.collectAsStateWithLifecycle()
|
val directMap by viewModel.peerDirect.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val offlineFavoriteRows = offlineFavorites.filterNot { isFavoriteMappedToConnected(it) }
|
val offlineFavoriteRows = offlineFavorites.filterNot { favorite ->
|
||||||
|
val favoriteNoiseKey = ContactIdentityResolver.noiseKeyHex(
|
||||||
|
favorite.peerNoisePublicKey
|
||||||
|
)
|
||||||
|
favoriteNoiseKey.lowercase() in excludedIdentityAliases ||
|
||||||
|
isFavoriteMappedToConnected(favorite)
|
||||||
|
}
|
||||||
val rowKeys: List<String> = sortedPeers +
|
val rowKeys: List<String> = sortedPeers +
|
||||||
offlineFavoriteRows.map { ContactIdentityResolver.noiseKeyHex(it.peerNoisePublicKey) }
|
offlineFavoriteRows.map { ContactIdentityResolver.noiseKeyHex(it.peerNoisePublicKey) }
|
||||||
|
|
||||||
@ -589,6 +615,126 @@ fun PeopleSection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UnreadDirectMessagesSection(
|
||||||
|
conversations: List<UnreadConversationSummary>,
|
||||||
|
viewModel: ChatViewModel,
|
||||||
|
onPrivateChatStart: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val palette = LocalBitchatPalette.current
|
||||||
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
SheetIconSectionHeader(
|
||||||
|
iconRes = R.drawable.ic_spec_envelope,
|
||||||
|
title = stringResource(R.string.cd_unread_private_messages)
|
||||||
|
)
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = AboutHorizontalPadding)
|
||||||
|
.padding(top = 10.dp),
|
||||||
|
color = colorScheme.surface,
|
||||||
|
shape = AboutCardShape
|
||||||
|
) {
|
||||||
|
AnimatedRowColumn(
|
||||||
|
items = conversations,
|
||||||
|
key = { it.conversationID }
|
||||||
|
) { index, conversation ->
|
||||||
|
Column {
|
||||||
|
if (index > 0) SheetCardDivider()
|
||||||
|
|
||||||
|
val subtitle = when {
|
||||||
|
conversation.sourceGeohash != null -> "#${conversation.sourceGeohash}"
|
||||||
|
conversation.transport == DirectMessageTransport.NOSTR ->
|
||||||
|
stringResource(R.string.cd_reachable_via_nostr)
|
||||||
|
!conversation.isConnected ->
|
||||||
|
stringResource(R.string.cd_offline_mesh_chat)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val peerIdentity = conversation.nostrPubkey
|
||||||
|
?.let(viewModel::peerIdentityForNostrPubkey)
|
||||||
|
?: viewModel.peerIdentityForMeshPeer(conversation.conversationID)
|
||||||
|
val assignedColor = colorForPeer(peerIdentity, palette)
|
||||||
|
val (baseNameRaw, suffix) = splitSuffix(conversation.displayName)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
onPrivateChatStart(conversation.conversationID)
|
||||||
|
}
|
||||||
|
.padding(
|
||||||
|
horizontal = SheetRowHorizontal,
|
||||||
|
vertical = SheetRowVertical
|
||||||
|
),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(SheetRowLeadingSlot),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_spec_envelope),
|
||||||
|
contentDescription = stringResource(R.string.cd_unread_message),
|
||||||
|
modifier = Modifier.size(PeerRowIconSize),
|
||||||
|
tint = palette.accentOrange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(SheetRowLeadingGutter))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = truncateNickname(baseNameRaw),
|
||||||
|
fontFamily = BitchatFontFamily,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = assignedColor,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
|
||||||
|
if (suffix.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = suffix,
|
||||||
|
fontFamily = BitchatFontFamily,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = assignedColor.copy(alpha = SUFFIX_ALPHA)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subtitle != null) {
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
fontFamily = BitchatFontFamily,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = palette.textTertiary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
UnreadBadge(
|
||||||
|
count = conversation.unreadCount,
|
||||||
|
colorScheme = colorScheme
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PeerItem(
|
private fun PeerItem(
|
||||||
peerID: String,
|
peerID: String,
|
||||||
|
|||||||
@ -153,11 +153,17 @@ class MessageManager(private val state: ChatState) {
|
|||||||
state.setPrivateChats(updatedChats)
|
state.setPrivateChats(updatedChats)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearPrivateUnreadMessages(peerID: String) {
|
fun clearPrivateUnreadMessages(
|
||||||
|
peerID: String,
|
||||||
|
aliases: Set<String> = emptySet()
|
||||||
|
) {
|
||||||
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
||||||
val updatedUnread = state.getUnreadPrivateMessagesValue().toMutableSet()
|
val updatedUnread = state.getUnreadPrivateMessagesValue().toMutableSet()
|
||||||
updatedUnread.remove(peerID)
|
val normalizedAliases = (aliases + peerID + conversationID)
|
||||||
updatedUnread.remove(conversationID)
|
.mapTo(mutableSetOf()) { it.lowercase() }
|
||||||
|
updatedUnread.removeAll { unreadID ->
|
||||||
|
unreadID.lowercase() in normalizedAliases
|
||||||
|
}
|
||||||
state.setUnreadPrivateMessages(updatedUnread)
|
state.setUnreadPrivateMessages(updatedUnread)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -49,7 +49,11 @@ class PrivateChatManager(
|
|||||||
|
|
||||||
// MARK: - Private Chat Lifecycle
|
// MARK: - Private Chat Lifecycle
|
||||||
|
|
||||||
fun startPrivateChat(peerID: String, meshService: MeshService): Boolean {
|
fun startPrivateChat(
|
||||||
|
peerID: String,
|
||||||
|
meshService: MeshService,
|
||||||
|
unreadAliases: Set<String> = emptySet()
|
||||||
|
): Boolean {
|
||||||
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
||||||
val route = ContactDirectory.resolve(conversationID)
|
val route = ContactDirectory.resolve(conversationID)
|
||||||
val meshPeerID = route.meshPeerID ?: peerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) }
|
val meshPeerID = route.meshPeerID ?: peerID.takeIf { ContactIdentityResolver.isMeshPeerId(it) }
|
||||||
@ -77,7 +81,7 @@ class PrivateChatManager(
|
|||||||
state.setSelectedPrivateChatPeer(conversationID)
|
state.setSelectedPrivateChatPeer(conversationID)
|
||||||
|
|
||||||
// Clear unread
|
// Clear unread
|
||||||
messageManager.clearPrivateUnreadMessages(conversationID)
|
messageManager.clearPrivateUnreadMessages(conversationID, unreadAliases)
|
||||||
|
|
||||||
// Initialize chat if needed
|
// Initialize chat if needed
|
||||||
messageManager.initializePrivateChat(conversationID)
|
messageManager.initializePrivateChat(conversationID)
|
||||||
|
|||||||
@ -0,0 +1,105 @@
|
|||||||
|
package com.bitchat.android.ui
|
||||||
|
|
||||||
|
import com.bitchat.android.model.BitchatMessage
|
||||||
|
|
||||||
|
internal enum class DirectMessageTransport {
|
||||||
|
MESH,
|
||||||
|
NOSTR
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Presence-independent presentation state for an unread private conversation.
|
||||||
|
*
|
||||||
|
* A conversation remains in this model until it is read, even when none of its identities are in
|
||||||
|
* the current mesh or geohash participant lists.
|
||||||
|
*/
|
||||||
|
internal data class UnreadConversationSummary(
|
||||||
|
val conversationID: String,
|
||||||
|
val displayName: String,
|
||||||
|
val unreadCount: Int,
|
||||||
|
val latestMessageAt: Long,
|
||||||
|
val transport: DirectMessageTransport,
|
||||||
|
val nostrPubkey: String?,
|
||||||
|
val identityAliases: Set<String>,
|
||||||
|
val isConnected: Boolean = false,
|
||||||
|
val sourceGeohash: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun buildUnreadConversationSummaries(
|
||||||
|
unreadConversationIDs: Set<String>,
|
||||||
|
privateChats: Map<String, List<BitchatMessage>>,
|
||||||
|
currentUserIdentifiers: Set<String>,
|
||||||
|
canonicalize: (String) -> String,
|
||||||
|
isMessageRead: (BitchatMessage) -> Boolean
|
||||||
|
): List<UnreadConversationSummary> {
|
||||||
|
if (unreadConversationIDs.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
val normalizedCurrentUserIdentifiers = currentUserIdentifiers.filterTo(mutableSetOf()) {
|
||||||
|
it.isNotBlank()
|
||||||
|
}
|
||||||
|
val canonicalUnreadIDs = unreadConversationIDs
|
||||||
|
.mapTo(linkedSetOf()) { canonicalize(it) }
|
||||||
|
val unreadAliasesByCanonicalID = unreadConversationIDs.groupBy(canonicalize)
|
||||||
|
val messagesByCanonicalID = linkedMapOf<String, MutableList<BitchatMessage>>()
|
||||||
|
|
||||||
|
privateChats.forEach { (conversationID, messages) ->
|
||||||
|
val canonicalID = canonicalize(conversationID)
|
||||||
|
messagesByCanonicalID.getOrPut(canonicalID) { mutableListOf() }.addAll(messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
return canonicalUnreadIDs.map { conversationID ->
|
||||||
|
val messages = messagesByCanonicalID[conversationID]
|
||||||
|
.orEmpty()
|
||||||
|
.distinctBy { it.id }
|
||||||
|
val incomingMessages = messages.filterNot {
|
||||||
|
it.sender in normalizedCurrentUserIdentifiers
|
||||||
|
}
|
||||||
|
val unreadIncomingMessages = incomingMessages.filterNot(isMessageRead)
|
||||||
|
val latestMessage = (unreadIncomingMessages.ifEmpty { incomingMessages })
|
||||||
|
.maxWithOrNull(compareBy<BitchatMessage> { it.timestamp.time }.thenBy { it.id })
|
||||||
|
val aliases = unreadAliasesByCanonicalID[conversationID].orEmpty()
|
||||||
|
val nostrPubkey = latestMessage?.senderNostrPubkey
|
||||||
|
val isNostrConversation = nostrPubkey != null ||
|
||||||
|
aliases.any(::isNostrConversationID) ||
|
||||||
|
isNostrConversationID(conversationID)
|
||||||
|
|
||||||
|
UnreadConversationSummary(
|
||||||
|
conversationID = conversationID,
|
||||||
|
displayName = latestMessage
|
||||||
|
?.sender
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: conversationID.take(12),
|
||||||
|
unreadCount = unreadIncomingMessages.size.coerceAtLeast(1),
|
||||||
|
latestMessageAt = latestMessage?.timestamp?.time ?: Long.MIN_VALUE,
|
||||||
|
transport = if (isNostrConversation) {
|
||||||
|
DirectMessageTransport.NOSTR
|
||||||
|
} else {
|
||||||
|
DirectMessageTransport.MESH
|
||||||
|
},
|
||||||
|
nostrPubkey = nostrPubkey,
|
||||||
|
identityAliases = (aliases + conversationID)
|
||||||
|
.mapTo(mutableSetOf()) { it.lowercase() }
|
||||||
|
)
|
||||||
|
}.sortedWith(
|
||||||
|
compareByDescending<UnreadConversationSummary> { it.latestMessageAt }
|
||||||
|
.thenBy { it.displayName.lowercase() }
|
||||||
|
.thenBy { it.conversationID }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isNostrConversationID(value: String): Boolean =
|
||||||
|
value.startsWith("nostr_") || value.startsWith("nostr:")
|
||||||
|
|
||||||
|
internal fun matchingUnreadAliases(
|
||||||
|
unreadConversationIDs: Set<String>,
|
||||||
|
canonicalConversationID: String,
|
||||||
|
canonicalize: (String) -> String
|
||||||
|
): Set<String> {
|
||||||
|
val normalizedCanonicalID = canonicalConversationID.lowercase()
|
||||||
|
return unreadConversationIDs
|
||||||
|
.filterTo(mutableSetOf()) { unreadID ->
|
||||||
|
canonicalize(unreadID).equals(normalizedCanonicalID, ignoreCase = true)
|
||||||
|
}
|
||||||
|
.plus(canonicalConversationID)
|
||||||
|
.mapTo(mutableSetOf()) { it.lowercase() }
|
||||||
|
}
|
||||||
@ -41,10 +41,11 @@ data class BitchatPalette(
|
|||||||
val accentPurple: Color,
|
val accentPurple: Color,
|
||||||
|
|
||||||
// MARK: - Deterministic peer colors
|
// MARK: - Deterministic peer colors
|
||||||
/** Chroma applied after deriving a peer's stable hue. */
|
/**
|
||||||
val peerColorSaturation: Float,
|
* Saturation/value applied after deriving a peer's stable hue. Swap this when adding a
|
||||||
/** Brightness applied after deriving a peer's stable hue. */
|
* new theme — see [PeerColorStyle] for contrast guidelines.
|
||||||
val peerColorValue: Float,
|
*/
|
||||||
|
val peerColors: PeerColorStyle,
|
||||||
)
|
)
|
||||||
|
|
||||||
val DarkBitchatPalette = BitchatPalette(
|
val DarkBitchatPalette = BitchatPalette(
|
||||||
@ -56,8 +57,7 @@ val DarkBitchatPalette = BitchatPalette(
|
|||||||
textTertiary = Color(0xFF6B776B),
|
textTertiary = Color(0xFF6B776B),
|
||||||
accentOrange = Color(0xFFFF9F0A),
|
accentOrange = Color(0xFFFF9F0A),
|
||||||
accentPurple = Color(0xFFBF5AF2),
|
accentPurple = Color(0xFFBF5AF2),
|
||||||
peerColorSaturation = 1f,
|
peerColors = PeerColorStyle.Dark,
|
||||||
peerColorValue = 1f,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val LightBitchatPalette = BitchatPalette(
|
val LightBitchatPalette = BitchatPalette(
|
||||||
@ -69,8 +69,7 @@ val LightBitchatPalette = BitchatPalette(
|
|||||||
textTertiary = Color(0xFF757F75),
|
textTertiary = Color(0xFF757F75),
|
||||||
accentOrange = Color(0xFFFF9500),
|
accentOrange = Color(0xFFFF9500),
|
||||||
accentPurple = Color(0xFFAF52DE),
|
accentPurple = Color(0xFFAF52DE),
|
||||||
peerColorSaturation = 0.85f,
|
peerColors = PeerColorStyle.Light,
|
||||||
peerColorValue = 0.45f,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val LocalBitchatPalette = staticCompositionLocalOf { DarkBitchatPalette }
|
val LocalBitchatPalette = staticCompositionLocalOf { DarkBitchatPalette }
|
||||||
|
|||||||
@ -1,9 +1,36 @@
|
|||||||
package com.bitchat.android.ui.theme
|
package com.bitchat.android.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import com.bitchat.android.ui.PeerIdentity
|
import com.bitchat.android.ui.PeerIdentity
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Theme-specific chroma applied after a peer's stable hue is derived.
|
||||||
|
*
|
||||||
|
* Hue stays identity-stable across themes (and byte-identical to iOS). Only saturation
|
||||||
|
* and value change so peer labels remain readable on each background.
|
||||||
|
*
|
||||||
|
* Guidelines when adding a future theme:
|
||||||
|
* - Dim / dark backgrounds: keep [value] high so colors are not lost against the surface;
|
||||||
|
* prefer muted [saturation] over neon.
|
||||||
|
* - Light backgrounds: keep [value] moderate-low so colors are not blinding; avoid
|
||||||
|
* near-full saturation.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
data class PeerColorStyle(
|
||||||
|
val saturation: Float,
|
||||||
|
val value: Float,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
/** Soft pastels that stay bright enough on near-black chat surfaces. */
|
||||||
|
val Dark = PeerColorStyle(saturation = 0.55f, value = 0.82f)
|
||||||
|
|
||||||
|
/** Deeper, less saturated tones that stay readable on near-white surfaces. */
|
||||||
|
val Light = PeerColorStyle(saturation = 0.70f, value = 0.42f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The single identity-to-color boundary used by chat, people sheets, and mentions.
|
* The single identity-to-color boundary used by chat, people sheets, and mentions.
|
||||||
*
|
*
|
||||||
@ -22,9 +49,10 @@ fun colorForPeer(identity: PeerIdentity, palette: BitchatPalette): Color {
|
|||||||
hue = (hue + 0.12) % 1.0
|
hue = (hue + 0.12) % 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val style = palette.peerColors
|
||||||
return Color.hsv(
|
return Color.hsv(
|
||||||
hue = (hue * 360).toFloat(),
|
hue = (hue * 360).toFloat(),
|
||||||
saturation = palette.peerColorSaturation,
|
saturation = style.saturation,
|
||||||
value = palette.peerColorValue
|
value = style.value
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -131,7 +131,9 @@ object AppConstants {
|
|||||||
}
|
}
|
||||||
|
|
||||||
object Media {
|
object Media {
|
||||||
const val MAX_FILE_SIZE_BYTES: Long = 50L * 1024 * 1024
|
// A file is currently encoded into one protocol payload before BLE fragmentation.
|
||||||
|
// Reserve room for maximum filename/MIME TLVs and encryption envelope overhead.
|
||||||
|
const val MAX_FILE_SIZE_BYTES: Long = (10L * 1024 * 1024) - (132L * 1024)
|
||||||
}
|
}
|
||||||
|
|
||||||
object Services {
|
object Services {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
package com.bitchat.android.protocol
|
package com.bitchat.android.protocol
|
||||||
|
|
||||||
|
import com.bitchat.android.model.BitchatFilePacket
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertArrayEquals
|
import org.junit.Assert.assertArrayEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
@ -7,9 +8,11 @@ import org.junit.Assert.assertNotNull
|
|||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
import java.util.Random
|
import java.util.Random
|
||||||
|
import java.util.zip.Deflater
|
||||||
|
|
||||||
class BinaryProtocolTest {
|
class BinaryProtocolTest {
|
||||||
|
|
||||||
@ -987,6 +990,315 @@ class BinaryProtocolTest {
|
|||||||
assertNull("v2 compression bomb (ratio > 50,000:1) must be rejected", result)
|
assertNull("v2 compression bomb (ratio > 50,000:1) must be rejected", result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `v2 expanded payload at exact maximum passes bound without allocating output`() {
|
||||||
|
val max = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
|
||||||
|
val raw = compressedPacket(
|
||||||
|
version = 2u,
|
||||||
|
originalSize = max,
|
||||||
|
compressedData = ByteArray(256) { it.toByte() }
|
||||||
|
)
|
||||||
|
var calls = 0
|
||||||
|
|
||||||
|
val result = BinaryProtocol.decodeForTesting(raw) { _, requestedSize ->
|
||||||
|
calls += 1
|
||||||
|
assertEquals(max, requestedSize)
|
||||||
|
null // Prove the boundary reached this seam without allocating a 10 MiB result.
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNull("The test decompressor deliberately returns no payload", result)
|
||||||
|
assertEquals(1, calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `v2 expanded payload above maximum never reaches decompressor`() {
|
||||||
|
val max = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
|
||||||
|
val raw = compressedPacket(
|
||||||
|
version = 2u,
|
||||||
|
originalSize = max + 1,
|
||||||
|
compressedData = ByteArray(256) { it.toByte() }
|
||||||
|
)
|
||||||
|
var calls = 0
|
||||||
|
|
||||||
|
val result = BinaryProtocol.decodeForTesting(raw) { _, _ ->
|
||||||
|
calls += 1
|
||||||
|
byteArrayOf(0x42)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNull("An oversized expansion must be rejected before inflation", result)
|
||||||
|
assertEquals("The decompressor must not be invoked", 0, calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `v2 negative expanded payload never reaches decompressor`() {
|
||||||
|
val raw = compressedPacket(
|
||||||
|
version = 2u,
|
||||||
|
originalSize = -1,
|
||||||
|
compressedData = byteArrayOf(0x03)
|
||||||
|
)
|
||||||
|
var calls = 0
|
||||||
|
|
||||||
|
val result = BinaryProtocol.decodeForTesting(raw) { _, _ ->
|
||||||
|
calls += 1
|
||||||
|
byteArrayOf(0x42)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNull("A negative expansion must be rejected before inflation", result)
|
||||||
|
assertEquals("The decompressor must not be invoked", 0, calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `zero expanded payload never reaches decompressor`() {
|
||||||
|
val raw = compressedPacket(
|
||||||
|
version = 2u,
|
||||||
|
originalSize = 0,
|
||||||
|
compressedData = rawDeflate(ByteArray(0))
|
||||||
|
)
|
||||||
|
var calls = 0
|
||||||
|
|
||||||
|
val result = BinaryProtocol.decodeForTesting(raw) { _, _ ->
|
||||||
|
calls += 1
|
||||||
|
ByteArray(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNull("Compressed zero-length payloads are non-canonical and must be rejected", result)
|
||||||
|
assertEquals("The decompressor must not be invoked", 0, calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty compressed body never reaches decompressor`() {
|
||||||
|
val raw = compressedPacket(
|
||||||
|
version = 2u,
|
||||||
|
originalSize = 128,
|
||||||
|
compressedData = ByteArray(0)
|
||||||
|
)
|
||||||
|
var calls = 0
|
||||||
|
|
||||||
|
val result = BinaryProtocol.decodeForTesting(raw) { _, _ ->
|
||||||
|
calls += 1
|
||||||
|
ByteArray(128)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNull("A compressed payload must contain deflate bytes", result)
|
||||||
|
assertEquals("The decompressor must not be invoked", 0, calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `v1 unsigned maximum expanded size reaches decompressor`() {
|
||||||
|
val originalSize = 0xFFFF
|
||||||
|
val raw = compressedPacket(
|
||||||
|
version = 1u,
|
||||||
|
originalSize = originalSize,
|
||||||
|
compressedData = byteArrayOf(0x01, 0x02)
|
||||||
|
)
|
||||||
|
var calls = 0
|
||||||
|
|
||||||
|
val result = BinaryProtocol.decodeForTesting(raw) { _, requestedSize ->
|
||||||
|
calls += 1
|
||||||
|
assertEquals(originalSize, requestedSize)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNull("The test decompressor deliberately returns no payload", result)
|
||||||
|
assertEquals(1, calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `compression utility rejects invalid expansion sizes directly`() {
|
||||||
|
val max = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
|
||||||
|
|
||||||
|
assertNull(CompressionUtil.decompress(ByteArray(0), 1))
|
||||||
|
assertNull(CompressionUtil.decompress(rawDeflate(ByteArray(0)), 0))
|
||||||
|
assertNull(CompressionUtil.decompress(byteArrayOf(0x03), -1))
|
||||||
|
assertNull(CompressionUtil.decompress(byteArrayOf(0x03), max + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `raw deflate expands only when size and stream completion are exact`() {
|
||||||
|
val payload = ByteArray(4_096) { index -> (index % 17).toByte() }
|
||||||
|
val compressed = rawDeflate(payload)
|
||||||
|
|
||||||
|
val decoded = BinaryProtocol.decode(
|
||||||
|
compressedPacket(version = 2u, originalSize = payload.size, compressedData = compressed)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNotNull(decoded)
|
||||||
|
assertArrayEquals(payload, decoded!!.payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `zlib wrapped payload remains compatible when size and stream completion are exact`() {
|
||||||
|
val payload = ByteArray(4_096) { index -> (index % 23).toByte() }
|
||||||
|
val compressed = zlibDeflate(payload)
|
||||||
|
|
||||||
|
val decoded = BinaryProtocol.decode(
|
||||||
|
compressedPacket(version = 2u, originalSize = payload.size, compressedData = compressed)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNotNull(decoded)
|
||||||
|
assertArrayEquals(payload, decoded!!.payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `raw deflate with zlib-looking prefix falls back after non-exact zlib parse`() {
|
||||||
|
val payload = ByteArray(29) { index -> (index + 1).toByte() }
|
||||||
|
val compressed = byteArrayOf(
|
||||||
|
0x08, // non-final raw stored block; also zlib CMF
|
||||||
|
0x1d, 0x00, // LEN = 29; 0x08 0x1d passes the RFC 1950 header check
|
||||||
|
0xe2.toByte(), 0xff.toByte() // one's complement of LEN
|
||||||
|
) + payload + byteArrayOf(0x03, 0x00) // final empty fixed-Huffman block
|
||||||
|
|
||||||
|
assertArrayEquals(payload, CompressionUtil.decompress(compressed, payload.size))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `under-declared zlib expansion is rejected by fallback`() {
|
||||||
|
val payload = ByteArray(4_096) { 0x51 }
|
||||||
|
val compressed = zlibDeflate(payload)
|
||||||
|
|
||||||
|
val decoded = BinaryProtocol.decode(
|
||||||
|
compressedPacket(version = 2u, originalSize = 128, compressedData = compressed)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull("Zlib fallback must reject output beyond the declaration", decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `over-declared zlib expansion is rejected by fallback`() {
|
||||||
|
val payload = ByteArray(128) { 0x52 }
|
||||||
|
val compressed = zlibDeflate(payload)
|
||||||
|
|
||||||
|
val decoded = BinaryProtocol.decode(
|
||||||
|
compressedPacket(version = 2u, originalSize = 256, compressedData = compressed)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull("Zlib fallback must reject output shorter than the declaration", decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `truncated zlib stream is rejected by fallback`() {
|
||||||
|
val payload = ByteArray(4_096) { index -> (index % 29).toByte() }
|
||||||
|
val compressed = zlibDeflate(payload)
|
||||||
|
val truncated = compressed.copyOf(compressed.size - 1)
|
||||||
|
|
||||||
|
val decoded = BinaryProtocol.decode(
|
||||||
|
compressedPacket(version = 2u, originalSize = payload.size, compressedData = truncated)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull("Zlib fallback must require the stream end marker and checksum", decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `zlib stream with trailing bytes is rejected by fallback`() {
|
||||||
|
val payload = ByteArray(4_096) { index -> (index % 13).toByte() }
|
||||||
|
val compressedWithTrailingByte = zlibDeflate(payload) + byteArrayOf(0x00)
|
||||||
|
|
||||||
|
val decoded = BinaryProtocol.decode(
|
||||||
|
compressedPacket(
|
||||||
|
version = 2u,
|
||||||
|
originalSize = payload.size,
|
||||||
|
compressedData = compressedWithTrailingByte
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull("Zlib fallback must consume the complete input and nothing more", decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `under-declared raw expansion is rejected even when output buffer fills`() {
|
||||||
|
val payload = ByteArray(4_096) { 0x41 }
|
||||||
|
val compressed = rawDeflate(payload)
|
||||||
|
|
||||||
|
val decoded = BinaryProtocol.decode(
|
||||||
|
compressedPacket(version = 2u, originalSize = 128, compressedData = compressed)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull("Inflater must be finished, not merely fill the declared buffer", decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `over-declared raw expansion is rejected instead of returning a prefix`() {
|
||||||
|
val payload = ByteArray(128) { 0x42 }
|
||||||
|
val compressed = rawDeflate(payload)
|
||||||
|
|
||||||
|
val decoded = BinaryProtocol.decode(
|
||||||
|
compressedPacket(version = 2u, originalSize = 256, compressedData = compressed)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull("The expanded byte count must equal the declaration", decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `truncated raw stream is rejected even if all declared bytes were emitted`() {
|
||||||
|
val payload = ByteArray(4_096) { index -> (index % 31).toByte() }
|
||||||
|
val compressed = rawDeflate(payload)
|
||||||
|
val truncated = compressed.copyOf(compressed.size - 1)
|
||||||
|
|
||||||
|
val decoded = BinaryProtocol.decode(
|
||||||
|
compressedPacket(version = 2u, originalSize = payload.size, compressedData = truncated)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull("A stream without its end marker must not be accepted", decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `raw stream with trailing bytes is rejected`() {
|
||||||
|
val payload = ByteArray(4_096) { index -> (index % 19).toByte() }
|
||||||
|
val compressedWithTrailingByte = rawDeflate(payload) + byteArrayOf(0x00)
|
||||||
|
|
||||||
|
val decoded = BinaryProtocol.decode(
|
||||||
|
compressedPacket(
|
||||||
|
version = 2u,
|
||||||
|
originalSize = payload.size,
|
||||||
|
compressedData = compressedWithTrailingByte
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull("Trailing bytes after a complete stream must not be accepted", decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `decoder rejects a decompressor result shorter than its declaration`() {
|
||||||
|
val raw = compressedPacket(
|
||||||
|
version = 2u,
|
||||||
|
originalSize = 128,
|
||||||
|
compressedData = ByteArray(16) { it.toByte() }
|
||||||
|
)
|
||||||
|
|
||||||
|
val decoded = BinaryProtocol.decodeForTesting(raw) { _, _ -> byteArrayOf(0x01) }
|
||||||
|
|
||||||
|
assertNull("BinaryProtocol must independently enforce the declared expanded size", decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `new sender refuses legacy 11 MiB public file transfer before transmission`() {
|
||||||
|
val content = ByteArray(11 * 1024 * 1024) { 0x41 }
|
||||||
|
val filePayload = BitchatFilePacket(
|
||||||
|
fileName = "legacy-11m.bin",
|
||||||
|
fileSize = content.size.toLong(),
|
||||||
|
mimeType = "application/octet-stream",
|
||||||
|
content = content
|
||||||
|
).encode()
|
||||||
|
assertNotNull(filePayload)
|
||||||
|
|
||||||
|
val encoded = BinaryProtocol.encode(
|
||||||
|
BitchatPacket(
|
||||||
|
version = 2u,
|
||||||
|
type = MessageType.FILE_TRANSFER.value,
|
||||||
|
senderID = hexToBytes(senderHex),
|
||||||
|
recipientID = SpecialRecipients.BROADCAST,
|
||||||
|
timestamp = fixedTimestamp,
|
||||||
|
payload = filePayload!!,
|
||||||
|
ttl = 5u
|
||||||
|
),
|
||||||
|
padding = false
|
||||||
|
)
|
||||||
|
assertNull(
|
||||||
|
"Sender and receiver must enforce the same expanded-payload ceiling",
|
||||||
|
encoded
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compression bomb is rejected
|
* Compression bomb is rejected
|
||||||
*
|
*
|
||||||
@ -1163,6 +1475,59 @@ class BinaryProtocolTest {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun compressedPacket(
|
||||||
|
version: UByte,
|
||||||
|
originalSize: Int,
|
||||||
|
compressedData: ByteArray,
|
||||||
|
type: UByte = MessageType.MESSAGE.value
|
||||||
|
): ByteArray {
|
||||||
|
val originalSizeFieldBytes = if (version >= 2u.toUByte()) 4 else 2
|
||||||
|
val payloadLength = originalSizeFieldBytes + compressedData.size
|
||||||
|
val headerSize = if (version >= 2u.toUByte()) 16 else 14
|
||||||
|
val buffer = ByteBuffer.allocate(headerSize + 8 + payloadLength).apply {
|
||||||
|
order(ByteOrder.BIG_ENDIAN)
|
||||||
|
put(version.toByte())
|
||||||
|
put(type.toByte())
|
||||||
|
put(5.toByte())
|
||||||
|
putLong(fixedTimestamp.toLong())
|
||||||
|
put(BinaryProtocol.Flags.IS_COMPRESSED.toByte())
|
||||||
|
if (version >= 2u.toUByte()) {
|
||||||
|
putInt(payloadLength)
|
||||||
|
} else {
|
||||||
|
putShort(payloadLength.toShort())
|
||||||
|
}
|
||||||
|
put(hexToBytes(senderHex))
|
||||||
|
if (version >= 2u.toUByte()) {
|
||||||
|
putInt(originalSize)
|
||||||
|
} else {
|
||||||
|
putShort(originalSize.toShort())
|
||||||
|
}
|
||||||
|
put(compressedData)
|
||||||
|
}
|
||||||
|
return buffer.array()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rawDeflate(data: ByteArray): ByteArray = deflate(data, nowrap = true)
|
||||||
|
|
||||||
|
private fun zlibDeflate(data: ByteArray): ByteArray = deflate(data, nowrap = false)
|
||||||
|
|
||||||
|
private fun deflate(data: ByteArray, nowrap: Boolean): ByteArray {
|
||||||
|
val deflater = Deflater(Deflater.DEFAULT_COMPRESSION, nowrap)
|
||||||
|
return try {
|
||||||
|
deflater.setInput(data)
|
||||||
|
deflater.finish()
|
||||||
|
val output = ByteArrayOutputStream()
|
||||||
|
val buffer = ByteArray(1_024)
|
||||||
|
while (!deflater.finished()) {
|
||||||
|
val count = deflater.deflate(buffer)
|
||||||
|
output.write(buffer, 0, count)
|
||||||
|
}
|
||||||
|
output.toByteArray()
|
||||||
|
} finally {
|
||||||
|
deflater.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun makePacket(
|
private fun makePacket(
|
||||||
version: UByte = 1u,
|
version: UByte = 1u,
|
||||||
type: UByte = MessageType.MESSAGE.value,
|
type: UByte = MessageType.MESSAGE.value,
|
||||||
|
|||||||
@ -0,0 +1,117 @@
|
|||||||
|
package com.bitchat.android.protocol
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class DecompressionResourcePoolTest {
|
||||||
|
@Test
|
||||||
|
fun `small reservations run concurrently and next waits only when budget is full`() {
|
||||||
|
val pool = DecompressionResourcePool(
|
||||||
|
budgetBytes = 2_000,
|
||||||
|
unitBytes = 1_000,
|
||||||
|
waitTimeoutMs = 2_000
|
||||||
|
)
|
||||||
|
val executor = Executors.newFixedThreadPool(3)
|
||||||
|
val entered = CountDownLatch(2)
|
||||||
|
val release = CountDownLatch(1)
|
||||||
|
val thirdEntered = CountDownLatch(1)
|
||||||
|
|
||||||
|
try {
|
||||||
|
repeat(2) {
|
||||||
|
executor.submit {
|
||||||
|
pool.withReservation(1_000) {
|
||||||
|
entered.countDown()
|
||||||
|
release.await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue(entered.await(1, TimeUnit.SECONDS))
|
||||||
|
|
||||||
|
executor.submit {
|
||||||
|
pool.withReservation(1_000) {
|
||||||
|
thirdEntered.countDown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertFalse("third reservation must wait while budget is full", thirdEntered.await(100, TimeUnit.MILLISECONDS))
|
||||||
|
|
||||||
|
release.countDown()
|
||||||
|
assertTrue("third reservation must proceed after release", thirdEntered.await(1, TimeUnit.SECONDS))
|
||||||
|
} finally {
|
||||||
|
release.countDown()
|
||||||
|
executor.shutdownNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `timed admission drops work instead of waiting indefinitely`() {
|
||||||
|
val pool = DecompressionResourcePool(
|
||||||
|
budgetBytes = 1_000,
|
||||||
|
unitBytes = 1_000,
|
||||||
|
waitTimeoutMs = 50
|
||||||
|
)
|
||||||
|
val entered = CountDownLatch(1)
|
||||||
|
val release = CountDownLatch(1)
|
||||||
|
val executor = Executors.newSingleThreadExecutor()
|
||||||
|
|
||||||
|
try {
|
||||||
|
executor.submit {
|
||||||
|
pool.withReservation(1_000) {
|
||||||
|
entered.countDown()
|
||||||
|
release.await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue(entered.await(1, TimeUnit.SECONDS))
|
||||||
|
assertNull(pool.withReservation(1_000) { "unexpected" })
|
||||||
|
} finally {
|
||||||
|
release.countDown()
|
||||||
|
executor.shutdownNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `permits are released when decode throws`() {
|
||||||
|
val pool = DecompressionResourcePool(2_000, 1_000, 50)
|
||||||
|
|
||||||
|
try {
|
||||||
|
pool.withReservation(2_000) { error("boom") }
|
||||||
|
} catch (_: IllegalStateException) {
|
||||||
|
// Expected.
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(2, pool.availablePermits)
|
||||||
|
assertEquals("ok", pool.withReservation(2_000) { "ok" })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `runtime budget is based on heap memory and always admits one maximum packet`() {
|
||||||
|
val maxPacketResources = 20L * 1024 * 1024
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
maxPacketResources,
|
||||||
|
DecompressionResourcePool.recommendedBudgetBytes(
|
||||||
|
maxHeapBytes = 64L * 1024 * 1024,
|
||||||
|
maxPacketResourceBytes = maxPacketResources
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
32L * 1024 * 1024,
|
||||||
|
DecompressionResourcePool.recommendedBudgetBytes(
|
||||||
|
maxHeapBytes = 256L * 1024 * 1024,
|
||||||
|
maxPacketResourceBytes = maxPacketResources
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
64L * 1024 * 1024,
|
||||||
|
DecompressionResourcePool.recommendedBudgetBytes(
|
||||||
|
maxHeapBytes = 2L * 1024 * 1024 * 1024,
|
||||||
|
maxPacketResourceBytes = maxPacketResources
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,6 +14,7 @@ import com.bitchat.android.ui.theme.LightBitchatColorScheme
|
|||||||
import com.bitchat.android.ui.theme.LightBitchatPalette
|
import com.bitchat.android.ui.theme.LightBitchatPalette
|
||||||
import com.bitchat.android.ui.theme.MessageBodyTextStyle
|
import com.bitchat.android.ui.theme.MessageBodyTextStyle
|
||||||
import com.bitchat.android.ui.theme.MessageSenderTextStyle
|
import com.bitchat.android.ui.theme.MessageSenderTextStyle
|
||||||
|
import com.bitchat.android.ui.theme.PeerColorStyle
|
||||||
import com.bitchat.android.ui.theme.colorForPeer
|
import com.bitchat.android.ui.theme.colorForPeer
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
@ -444,8 +445,8 @@ class ChatUIUtilsTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `peer color hue is stable across light and dark, only chroma differs`() {
|
fun `peer color hue is stable across light and dark, only chroma differs`() {
|
||||||
// Hue derivation must stay byte-identical to iOS; only saturation/value are tuned for
|
// Hue derivation must stay byte-identical to iOS; only saturation/value are tuned per
|
||||||
// the redesigned neutral message body.
|
// theme so dark mode stays muted-but-bright and light mode stays deep-but-readable.
|
||||||
val identity = PeerIdentity.mesh("abc")
|
val identity = PeerIdentity.mesh("abc")
|
||||||
val dark = colorForPeer(identity, DarkBitchatPalette)
|
val dark = colorForPeer(identity, DarkBitchatPalette)
|
||||||
val light = colorForPeer(identity, LightBitchatPalette)
|
val light = colorForPeer(identity, LightBitchatPalette)
|
||||||
@ -456,10 +457,16 @@ class ChatUIUtilsTest {
|
|||||||
rgbToHsv(light.red, light.green, light.blue, lightHsv)
|
rgbToHsv(light.red, light.green, light.blue, lightHsv)
|
||||||
|
|
||||||
assertEquals(darkHsv[0].toDouble(), lightHsv[0].toDouble(), 1.0)
|
assertEquals(darkHsv[0].toDouble(), lightHsv[0].toDouble(), 1.0)
|
||||||
assertEquals(1.0, darkHsv[1].toDouble(), 0.01)
|
assertEquals(PeerColorStyle.Dark.saturation.toDouble(), darkHsv[1].toDouble(), 0.01)
|
||||||
assertEquals(1.0, darkHsv[2].toDouble(), 0.01)
|
assertEquals(PeerColorStyle.Dark.value.toDouble(), darkHsv[2].toDouble(), 0.01)
|
||||||
assertEquals(0.85, lightHsv[1].toDouble(), 0.01)
|
assertEquals(PeerColorStyle.Light.saturation.toDouble(), lightHsv[1].toDouble(), 0.01)
|
||||||
assertEquals(0.45, lightHsv[2].toDouble(), 0.01)
|
assertEquals(PeerColorStyle.Light.value.toDouble(), lightHsv[2].toDouble(), 0.01)
|
||||||
|
// Dark theme: muted chroma, never dark (readable on near-black).
|
||||||
|
assertTrue(darkHsv[1] < 0.75f)
|
||||||
|
assertTrue(darkHsv[2] >= 0.75f)
|
||||||
|
// Light theme: avoid neon / near-white peer labels.
|
||||||
|
assertTrue(lightHsv[1] < 0.85f)
|
||||||
|
assertTrue(lightHsv[2] <= 0.55f)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -485,7 +492,7 @@ class ChatUIUtilsTest {
|
|||||||
assertEquals(Color(0xFFF5F5F5), DarkBitchatColorScheme.onSurface)
|
assertEquals(Color(0xFFF5F5F5), DarkBitchatColorScheme.onSurface)
|
||||||
assertTrue(LightBitchatColorScheme.onSurface != DarkBitchatColorScheme.onSurface)
|
assertTrue(LightBitchatColorScheme.onSurface != DarkBitchatColorScheme.onSurface)
|
||||||
assertTrue(
|
assertTrue(
|
||||||
LightBitchatPalette.peerColorValue != DarkBitchatPalette.peerColorValue
|
LightBitchatPalette.peerColors != DarkBitchatPalette.peerColors
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -237,7 +237,7 @@ class FileTransferTest {
|
|||||||
|
|
||||||
// Given: Large file size (simulated)
|
// Given: Large file size (simulated)
|
||||||
val largeFileSize = 100L * 1024 * 1024 // 100MB
|
val largeFileSize = 100L * 1024 * 1024 // 100MB
|
||||||
val maxAllowedSize = 50L * 1024 * 1024 // 50MB
|
val maxAllowedSize = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES
|
||||||
|
|
||||||
// When: Checking if file can be transferred
|
// When: Checking if file can be transferred
|
||||||
val isAllowed = largeFileSize <= maxAllowedSize
|
val isAllowed = largeFileSize <= maxAllowedSize
|
||||||
|
|||||||
@ -287,6 +287,43 @@ class MediaSendingManagerMigrationTest {
|
|||||||
assertTrue(messages.none { it.type == com.bitchat.android.model.BitchatMessageType.Image })
|
assertTrue(messages.none { it.type == com.bitchat.android.model.BitchatMessageType.Image })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `oversized file failure is posted to the private conversation and nothing is sent`() {
|
||||||
|
val bigFile = kotlin.io.path.createTempFile("oversized-private", ".jpg").toFile()
|
||||||
|
try {
|
||||||
|
bigFile.writeBytes(ByteArray(11 * 1024 * 1024) { 0x42 })
|
||||||
|
|
||||||
|
manager.sendImageNote(peerID, null, bigFile.absolutePath)
|
||||||
|
|
||||||
|
val messages = state.privateChats.value[peerID].orEmpty()
|
||||||
|
assertEquals(1, messages.size)
|
||||||
|
assertTrue(messages.single().content.contains("too large"))
|
||||||
|
assertTrue(messages.none { it.type == com.bitchat.android.model.BitchatMessageType.Image })
|
||||||
|
assertTrue(state.getMessagesValue().none { it.content.contains("too large") })
|
||||||
|
verify(mesh, never()).prepareFilePrivate(any(), any(), any(), any())
|
||||||
|
} finally {
|
||||||
|
bigFile.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `oversized file failure is posted to the channel and nothing is sent`() {
|
||||||
|
val bigFile = kotlin.io.path.createTempFile("oversized-channel", ".jpg").toFile()
|
||||||
|
try {
|
||||||
|
bigFile.writeBytes(ByteArray(11 * 1024 * 1024) { 0x42 })
|
||||||
|
|
||||||
|
manager.sendImageNote(null, "#test", bigFile.absolutePath)
|
||||||
|
|
||||||
|
val channelMessages = state.getChannelMessagesValue()["#test"].orEmpty()
|
||||||
|
assertEquals(1, channelMessages.size)
|
||||||
|
assertTrue(channelMessages.single().content.contains("too large"))
|
||||||
|
assertTrue(state.getMessagesValue().none { it.content.contains("too large") })
|
||||||
|
verify(mesh, never()).prepareFilePrivate(any(), any(), any(), any())
|
||||||
|
} finally {
|
||||||
|
bigFile.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `cancelled consent cannot later send or echo`() {
|
fun `cancelled consent cannot later send or echo`() {
|
||||||
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
|
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
|
||||||
|
|||||||
@ -107,6 +107,29 @@ class PrivateChatManagerTest {
|
|||||||
verify(meshService).sendReadReceipt(message.id, meshPeerID, "bob")
|
verify(meshService).sendReadReceipt(message.id, meshPeerID, "bob")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `opening canonical unread conversation clears all source aliases`() {
|
||||||
|
val canonicalID = "contact_alice"
|
||||||
|
val nostrAlias = "nostr_0123456789abcdef"
|
||||||
|
val meshAlias = "0123456789abcdef"
|
||||||
|
val unrelatedConversation = "other-contact"
|
||||||
|
val meshService = mock<MeshService>()
|
||||||
|
state.setUnreadPrivateMessages(
|
||||||
|
setOf(canonicalID, nostrAlias, meshAlias, unrelatedConversation)
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.startPrivateChat(
|
||||||
|
peerID = canonicalID,
|
||||||
|
meshService = meshService,
|
||||||
|
unreadAliases = setOf(canonicalID, nostrAlias, meshAlias)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
setOf(unrelatedConversation),
|
||||||
|
state.getUnreadPrivateMessagesValue()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `opening chat skips messages whose receipt send already completed`() {
|
fun `opening chat skips messages whose receipt send already completed`() {
|
||||||
val noiseKey = ByteArray(32) { 8 }
|
val noiseKey = ByteArray(32) { 8 }
|
||||||
|
|||||||
@ -0,0 +1,137 @@
|
|||||||
|
package com.bitchat.android.ui
|
||||||
|
|
||||||
|
import com.bitchat.android.model.BitchatMessage
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
class UnreadConversationSummaryTest {
|
||||||
|
@Test
|
||||||
|
fun `unread conversations survive missing presence and sort by latest unread`() {
|
||||||
|
val older = incoming(
|
||||||
|
id = "older",
|
||||||
|
sender = "alice",
|
||||||
|
timestamp = 100
|
||||||
|
)
|
||||||
|
val newer = incoming(
|
||||||
|
id = "newer",
|
||||||
|
sender = "bob",
|
||||||
|
timestamp = 200
|
||||||
|
)
|
||||||
|
|
||||||
|
val rows = buildUnreadConversationSummaries(
|
||||||
|
unreadConversationIDs = setOf("alice-peer", "bob-peer"),
|
||||||
|
privateChats = mapOf(
|
||||||
|
"alice-peer" to listOf(older),
|
||||||
|
"bob-peer" to listOf(newer)
|
||||||
|
),
|
||||||
|
currentUserIdentifiers = setOf("me"),
|
||||||
|
canonicalize = { it },
|
||||||
|
isMessageRead = { false }
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf("bob-peer", "alice-peer"), rows.map { it.conversationID })
|
||||||
|
assertEquals(listOf("bob", "alice"), rows.map { it.displayName })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `canonical aliases produce one unread conversation row`() {
|
||||||
|
val message = incoming(
|
||||||
|
id = "message",
|
||||||
|
sender = "alice",
|
||||||
|
timestamp = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
val rows = buildUnreadConversationSummaries(
|
||||||
|
unreadConversationIDs = setOf("mesh-alias", "nostr_alias"),
|
||||||
|
privateChats = mapOf(
|
||||||
|
"mesh-alias" to listOf(message),
|
||||||
|
"nostr_alias" to listOf(message)
|
||||||
|
),
|
||||||
|
currentUserIdentifiers = setOf("me"),
|
||||||
|
canonicalize = { "contact_alice" },
|
||||||
|
isMessageRead = { false }
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, rows.size)
|
||||||
|
assertEquals("contact_alice", rows.single().conversationID)
|
||||||
|
assertEquals(DirectMessageTransport.NOSTR, rows.single().transport)
|
||||||
|
assertEquals(
|
||||||
|
setOf("mesh-alias", "nostr_alias", "contact_alice"),
|
||||||
|
rows.single().identityAliases
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `only unseen incoming messages contribute to unread count`() {
|
||||||
|
val read = incoming(
|
||||||
|
id = "read",
|
||||||
|
sender = "alice",
|
||||||
|
timestamp = 100
|
||||||
|
)
|
||||||
|
val unread = incoming(
|
||||||
|
id = "unread",
|
||||||
|
sender = "alice",
|
||||||
|
timestamp = 200
|
||||||
|
)
|
||||||
|
val outgoing = incoming(
|
||||||
|
id = "outgoing",
|
||||||
|
sender = "me",
|
||||||
|
timestamp = 300
|
||||||
|
)
|
||||||
|
|
||||||
|
val row = buildUnreadConversationSummaries(
|
||||||
|
unreadConversationIDs = setOf("alice-peer"),
|
||||||
|
privateChats = mapOf("alice-peer" to listOf(read, unread, outgoing)),
|
||||||
|
currentUserIdentifiers = setOf("me"),
|
||||||
|
canonicalize = { it },
|
||||||
|
isMessageRead = { it.id == "read" }
|
||||||
|
).single()
|
||||||
|
|
||||||
|
assertEquals(1, row.unreadCount)
|
||||||
|
assertEquals(200, row.latestMessageAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unread key without hydrated messages still produces a row`() {
|
||||||
|
val row = buildUnreadConversationSummaries(
|
||||||
|
unreadConversationIDs = setOf("orphan-peer"),
|
||||||
|
privateChats = emptyMap(),
|
||||||
|
currentUserIdentifiers = setOf("me"),
|
||||||
|
canonicalize = { it },
|
||||||
|
isMessageRead = { false }
|
||||||
|
).single()
|
||||||
|
|
||||||
|
assertEquals("orphan-peer", row.conversationID)
|
||||||
|
assertEquals(1, row.unreadCount)
|
||||||
|
assertTrue(row.displayName.isNotBlank())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `canonical unread lookup returns every matching source alias`() {
|
||||||
|
val aliases = matchingUnreadAliases(
|
||||||
|
unreadConversationIDs = setOf("mesh-alias", "nostr_alias", "other-contact"),
|
||||||
|
canonicalConversationID = "contact_alice",
|
||||||
|
canonicalize = { unreadID ->
|
||||||
|
if (unreadID == "other-contact") unreadID else "contact_alice"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
setOf("mesh-alias", "nostr_alias", "contact_alice"),
|
||||||
|
aliases
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun incoming(
|
||||||
|
id: String,
|
||||||
|
sender: String,
|
||||||
|
timestamp: Long
|
||||||
|
) = BitchatMessage(
|
||||||
|
id = id,
|
||||||
|
sender = sender,
|
||||||
|
content = "hello",
|
||||||
|
timestamp = Date(timestamp)
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -107,6 +107,28 @@ source-route metadata. It does not imply multi-gigabyte mesh transfer support.
|
|||||||
transport threshold; the data portion is at most 469 bytes and becomes
|
transport threshold; the data portion is at most 469 bytes and becomes
|
||||||
smaller when recipient or source-route overhead is present.
|
smaller when recipient or source-route overhead is present.
|
||||||
|
|
||||||
|
#### Compressed expansion rollout gate (resolved)
|
||||||
|
|
||||||
|
Android's bounded decoder applies the same 10 MiB expanded-payload ceiling to every outer
|
||||||
|
message type. It also requires a non-empty compressed body, an exact declared output size, and a
|
||||||
|
complete deflate stream. The `FILE_TRANSFER (0x22)` byte cannot safely grant a larger ceiling: it is
|
||||||
|
attacker-controlled before packet signature verification, and the current receive pipeline must
|
||||||
|
inflate before it can perform that verification.
|
||||||
|
|
||||||
|
New Android senders cap files just below 10 MiB (reserving envelope overhead) and refuse to encode
|
||||||
|
any payload above the receiver ceiling; exceeding the cap surfaces a user-visible error in chat.
|
||||||
|
Support for legacy >10 MiB compressed transfers is explicitly ended: legacy Android senders can
|
||||||
|
still produce a highly compressible public file between 10 MiB and their 50 MiB UI limit that the
|
||||||
|
bounded decoder rejects. Grandfathering 50 MiB is not safe after only a type check: inflation
|
||||||
|
allocates the declared payload and
|
||||||
|
`BitchatFilePacket.decode` currently copies the content again, creating a greater than 100 MiB peak
|
||||||
|
for a maximum-size transfer.
|
||||||
|
|
||||||
|
Before enabling that legacy range, receive processing needs an authenticated admission decision
|
||||||
|
made before large allocation plus streaming inflation/TLV parsing into a bounded temporary file (or
|
||||||
|
another ownership-preserving design that avoids the second full-size copy). The sender limit and a
|
||||||
|
wire capability/version transition must then be coordinated so old and new clients fail predictably.
|
||||||
|
|
||||||
### 1.3 File Transfer TLV payload (BitchatFilePacket)
|
### 1.3 File Transfer TLV payload (BitchatFilePacket)
|
||||||
|
|
||||||
The file payload is a TLV structure with mixed length field sizes to support large contents efficiently.
|
The file payload is a TLV structure with mixed length field sizes to support large contents efficiently.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user