Integrate signed notices and typed mesh synchronization

This commit is contained in:
callebtc 2026-09-07 23:36:39 +03:00
parent ffa8ada6a3
commit 85f707932d
64 changed files with 3601 additions and 64 deletions

View File

@ -0,0 +1,212 @@
package com.bitchat.android.board
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.nostr.LocationNotesManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.security.SecureRandom
/**
* Creates and removes signed board entries while keeping UI-only state out of
* the transport layer.
*/
class BoardManager(
private val store: BoardStore,
private val scope: CoroutineScope,
private val meshProvider: () -> MeshService,
private val geoIdentityProvider: (String) -> BoardSigningIdentity? = { null },
private val notesManager: LocationNotesManager = LocationNotesManager.getInstance(),
private val nowMs: () -> ULong = { System.currentTimeMillis().coerceAtLeast(0).toULong() },
private val random: SecureRandom = SecureRandom(),
private val onUrgentPosts: (geohash: String, posts: List<BoardPostPacket>) -> Unit = { _, _ -> }
) {
private val _unseenScopes = MutableStateFlow<Set<String>>(emptySet())
private val bridgedEventIDs = mutableMapOf<String, String>()
private val handledPostIDs = mutableSetOf<String>()
private val pendingUrgent = mutableMapOf<String, MutableList<BoardPostPacket>>()
private var alertFlushJob: Job? = null
val posts: StateFlow<List<BoardPostPacket>> = store.postsSnapshot
val unseenScopes: StateFlow<Set<String>> = _unseenScopes.asStateFlow()
init {
scope.launch {
store.postArrivals.collect { post ->
handleArrival(post)
}
}
}
fun posts(geohash: String): List<BoardPostPacket> = store.posts(geohash.lowercase())
fun isOwnPost(post: BoardPostPacket): Boolean =
signingIdentityFor(post.geohash)
?.publicKey
?.contentEquals(post.authorSigningKey) == true
fun createPost(
content: String,
geohash: String,
nickname: String?,
urgent: Boolean,
expiryDays: Int
): Boolean {
val trimmed = content.trim()
val contentBytes = trimmed.toByteArray(Charsets.UTF_8)
val normalizedGeohash = geohash.lowercase()
if (contentBytes.size !in 1..BoardWireConstants.CONTENT_MAX_BYTES ||
expiryDays !in 1..7 ||
!isValidGeohash(normalizedGeohash)
) {
return false
}
val mesh = meshProvider()
val identity = signingIdentityFor(normalizedGeohash) ?: return false
val signingKey = identity.publicKey.copyOf()
val postID = ByteArray(BoardWireConstants.POST_ID_LENGTH).also(random::nextBytes)
val createdAt = nowMs()
val expiresAt = createdAt + expiryDays.toULong() * DAY_MS
val authorNickname = truncateUtf8(nickname.orEmpty(), BoardWireConstants.NICKNAME_MAX_BYTES)
val flags: UByte = if (urgent) BoardPostPacket.URGENT_FLAG else 0u
val signingBytes = BoardPostPacket.signingBytes(
postID = postID,
geohash = normalizedGeohash,
content = trimmed,
authorSigningKey = signingKey,
authorNickname = authorNickname,
createdAt = createdAt,
expiresAt = expiresAt,
flags = flags
)
val signature = identity.sign(signingBytes)
?.takeIf { it.size == BoardWireConstants.SIGNATURE_LENGTH }
?: return false
val post = BoardPostPacket(
postID = postID,
geohash = normalizedGeohash,
content = trimmed,
authorSigningKey = signingKey,
authorNickname = authorNickname,
createdAt = createdAt,
expiresAt = expiresAt,
flags = flags,
signature = signature
)
mesh.sendBoardPayload(BoardWireCodec.encode(BoardWire.Post(post)))
if (normalizedGeohash.isNotEmpty()) {
notesManager.publishBoardBridge(
content = trimmed,
geohash = normalizedGeohash,
nickname = authorNickname,
expiresAtSeconds = (expiresAt / 1_000u).coerceAtMost(Int.MAX_VALUE.toULong()).toInt(),
urgent = urgent
) { eventID ->
synchronized(bridgedEventIDs) {
bridgedEventIDs[post.identityKey()] = eventID
}
}
}
return true
}
fun deletePost(post: BoardPostPacket): Boolean {
val identity = signingIdentityFor(post.geohash)
?.takeIf { it.publicKey.contentEquals(post.authorSigningKey) }
?: return false
val deletedAt = nowMs()
val signature = identity.sign(
BoardTombstonePacket.signingBytes(post.postID, deletedAt)
)?.takeIf { it.size == BoardWireConstants.SIGNATURE_LENGTH } ?: return false
val tombstone = BoardTombstonePacket(
postID = post.postID,
authorSigningKey = post.authorSigningKey,
deletedAt = deletedAt,
signature = signature
)
meshProvider().sendBoardPayload(BoardWireCodec.encode(BoardWire.Tombstone(tombstone)))
if (post.geohash.isNotEmpty()) {
val eventID = synchronized(bridgedEventIDs) {
bridgedEventIDs.remove(post.identityKey())
}
if (eventID != null) notesManager.deleteEvent(eventID, post.geohash)
}
return true
}
fun markSeen(scopes: Set<String>) {
if (scopes.isEmpty()) return
_unseenScopes.value = _unseenScopes.value - scopes
}
fun clearTransientState() {
_unseenScopes.value = emptySet()
synchronized(bridgedEventIDs) { bridgedEventIDs.clear() }
handledPostIDs.clear()
pendingUrgent.clear()
alertFlushJob?.cancel()
alertFlushJob = null
}
private fun handleArrival(post: BoardPostPacket) {
if (!handledPostIDs.add(post.identityKey()) || isOwnPost(post)) return
_unseenScopes.value = _unseenScopes.value + post.geohash
val age = nowMs().toLong() -
post.createdAt.coerceAtMost(Long.MAX_VALUE.toULong()).toLong()
if (!post.isUrgent || age > URGENT_RECENCY_MS) return
pendingUrgent.getOrPut(post.geohash) { mutableListOf() } += post
if (alertFlushJob == null) {
alertFlushJob = scope.launch {
delay(ALERT_COLLAPSE_MS)
val pending = pendingUrgent.mapValues { it.value.toList() }
pendingUrgent.clear()
alertFlushJob = null
pending.forEach { (geohash, posts) -> onUrgentPosts(geohash, posts) }
}
}
}
private fun isValidGeohash(value: String): Boolean =
value.isEmpty() ||
(value.length <= BoardWireConstants.GEOHASH_MAX_LENGTH &&
value.all { it in BoardWireConstants.GEOHASH_ALPHABET })
private fun truncateUtf8(value: String, maxBytes: Int): String {
var result = value
while (result.toByteArray(Charsets.UTF_8).size > maxBytes && result.isNotEmpty()) {
result = result.dropLast(1)
}
return result
}
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
private fun BoardPostPacket.identityKey(): String =
"${authorSigningKey.toHex()}:${postID.toHex()}"
private fun signingIdentityFor(geohash: String): BoardSigningIdentity? {
if (geohash.isNotEmpty()) {
// Never fall back to the stable mesh identity for a location scope.
return runCatching { geoIdentityProvider(geohash) }.getOrNull()
}
val mesh = meshProvider()
val publicKey = mesh.getSigningPublicKey()
?.takeIf { it.size == BoardWireConstants.SIGNING_KEY_LENGTH }
?: return null
return BoardSigningIdentity(publicKey, mesh::signData)
}
private companion object {
const val DAY_MS: ULong = 86_400_000uL
const val URGENT_RECENCY_MS = 30 * 60 * 1_000L
const val ALERT_COLLAPSE_MS = 4_000L
}
}

View File

@ -0,0 +1,402 @@
package com.bitchat.android.board
import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
import org.bouncycastle.crypto.signers.Ed25519Signer
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.charset.CodingErrorAction
import java.security.MessageDigest
object BoardWireConstants {
const val POST_ID_LENGTH = 16
const val SIGNING_KEY_LENGTH = 32
const val SIGNATURE_LENGTH = 64
const val TRANSPORT_SENDER_ID_LENGTH = 8
const val CONTENT_MAX_BYTES = 512
const val NICKNAME_MAX_BYTES = 64
const val GEOHASH_MAX_LENGTH = 12
const val MAX_LIFETIME_MS: ULong = 604_800_000uL
const val POST_SIGNING_CONTEXT = "bitchat-board-v1"
const val TOMBSTONE_SIGNING_CONTEXT = "bitchat-board-del-v1"
const val GEOHASH_ALPHABET = "0123456789bcdefghjkmnpqrstuvwxyz"
}
class BoardPostPacket(
val postID: ByteArray,
val geohash: String,
val content: String,
val authorSigningKey: ByteArray,
val authorNickname: String,
val createdAt: ULong,
val expiresAt: ULong,
val flags: UByte,
val signature: ByteArray
) {
val isUrgent: Boolean
get() = (flags.toInt() and URGENT_FLAG.toInt()) != 0
val signingBytes: ByteArray
get() = signingBytes(
postID = postID,
geohash = geohash,
content = content,
authorSigningKey = authorSigningKey,
authorNickname = authorNickname,
createdAt = createdAt,
expiresAt = expiresAt,
flags = flags
)
fun verifySignature(): Boolean =
BoardWireCodec.verify(signature, signingBytes, authorSigningKey)
override fun equals(other: Any?): Boolean =
other is BoardPostPacket &&
postID.contentEquals(other.postID) &&
geohash == other.geohash &&
content == other.content &&
authorSigningKey.contentEquals(other.authorSigningKey) &&
authorNickname == other.authorNickname &&
createdAt == other.createdAt &&
expiresAt == other.expiresAt &&
flags == other.flags &&
signature.contentEquals(other.signature)
override fun hashCode(): Int {
var result = postID.contentHashCode()
result = 31 * result + geohash.hashCode()
result = 31 * result + content.hashCode()
result = 31 * result + authorSigningKey.contentHashCode()
result = 31 * result + authorNickname.hashCode()
result = 31 * result + createdAt.hashCode()
result = 31 * result + expiresAt.hashCode()
result = 31 * result + flags.hashCode()
return 31 * result + signature.contentHashCode()
}
companion object {
const val URGENT_FLAG: UByte = 0x01u
fun signingBytes(
postID: ByteArray,
geohash: String,
content: String,
authorSigningKey: ByteArray,
authorNickname: String,
createdAt: ULong,
expiresAt: ULong,
flags: UByte
): ByteArray = ByteArrayOutputStream().apply {
appendContext(BoardWireConstants.POST_SIGNING_CONTEXT)
write(postID)
appendLengthPrefixed(geohash.toByteArray(Charsets.UTF_8))
appendLengthPrefixed(content.toByteArray(Charsets.UTF_8))
write(authorSigningKey)
appendLengthPrefixed(authorNickname.toByteArray(Charsets.UTF_8))
appendULong(createdAt)
appendULong(expiresAt)
write(flags.toInt())
}.toByteArray()
}
}
class BoardTombstonePacket(
val postID: ByteArray,
val authorSigningKey: ByteArray,
val deletedAt: ULong,
val signature: ByteArray
) {
val signingBytes: ByteArray
get() = signingBytes(postID, deletedAt)
fun verifySignature(): Boolean =
BoardWireCodec.verify(signature, signingBytes, authorSigningKey)
override fun equals(other: Any?): Boolean =
other is BoardTombstonePacket &&
postID.contentEquals(other.postID) &&
authorSigningKey.contentEquals(other.authorSigningKey) &&
deletedAt == other.deletedAt &&
signature.contentEquals(other.signature)
override fun hashCode(): Int {
var result = postID.contentHashCode()
result = 31 * result + authorSigningKey.contentHashCode()
result = 31 * result + deletedAt.hashCode()
return 31 * result + signature.contentHashCode()
}
companion object {
fun signingBytes(postID: ByteArray, deletedAt: ULong): ByteArray =
ByteArrayOutputStream().apply {
appendContext(BoardWireConstants.TOMBSTONE_SIGNING_CONTEXT)
write(postID)
appendULong(deletedAt)
}.toByteArray()
}
}
sealed interface BoardWire {
data class Post(val packet: BoardPostPacket) : BoardWire
data class Tombstone(val packet: BoardTombstonePacket) : BoardWire
fun verifySignature(): Boolean = when (this) {
is Post -> packet.verifySignature()
is Tombstone -> packet.verifySignature()
}
}
/**
* Board payloads authenticate their embedded author key, so their outer mesh
* sender must not expose the device's stable peer ID. The pseudonym remains
* stable only for one board signing identity.
*/
fun BoardWire.transportSenderID(): ByteArray {
val authorKey = when (this) {
is BoardWire.Post -> packet.authorSigningKey
is BoardWire.Tombstone -> packet.authorSigningKey
}
return MessageDigest.getInstance("SHA-256")
.digest(authorKey)
.copyOf(BoardWireConstants.TRANSPORT_SENDER_ID_LENGTH)
}
object BoardWireCodec {
private const val TLV_KIND = 0x01
private const val TLV_POST_ID = 0x02
private const val TLV_GEOHASH = 0x03
private const val TLV_CONTENT = 0x04
private const val TLV_AUTHOR_SIGNING_KEY = 0x05
private const val TLV_AUTHOR_NICKNAME = 0x06
private const val TLV_CREATED_AT = 0x07
private const val TLV_EXPIRES_AT = 0x08
private const val TLV_FLAGS = 0x09
private const val TLV_SIGNATURE = 0x0A
private const val TLV_DELETED_AT = 0x0B
private const val KIND_POST = 0x01
private const val KIND_TOMBSTONE = 0x02
fun encode(wire: BoardWire): ByteArray = ByteArrayOutputStream().apply {
when (wire) {
is BoardWire.Post -> with(wire.packet) {
appendTlv(TLV_KIND, byteArrayOf(KIND_POST.toByte()))
appendTlv(TLV_POST_ID, postID)
appendTlv(TLV_GEOHASH, geohash.toByteArray(Charsets.UTF_8))
appendTlv(TLV_CONTENT, content.toByteArray(Charsets.UTF_8))
appendTlv(TLV_AUTHOR_SIGNING_KEY, authorSigningKey)
appendTlv(TLV_AUTHOR_NICKNAME, authorNickname.toByteArray(Charsets.UTF_8))
appendTlv(TLV_CREATED_AT, createdAt.toBigEndianBytes())
appendTlv(TLV_EXPIRES_AT, expiresAt.toBigEndianBytes())
appendTlv(TLV_FLAGS, byteArrayOf(flags.toByte()))
appendTlv(TLV_SIGNATURE, signature)
}
is BoardWire.Tombstone -> with(wire.packet) {
appendTlv(TLV_KIND, byteArrayOf(KIND_TOMBSTONE.toByte()))
appendTlv(TLV_POST_ID, postID)
appendTlv(TLV_AUTHOR_SIGNING_KEY, authorSigningKey)
appendTlv(TLV_DELETED_AT, deletedAt.toBigEndianBytes())
appendTlv(TLV_SIGNATURE, signature)
}
}
}.toByteArray()
fun decode(data: ByteArray): BoardWire? {
var offset = 0
var kind: Int? = null
var postID: ByteArray? = null
var geohash: String? = null
var content: String? = null
var contentBytes = 0
var authorSigningKey: ByteArray? = null
var authorNickname: String? = null
var nicknameBytes = 0
var createdAt: ULong? = null
var expiresAt: ULong? = null
var flags: UByte? = null
var signature: ByteArray? = null
var deletedAt: ULong? = null
while (offset + 3 <= data.size) {
val type = data[offset].toInt() and 0xFF
offset += 1
val length =
((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
offset += 2
if (length > data.size - offset) return null
val value = data.copyOfRange(offset, offset + length)
offset += length
when (type) {
TLV_KIND -> {
if (value.size != 1) return null
kind = value[0].toInt() and 0xFF
}
TLV_POST_ID -> {
if (value.size != BoardWireConstants.POST_ID_LENGTH) return null
postID = value
}
TLV_GEOHASH -> {
if (value.size > BoardWireConstants.GEOHASH_MAX_LENGTH) return null
geohash = decodeUtf8(value) ?: return null
}
TLV_CONTENT -> {
if (value.size > BoardWireConstants.CONTENT_MAX_BYTES) return null
contentBytes = value.size
content = decodeUtf8(value) ?: return null
}
TLV_AUTHOR_SIGNING_KEY -> {
if (value.size != BoardWireConstants.SIGNING_KEY_LENGTH) return null
authorSigningKey = value
}
TLV_AUTHOR_NICKNAME -> {
if (value.size > BoardWireConstants.NICKNAME_MAX_BYTES) return null
nicknameBytes = value.size
authorNickname = decodeUtf8(value) ?: return null
}
TLV_CREATED_AT -> createdAt = value.toULongBigEndian() ?: return null
TLV_EXPIRES_AT -> expiresAt = value.toULongBigEndian() ?: return null
TLV_FLAGS -> {
if (value.size != 1) return null
flags = value[0].toUByte()
}
TLV_SIGNATURE -> {
if (value.size != BoardWireConstants.SIGNATURE_LENGTH) return null
signature = value
}
TLV_DELETED_AT -> deletedAt = value.toULongBigEndian() ?: return null
}
}
val requiredPostID = postID ?: return null
val requiredKey = authorSigningKey ?: return null
val requiredSignature = signature ?: return null
return when (kind) {
KIND_POST -> {
val requiredGeohash = geohash ?: return null
val requiredContent = content ?: return null
val requiredNickname = authorNickname ?: return null
val requiredCreatedAt = createdAt ?: return null
val requiredExpiresAt = expiresAt ?: return null
val requiredFlags = flags ?: return null
if (contentBytes !in 1..BoardWireConstants.CONTENT_MAX_BYTES) return null
if (nicknameBytes > BoardWireConstants.NICKNAME_MAX_BYTES) return null
if (!isValidGeohash(requiredGeohash)) return null
if (requiredExpiresAt <= requiredCreatedAt) return null
if (requiredExpiresAt - requiredCreatedAt > BoardWireConstants.MAX_LIFETIME_MS) return null
BoardWire.Post(
BoardPostPacket(
postID = requiredPostID,
geohash = requiredGeohash,
content = requiredContent,
authorSigningKey = requiredKey,
authorNickname = requiredNickname,
createdAt = requiredCreatedAt,
expiresAt = requiredExpiresAt,
flags = requiredFlags,
signature = requiredSignature
)
)
}
KIND_TOMBSTONE -> BoardWire.Tombstone(
BoardTombstonePacket(
postID = requiredPostID,
authorSigningKey = requiredKey,
deletedAt = deletedAt ?: return null,
signature = requiredSignature
)
)
else -> null
}
}
fun urgentFlag(data: ByteArray): Boolean {
var offset = 0
while (offset + 3 <= data.size) {
val type = data[offset].toInt() and 0xFF
offset += 1
val length =
((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
offset += 2
if (length > data.size - offset) return false
if (type == TLV_FLAGS && length == 1) {
return (data[offset].toInt() and BoardPostPacket.URGENT_FLAG.toInt()) != 0
}
offset += length
}
return false
}
internal fun verify(signature: ByteArray, message: ByteArray, publicKey: ByteArray): Boolean =
try {
if (signature.size != BoardWireConstants.SIGNATURE_LENGTH ||
publicKey.size != BoardWireConstants.SIGNING_KEY_LENGTH
) {
false
} else {
Ed25519Signer().run {
init(false, Ed25519PublicKeyParameters(publicKey, 0))
update(message, 0, message.size)
verifySignature(signature)
}
}
} catch (_: Exception) {
false
}
private fun isValidGeohash(value: String): Boolean =
value.isEmpty() ||
(value.length <= BoardWireConstants.GEOHASH_MAX_LENGTH &&
value.all { it in BoardWireConstants.GEOHASH_ALPHABET })
private fun decodeUtf8(value: ByteArray): String? =
try {
Charsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(value))
.toString()
} catch (_: Exception) {
null
}
}
private fun ByteArrayOutputStream.appendTlv(type: Int, value: ByteArray) {
require(value.size <= 0xFFFF)
write(type)
write((value.size ushr 8) and 0xFF)
write(value.size and 0xFF)
write(value)
}
private fun ByteArrayOutputStream.appendContext(context: String) {
val value = context.toByteArray(Charsets.UTF_8).take(255).toByteArray()
write(value.size)
write(value)
}
private fun ByteArrayOutputStream.appendLengthPrefixed(value: ByteArray) {
val limited = value.take(0xFFFF).toByteArray()
write((limited.size ushr 8) and 0xFF)
write(limited.size and 0xFF)
write(limited)
}
private fun ByteArrayOutputStream.appendULong(value: ULong) {
write(value.toBigEndianBytes())
}
private fun ULong.toBigEndianBytes(): ByteArray =
ByteArray(8) { index -> (this shr ((7 - index) * 8)).toByte() }
private fun ByteArray.toULongBigEndian(): ULong? {
if (size != 8) return null
var value = 0uL
for (byte in this) {
value = (value shl 8) or (byte.toULong() and 0xFFuL)
}
return value
}

View File

@ -0,0 +1,59 @@
package com.bitchat.android.board
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
import org.bouncycastle.crypto.signers.Ed25519Signer
import java.security.MessageDigest
/**
* Signing identity used by board payloads.
*
* Location boards use a key derived from the already-unlinkable per-geohash
* Nostr secret. Domain separation keeps the board Ed25519 identity distinct
* from the secp256k1 identity used on relays.
*/
class BoardSigningIdentity(
publicKey: ByteArray,
private val signer: (ByteArray) -> ByteArray?
) {
val publicKey: ByteArray = publicKey.copyOf()
init {
require(publicKey.size == BoardWireConstants.SIGNING_KEY_LENGTH)
}
fun sign(message: ByteArray): ByteArray? = signer(message)?.copyOf()
companion object {
private const val GEO_IDENTITY_CONTEXT = "bitchat-board-geo-identity-v1"
fun fromNostrPrivateKeyHex(privateKeyHex: String): BoardSigningIdentity? =
runCatching {
val nostrSecret = privateKeyHex.hexToByteArray()
.takeIf { it.size == BoardWireConstants.SIGNING_KEY_LENGTH }
?: return@runCatching null
val digest = MessageDigest.getInstance("SHA-256")
digest.update(GEO_IDENTITY_CONTEXT.toByteArray(Charsets.UTF_8))
digest.update(nostrSecret)
fromEd25519Seed(digest.digest())
}.getOrNull()
fun fromEd25519Seed(seed: ByteArray): BoardSigningIdentity {
require(seed.size == BoardWireConstants.SIGNING_KEY_LENGTH)
val privateKey = Ed25519PrivateKeyParameters(seed.copyOf(), 0)
return BoardSigningIdentity(privateKey.generatePublicKey().encoded) { message ->
Ed25519Signer().run {
init(true, privateKey)
update(message, 0, message.size)
generateSignature()
}
}
}
private fun String.hexToByteArray(): ByteArray {
require(length % 2 == 0)
return ByteArray(length / 2) { index ->
substring(index * 2, index * 2 + 2).toInt(16).toByte()
}
}
}
}

View File

@ -0,0 +1,382 @@
package com.bitchat.android.board
import android.content.Context
import android.util.Log
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import java.io.File
import java.util.Base64
enum class BoardIngestResult {
ACCEPTED,
DUPLICATE,
REJECTED
}
enum class BoardIngestSource {
REMOTE,
LOCAL,
RESTORE
}
class BoardStore(
private val file: File? = null,
private val nowMs: () -> ULong = { System.currentTimeMillis().coerceAtLeast(0).toULong() }
) {
object Limits {
const val MAX_POSTS = 200
const val MAX_POSTS_PER_AUTHOR = 5
const val MAX_ORPHAN_TOMBSTONES = 100
const val MAX_ORPHAN_TOMBSTONES_PER_AUTHOR = 5
const val CLOCK_SKEW_MS: ULong = 3_600_000uL
const val ORPHAN_TOMBSTONE_LIFETIME_MS: ULong = BoardWireConstants.MAX_LIFETIME_MS
}
private data class StoredPost(
val post: BoardPostPacket,
val packet: BitchatPacket,
val rawPacket: ByteArray
)
private data class StoredTombstone(
val tombstone: BoardTombstonePacket,
val packet: BitchatPacket,
val rawPacket: ByteArray,
val retainUntil: ULong,
val isOrphan: Boolean
)
private data class PersistedEntry(
val packet: String,
val retainUntil: String?
)
private val lock = Any()
private val posts = mutableListOf<StoredPost>()
private val tombstones = mutableListOf<StoredTombstone>()
private val _postsSnapshot = MutableStateFlow<List<BoardPostPacket>>(emptyList())
private val _postArrivals = MutableSharedFlow<BoardPostPacket>(extraBufferCapacity = 64)
val postsSnapshot: StateFlow<List<BoardPostPacket>> = _postsSnapshot.asStateFlow()
val postArrivals: SharedFlow<BoardPostPacket> = _postArrivals.asSharedFlow()
init {
loadFromDisk()
}
fun ingest(
wire: BoardWire,
packet: BitchatPacket,
source: BoardIngestSource = BoardIngestSource.REMOTE
): BoardIngestResult {
if (packet.type != MessageType.BOARD_POST.value || !wire.verifySignature()) {
return BoardIngestResult.REJECTED
}
val rawPacket = packet.toBinaryData(padding = false) ?: return BoardIngestResult.REJECTED
val now = nowMs()
var arrival: BoardPostPacket? = null
val result = synchronized(lock) {
val outcome = ingestLocked(
wire = wire,
packet = packet,
rawPacket = rawPacket,
now = now,
retainUntilOverride = null
)
if (outcome == BoardIngestResult.ACCEPTED && source != BoardIngestSource.RESTORE) {
persistLocked()
}
if (outcome == BoardIngestResult.ACCEPTED &&
source == BoardIngestSource.REMOTE &&
wire is BoardWire.Post
) {
arrival = wire.packet
}
outcome
}
arrival?.let(_postArrivals::tryEmit)
return result
}
/**
* Ingests a packet received from the mesh and reports whether this exact
* arrival may continue through the live relay path.
*
* A duplicate board payload must not relay again: the payload signature
* does not authenticate mutable outer packet fields such as timestamp, so
* accepting duplicates for relay would let one captured notice be wrapped
* in infinitely many distinct outer packets.
*/
fun ingestRemoteForRelay(wire: BoardWire, packet: BitchatPacket): Boolean =
ingest(wire, packet, BoardIngestSource.REMOTE) == BoardIngestResult.ACCEPTED
fun posts(forGeohash: String): List<BoardPostPacket> = synchronized(lock) {
pruneExpiredLocked(nowMs())
posts.asSequence()
.map { it.post }
.filter { it.geohash == forGeohash }
.sortedWith(
compareByDescending<BoardPostPacket> { it.isUrgent }
.thenByDescending { it.createdAt }
)
.toList()
}
fun syncCandidates(): List<BitchatPacket> = synchronized(lock) {
pruneExpiredLocked(nowMs())
posts.map { it.packet } + tombstones.map { it.packet }
}
fun pruneExpired() = synchronized(lock) {
val changed = pruneExpiredLocked(nowMs())
if (changed) persistLocked()
}
fun wipe() = synchronized(lock) {
posts.clear()
tombstones.clear()
file?.let { runCatching { if (it.exists()) it.delete() } }
publishSnapshotLocked()
}
private fun ingestLocked(
wire: BoardWire,
packet: BitchatPacket,
rawPacket: ByteArray,
now: ULong,
retainUntilOverride: ULong?
): BoardIngestResult {
pruneExpiredLocked(now)
return when (wire) {
is BoardWire.Post -> ingestPostLocked(wire.packet, packet, rawPacket, now)
is BoardWire.Tombstone -> ingestTombstoneLocked(
wire.packet,
packet,
rawPacket,
now,
retainUntilOverride
)
}
}
private fun ingestPostLocked(
post: BoardPostPacket,
packet: BitchatPacket,
rawPacket: ByteArray,
now: ULong
): BoardIngestResult {
if (post.expiresAt <= now) return BoardIngestResult.REJECTED
if (post.createdAt > now.saturatedAdd(Limits.CLOCK_SKEW_MS)) {
return BoardIngestResult.REJECTED
}
if (post.expiresAt > now.saturatedAdd(BoardWireConstants.MAX_LIFETIME_MS)
.saturatedAdd(Limits.CLOCK_SKEW_MS)
) {
return BoardIngestResult.REJECTED
}
if (tombstones.any {
it.tombstone.postID.contentEquals(post.postID) &&
it.tombstone.authorSigningKey.contentEquals(post.authorSigningKey)
}
) {
return BoardIngestResult.REJECTED
}
if (posts.any {
it.post.postID.contentEquals(post.postID) &&
it.post.authorSigningKey.contentEquals(post.authorSigningKey)
}
) {
return BoardIngestResult.DUPLICATE
}
posts += StoredPost(post, packet, rawPacket)
enforcePostCapsLocked(post.authorSigningKey)
publishSnapshotLocked()
return BoardIngestResult.ACCEPTED
}
private fun ingestTombstoneLocked(
tombstone: BoardTombstonePacket,
packet: BitchatPacket,
rawPacket: ByteArray,
now: ULong,
retainUntilOverride: ULong?
): BoardIngestResult {
if (tombstones.any {
it.tombstone.postID.contentEquals(tombstone.postID) &&
it.tombstone.authorSigningKey.contentEquals(tombstone.authorSigningKey)
}
) {
return BoardIngestResult.DUPLICATE
}
val maxRetain = minOf(
tombstone.deletedAt.saturatedAdd(Limits.ORPHAN_TOMBSTONE_LIFETIME_MS),
now.saturatedAdd(Limits.ORPHAN_TOMBSTONE_LIFETIME_MS)
.saturatedAdd(Limits.CLOCK_SKEW_MS)
)
val matchingPostIndex = posts.indexOfFirst {
it.post.postID.contentEquals(tombstone.postID) &&
it.post.authorSigningKey.contentEquals(tombstone.authorSigningKey)
}
val retainUntil: ULong
val isOrphan: Boolean
if (matchingPostIndex >= 0) {
val target = posts[matchingPostIndex].post
retainUntil = target.expiresAt
isOrphan = false
posts.removeAt(matchingPostIndex)
publishSnapshotLocked()
} else if (retainUntilOverride != null) {
retainUntil = minOf(retainUntilOverride, maxRetain)
isOrphan = false
} else {
retainUntil = maxRetain
isOrphan = true
}
if (retainUntil <= now) return BoardIngestResult.REJECTED
tombstones += StoredTombstone(
tombstone = tombstone,
packet = packet,
rawPacket = rawPacket,
retainUntil = retainUntil,
isOrphan = isOrphan
)
if (isOrphan) enforceOrphanTombstoneCapsLocked(tombstone.authorSigningKey)
return BoardIngestResult.ACCEPTED
}
private fun enforcePostCapsLocked(author: ByteArray) {
val authorPosts = posts.filter { it.post.authorSigningKey.contentEquals(author) }
evictOldestPostsLocked(authorPosts, Limits.MAX_POSTS_PER_AUTHOR)
evictOldestPostsLocked(posts.toList(), Limits.MAX_POSTS)
}
private fun evictOldestPostsLocked(candidates: List<StoredPost>, keep: Int) {
val victims = candidates.sortedBy { it.post.createdAt }
.take((candidates.size - keep).coerceAtLeast(0))
if (victims.isNotEmpty()) {
posts.removeAll { stored -> victims.any { it === stored } }
}
}
private fun enforceOrphanTombstoneCapsLocked(author: ByteArray) {
val authorOrphans = tombstones.filter {
it.isOrphan && it.tombstone.authorSigningKey.contentEquals(author)
}
removeOldestTombstonesLocked(
authorOrphans,
authorOrphans.size - Limits.MAX_ORPHAN_TOMBSTONES_PER_AUTHOR
)
val allOrphans = tombstones.filter { it.isOrphan }
removeOldestTombstonesLocked(
allOrphans,
allOrphans.size - Limits.MAX_ORPHAN_TOMBSTONES
)
}
private fun removeOldestTombstonesLocked(
candidates: List<StoredTombstone>,
count: Int
) {
if (count <= 0) return
val victims = candidates.take(count)
tombstones.removeAll { stored -> victims.any { it === stored } }
}
private fun pruneExpiredLocked(now: ULong): Boolean {
val postsBefore = posts.size
val tombstonesBefore = tombstones.size
posts.removeAll { it.post.expiresAt <= now }
tombstones.removeAll { it.retainUntil <= now }
if (posts.size != postsBefore) publishSnapshotLocked()
return posts.size != postsBefore || tombstones.size != tombstonesBefore
}
private fun publishSnapshotLocked() {
_postsSnapshot.value = posts.map { it.post }
}
private fun persistLocked() {
val target = file ?: return
val entries = posts.map {
PersistedEntry(
packet = Base64.getEncoder().encodeToString(it.rawPacket),
retainUntil = null
)
} + tombstones.map {
PersistedEntry(
packet = Base64.getEncoder().encodeToString(it.rawPacket),
retainUntil = it.retainUntil.toString()
)
}
runCatching {
if (entries.isEmpty()) {
if (target.exists()) target.delete()
return
}
target.parentFile?.mkdirs()
val temporary = File(target.parentFile, "${target.name}.tmp")
temporary.writeText(Gson().toJson(entries))
if (!temporary.renameTo(target)) {
temporary.copyTo(target, overwrite = true)
temporary.delete()
}
}.onFailure {
Log.e(TAG, "Failed to persist board store: ${it.message}")
}
}
private fun loadFromDisk() {
val target = file ?: return
if (!target.isFile) return
val entries = runCatching {
val type = object : TypeToken<List<PersistedEntry>>() {}.type
Gson().fromJson<List<PersistedEntry>>(target.readText(), type)
}.getOrNull() ?: return
val now = nowMs()
synchronized(lock) {
for (entry in entries) {
val raw = runCatching { Base64.getDecoder().decode(entry.packet) }.getOrNull() ?: continue
val packet = BitchatPacket.fromBinaryData(raw) ?: continue
if (packet.type != MessageType.BOARD_POST.value) continue
val wire = BoardWireCodec.decode(packet.payload) ?: continue
if (!wire.verifySignature()) continue
ingestLocked(
wire = wire,
packet = packet,
rawPacket = raw,
now = now,
retainUntilOverride = entry.retainUntil?.toULongOrNull()
)
}
publishSnapshotLocked()
}
}
companion object {
private const val TAG = "BoardStore"
@Volatile
private var instance: BoardStore? = null
fun getInstance(context: Context): BoardStore =
instance ?: synchronized(this) {
instance ?: BoardStore(
File(context.applicationContext.filesDir, "board/posts.json")
).also { instance = it }
}
}
}
private fun ULong.saturatedAdd(other: ULong): ULong =
if (ULong.MAX_VALUE - this < other) ULong.MAX_VALUE else this + other

View File

@ -0,0 +1,87 @@
package com.bitchat.android.board
import com.bitchat.android.nostr.LocationNotesManager
import kotlin.math.abs
enum class NoticeSource {
MESH,
NOSTR
}
data class UnifiedNotice(
val id: String,
val content: String,
val nickname: String,
val createdAtMs: Long,
val geohash: String,
val urgent: Boolean,
val expiresAtMs: Long?,
val source: NoticeSource,
val boardPost: BoardPostPacket? = null,
val nostrNote: LocationNotesManager.Note? = null
)
object UnifiedNotices {
private const val DEDUPLICATION_WINDOW_MS = 15 * 60 * 1_000L
/**
* Combines exact-scope mesh board posts with relay notes. When a relay
* bridge copy matches a board post, the signed board copy is authoritative.
*/
fun merge(
geohash: String,
boardPosts: List<BoardPostPacket>,
relayNotes: List<LocationNotesManager.Note>
): List<UnifiedNotice> {
val normalized = geohash.lowercase()
val scopedPosts = boardPosts.filter { it.geohash == normalized }
val boardNotices = scopedPosts.map { post ->
UnifiedNotice(
id = "mesh:${post.authorSigningKey.toHex()}:${post.postID.toHex()}",
content = post.content,
nickname = post.authorNickname,
createdAtMs = post.createdAt.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(),
geohash = post.geohash,
urgent = post.isUrgent,
expiresAtMs = post.expiresAt.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(),
source = NoticeSource.MESH,
boardPost = post
)
}
val relayNotices = relayNotes.asSequence()
.filterNot { note ->
scopedPosts.any { post ->
post.geohash == note.geohash.lowercase() &&
post.content == note.content &&
post.authorNickname.ifBlank { "anon" } ==
note.nickname?.trim()?.takeIf { it.isNotEmpty() }.orEmpty()
.ifEmpty { "anon" } &&
abs(
post.createdAt.coerceAtMost(Long.MAX_VALUE.toULong()).toLong() -
note.createdAt.toLong() * 1_000L
) <= DEDUPLICATION_WINDOW_MS
}
}
.map { note ->
UnifiedNotice(
id = "nostr:${note.id}",
content = note.content,
nickname = note.nickname.orEmpty(),
createdAtMs = note.createdAt.toLong() * 1_000L,
geohash = note.geohash.lowercase(),
urgent = note.isUrgent,
expiresAtMs = note.expiresAt?.toLong()?.times(1_000L),
source = NoticeSource.NOSTR,
nostrNote = note
)
}
.toList()
return (boardNotices + relayNotices).sortedWith(
compareByDescending<UnifiedNotice> { it.urgent }
.thenByDescending { it.createdAtMs }
)
}
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
}

View File

@ -2,6 +2,7 @@ package com.bitchat.android.mesh
import android.content.Context
import android.util.Log
import com.bitchat.android.board.transportSenderID
import com.bitchat.android.crypto.EncryptionService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.AuthenticatedPeerState
@ -115,6 +116,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
private val securityManager = SecurityManager(encryptionService, myPeerID)
private val storeForwardManager = StoreForwardManager()
private val boardStore = com.bitchat.android.board.BoardStore.getInstance(context)
private val messageHandler = MessageHandler(myPeerID, context.applicationContext)
internal val connectionManager = BluetoothConnectionManager(context, myPeerID, fragmentManager) // Made internal for access
private val packetProcessor = PacketProcessor(myPeerID)
@ -177,6 +179,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
} catch (_: Exception) { 0.01 }
}
)
gossipSyncManager.boardPacketsProvider = boardStore::syncCandidates
com.bitchat.android.service.MeshServiceHolder.setGossipManager(gossipSyncManager) { packet ->
signPacketBeforeBroadcast(packet)
@ -700,6 +703,12 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
val req = RequestSyncPacket.decode(routed.packet.payload) ?: return
gossipSyncManager.handleRequestSync(fromPeer, req)
}
override fun handleBoardPost(routed: RoutedPacket): Boolean {
val wire = com.bitchat.android.board.BoardWireCodec.decode(routed.packet.payload)
?: return false
return boardStore.ingestRemoteForRelay(wire, routed.packet)
}
}
// BluetoothConnectionManager delegates
@ -923,6 +932,33 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
}
fun sendBoardPayload(payload: ByteArray) {
val wire = com.bitchat.android.board.BoardWireCodec.decode(payload) ?: return
if (!wire.verifySignature()) return
serviceScope.launch {
// The inner board signature is authoritative. A stable outer
// sender/signature would re-link otherwise isolated location scopes.
val packet = BitchatPacket(
version = 1u,
type = MessageType.BOARD_POST.value,
senderID = wire.transportSenderID(),
recipientID = null,
timestamp = System.currentTimeMillis().coerceAtLeast(0).toULong(),
payload = payload,
signature = null,
ttl = MAX_TTL
)
boardStore.ingest(
wire,
packet,
com.bitchat.android.board.BoardIngestSource.LOCAL
)
broadcastRoutedPacket(RoutedPacket(packet))
}
}
/**
* Send a file over mesh as a broadcast MESSAGE (public mesh timeline/channels).
*/
@ -1918,6 +1954,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
peerManager::clearAllPeers,
peerManager::clearAllFingerprints,
courierStore::wipe,
{ boardStore.wipe() },
bridgeCourierService::stop,
gossipSyncManager::clear
)

View File

@ -2,6 +2,7 @@ package com.bitchat.android.mesh
import android.content.Context
import android.util.Log
import com.bitchat.android.board.transportSenderID
import com.bitchat.android.crypto.EncryptionService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatFilePacket
@ -110,6 +111,7 @@ class MeshCore(
}
private val securityManager = SecurityManager(encryptionService, myPeerID)
private val storeForwardManager = StoreForwardManager()
private val boardStore = com.bitchat.android.board.BoardStore.getInstance(context)
private val messageHandler = MessageHandler(myPeerID, context.applicationContext)
private val packetProcessor = PacketProcessor(myPeerID)
private data class VoiceFrameRequest(val recipientPeerID: String?, val payload: ByteArray)
@ -129,6 +131,7 @@ class MeshCore(
for (request in voiceFrameQueue) dispatchVoiceFrame(request)
}
messageHandler.packetProcessor = packetProcessor
gossipSyncManager.boardPacketsProvider = boardStore::syncCandidates
peerManager.isPeerDirectlyConnected = { peerID -> directPeers.contains(peerID) }
setupDelegates()
@ -553,6 +556,12 @@ class MeshCore(
val req = RequestSyncPacket.decode(routed.packet.payload) ?: return
gossipSyncManager.handleRequestSync(fromPeer, req)
}
override fun handleBoardPost(routed: RoutedPacket): Boolean {
val wire = com.bitchat.android.board.BoardWireCodec.decode(routed.packet.payload)
?: return false
return boardStore.ingestRemoteForRelay(wire, routed.packet)
}
}
}
@ -575,6 +584,33 @@ class MeshCore(
}
}
fun sendBoardPayload(payload: ByteArray) {
val wire = com.bitchat.android.board.BoardWireCodec.decode(payload) ?: return
if (!wire.verifySignature()) return
scope.launch {
// The inner board signature is authoritative. A stable outer
// sender/signature would re-link otherwise isolated location scopes.
val packet = BitchatPacket(
version = 1u,
type = MessageType.BOARD_POST.value,
senderID = wire.transportSenderID(),
recipientID = null,
timestamp = System.currentTimeMillis().coerceAtLeast(0).toULong(),
payload = payload,
signature = null,
ttl = maxTtl
)
boardStore.ingest(
wire,
packet,
com.bitchat.android.board.BoardIngestSource.LOCAL
)
dispatchGlobal(RoutedPacket(packet))
}
}
private fun sendAuthenticatedPeerState(
peerID: String,
state: AuthenticatedPeerState,
@ -1099,6 +1135,7 @@ class MeshCore(
peerManager.clearAllPeers()
peerManager.clearAllFingerprints()
try { gossipSyncManager.clear() } catch (_: Exception) { }
boardStore.wipe()
}
fun clearAllEncryptionData() {

View File

@ -25,6 +25,7 @@ interface MeshService {
fun sendFileBroadcast(file: BitchatFilePacket)
fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket)
fun sendVoiceFrame(recipientPeerID: String?, payload: ByteArray)
fun sendBoardPayload(payload: ByteArray) {}
fun prepareFilePrivate(
recipientPeerID: String,
file: BitchatFilePacket,

View File

@ -154,6 +154,7 @@ class PacketProcessor(private val myPeerID: String) {
MessageType.FILE_TRANSFER -> handleMessage(routed) // treat same routing path; parsing happens in handler
MessageType.VOICE_FRAME -> validPacket = delegate?.handleVoiceFrame(routed) ?: false
MessageType.GROUP_MESSAGE -> handleGroupMessage(routed)
MessageType.BOARD_POST -> validPacket = handleBoardPost(routed)
MessageType.LEAVE -> handleLeave(routed)
MessageType.FRAGMENT -> handleFragment(routed)
MessageType.REQUEST_SYNC -> handleRequestSync(routed)
@ -250,8 +251,24 @@ class PacketProcessor(private val myPeerID: String) {
* Handle REQUEST_SYNC packets (public, TTL=1)
*/
private suspend fun handleRequestSync(routed: RoutedPacket) {
val peerID = routed.peerID ?: "unknown"
if (routed.packet.ttl != com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS) {
Log.w(TAG, "Dropping non-link-local REQUEST_SYNC from ${formatPeerForLog(peerID)}")
return
}
Log.d(TAG, "Processing REQUEST_SYNC from ${formatPeerForLog(peerID)}")
delegate?.handleRequestSync(routed)
}
/**
* Board packets are self-authenticating. The delegate verifies their
* embedded Ed25519 signature and returns false for anything that must not relay.
*/
private fun handleBoardPost(routed: RoutedPacket): Boolean {
val peerID = routed.peerID ?: "unknown"
Log.d(TAG, "Processing board packet from ${formatPeerForLog(peerID)}")
return delegate?.handleBoardPost(routed) ?: false
}
/**
* Handle delivery acknowledgment
@ -327,6 +344,7 @@ interface PacketProcessorDelegate {
fun handleLeave(routed: RoutedPacket)
fun handleFragment(packet: BitchatPacket): BitchatPacket?
fun handleRequestSync(routed: RoutedPacket)
fun handleBoardPost(routed: RoutedPacket): Boolean = false
// Communication
fun sendAnnouncementToPeer(peerID: String)

View File

@ -270,7 +270,8 @@ class SecurityManager(private val encryptionService: EncryptionService, private
MessageType.FILE_TRANSFER,
MessageType.COURIER_ENVELOPE,
MessageType.VOICE_FRAME,
MessageType.LEAVE
MessageType.LEAVE,
MessageType.REQUEST_SYNC
)) {
return true
}

View File

@ -224,6 +224,15 @@ class UnifiedMeshService(
}
}
override fun sendBoardPayload(payload: ByteArray) {
when {
isBleEnabled() -> bluetooth.sendBoardPayload(payload)
else -> wifiService()?.sendBoardPayload(payload)
}
}
override fun prepareFilePrivate(
recipientPeerID: String,
file: BitchatFilePacket,

View File

@ -47,7 +47,7 @@ data class PeerCapabilities(val rawValue: Long) : Parcelable {
val NON_DESTRUCTIVE_NOISE_REPLACEMENT = PeerCapabilities(1L shl 10)
/** Capabilities implemented by this Android build. */
val LOCAL_SUPPORTED = PeerCapabilities(PRIVATE_MEDIA.rawValue or GROUPS.rawValue)
val LOCAL_SUPPORTED = PeerCapabilities(PRIVATE_MEDIA.rawValue or GROUPS.rawValue or BOARD.rawValue)
/**
* Decode the low 64 bits and ignore any future extension bytes, which

View File

@ -38,7 +38,10 @@ class LocationNotesManager private constructor() {
val pubkey: String,
val content: String,
val createdAt: Int,
val nickname: String?
val nickname: String?,
val geohash: String,
val expiresAt: Int? = null,
val isUrgent: Boolean = false
) {
/**
* Display name for the note - matches iOS exactly
@ -97,6 +100,15 @@ class LocationNotesManager private constructor() {
// Coroutine scope for background operations
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var liveLocationToken: Long? = null
init {
scope.launch {
while (isActive) {
delay(60_000)
pruneExpiredNotes()
}
}
}
private var subscribeRetryJob: Job? = null
private var initialLoadJob: Job? = null
@ -139,7 +151,7 @@ class LocationNotesManager private constructor() {
}
// Validate geohash (building-level precision: 8 chars) - matches iOS
if (!isValidBuildingGeohash(normalized)) {
if (normalized.length != 8 || !isValidGeohash(normalized)) {
Log.w(TAG, "LocationNotesManager rejected an invalid building geohash")
return
}
@ -172,8 +184,8 @@ class LocationNotesManager private constructor() {
/**
* Validate building-level geohash (precision 8) - matches iOS Geohash.isValidBuildingGeohash
*/
private fun isValidBuildingGeohash(geohash: String): Boolean {
if (geohash.length != 8) return false
private fun isValidGeohash(geohash: String): Boolean {
if (geohash.length !in 1..12) return false
val base32Chars = "0123456789bcdefghjkmnpqrstuvwxyz"
return geohash.all { it in base32Chars }
}
@ -210,7 +222,7 @@ class LocationNotesManager private constructor() {
/**
* Send a new location note
*/
fun send(content: String, nickname: String?) {
fun send(content: String, nickname: String?, expiresAt: Int? = null, urgent: Boolean = false) {
val token = LiveLocationPrivacyGate.captureToken() ?: run {
stop()
return
@ -275,7 +287,9 @@ class LocationNotesManager private constructor() {
content = trimmed,
geohash = currentGeohash,
senderIdentity = preparedIdentity,
nickname = nickname
nickname = nickname,
expiresAt = expiresAt,
urgent = urgent
)
}
if (!LiveLocationPrivacyGate.accepts(token)) return@launch
@ -286,7 +300,10 @@ class LocationNotesManager private constructor() {
pubkey = preparedEvent.pubkey,
content = trimmed,
createdAt = preparedEvent.createdAt,
nickname = nickname
nickname = nickname,
geohash = currentGeohash,
expiresAt = expiresAt,
isUrgent = urgent
)
if (!noteIDs.contains(preparedEvent.id)) {
@ -301,7 +318,7 @@ class LocationNotesManager private constructor() {
}
// CRITICAL FIX: Send to geo-specific relays (matching iOS pattern)
// iOS: dependencies.sendEvent(event, relays)
// iOS: dependencies.sendEvent(event, relays, token)
val sent = withContext(Dispatchers.IO) {
LiveLocationPrivacyGate.runIfAllowed(token) {
sendEventFunc?.invoke(preparedEvent, relays, token)
@ -443,6 +460,16 @@ class LocationNotesManager private constructor() {
// Extract nickname from tags
val nicknameTag = event.tags.firstOrNull { it.size >= 2 && it[0] == "n" }
val nickname = nicknameTag?.get(1)
val expiresAt = event.tags
.firstOrNull { it.size >= 2 && it[0].equals("expiration", ignoreCase = true) }
?.get(1)
?.toIntOrNull()
if (expiresAt != null && expiresAt <= currentEpochSeconds()) return
val urgent = event.tags.any {
it.size >= 2 &&
it[0].equals("t", ignoreCase = true) &&
it[1].equals("urgent", ignoreCase = true)
}
// Create note
val note = Note(
@ -450,7 +477,10 @@ class LocationNotesManager private constructor() {
pubkey = event.pubkey,
content = event.content,
createdAt = event.createdAt,
nickname = nickname
nickname = nickname,
geohash = eventGeohash.lowercase(),
expiresAt = expiresAt,
isUrgent = urgent
)
// Add to collection
@ -493,6 +523,89 @@ class LocationNotesManager private constructor() {
fun clearError() {
_errorMessage.value = null
}
fun isOwnNote(note: Note): Boolean {
val current = _geohash.value ?: return false
val deriveIdentity = deriveIdentityFunc ?: return false
return runCatching { deriveIdentity(current).publicKeyHex == note.pubkey }.getOrDefault(false)
}
fun delete(note: Note): Boolean {
if (!isOwnNote(note)) return false
return deleteEvent(note.id, note.geohash) {
_notes.value = _notes.value.filterNot { it.id == note.id }
}
}
/**
* Publishes the relay copy of a geohash board post without changing the
* active notes subscription. The callback lets BoardManager retract the
* copy with NIP-09 while the app remains alive.
*/
fun publishBoardBridge(
content: String,
geohash: String,
nickname: String,
expiresAtSeconds: Int,
urgent: Boolean,
onPublished: (String) -> Unit
) {
val token = LiveLocationPrivacyGate.captureToken() ?: return
val deriveIdentity = deriveIdentityFunc ?: return
val sendEvent = sendEventFunc ?: return
val relays = runCatching {
RelayDirectory.closestRelaysForGeohash(geohash, 5)
}.getOrDefault(emptyList())
if (relays.isEmpty()) return
scope.launch {
runCatching {
val identity = withContext(Dispatchers.IO) { deriveIdentity(geohash) }
val event = NostrProtocol.createGeohashTextNote(
content = content,
geohash = geohash,
senderIdentity = identity,
nickname = nickname,
expiresAt = expiresAtSeconds,
urgent = urgent
)
withContext(Dispatchers.IO) { sendEvent(event, relays, token) }
onPublished(event.id)
}.onFailure {
Log.e(TAG, "Failed to bridge board post to Nostr: ${it.message}")
}
}
}
fun deleteEvent(eventID: String, geohash: String, onDeleted: () -> Unit = {}): Boolean {
val token = LiveLocationPrivacyGate.captureToken() ?: return false
val deriveIdentity = deriveIdentityFunc ?: return false
val sendEvent = sendEventFunc ?: return false
val relays = runCatching {
RelayDirectory.closestRelaysForGeohash(geohash, 5)
}.getOrDefault(emptyList())
if (relays.isEmpty()) return false
scope.launch {
runCatching {
val identity = withContext(Dispatchers.IO) { deriveIdentity(geohash) }
val deletion = NostrProtocol.createDeleteEvent(eventID, identity)
withContext(Dispatchers.IO) { sendEvent(deletion, relays, token) }
onDeleted()
}.onFailure {
Log.e(TAG, "Failed to delete Nostr notice: ${it.message}")
}
}
return true
}
fun pruneExpiredNotes() {
val now = currentEpochSeconds()
_notes.value = _notes.value.filter { note ->
note.expiresAt?.let { it > now } ?: true
}
}
private fun currentEpochSeconds(): Int =
(System.currentTimeMillis() / 1_000L).coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
/**
* Cancel subscription and clear state

View File

@ -210,6 +210,7 @@ data class NostrEvent(
object NostrKind {
const val METADATA = 0
const val TEXT_NOTE = 1
const val DELETION = 5
const val DIRECT_MESSAGE = 14 // NIP-17 direct message (unsigned)
const val FILE_MESSAGE = 15 // NIP-17 file message (unsigned)
const val SEAL = 13 // NIP-17 sealed event

View File

@ -108,7 +108,9 @@ object NostrProtocol {
content: String,
geohash: String,
senderIdentity: NostrIdentity,
nickname: String? = null
nickname: String? = null,
expiresAt: Int? = null,
urgent: Boolean = false
): NostrEvent = withContext(Dispatchers.Default) {
val tags = mutableListOf<List<String>>()
tags.add(listOf("g", geohash))
@ -116,6 +118,12 @@ object NostrProtocol {
if (!nickname.isNullOrEmpty()) {
tags.add(listOf("n", nickname))
}
expiresAt?.let {
tags.add(listOf("expiration", it.toString()))
}
if (urgent) {
tags.add(listOf("t", "urgent"))
}
val event = NostrEvent(
pubkey = senderIdentity.publicKeyHex,
@ -128,6 +136,21 @@ object NostrProtocol {
return@withContext senderIdentity.signEvent(event)
}
/** Create a NIP-09 deletion request signed by the original event identity. */
suspend fun createDeleteEvent(
eventID: String,
senderIdentity: NostrIdentity
): NostrEvent = withContext(Dispatchers.Default) {
val event = NostrEvent(
pubkey = senderIdentity.publicKeyHex,
createdAt = (System.currentTimeMillis() / 1000).toInt(),
kind = NostrKind.DELETION,
tags = listOf(listOf("e", eventID)),
content = ""
)
senderIdentity.signEvent(event)
}
/**
* Create a geohash-scoped presence event (kind 20001)
* Has no content and no nickname, used for participant counting

View File

@ -21,6 +21,7 @@ enum class MessageType(val value: UByte) {
REQUEST_SYNC(0x21u), // GCS-based sync request
FILE_TRANSFER(0x22u), // New: File transfer packet (BLE voice notes, etc.)
VOICE_FRAME(0x29u), // Ephemeral live push-to-talk frame; never added to gossip sync
BOARD_POST(0x23u),
GROUP_MESSAGE(0x25u); // Opaque private-group ciphertext broadcast
companion object {

View File

@ -43,6 +43,15 @@ class GossipSyncManager(
private const val ARCHIVE_FILE = "gossip-public-history.bin"
}
var boardPacketsProvider: (() -> List<BitchatPacket>)? = null
private val responseTimesByPeer = ConcurrentHashMap<String, ArrayDeque<Long>>()
private val observedBoardSyncPeers = ConcurrentHashMap.newKeySet<String>()
private fun peerSupportsBoard(peerID: String): Boolean =
peerID.lowercase() in observedBoardSyncPeers ||
latestAnnouncementByPeer[peerID.lowercase()]?.second?.payload?.let {
com.bitchat.android.model.IdentityAnnouncement.decode(it)?.capabilities
?.contains(com.bitchat.android.model.PeerCapabilities.BOARD)
} == true
var delegate: Delegate? = null
// Defaults (configurable constants)
@ -100,6 +109,8 @@ class GossipSyncManager(
synchronized(fragments) { fragments.clear() }
synchronized(groupMessages) { groupMessages.clear() }
latestAnnouncementByPeer.clear()
responseTimesByPeer.clear()
observedBoardSyncPeers.clear()
archiveFile?.delete()
Log.d(TAG, "Cleared all gossip sync messages and announcements")
}
@ -206,10 +217,17 @@ class GossipSyncManager(
val signed = delegate?.signPacketForBroadcast(packet) ?: packet
delegate?.sendPacket(signed)
}
if (boardPacketsProvider != null) {
latestAnnouncementByPeer.keys.filter(::peerSupportsBoard).forEach {
sendRequestSyncToPeer(it, SyncTypeFlags.BOARD)
}
}
}
private fun sendRequestSyncToPeer(peerID: String) {
val types = SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.FRAGMENTS_AND_FILES).union(SyncTypeFlags.GROUP_MESSAGE)
private fun sendRequestSyncToPeer(peerID: String, requestedTypes: SyncTypeFlags? = null) {
val types = requestedTypes ?: SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.FRAGMENTS_AND_FILES)
.union(SyncTypeFlags.GROUP_MESSAGE)
.let { if (boardPacketsProvider != null && peerSupportsBoard(peerID)) it.union(SyncTypeFlags.BOARD) else it }
val payload = buildGcsPayload(types)
val packet = BitchatPacket(
@ -228,6 +246,10 @@ class GossipSyncManager(
fun handleRequestSync(fromPeerID: String, request: RequestSyncPacket) {
val requestedTypes = request.types ?: SyncTypeFlags.PUBLIC_MESSAGES
if (requestedTypes.contains(MessageType.BOARD_POST) && latestAnnouncementByPeer.containsKey(fromPeerID.lowercase())) {
observedBoardSyncPeers.add(fromPeerID.lowercase())
}
if (!shouldRespondTo(fromPeerID)) return
val sinceTimestamp = request.sinceTimestamp
// Decode GCS into sorted set for membership checks
val sorted = GCSFilter.decodeToSortedSet(request.p, request.m, request.data)
@ -272,6 +294,14 @@ class GossipSyncManager(
}
}
}
if (requestedTypes.contains(MessageType.BOARD_POST)) {
boardPacketsProvider?.invoke().orEmpty().take(200).forEach { packet ->
if ((sinceTimestamp == null || packet.timestamp >= sinceTimestamp) &&
!mightContain(PacketIdUtil.computeIdBytes(packet))) {
delegate?.sendPacketToPeer(fromPeerID, packet.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS))
}
}
}
val toSendFragments = synchronized(fragments) { fragments.values.toList() }
for (pkt in toSendFragments) {
val type = MessageType.fromValue(pkt.type) ?: continue
@ -284,6 +314,23 @@ class GossipSyncManager(
}
}
private fun shouldRespondTo(peerID: String): Boolean {
val now = System.currentTimeMillis()
if (responseTimesByPeer.size >= 256 && !responseTimesByPeer.containsKey(peerID)) return false
val times = responseTimesByPeer.computeIfAbsent(peerID) { ArrayDeque() }
return synchronized(times) {
while (times.isNotEmpty() && now - times.first() >= 30_000L) {
times.removeFirst()
}
if (times.size >= 8) {
false
} else {
times.addLast(now)
true
}
}
}
private fun hexStringToByteArray(hexString: String): ByteArray {
val result = ByteArray(8) { 0 }
var tempID = hexString
@ -312,6 +359,7 @@ class GossipSyncManager(
internal fun buildGcsPayload(types: SyncTypeFlags): ByteArray {
// Collect only the packet types represented by this filter.
val list = ArrayList<BitchatPacket>()
if (types.contains(MessageType.BOARD_POST)) list.addAll(boardPacketsProvider?.invoke().orEmpty().take(200))
if (types.contains(MessageType.GROUP_MESSAGE)) {
synchronized(groupMessages) { list.addAll(groupMessages.values) }
}

View File

@ -6,8 +6,9 @@ import com.bitchat.android.protocol.MessageType
@JvmInline
value class SyncTypeFlags private constructor(val rawValue: ULong) {
companion object {
private const val KNOWN_TYPE_MASK: ULong = 0x4ffu
private const val KNOWN_TYPE_MASK: ULong = 0x5ffu
val BOARD = fromMessageTypes(MessageType.BOARD_POST)
val GROUP_MESSAGE = fromMessageTypes(MessageType.GROUP_MESSAGE)
fun of(vararg types: MessageType) = fromMessageTypes(*types)
@ -46,6 +47,7 @@ value class SyncTypeFlags private constructor(val rawValue: ULong) {
MessageType.FRAGMENT -> 5
MessageType.REQUEST_SYNC -> 6
MessageType.FILE_TRANSFER -> 7
MessageType.BOARD_POST -> 8
MessageType.GROUP_MESSAGE -> 10
MessageType.COURIER_ENVELOPE,
MessageType.VOICE_FRAME -> null

View File

@ -876,9 +876,9 @@ private fun ChatDialogs(
)
}
// Location notes sheet (extracted to separate presenter)
// Unified mesh and geohash notices sheet.
if (showLocationNotesSheet) {
LocationNotesSheetPresenter(
NoticesSheetPresenter(
viewModel = viewModel,
onDismiss = onLocationNotesSheetDismiss
)

View File

@ -2,6 +2,7 @@ package com.bitchat.android.ui
import android.app.Application
import android.util.Log
import com.bitchat.android.R
import androidx.core.app.NotificationManagerCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
@ -52,6 +53,10 @@ import com.bitchat.android.groups.GroupPeerIdentity
import com.bitchat.android.groups.GroupStore
import com.bitchat.android.groups.PeerGroupCapability
import com.bitchat.android.board.BoardManager
import com.bitchat.android.board.BoardSigningIdentity
import com.bitchat.android.board.BoardStore
private data class ConversationLiveIdentityState(
val connectedPeerIDs: List<String>,
val peerNicknames: Map<String, String>,
@ -192,6 +197,49 @@ class ChatViewModel(
privateChatManager,
viewModelScope
)
val boardManager = BoardManager(
store = BoardStore.getInstance(application.applicationContext),
scope = viewModelScope,
meshProvider = { mesh },
geoIdentityProvider = { geohash ->
runCatching {
NostrIdentityBridge.deriveIdentity(
forGeohash = geohash,
context = application.applicationContext
)
}.getOrNull()?.let { identity ->
BoardSigningIdentity.fromNostrPrivateKeyHex(identity.privateKeyHex)
}
},
onUrgentPosts = { geohash, posts ->
val text = if (posts.size == 1) {
val post = posts.single()
application.getString(
R.string.notices_alert_urgent_single,
post.authorNickname.trim().ifEmpty { "anon" },
post.content.truncateNoticeAlert()
)
} else {
application.getString(
R.string.notices_alert_urgent_collapsed,
posts.size
)
}
if (geohash.isEmpty()) {
messageManager.addSystemMessage(text)
} else {
messageManager.addChannelMessage(
"geo:$geohash",
BitchatMessage(
sender = "system",
content = text,
timestamp = Date(),
isRelay = false
)
)
}
}
)
private val notificationManager = NotificationManager(
application.applicationContext,
NotificationManagerCompat.from(application.applicationContext)
@ -1671,6 +1719,7 @@ class ChatViewModel(
val groupsCleared = groupStore.wipe()
dataManager.clearAllData()
conversationListPreferences.clearAll()
boardManager.clearTransientState()
// Clear seen message store and MessageRouter outbox
try {
@ -1925,4 +1974,6 @@ class ChatViewModel(
fun peerIdentityForNostrPubkey(pubkeyHex: String): PeerIdentity =
geohashViewModel.peerIdentityForNostrPubkey(pubkeyHex)
private fun String.truncateNoticeAlert(): String =
if (length <= 120) this else take(120) + "…"
}

View File

@ -1,31 +1,24 @@
package com.bitchat.android.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.outlined.PushPin
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.bitchat.android.R
import com.bitchat.android.geohash.ChannelID
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.nostr.LocationNotesManager
/**
* Location Notes button for MainHeader.
* Mesh-only with location authorized. Tor health tints the glyph with muted colours
* and a slow glow while connecting (via [rememberTorConnectionVisual]).
* Unified notices button for both mesh and geohash timelines.
*/
@Composable
fun LocationNotesButton(
@ -33,39 +26,29 @@ fun LocationNotesButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val colorScheme = MaterialTheme.colorScheme
val context = LocalContext.current
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val locationManager = remember { LocationChannelManager.getInstance(context) }
val permissionState by locationManager.permissionState.collectAsStateWithLifecycle()
val locationServicesEnabled by locationManager.effectiveLocationEnabled.collectAsStateWithLifecycle(false)
val boardPosts by viewModel.boardManager.posts.collectAsStateWithLifecycle()
val unseenScopes by viewModel.boardManager.unseenScopes.collectAsStateWithLifecycle()
val currentScope = when (val selected = selectedLocationChannel) {
is ChannelID.Location -> selected.channel.geohash
else -> ""
}
val hasNotices = boardPosts.any { it.geohash == currentScope }
val hasUnseen = currentScope in unseenScopes
val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED
val locationEnabled = locationPermissionGranted && locationServicesEnabled
val notesManager = remember { LocationNotesManager.getInstance() }
val notes by notesManager.notes.collectAsStateWithLifecycle()
val notesCount = notes.size
// Keep the header quiet until there is at least one nearby note worth opening.
if (selectedLocationChannel is ChannelID.Mesh && locationEnabled && notesCount > 0) {
val contentDescription = stringResource(R.string.cd_location_notes)
val torVisual = rememberTorConnectionVisual(normal = colorScheme.primary)
Box(
modifier = modifier
.size(44.dp)
.clip(CircleShape)
.pressScaleClickable(onClick = onClick, onClickLabel = contentDescription),
contentAlignment = Alignment.Center
) {
TorAwareHeaderIcon(
painter = painterResource(R.drawable.ic_spec_chat_bubbles),
tint = torVisual.tint,
isProgress = torVisual.isProgress,
contentDescription = contentDescription
)
}
IconButton(
onClick = onClick,
modifier = modifier.size(48.dp)
) {
Icon(
imageVector = if (hasUnseen) Icons.Filled.PushPin else Icons.Outlined.PushPin,
contentDescription = stringResource(R.string.cd_notices),
modifier = Modifier.size(19.dp),
tint = when {
hasUnseen -> MaterialTheme.colorScheme.primary
hasNotices -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
)
}
}

View File

@ -0,0 +1,11 @@
package com.bitchat.android.ui
internal object NoticeComposerPolicy {
private val finiteExpiryDays = listOf(1, 3, 7)
fun expiryOptions(isGeo: Boolean): List<Int> =
if (isGeo) listOf(0) + finiteExpiryDays else finiteExpiryDays
fun isPermanentRelayOnlyGeo(isGeo: Boolean, expiryDays: Int): Boolean =
isGeo && expiryDays == 0
}

View File

@ -0,0 +1,548 @@
package com.bitchat.android.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
import com.bitchat.android.board.BoardPostPacket
import com.bitchat.android.board.NoticeSource
import com.bitchat.android.board.UnifiedNotice
import com.bitchat.android.board.UnifiedNotices
import com.bitchat.android.core.ui.component.sheet.BitchatBottomSheet
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTitle
import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
import com.bitchat.android.geohash.ChannelID
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.nostr.LocationNotesManager
import java.text.DateFormat
import java.util.Date
private enum class NoticesTab {
GEO,
MESH
}
@Composable
fun NoticesSheetPresenter(
viewModel: ChatViewModel,
onDismiss: () -> Unit
) {
val context = LocalContext.current
val locationManager = remember { LocationChannelManager.getInstance(context) }
val availableChannels by locationManager.availableChannels.collectAsStateWithLifecycle()
val selectedChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
val selectedGeohash = (selectedChannel as? ChannelID.Location)?.channel?.geohash
val buildingGeohash = availableChannels
.firstOrNull { it.level == GeohashChannelLevel.BUILDING }
?.geohash
LaunchedEffect(Unit) {
locationManager.refreshChannels()
}
NoticesSheet(
viewModel = viewModel,
geoGeohash = selectedGeohash ?: buildingGeohash,
startOnGeo = selectedGeohash != null,
onEnableLocation = {
locationManager.enableLocationServices()
locationManager.enableLocationChannels()
locationManager.refreshChannels()
},
onDismiss = onDismiss
)
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun NoticesSheet(
viewModel: ChatViewModel,
geoGeohash: String?,
startOnGeo: Boolean,
onEnableLocation: () -> Unit,
onDismiss: () -> Unit
) {
val boardPosts by viewModel.boardManager.posts.collectAsStateWithLifecycle()
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
val notesManager = remember { LocationNotesManager.getInstance() }
val relayNotes by notesManager.notes.collectAsStateWithLifecycle()
var selectedTab by remember {
mutableStateOf(if (startOnGeo) NoticesTab.GEO else NoticesTab.MESH)
}
var draft by remember { mutableStateOf("") }
var urgent by remember { mutableStateOf(false) }
var expiryDays by remember { mutableIntStateOf(if (startOnGeo) 0 else 7) }
var sendFailed by remember { mutableStateOf(false) }
val scope = if (selectedTab == NoticesTab.GEO) geoGeohash.orEmpty() else ""
val notices = remember(scope, boardPosts, relayNotes) {
UnifiedNotices.merge(
geohash = scope,
boardPosts = boardPosts,
relayNotes = if (scope.isEmpty()) emptyList() else relayNotes
)
}
val geoCount = remember(geoGeohash, boardPosts, relayNotes) {
geoGeohash?.let { UnifiedNotices.merge(it, boardPosts, relayNotes).size } ?: 0
}
val meshCount = remember(boardPosts) {
boardPosts.count { it.geohash.isEmpty() }
}
LaunchedEffect(selectedTab, geoGeohash) {
if (selectedTab == NoticesTab.GEO && geoGeohash != null) {
if (notesManager.geohash.value == geoGeohash.lowercase() &&
notesManager.state.value == LocationNotesManager.State.IDLE
) {
notesManager.refresh()
} else {
notesManager.setGeohash(geoGeohash)
}
} else {
notesManager.cancel()
}
expiryDays = if (selectedTab == NoticesTab.GEO) 0 else 7
urgent = false
viewModel.boardManager.markSeen(setOf(scope))
}
LaunchedEffect(geoGeohash) {
viewModel.boardManager.markSeen(
buildSet {
add("")
geoGeohash?.let(::add)
}
)
}
DisposableEffect(Unit) {
onDispose { notesManager.cancel() }
}
BitchatBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.9f)
) {
BitchatSheetTopBar(
onClose = onDismiss,
title = {
BitchatSheetTitle(
text = if (scope.isEmpty() && selectedTab == NoticesTab.GEO) {
stringResource(R.string.notices_title)
} else {
"${stringResource(R.string.notices_title)} @ #" +
if (scope.isEmpty()) "mesh" else scope
}
)
}
)
PrimaryTabRow(selectedTabIndex = selectedTab.ordinal) {
Tab(
selected = selectedTab == NoticesTab.GEO,
onClick = { selectedTab = NoticesTab.GEO },
text = {
Text(stringResource(R.string.notices_tab_geo_count, geoCount))
}
)
Tab(
selected = selectedTab == NoticesTab.MESH,
onClick = { selectedTab = NoticesTab.MESH },
text = {
Text(stringResource(R.string.notices_tab_mesh_count, meshCount))
}
)
}
if (selectedTab == NoticesTab.GEO && geoGeohash == null) {
LocationUnavailable(
onEnableLocation = onEnableLocation,
modifier = Modifier.weight(1f)
)
} else {
NoticesContent(
notices = notices,
scope = scope,
isGeo = selectedTab == NoticesTab.GEO,
showsSource = selectedTab == NoticesTab.GEO,
viewModel = viewModel,
notesManager = notesManager,
modifier = Modifier.weight(1f)
)
}
if (selectedTab == NoticesTab.MESH || geoGeohash != null) {
HorizontalDivider()
Composer(
draft = draft,
onDraftChange = {
if (it.toByteArray(Charsets.UTF_8).size <= 512) {
draft = it
sendFailed = false
}
},
isGeo = selectedTab == NoticesTab.GEO,
urgent = urgent,
onUrgentChange = { urgent = it },
expiryDays = expiryDays,
onExpiryChange = { expiryDays = it },
enabled = true,
sendFailed = sendFailed,
onSend = {
if (NoticeComposerPolicy.isPermanentRelayOnlyGeo(
isGeo = selectedTab == NoticesTab.GEO,
expiryDays = expiryDays
)
) {
notesManager.send(
content = draft,
nickname = nickname,
expiresAt = null
)
draft = ""
sendFailed = false
} else {
val sent = viewModel.boardManager.createPost(
content = draft,
geohash = scope,
nickname = nickname,
urgent = selectedTab == NoticesTab.MESH && urgent,
expiryDays = expiryDays
)
if (sent) draft = "" else sendFailed = true
}
}
)
}
}
}
}
@Composable
private fun NoticesContent(
notices: List<UnifiedNotice>,
scope: String,
isGeo: Boolean,
showsSource: Boolean,
viewModel: ChatViewModel,
notesManager: LocationNotesManager,
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(18.dp)
) {
item(key = "description") {
Column {
Text(
text = if (isGeo) {
stringResource(R.string.location_notes_description)
} else {
stringResource(R.string.notices_description_mesh)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (scope.isNotEmpty()) {
Spacer(Modifier.height(6.dp))
Text(
text = "#$scope",
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary
)
}
}
}
if (notices.isEmpty()) {
item(key = "empty") {
Column(modifier = Modifier.padding(vertical = 32.dp)) {
Text(
text = stringResource(R.string.location_notes_empty_title),
fontWeight = FontWeight.SemiBold
)
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(R.string.location_notes_empty_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else {
items(notices, key = { it.id }) { notice ->
val ownBoard = notice.boardPost?.let(viewModel.boardManager::isOwnPost) == true
val ownRelay = notice.nostrNote?.let(notesManager::isOwnNote) == true
NoticeRow(
notice = notice,
showsSource = showsSource,
canDelete = ownBoard || ownRelay,
onDelete = {
notice.boardPost?.let(viewModel.boardManager::deletePost)
?: notice.nostrNote?.let(notesManager::delete)
}
)
}
}
}
}
@Composable
private fun NoticeRow(
notice: UnifiedNotice,
showsSource: Boolean,
canDelete: Boolean,
onDelete: () -> Unit
) {
val boardAuthor = notice.boardPost?.let { post ->
post.authorNickname.trim().ifEmpty {
"anon#${post.authorSigningKey.takeLast(2).toByteArray().toHex()}"
}
}
val relayAuthor = notice.nostrNote?.displayName
val author = boardAuthor ?: relayAuthor ?: notice.nickname.ifEmpty { "anon" }
val time = remember(notice.createdAtMs) {
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT)
.format(Date(notice.createdAtMs))
}
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
if (notice.urgent) {
Icon(
imageVector = Icons.Filled.PushPin,
contentDescription = stringResource(R.string.notices_urgent),
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(6.dp))
}
Text(
text = "@$author",
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f)
)
if (showsSource) {
Text(
text = if (notice.source == NoticeSource.MESH) {
stringResource(R.string.notices_source_mesh)
} else {
stringResource(R.string.notices_source_network)
},
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
color = MaterialTheme.colorScheme.primary
)
}
if (canDelete) {
IconButton(onClick = onDelete, modifier = Modifier.size(48.dp)) {
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = stringResource(R.string.notices_delete),
modifier = Modifier.size(18.dp)
)
}
}
}
Text(
text = notice.content,
style = MaterialTheme.typography.bodyMedium
)
Spacer(Modifier.height(4.dp))
Text(
text = notice.expiresAtMs?.let {
"$time · ${stringResource(R.string.notices_fades, relativeExpiry(it))}"
} ?: time,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun LocationUnavailable(
onEnableLocation: () -> Unit,
modifier: Modifier = Modifier
) {
Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(24.dp)
) {
Text(
text = stringResource(R.string.notices_location_unavailable),
style = MaterialTheme.typography.titleMedium
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.notices_location_unavailable_desc),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(20.dp))
Button(onClick = onEnableLocation) {
Text(stringResource(R.string.notices_enable_location))
}
}
}
}
@Composable
private fun Composer(
draft: String,
onDraftChange: (String) -> Unit,
isGeo: Boolean,
urgent: Boolean,
onUrgentChange: (Boolean) -> Unit,
expiryDays: Int,
onExpiryChange: (Int) -> Unit,
enabled: Boolean,
sendFailed: Boolean,
onSend: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
if (!isGeo) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.notices_urgent),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.weight(1f)
)
Switch(checked = urgent, onCheckedChange = onUrgentChange)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.notices_expiry),
style = MaterialTheme.typography.labelMedium
)
NoticeComposerPolicy.expiryOptions(isGeo).forEach { days ->
val optionDescription = if (days == 0) {
stringResource(R.string.notices_permanent)
} else {
stringResource(R.string.notices_days, days)
}
FilterChip(
selected = expiryDays == days,
onClick = { onExpiryChange(days) },
modifier = Modifier.semantics {
contentDescription = optionDescription
},
label = {
Text(if (days == 0) "∞" else "${days}d")
}
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = draft,
onValueChange = onDraftChange,
enabled = enabled,
placeholder = { Text(stringResource(R.string.notices_placeholder)) },
supportingText = if (sendFailed) {
{ Text(stringResource(R.string.notices_send_failed)) }
} else {
null
},
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
modifier = Modifier.weight(1f),
maxLines = 4
)
Spacer(Modifier.width(8.dp))
IconButton(
onClick = onSend,
enabled = enabled && draft.isNotBlank(),
modifier = Modifier.size(48.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(R.string.notices_post)
)
}
}
}
}
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
private fun relativeExpiry(expiresAtMs: Long): String {
val remainingMs = (expiresAtMs - System.currentTimeMillis()).coerceAtLeast(0)
val hours = remainingMs / 3_600_000L
return if (hours >= 24) "${hours / 24}d" else "${hours}h"
}

View File

@ -1426,6 +1426,12 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
meshCore.sendFileBroadcast(file)
}
override fun sendBoardPayload(payload: ByteArray) {
meshCore.sendBoardPayload(payload)
}
/**
* Sends a file privately to a specific peer. If no Noise session is established,
* a handshake will be initiated and the send is deferred/aborted for now.

View File

@ -385,6 +385,20 @@
<string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_notices">إعلانات</string>
<string name="notices_title">إعلانات</string>
<string name="notices_tab_geo_count">جغرافي · %1$d</string>
<string name="notices_tab_mesh_count">mesh · %1$d</string>
<string name="notices_description_mesh">ثبّت إعلانات قصيرة لمن حولك. تنتقل من هاتف إلى هاتف حتى دون اتصال، وتختفي وحدها بعد أيام قليلة.</string>
<string name="notices_permanent">دائم</string>
<string name="notices_fades">يتلاشى %1$s</string>
<string name="notices_source_mesh">mesh</string>
<string name="notices_source_network">نت</string>
<string name="notices_location_unavailable">الموقع غير متاح</string>
<string name="notices_enable_location">تفعيل الموقع</string>
<string name="notices_alert_urgent_single">📌 إعلان عاجل من @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d إعلانات عاجلة جديدة — اضغط على الدبوس للعرض</string>
<string name="cd_open_about">فتح قسم حول</string>
<string name="nearby_notes_reveal">تحقّق من الملاحظات المتروكة هنا</string>
<string name="nearby_notes_one">تُركت ملاحظة واحدة هنا — انقر للقراءة</string>

View File

@ -372,6 +372,20 @@
<string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_notices">নোটিশ</string>
<string name="notices_title">নোটিশ</string>
<string name="notices_tab_geo_count">এলাকা · %1$d</string>
<string name="notices_tab_mesh_count">mesh · %1$d</string>
<string name="notices_description_mesh">আশেপাশের মানুষের জন্য ছোট নোটিশ পিন করুন। অফলাইনেও ফোন থেকে ফোনে পৌঁছে যায় আর কয়েক দিন পরে নিজে থেকেই মুছে যায়।</string>
<string name="notices_permanent">স্থায়ী</string>
<string name="notices_fades">%1$s মুছে যাবে</string>
<string name="notices_source_mesh">mesh</string>
<string name="notices_source_network">নেট</string>
<string name="notices_location_unavailable">অবস্থান অনুপলব্ধ</string>
<string name="notices_enable_location">লোকেশন চালু করুন</string>
<string name="notices_alert_urgent_single">📌 @%1$s-এর জরুরি নোটিশ: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$dটি নতুন জরুরি নোটিশ — দেখতে পিনে ট্যাপ করুন</string>
<string name="cd_open_about">পরিচিতি খুলুন</string>
<string name="nearby_notes_reveal">এখানে রাখা নোট আছে কি না দেখুন</string>
<string name="nearby_notes_one">এখানে 1টি নোট রাখা আছে — পড়তে ট্যাপ করুন</string>

View File

@ -386,6 +386,20 @@
<string name="verify_success_title">Verifiziert</string>
<string name="verify_success_body">Du hast %1$s verifiziert</string>
<string name="verify_success_system_message">verifiziert %1$s</string>
<string name="cd_notices">hinweise</string>
<string name="notices_title">hinweise</string>
<string name="notices_tab_geo_count">geo · %1$d</string>
<string name="notices_tab_mesh_count">mesh · %1$d</string>
<string name="notices_description_mesh">hefte kurze hinweise für leute in deiner nähe an. sie springen von handy zu handy, auch offline, und verschwinden nach ein paar tagen von selbst.</string>
<string name="notices_permanent">dauerhaft</string>
<string name="notices_fades">verblasst %1$s</string>
<string name="notices_source_mesh">mesh</string>
<string name="notices_source_network">netz</string>
<string name="notices_location_unavailable">standort nicht verfügbar</string>
<string name="notices_enable_location">standort aktivieren</string>
<string name="notices_alert_urgent_single">📌 dringender hinweis von @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d neue dringende hinweise — tippe zum ansehen auf den pin</string>
<string name="cd_open_about">Info öffnen</string>
<string name="nearby_notes_reveal">nachsehen, ob hier notizen hinterlassen wurden</string>
<string name="nearby_notes_one">1 notiz hier hinterlassen — tippen zum lesen</string>

View File

@ -385,6 +385,20 @@
<string name="verify_success_title">Verificado</string>
<string name="verify_success_body">Verificaste a %1$s</string>
<string name="verify_success_system_message">verificado %1$s</string>
<string name="cd_notices">avisos</string>
<string name="notices_title">avisos</string>
<string name="notices_tab_geo_count">geo · %1$d</string>
<string name="notices_tab_mesh_count">mesh · %1$d</string>
<string name="notices_description_mesh">fija avisos cortos para la gente cercana. saltan de teléfono a teléfono, incluso sin conexión, y desaparecen solos después de unos días.</string>
<string name="notices_permanent">permanente</string>
<string name="notices_fades">se desvanece %1$s</string>
<string name="notices_source_mesh">mesh</string>
<string name="notices_source_network">red</string>
<string name="notices_location_unavailable">ubicación no disponible</string>
<string name="notices_enable_location">activar ubicación</string>
<string name="notices_alert_urgent_single">📌 aviso urgente de @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d avisos urgentes nuevos — toca el pin para verlos</string>
<string name="cd_open_about">Abrir Acerca de</string>
<string name="nearby_notes_reveal">buscar notas dejadas aquí</string>
<string name="nearby_notes_one">1 nota dejada aquí — toca para leer</string>

View File

@ -372,6 +372,20 @@
<string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_notices">اطلاعیه‌ها</string>
<string name="notices_title">اطلاعیه‌ها</string>
<string name="notices_tab_geo_count">ژئو · %1$d</string>
<string name="notices_tab_mesh_count">مش · %1$d</string>
<string name="notices_description_mesh">اطلاعیه‌های کوتاه برای اطرافیان‌تان سنجاق کنید. گوشی به گوشی جابه‌جا می‌شوند، حتی آفلاین، و بعد از چند روز خودبه‌خود ناپدید می‌شوند.</string>
<string name="notices_permanent">دائمی</string>
<string name="notices_fades">%1$s محو می‌شود</string>
<string name="notices_source_mesh">مش</string>
<string name="notices_source_network">نت</string>
<string name="notices_location_unavailable">موقعیت در دسترس نیست</string>
<string name="notices_enable_location">فعال‌سازی موقعیت</string>
<string name="notices_alert_urgent_single">📌 اطلاعیهٔ فوری از @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d اطلاعیهٔ فوری جدید — برای مشاهده روی سنجاق بزنید</string>
<string name="cd_open_about">باز کردن درباره</string>
<string name="nearby_notes_reveal">یادداشت‌های باقی‌مانده در اینجا را بررسی کنید</string>
<string name="nearby_notes_one">۱ یادداشت اینجا باقی مانده — برای خواندن ضربه بزنید</string>

View File

@ -383,6 +383,20 @@
<string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_notices">mga paunawa</string>
<string name="notices_title">mga paunawa</string>
<string name="notices_tab_geo_count">geo · %1$d</string>
<string name="notices_tab_mesh_count">mesh · %1$d</string>
<string name="notices_description_mesh">mag-pin ng maiikling paunawa para sa mga taong nasa paligid mo. lumilipat ito mula sa isang telepono patungo sa iba, kahit offline, at kusang nawawala pagkatapos ng ilang araw.</string>
<string name="notices_permanent">permanente</string>
<string name="notices_fades">maglalaho %1$s</string>
<string name="notices_source_mesh">mesh</string>
<string name="notices_source_network">net</string>
<string name="notices_location_unavailable">walang lokasyon</string>
<string name="notices_enable_location">i-on ang lokasyon</string>
<string name="notices_alert_urgent_single">📌 agarang paunawa mula kay @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d bagong agarang paunawa — i-tap ang pin para makita</string>
<string name="cd_open_about">Buksan ang Tungkol</string>
<string name="nearby_notes_reveal">tingnan kung may mga note na naiwan dito</string>
<string name="nearby_notes_one">1 note ang naiwan dito — i-tap para basahin</string>

View File

@ -397,6 +397,20 @@
<string name="verify_success_title">Vérifié</string>
<string name="verify_success_body">Vous avez vérifié %1$s</string>
<string name="verify_success_system_message">vérifié %1$s</string>
<string name="cd_notices">annonces</string>
<string name="notices_title">annonces</string>
<string name="notices_tab_geo_count">géo · %1$d</string>
<string name="notices_tab_mesh_count">mesh · %1$d</string>
<string name="notices_description_mesh">épingle de courtes annonces pour les gens autour de toi. elles passent de téléphone en téléphone, même hors ligne, et disparaissent d\'elles-mêmes après quelques jours.</string>
<string name="notices_permanent">permanent</string>
<string name="notices_fades">s\'efface %1$s</string>
<string name="notices_source_mesh">mesh</string>
<string name="notices_source_network">net</string>
<string name="notices_location_unavailable">localisation indisponible</string>
<string name="notices_enable_location">activer la localisation</string>
<string name="notices_alert_urgent_single">📌 annonce urgente de @%1$s : %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d nouvelles annonces urgentes — touche l\'épingle pour voir</string>
<string name="cd_open_about">Ouvrir À propos</string>
<string name="nearby_notes_reveal">vérifier s\'il y a des notes laissées ici</string>
<string name="nearby_notes_one">1 note laissée ici — appuyez pour lire</string>

View File

@ -392,4 +392,21 @@
<string name="about_app_language">שפת האפליקציה</string>
<string name="about_system_default">ברירת המחדל של המערכת</string>
<string name="about_select_language">בחירת שפה</string>
<string name="cd_notices">מודעות</string>
<string name="notices_title">מודעות</string>
<string name="notices_tab_geo_count">אזור · %1$d</string>
<string name="notices_tab_mesh_count">mesh · %1$d</string>
<string name="notices_description_mesh">הצמד מודעות קצרות לאנשים סביבך. הן עוברות מטלפון לטלפון, גם בלי אינטרנט, ונעלמות מעצמן אחרי כמה ימים.</string>
<string name="notices_permanent">קבוע</string>
<string name="notices_fades">דוהה %1$s</string>
<string name="notices_source_mesh">mesh</string>
<string name="notices_source_network">רשת</string>
<string name="notices_location_unavailable">המיקום לא זמין</string>
<string name="notices_enable_location">הפעל מיקום</string>
<string name="notices_alert_urgent_single">📌 מודעה דחופה מאת @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d מודעות דחופות חדשות — הקש על הנעץ לצפייה</string>
<string name="cd_open_about">פתיחת אודות</string>
<string name="nearby_notes_reveal">בדיקה אם הושארו כאן פתקים</string>
<string name="nearby_notes_one">פתק אחד הושאר כאן — הקש לקריאה</string>
<string name="nearby_notes_many">%d פתקים הושארו כאן — הקש לקריאה</string>
</resources>

View File

@ -385,6 +385,20 @@
<string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_notices">सूचनाएँ</string>
<string name="notices_title">सूचनाएँ</string>
<string name="notices_tab_geo_count">क्षेत्र · %1$d</string>
<string name="notices_tab_mesh_count">mesh · %1$d</string>
<string name="notices_description_mesh">आस-पास के लोगों के लिए छोटी सूचनाएँ पिन करें। ये ऑफ़लाइन भी फ़ोन से फ़ोन तक पहुँचती हैं और कुछ दिनों बाद अपने आप मिट जाती हैं।</string>
<string name="notices_permanent">स्थायी</string>
<string name="notices_fades">%1$s मिट जाएगा</string>
<string name="notices_source_mesh">mesh</string>
<string name="notices_source_network">नेट</string>
<string name="notices_location_unavailable">लोकेशन उपलब्ध नहीं</string>
<string name="notices_enable_location">लोकेशन सक्षम करें</string>
<string name="notices_alert_urgent_single">📌 @%1$s की ज़रूरी सूचना: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d नई ज़रूरी सूचनाएँ — देखने के लिए पिन टैप करें</string>
<string name="cd_open_about">परिचय खोलें</string>
<string name="nearby_notes_reveal">देखें कि यहाँ नोट छोड़े गए हैं या नहीं</string>
<string name="nearby_notes_one">यहाँ 1 नोट छोड़ा गया है — पढ़ने के लिए टैप करें</string>

View File

@ -385,6 +385,20 @@
<string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_notices">pengumuman</string>
<string name="notices_title">pengumuman</string>
<string name="notices_tab_geo_count">area · %1$d</string>
<string name="notices_tab_mesh_count">mesh · %1$d</string>
<string name="notices_description_mesh">sematkan pengumuman singkat untuk orang di sekitarmu. berpindah dari ponsel ke ponsel, bahkan saat offline, dan hilang sendiri setelah beberapa hari.</string>
<string name="notices_permanent">permanen</string>
<string name="notices_fades">memudar %1$s</string>
<string name="notices_source_mesh">mesh</string>
<string name="notices_source_network">net</string>
<string name="notices_location_unavailable">lokasi tidak tersedia</string>
<string name="notices_enable_location">aktifkan lokasi</string>
<string name="notices_alert_urgent_single">📌 pengumuman mendesak dari @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d pengumuman mendesak baru — ketuk pin untuk melihat</string>
<string name="cd_open_about">Buka Tentang</string>
<string name="nearby_notes_reveal">periksa catatan yang ditinggalkan di sini</string>
<string name="nearby_notes_one">1 catatan ditinggalkan di sini — ketuk untuk membaca</string>

View File

@ -412,6 +412,20 @@
<string name="verify_success_title">Verificato</string>
<string name="verify_success_body">Hai verificato %1$s</string>
<string name="verify_success_system_message">verificato %1$s</string>
<string name="cd_notices">avvisi</string>
<string name="notices_title">avvisi</string>
<string name="notices_tab_geo_count">geo · %1$d</string>
<string name="notices_tab_mesh_count">mesh · %1$d</string>
<string name="notices_description_mesh">appunta brevi avvisi per chi ti sta intorno. passano da telefono a telefono, anche offline, e spariscono da soli dopo qualche giorno.</string>
<string name="notices_permanent">permanente</string>
<string name="notices_fades">svanisce %1$s</string>
<string name="notices_source_mesh">mesh</string>
<string name="notices_source_network">rete</string>
<string name="notices_location_unavailable">posizione non disponibile</string>
<string name="notices_enable_location">attiva posizione</string>
<string name="notices_alert_urgent_single">📌 avviso urgente da @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d nuovi avvisi urgenti — tocca la puntina per vederli</string>
<string name="cd_open_about">Apri Informazioni</string>
<string name="nearby_notes_reveal">controlla se ci sono note lasciate qui</string>
<string name="nearby_notes_one">1 nota lasciata qui — tocca per leggere</string>

View File

@ -385,6 +385,20 @@
<string name="verify_success_title">検証済み</string>
<string name="verify_success_body">%1$s を検証しました</string>
<string name="verify_success_system_message">%1$s を検証しました</string>
<string name="cd_notices">お知らせ</string>
<string name="notices_title">お知らせ</string>
<string name="notices_tab_geo_count">エリア · %1$d</string>
<string name="notices_tab_mesh_count">mesh · %1$d</string>
<string name="notices_description_mesh">近くの人に向けて短いお知らせをピン留め。オフラインでもスマホからスマホへ伝わり、数日後に自動的に消えます。</string>
<string name="notices_permanent">無期限</string>
<string name="notices_fades">%1$sに消えます</string>
<string name="notices_source_mesh">mesh</string>
<string name="notices_source_network">ネット</string>
<string name="notices_location_unavailable">位置情報を取得できません</string>
<string name="notices_enable_location">位置情報を有効化</string>
<string name="notices_alert_urgent_single">📌 @%1$sからの緊急のお知らせ: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 新しい緊急のお知らせが%1$d件 — ピンをタップして表示</string>
<string name="cd_open_about">このアプリについてを開く</string>
<string name="nearby_notes_reveal">ここに残されたメモを確認</string>
<string name="nearby_notes_one">ここに1件のメモがあります — タップして読む</string>

View File

@ -385,6 +385,20 @@
<string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_notices">공지</string>
<string name="notices_title">공지</string>
<string name="notices_tab_geo_count">지역 · %1$d</string>
<string name="notices_tab_mesh_count">mesh · %1$d</string>
<string name="notices_description_mesh">주변 사람들을 위해 짧은 공지를 고정하세요. 오프라인에서도 휴대폰에서 휴대폰으로 전달되고 며칠 후 스스로 사라집니다.</string>
<string name="notices_permanent">영구</string>
<string name="notices_fades">%1$s 사라짐</string>
<string name="notices_source_mesh">mesh</string>
<string name="notices_source_network">넷</string>
<string name="notices_location_unavailable">위치 사용 불가</string>
<string name="notices_enable_location">위치 활성화</string>
<string name="notices_alert_urgent_single">📌 @%1$s님의 긴급 공지: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 새 긴급 공지 %1$d개 — 핀을 탭하여 확인</string>
<string name="cd_open_about">정보 열기</string>
<string name="nearby_notes_reveal">여기 남겨진 쪽지 확인</string>
<string name="nearby_notes_one">여기 남겨진 쪽지 1개 — 탭하여 읽기</string>

View File

@ -433,4 +433,21 @@
<string name="about_app_language">Bahasa aplikasi</string>
<string name="about_system_default">Lalai sistem</string>
<string name="about_select_language">Pilih bahasa</string>
<string name="cd_notices">pengumuman</string>
<string name="notices_title">pengumuman</string>
<string name="notices_tab_geo_count">kawasan · %1$d</string>
<string name="notices_tab_mesh_count">mesh · %1$d</string>
<string name="notices_description_mesh">semat pengumuman ringkas untuk orang di sekeliling anda. ia berpindah dari telefon ke telefon, walaupun di luar talian, dan hilang sendiri selepas beberapa hari.</string>
<string name="notices_permanent">kekal</string>
<string name="notices_fades">pudar %1$s</string>
<string name="notices_source_mesh">mesh</string>
<string name="notices_source_network">net</string>
<string name="notices_location_unavailable">lokasi tidak tersedia</string>
<string name="notices_enable_location">aktifkan lokasi</string>
<string name="notices_alert_urgent_single">📌 pengumuman segera daripada @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d pengumuman segera baharu — ketik pin untuk melihat</string>
<string name="cd_open_about">Buka Perihal</string>
<string name="nearby_notes_reveal">semak nota yang ditinggalkan di sini</string>
<string name="nearby_notes_one">1 nota ditinggalkan di sini — ketik untuk baca</string>
<string name="nearby_notes_many">%d nota ditinggalkan di sini — ketik untuk baca</string>
</resources>

View File

@ -383,6 +383,20 @@
<string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_notices">सूचनाहरू</string>
<string name="notices_title">सूचनाहरू</string>
<string name="notices_tab_geo_count">क्षेत्र · %1$d</string>
<string name="notices_tab_mesh_count">mesh · %1$d</string>
<string name="notices_description_mesh">वरपरका मानिसहरूका लागि छोटा सूचना पिन गर। अफलाइनमा पनि फोनबाट फोनमा पुग्छन् र केही दिनपछि आफैँ हराउँछन्।</string>
<string name="notices_permanent">स्थायी</string>
<string name="notices_fades">%1$s मेटिन्छ</string>
<string name="notices_source_mesh">mesh</string>
<string name="notices_source_network">नेट</string>
<string name="notices_location_unavailable">स्थान उपलब्ध छैन</string>
<string name="notices_enable_location">स्थान सक्षम गर</string>
<string name="notices_alert_urgent_single">📌 @%1$sको जरुरी सूचना: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d नयाँ जरुरी सूचना — हेर्न पिन ट्याप गर</string>
<string name="cd_open_about">परिचय खोल्नुहोस्</string>
<string name="nearby_notes_reveal">यहाँ छोडिएका नोटहरू छन् कि हेर्नुहोस्</string>
<string name="nearby_notes_one">यहाँ 1 नोट छोडिएको छ — पढ्न ट्याप गर्नुहोस्</string>

View File

@ -410,6 +410,20 @@
<string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_notices">mededelingen</string>
<string name="notices_title">mededelingen</string>
<string name="notices_tab_geo_count">geo · %1$d</string>
<string name="notices_tab_mesh_count">mesh · %1$d</string>
<string name="notices_description_mesh">prik korte mededelingen voor mensen om je heen. ze springen van telefoon naar telefoon, ook offline, en verdwijnen vanzelf na een paar dagen.</string>
<string name="notices_permanent">permanent</string>
<string name="notices_fades">vervaagt %1$s</string>
<string name="notices_source_mesh">mesh</string>
<string name="notices_source_network">net</string>
<string name="notices_location_unavailable">locatie niet beschikbaar</string>
<string name="notices_enable_location">locatie inschakelen</string>
<string name="notices_alert_urgent_single">📌 dringende mededeling van @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d nieuwe dringende mededelingen — tik op de pin om te bekijken</string>
<string name="cd_open_about">Info openen</string>
<string name="nearby_notes_reveal">kijk of hier notities zijn achtergelaten</string>
<string name="nearby_notes_one">1 notitie hier achtergelaten — tik om te lezen</string>

View File

@ -398,4 +398,21 @@
<string name="about_app_language">Język aplikacji</string>
<string name="about_system_default">Domyślny systemu</string>
<string name="about_select_language">Wybierz język</string>
<string name="cd_notices">ogłoszenia</string>
<string name="notices_title">ogłoszenia</string>
<string name="notices_tab_geo_count">geo · %1$d</string>
<string name="notices_tab_mesh_count">mesh · %1$d</string>
<string name="notices_description_mesh">przypinaj krótkie ogłoszenia dla ludzi w pobliżu. przeskakują z telefonu na telefon, nawet offline, i same znikają po kilku dniach.</string>
<string name="notices_permanent">na stałe</string>
<string name="notices_fades">zniknie %1$s</string>
<string name="notices_source_mesh">mesh</string>
<string name="notices_source_network">sieć</string>
<string name="notices_location_unavailable">lokalizacja niedostępna</string>
<string name="notices_enable_location">włącz lokalizację</string>
<string name="notices_alert_urgent_single">📌 pilne ogłoszenie od @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d nowych pilnych ogłoszeń — dotknij pinezki, aby zobaczyć</string>
<string name="cd_open_about">Otwórz informacje</string>
<string name="nearby_notes_reveal">sprawdź, czy zostawiono tutaj notatki</string>
<string name="nearby_notes_one">1 notatka zostawiona tutaj — stuknij, aby przeczytać</string>
<string name="nearby_notes_many">%d notatek zostawionych tutaj — stuknij, aby przeczytać</string>
</resources>

View File

@ -385,6 +385,19 @@
<string name="verify_success_title">Verificado</string>
<string name="verify_success_body">Você verificou %1$s</string>
<string name="verify_success_system_message">verificou %1$s</string>
<string name="cd_notices">avisos</string>
<string name="notices_title">avisos</string>
<string name="notices_tab_geo_count">geo · %1$d</string>
<string name="notices_tab_mesh_count">mesh · %1$d</string>
<string name="notices_description_mesh">fixe avisos curtos para as pessoas por perto. eles pulam de celular em celular, mesmo offline, e somem sozinhos depois de alguns dias.</string>
<string name="notices_permanent">permanente</string>
<string name="notices_fades">desaparece %1$s</string>
<string name="notices_source_mesh">mesh</string>
<string name="notices_source_network">rede</string>
<string name="notices_location_unavailable">localização indisponível</string>
<string name="notices_enable_location">habilitar localização</string>
<string name="notices_alert_urgent_single">📌 aviso urgente de @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d avisos urgentes novos — toque no pin para ver</string>
<string name="nearby_notes_reveal">ver se há notas deixadas aqui</string>
<string name="nearby_notes_one">1 nota deixada aqui — toque para ler</string>
<string name="nearby_notes_many">%d notas deixadas aqui — toque para ler</string>

View File

@ -385,6 +385,20 @@
<string name="verify_success_title">Verificado</string>
<string name="verify_success_body">Você verificou %1$s</string>
<string name="verify_success_system_message">verificou %1$s</string>
<string name="cd_notices">avisos</string>
<string name="notices_title">avisos</string>
<string name="notices_tab_geo_count">geo · %1$d</string>
<string name="notices_tab_mesh_count">mesh · %1$d</string>
<string name="notices_description_mesh">afixa avisos curtos para quem está por perto. saltam de telemóvel em telemóvel, mesmo offline, e desaparecem sozinhos após alguns dias.</string>
<string name="notices_permanent">permanente</string>
<string name="notices_fades">desvanece %1$s</string>
<string name="notices_source_mesh">mesh</string>
<string name="notices_source_network">rede</string>
<string name="notices_location_unavailable">localização indisponível</string>
<string name="notices_enable_location">ativar localização</string>
<string name="notices_alert_urgent_single">📌 aviso urgente de @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d novos avisos urgentes — toca no pin para ver</string>
<string name="cd_open_about">Abrir Sobre</string>
<string name="nearby_notes_reveal">ver se há notas deixadas aqui</string>
<string name="nearby_notes_one">1 nota deixada aqui — toque para ler</string>

View File

@ -372,6 +372,20 @@
<string name="verify_success_title">Проверено</string>
<string name="verify_success_body">Вы проверили %1$s</string>
<string name="verify_success_system_message">проверен %1$s</string>
<string name="cd_notices">объявления</string>
<string name="notices_title">объявления</string>
<string name="notices_tab_geo_count">гео · %1$d</string>
<string name="notices_tab_mesh_count">mesh · %1$d</string>
<string name="notices_description_mesh">закрепляй короткие объявления для людей рядом. они передаются с телефона на телефон, даже офлайн, и сами исчезают через несколько дней.</string>
<string name="notices_permanent">навсегда</string>
<string name="notices_fades">исчезнет %1$s</string>
<string name="notices_source_mesh">mesh</string>
<string name="notices_source_network">сеть</string>
<string name="notices_location_unavailable">локация недоступна</string>
<string name="notices_enable_location">включить локацию</string>
<string name="notices_alert_urgent_single">📌 срочное объявление от @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d новых срочных объявлений — нажми на булавку, чтобы посмотреть</string>
<string name="cd_open_about">Открыть раздел «О приложении»</string>
<string name="nearby_notes_reveal">проверить, есть ли здесь заметки</string>
<string name="nearby_notes_one">здесь оставлена 1 заметка — нажмите, чтобы прочитать</string>

View File

@ -370,6 +370,20 @@
<string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_notices">anslag</string>
<string name="notices_title">anslag</string>
<string name="notices_tab_geo_count">geo · %1$d</string>
<string name="notices_tab_mesh_count">mesh · %1$d</string>
<string name="notices_description_mesh">nåla upp korta anslag för folk i närheten. de hoppar från telefon till telefon, även offline, och försvinner av sig själva efter några dagar.</string>
<string name="notices_permanent">permanent</string>
<string name="notices_fades">tonar bort %1$s</string>
<string name="notices_source_mesh">mesh</string>
<string name="notices_source_network">nät</string>
<string name="notices_location_unavailable">plats ej tillgänglig</string>
<string name="notices_enable_location">aktivera plats</string>
<string name="notices_alert_urgent_single">📌 brådskande anslag från @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d nya brådskande anslag — tryck på nålen för att visa</string>
<string name="cd_open_about">Öppna Om</string>
<string name="nearby_notes_reveal">kolla om anteckningar lämnats här</string>
<string name="nearby_notes_one">1 anteckning lämnad här — tryck för att läsa</string>

View File

@ -390,4 +390,21 @@
<string name="about_app_language">பயன்பாட்டு மொழி</string>
<string name="about_system_default">கணினி இயல்புநிலை</string>
<string name="about_select_language">மொழியைத் தேர்ந்தெடுக்கவும்</string>
<string name="cd_notices">அறிவிப்புகள்</string>
<string name="notices_title">அறிவிப்புகள்</string>
<string name="notices_tab_geo_count">பகுதி · %1$d</string>
<string name="notices_tab_mesh_count">mesh · %1$d</string>
<string name="notices_description_mesh">அருகிலுள்ளவர்களுக்காக சிறிய அறிவிப்புகளைப் பின் செய்யவும். ஆஃப்லைனிலும் ஃபோனிலிருந்து ஃபோனுக்குப் பரவி, சில நாட்களில் தானாக மறைந்துவிடும்.</string>
<string name="notices_permanent">நிரந்தரம்</string>
<string name="notices_fades">%1$s மறையும்</string>
<string name="notices_source_mesh">mesh</string>
<string name="notices_source_network">நெட்</string>
<string name="notices_location_unavailable">இடம் கிடைக்கவில்லை</string>
<string name="notices_enable_location">இடத்தை இயக்கு</string>
<string name="notices_alert_urgent_single">📌 @%1$s இன் அவசர அறிவிப்பு: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d புதிய அவசர அறிவிப்புகள் — பார்க்க பின்னைத் தட்டவும்</string>
<string name="cd_open_about">அறிமுகத்தைத் திற</string>
<string name="nearby_notes_reveal">இங்கே விடப்பட்ட குறிப்புகள் உள்ளதா எனப் பார்க்கவும்</string>
<string name="nearby_notes_one">இங்கே 1 குறிப்பு விடப்பட்டுள்ளது — படிக்க தட்டவும்</string>
<string name="nearby_notes_many">இங்கே %d குறிப்புகள் விடப்பட்டுள்ளன — படிக்க தட்டவும்</string>
</resources>

View File

@ -372,6 +372,20 @@
<string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_notices">ประกาศ</string>
<string name="notices_title">ประกาศ</string>
<string name="notices_tab_geo_count">พื้นที่ · %1$d</string>
<string name="notices_tab_mesh_count">mesh · %1$d</string>
<string name="notices_description_mesh">ปักประกาศสั้น ๆ ให้คนรอบตัว ส่งต่อจากมือถือสู่มือถือได้แม้ออฟไลน์ และหายไปเองหลังผ่านไปสองสามวัน</string>
<string name="notices_permanent">ถาวร</string>
<string name="notices_fades">เลือนหาย %1$s</string>
<string name="notices_source_mesh">mesh</string>
<string name="notices_source_network">เน็ต</string>
<string name="notices_location_unavailable">ไม่มีข้อมูลตำแหน่ง</string>
<string name="notices_enable_location">เปิดใช้งานตำแหน่ง</string>
<string name="notices_alert_urgent_single">📌 ประกาศด่วนจาก @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 ประกาศด่วนใหม่ %1$d รายการ — แตะหมุดเพื่อดู</string>
<string name="cd_open_about">เปิดเกี่ยวกับ</string>
<string name="nearby_notes_reveal">ดูว่ามีโน้ตทิ้งไว้ที่นี่หรือไม่</string>
<string name="nearby_notes_one">มี 1 โน้ตทิ้งไว้ที่นี่ — แตะเพื่ออ่าน</string>

View File

@ -370,6 +370,20 @@
<string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_notices">duyurular</string>
<string name="notices_title">duyurular</string>
<string name="notices_tab_geo_count">bölge · %1$d</string>
<string name="notices_tab_mesh_count">mesh · %1$d</string>
<string name="notices_description_mesh">çevrendekiler için kısa duyurular sabitle. çevrimdışıyken bile telefondan telefona geçer ve birkaç gün sonra kendiliğinden kaybolur.</string>
<string name="notices_permanent">kalıcı</string>
<string name="notices_fades">%1$s kaybolur</string>
<string name="notices_source_mesh">mesh</string>
<string name="notices_source_network">ağ</string>
<string name="notices_location_unavailable">konum kullanılamıyor</string>
<string name="notices_enable_location">konumu etkinleştir</string>
<string name="notices_alert_urgent_single">📌 @%1$s kişisinden acil duyuru: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d yeni acil duyuru — görmek için raptiyeye dokun</string>
<string name="cd_open_about">Hakkında’yı aç</string>
<string name="nearby_notes_reveal">buraya bırakılan notlara bak</string>
<string name="nearby_notes_one">buraya 1 not bırakıldı — okumak için dokun</string>

View File

@ -400,4 +400,21 @@
<string name="about_app_language">Мова застосунку</string>
<string name="about_system_default">Системна мова</string>
<string name="about_select_language">Вибрати мову</string>
<string name="cd_notices">оголошення</string>
<string name="notices_title">оголошення</string>
<string name="notices_tab_geo_count">гео · %1$d</string>
<string name="notices_tab_mesh_count">mesh · %1$d</string>
<string name="notices_description_mesh">закріплюй короткі оголошення для людей поруч. вони передаються з телефона на телефон, навіть офлайн, і самі зникають за кілька днів.</string>
<string name="notices_permanent">назавжди</string>
<string name="notices_fades">зникне %1$s</string>
<string name="notices_source_mesh">mesh</string>
<string name="notices_source_network">мережа</string>
<string name="notices_location_unavailable">локація недоступна</string>
<string name="notices_enable_location">увімкнути локацію</string>
<string name="notices_alert_urgent_single">📌 термінове оголошення від @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d нових термінових оголошень — натисни на шпильку, щоб переглянути</string>
<string name="cd_open_about">Відкрити розділ «Про застосунок»</string>
<string name="nearby_notes_reveal">перевірити, чи залишено тут нотатки</string>
<string name="nearby_notes_one">тут залишено 1 нотатку — торкніться, щоб прочитати</string>
<string name="nearby_notes_many">тут залишено %d нотаток — торкніться, щоб прочитати</string>
</resources>

View File

@ -385,6 +385,20 @@
<string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_notices">اعلانات</string>
<string name="notices_title">اعلانات</string>
<string name="notices_tab_geo_count">علاقہ · %1$d</string>
<string name="notices_tab_mesh_count">mesh · %1$d</string>
<string name="notices_description_mesh">آس پاس کے لوگوں کیلئے مختصر اعلانات پن کریں۔ یہ آف لائن بھی فون سے فون تک پہنچتے ہیں اور کچھ دنوں بعد خود مٹ جاتے ہیں۔</string>
<string name="notices_permanent">مستقل</string>
<string name="notices_fades">%1$s مٹ جائے گا</string>
<string name="notices_source_mesh">mesh</string>
<string name="notices_source_network">نیٹ</string>
<string name="notices_location_unavailable">لوکیشن دستیاب نہیں</string>
<string name="notices_enable_location">لوکیشن فعال کریں</string>
<string name="notices_alert_urgent_single">📌 @%1$s کا فوری اعلان: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d نئے فوری اعلانات — دیکھنے کیلئے پن پر ٹیپ کریں</string>
<string name="cd_open_about">تعارف کھولیں</string>
<string name="nearby_notes_reveal">دیکھیں کہ یہاں نوٹ چھوڑے گئے ہیں یا نہیں</string>
<string name="nearby_notes_one">یہاں 1 نوٹ چھوڑا گیا ہے — پڑھنے کے لیے تھپتھپائیں</string>

View File

@ -372,6 +372,20 @@
<string name="verify_success_title">Verified</string>
<string name="verify_success_body">You verified %1$s</string>
<string name="verify_success_system_message">verified %1$s</string>
<string name="cd_notices">thông báo</string>
<string name="notices_title">thông báo</string>
<string name="notices_tab_geo_count">khu vực · %1$d</string>
<string name="notices_tab_mesh_count">mesh · %1$d</string>
<string name="notices_description_mesh">ghim thông báo ngắn cho những người quanh bạn. chúng truyền từ điện thoại này sang điện thoại khác, kể cả khi ngoại tuyến, và tự biến mất sau vài ngày.</string>
<string name="notices_permanent">vĩnh viễn</string>
<string name="notices_fades">mờ dần %1$s</string>
<string name="notices_source_mesh">mesh</string>
<string name="notices_source_network">mạng</string>
<string name="notices_location_unavailable">không có dữ liệu vị trí</string>
<string name="notices_enable_location">bật vị trí</string>
<string name="notices_alert_urgent_single">📌 thông báo khẩn từ @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d thông báo khẩn mới — chạm vào ghim để xem</string>
<string name="cd_open_about">Mở phần Giới thiệu</string>
<string name="nearby_notes_reveal">kiểm tra ghi chú để lại ở đây</string>
<string name="nearby_notes_one">có 1 ghi chú để lại ở đây — chạm để đọc</string>

View File

@ -388,4 +388,20 @@
<string name="about_app_language">应用语言</string>
<string name="about_system_default">跟随系统</string>
<string name="about_select_language">选择语言</string>
<string name="cd_notices">公告</string>
<string name="notices_title">公告</string>
<string name="notices_tab_geo_count">区域 · %1$d</string>
<string name="notices_tab_mesh_count">mesh · %1$d</string>
<string name="notices_description_mesh">为周围的人钉上简短公告。即使离线也能在手机间传递,几天后自动消失。</string>
<string name="notices_permanent">永久</string>
<string name="notices_fades">%1$s消失</string>
<string name="notices_source_mesh">mesh</string>
<string name="notices_source_network">网络</string>
<string name="notices_location_unavailable">位置不可用</string>
<string name="notices_enable_location">启用位置</string>
<string name="notices_alert_urgent_single">📌 来自 @%1$s 的紧急公告:%2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d 条新紧急公告 — 点按图钉查看</string>
<string name="nearby_notes_reveal">查看这里留下的留言</string>
<string name="nearby_notes_one">这里留有 1 条留言 — 点按阅读</string>
<string name="nearby_notes_many">这里留有 %d 条留言 — 点按阅读</string>
</resources>

View File

@ -389,4 +389,20 @@
<string name="about_app_language">應用程式語言</string>
<string name="about_system_default">跟隨系統</string>
<string name="about_select_language">選擇語言</string>
<string name="cd_notices">公告</string>
<string name="notices_title">公告</string>
<string name="notices_tab_geo_count">區域 · %1$d</string>
<string name="notices_tab_mesh_count">mesh · %1$d</string>
<string name="notices_description_mesh">為周圍的人釘上簡短公告。即使離線也能在手機間傳遞,幾天後自動消失。</string>
<string name="notices_permanent">永久</string>
<string name="notices_fades">%1$s消失</string>
<string name="notices_source_mesh">mesh</string>
<string name="notices_source_network">網路</string>
<string name="notices_location_unavailable">位置不可用</string>
<string name="notices_enable_location">啟用位置</string>
<string name="notices_alert_urgent_single">📌 來自 @%1$s 的緊急公告:%2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d 則新緊急公告 — 點按圖釘查看</string>
<string name="nearby_notes_reveal">查看這裡留下的留言</string>
<string name="nearby_notes_one">這裡留有 1 則留言 — 點按閱讀</string>
<string name="nearby_notes_many">這裡留有 %d 則留言 — 點按閱讀</string>
</resources>

View File

@ -396,6 +396,20 @@
<string name="verify_success_title">已验证</string>
<string name="verify_success_body">你已验证 %1$s</string>
<string name="verify_success_system_message">已验证 %1$s</string>
<string name="cd_notices">公告</string>
<string name="notices_title">公告</string>
<string name="notices_tab_geo_count">区域 · %1$d</string>
<string name="notices_tab_mesh_count">mesh · %1$d</string>
<string name="notices_description_mesh">为周围的人钉上简短公告。即使离线也能在手机间传递,几天后自动消失。</string>
<string name="notices_permanent">永久</string>
<string name="notices_fades">%1$s消失</string>
<string name="notices_source_mesh">mesh</string>
<string name="notices_source_network">网络</string>
<string name="notices_location_unavailable">位置不可用</string>
<string name="notices_enable_location">启用位置</string>
<string name="notices_alert_urgent_single">📌 来自 @%1$s 的紧急公告:%2$s</string>
<string name="notices_alert_urgent_collapsed">📌 %1$d 条新紧急公告 — 点按图钉查看</string>
<string name="cd_open_about">打开“关于”</string>
<string name="nearby_notes_reveal">查看这里留下的留言</string>
<string name="nearby_notes_one">这里留有 1 条留言 — 点按阅读</string>

View File

@ -453,6 +453,27 @@
<string name="location_notes_empty_desc">Be the first to add one for this spot.</string>
<string name="dismiss">Dismiss</string>
<string name="location_notes_input_placeholder">Add a note for this place</string>
<string name="cd_notices" tools:ignore="MissingTranslation">Notices</string>
<string name="notices_title" tools:ignore="MissingTranslation">notices</string>
<string name="notices_tab_geo_count" tools:ignore="MissingTranslation">geo · %1$d</string>
<string name="notices_tab_mesh_count" tools:ignore="MissingTranslation">mesh · %1$d</string>
<string name="notices_description_mesh" tools:ignore="MissingTranslation">pin short notices for people around you. they hop phone to phone, even offline, and disappear on their own after a few days.</string>
<string name="notices_urgent" tools:ignore="MissingTranslation">urgent</string>
<string name="notices_placeholder" tools:ignore="MissingTranslation">add a notice</string>
<string name="notices_post" tools:ignore="MissingTranslation">Post notice</string>
<string name="notices_delete" tools:ignore="MissingTranslation">Delete notice</string>
<string name="notices_expiry" tools:ignore="MissingTranslation">expires</string>
<string name="notices_days" tools:ignore="MissingTranslation,PluralsCandidate">%1$d days</string>
<string name="notices_permanent" tools:ignore="MissingTranslation">permanent</string>
<string name="notices_fades" tools:ignore="MissingTranslation">fades %1$s</string>
<string name="notices_source_mesh" tools:ignore="MissingTranslation">mesh</string>
<string name="notices_source_network" tools:ignore="MissingTranslation">net</string>
<string name="notices_location_unavailable" tools:ignore="MissingTranslation">location unavailable</string>
<string name="notices_location_unavailable_desc" tools:ignore="MissingTranslation">enable location to view persistent notices near you.</string>
<string name="notices_enable_location" tools:ignore="MissingTranslation">enable location</string>
<string name="notices_send_failed" tools:ignore="MissingTranslation">could not sign and send this notice</string>
<string name="notices_alert_urgent_single" tools:ignore="MissingTranslation">📌 urgent notice from @%1$s: %2$s</string>
<string name="notices_alert_urgent_collapsed" tools:ignore="MissingTranslation,PluralsCandidate">📌 %1$d new urgent notices — tap the pin to view</string>
<!-- Debug / Diagnostics -->
<string name="debug_tools">Debug Tools</string>

View File

@ -0,0 +1,190 @@
package com.bitchat.android.board
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.nostr.LocationNotesManager
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.ExperimentalCoroutinesApi
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
import org.bouncycastle.crypto.signers.Ed25519Signer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.mockito.kotlin.any
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import java.security.SecureRandom
class BoardManagerTest {
private val privateKey = Ed25519PrivateKeyParameters(ByteArray(32) { it.toByte() }, 0)
private val publicKey = privateKey.generatePublicKey().encoded
private val geoIdentity = BoardSigningIdentity.fromEd25519Seed(ByteArray(32) { (it + 64).toByte() })
@Test
fun `create and delete emit iOS-compatible signed wire payloads`() = runTest {
val mesh = mock<MeshService>()
val notes = mock<LocationNotesManager>()
whenever(mesh.getSigningPublicKey()).thenReturn(publicKey)
whenever(mesh.signData(any())).thenAnswer { invocation ->
sign(invocation.getArgument(0))
}
val manager = BoardManager(
store = BoardStore(nowMs = { NOW }),
scope = backgroundScope,
meshProvider = { mesh },
geoIdentityProvider = { geoIdentity },
notesManager = notes,
nowMs = { NOW },
random = SecureRandom(byteArrayOf(7))
)
assertTrue(
manager.createPost(
content = " water at gate two ",
geohash = "U33DC",
nickname = "alice",
urgent = false,
expiryDays = 3
)
)
val payloads = argumentCaptor<ByteArray>()
verify(mesh).sendBoardPayload(payloads.capture())
val post = (BoardWireCodec.decode(payloads.firstValue) as BoardWire.Post).packet
assertEquals("water at gate two", post.content)
assertEquals("u33dc", post.geohash)
assertEquals(NOW + 3uL * DAY_MS, post.expiresAt)
assertTrue(geoIdentity.publicKey.contentEquals(post.authorSigningKey))
assertTrue(post.verifySignature())
verify(mesh, never()).signData(any())
val reloadedManager = BoardManager(
store = BoardStore(nowMs = { NOW }),
scope = backgroundScope,
meshProvider = { mesh },
geoIdentityProvider = { geoIdentity },
notesManager = notes,
nowMs = { NOW }
)
assertTrue(reloadedManager.deletePost(post))
verify(mesh, times(2)).sendBoardPayload(payloads.capture())
val tombstone =
(BoardWireCodec.decode(payloads.allValues.last()) as BoardWire.Tombstone).packet
assertTrue(tombstone.postID.contentEquals(post.postID))
assertTrue(tombstone.verifySignature())
}
@Test
fun `mesh board keeps the established mesh signing identity`() = runTest {
val mesh = mock<MeshService>()
whenever(mesh.getSigningPublicKey()).thenReturn(publicKey)
whenever(mesh.signData(any())).thenAnswer { invocation ->
sign(invocation.getArgument(0))
}
val manager = BoardManager(
store = BoardStore(nowMs = { NOW }),
scope = backgroundScope,
meshProvider = { mesh },
geoIdentityProvider = { geoIdentity },
notesManager = mock(),
nowMs = { NOW }
)
assertTrue(manager.createPost("mesh notice", "", "alice", false, 1))
val payload = argumentCaptor<ByteArray>()
verify(mesh).sendBoardPayload(payload.capture())
val post = (BoardWireCodec.decode(payload.firstValue) as BoardWire.Post).packet
assertTrue(publicKey.contentEquals(post.authorSigningKey))
assertTrue(post.verifySignature())
verify(mesh).signData(any())
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun `remote urgent arrivals badge their scope and collapse into an alert`() = runTest {
val mesh = mock<MeshService>()
whenever(mesh.getSigningPublicKey()).thenReturn(ByteArray(32) { 9 })
val store = BoardStore(nowMs = { NOW })
val alerts = mutableListOf<Pair<String, List<BoardPostPacket>>>()
val manager = BoardManager(
store = store,
scope = backgroundScope,
meshProvider = { mesh },
notesManager = mock(),
nowMs = { NOW },
onUrgentPosts = { geohash, posts -> alerts += geohash to posts }
)
runCurrent()
val post = signedPost(urgent = true)
val wire = BoardWire.Post(post)
assertEquals(
BoardIngestResult.ACCEPTED,
store.ingest(
wire = wire,
packet = BitchatPacket(
type = MessageType.BOARD_POST.value,
senderID = ByteArray(8) { 1 },
timestamp = NOW,
payload = BoardWireCodec.encode(wire),
ttl = 7u
),
source = BoardIngestSource.REMOTE
)
)
runCurrent()
assertTrue("u33dc" in manager.unseenScopes.value)
advanceTimeBy(4_000)
runCurrent()
assertEquals(listOf("u33dc"), alerts.map { it.first })
assertEquals(listOf(post), alerts.single().second)
manager.markSeen(setOf("u33dc"))
assertFalse("u33dc" in manager.unseenScopes.value)
}
private fun signedPost(urgent: Boolean): BoardPostPacket {
val postID = ByteArray(16) { 4 }
val flags: UByte = if (urgent) BoardPostPacket.URGENT_FLAG else 0u
val signingBytes = BoardPostPacket.signingBytes(
postID,
"u33dc",
"road closed",
publicKey,
"alice",
NOW,
NOW + DAY_MS,
flags
)
return BoardPostPacket(
postID,
"u33dc",
"road closed",
publicKey,
"alice",
NOW,
NOW + DAY_MS,
flags,
sign(signingBytes)
)
}
private fun sign(message: ByteArray): ByteArray = Ed25519Signer().run {
init(true, privateKey)
update(message, 0, message.size)
generateSignature()
}
private companion object {
const val NOW: ULong = 1_700_000_000_000uL
const val DAY_MS: ULong = 86_400_000uL
}
}

View File

@ -0,0 +1,217 @@
package com.bitchat.android.board
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
import org.bouncycastle.crypto.signers.Ed25519Signer
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class BoardPacketsTest {
private val privateKey = Ed25519PrivateKeyParameters(ByteArray(32) { it.toByte() }, 0)
private val publicKey = privateKey.generatePublicKey().encoded
@Test
fun `post round trips and verifies`() {
val post = signedPost(geohash = "u33dc1", content = "water at gate 2", urgent = true)
val encoded = BoardWireCodec.encode(BoardWire.Post(post))
val decoded = BoardWireCodec.decode(encoded) as BoardWire.Post
assertEquals(post, decoded.packet)
assertTrue(decoded.verifySignature())
assertTrue(BoardWireCodec.urgentFlag(encoded))
}
@Test
fun `mesh-local post and tombstone round trip`() {
val post = signedPost(geohash = "", content = "mesh notice", urgent = false)
val deletedAt = 1_700_000_100_000uL
val tombstoneBytes = BoardTombstonePacket.signingBytes(post.postID, deletedAt)
val tombstone = BoardTombstonePacket(
postID = post.postID,
authorSigningKey = publicKey,
deletedAt = deletedAt,
signature = sign(tombstoneBytes)
)
val decoded = BoardWireCodec.decode(
BoardWireCodec.encode(BoardWire.Tombstone(tombstone))
) as BoardWire.Tombstone
assertEquals(tombstone, decoded.packet)
assertTrue(decoded.verifySignature())
assertFalse(BoardWireCodec.urgentFlag(BoardWireCodec.encode(BoardWire.Post(post))))
}
@Test
fun `tampered content fails signature verification`() {
val encoded = BoardWireCodec.encode(
BoardWire.Post(signedPost(content = "original"))
)
val decoded = BoardWireCodec.decode(encoded) as BoardWire.Post
val tampered = BoardPostPacket(
postID = decoded.packet.postID,
geohash = decoded.packet.geohash,
content = "changed",
authorSigningKey = decoded.packet.authorSigningKey,
authorNickname = decoded.packet.authorNickname,
createdAt = decoded.packet.createdAt,
expiresAt = decoded.packet.expiresAt,
flags = decoded.packet.flags,
signature = decoded.packet.signature
)
assertFalse(tampered.verifySignature())
}
@Test
fun `decoder rejects invalid field sizes lifetime and geohash`() {
assertNull(BoardWireCodec.decode(replaceTlv(
BoardWireCodec.encode(BoardWire.Post(signedPost())),
type = 0x02,
value = ByteArray(15)
)))
assertNull(BoardWireCodec.decode(replaceTlv(
BoardWireCodec.encode(BoardWire.Post(signedPost())),
type = 0x03,
value = "u33dio".toByteArray()
)))
val tooLong = signedPost(
createdAt = 1_700_000_000_000uL,
expiresAt = 1_700_000_000_000uL + BoardWireConstants.MAX_LIFETIME_MS + 1uL
)
assertNull(BoardWireCodec.decode(BoardWireCodec.encode(BoardWire.Post(tooLong))))
}
@Test
fun `unknown TLVs are ignored`() {
val post = signedPost()
val encoded = BoardWireCodec.encode(BoardWire.Post(post))
val unknown = byteArrayOf(0x7F, 0x00, 0x03, 0x01, 0x02, 0x03)
val decoded = BoardWireCodec.decode(encoded + unknown) as BoardWire.Post
assertEquals(post, decoded.packet)
assertTrue(decoded.verifySignature())
}
@Test
fun `canonical signing bytes use context and big-endian length prefixes`() {
val post = signedPost(
postID = ByteArray(16) { (it + 1).toByte() },
geohash = "u4",
content = "hi",
nickname = "n",
createdAt = 0x0102030405060708uL,
expiresAt = 0x1112131415161718uL,
urgent = true
)
val bytes = post.signingBytes
assertEquals(BoardWireConstants.POST_SIGNING_CONTEXT.length, bytes[0].toInt())
assertArrayEquals(
BoardWireConstants.POST_SIGNING_CONTEXT.toByteArray(),
bytes.copyOfRange(1, 1 + BoardWireConstants.POST_SIGNING_CONTEXT.length)
)
assertTrue(post.verifySignature())
}
@Test
fun `transport sender is scoped to embedded author identity`() {
val wire = BoardWire.Post(signedPost())
val sameAuthor = BoardWire.Post(signedPost(content = "another notice"))
val otherIdentity = BoardSigningIdentity.fromEd25519Seed(ByteArray(32) { (it + 7).toByte() })
val otherPost = BoardPostPacket(
postID = ByteArray(16) { 9 },
geohash = "u33dc1",
content = "other author",
authorSigningKey = otherIdentity.publicKey,
authorNickname = "bob",
createdAt = 1_700_000_000_000uL,
expiresAt = 1_700_086_400_000uL,
flags = 0u,
signature = ByteArray(64)
)
assertEquals(BoardWireConstants.TRANSPORT_SENDER_ID_LENGTH, wire.transportSenderID().size)
assertArrayEquals(wire.transportSenderID(), sameAuthor.transportSenderID())
assertFalse(wire.transportSenderID().contentEquals(BoardWire.Post(otherPost).transportSenderID()))
}
@Test
fun `geo board identity is deterministic and domain separated from Nostr key`() {
val nostrSecret = ByteArray(32) { (it + 11).toByte() }
val secretHex = nostrSecret.joinToString("") { "%02x".format(it) }
val first = BoardSigningIdentity.fromNostrPrivateKeyHex(secretHex)!!
val second = BoardSigningIdentity.fromNostrPrivateKeyHex(secretHex)!!
assertArrayEquals(first.publicKey, second.publicKey)
assertFalse(first.publicKey.contentEquals(nostrSecret))
val message = "ios-compatible-board-payload".toByteArray()
assertTrue(BoardWireCodec.verify(first.sign(message)!!, message, first.publicKey))
}
private fun signedPost(
postID: ByteArray = ByteArray(16) { (it + 1).toByte() },
geohash: String = "u33dc1",
content: String = "notice",
nickname: String = "alice",
createdAt: ULong = 1_700_000_000_000uL,
expiresAt: ULong = createdAt + 86_400_000uL,
urgent: Boolean = false
): BoardPostPacket {
val flags = if (urgent) BoardPostPacket.URGENT_FLAG else 0u.toUByte()
val signingBytes = BoardPostPacket.signingBytes(
postID = postID,
geohash = geohash,
content = content,
authorSigningKey = publicKey,
authorNickname = nickname,
createdAt = createdAt,
expiresAt = expiresAt,
flags = flags
)
return BoardPostPacket(
postID = postID,
geohash = geohash,
content = content,
authorSigningKey = publicKey,
authorNickname = nickname,
createdAt = createdAt,
expiresAt = expiresAt,
flags = flags,
signature = sign(signingBytes)
)
}
private fun sign(message: ByteArray): ByteArray = Ed25519Signer().run {
init(true, privateKey)
update(message, 0, message.size)
generateSignature()
}
private fun replaceTlv(encoded: ByteArray, type: Int, value: ByteArray): ByteArray {
val output = ArrayList<Byte>()
var offset = 0
while (offset + 3 <= encoded.size) {
val currentType = encoded[offset].toInt() and 0xFF
val length =
((encoded[offset + 1].toInt() and 0xFF) shl 8) or
(encoded[offset + 2].toInt() and 0xFF)
val end = offset + 3 + length
if (currentType == type) {
output += type.toByte()
output += ((value.size ushr 8) and 0xFF).toByte()
output += (value.size and 0xFF).toByte()
output += value.toList()
} else {
output += encoded.copyOfRange(offset, end).toList()
}
offset = end
}
return output.toByteArray()
}
}

View File

@ -0,0 +1,239 @@
package com.bitchat.android.board
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
import org.bouncycastle.crypto.signers.Ed25519Signer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class BoardStoreTest {
@get:Rule
val temporaryFolder = TemporaryFolder()
private var now = 1_700_000_000_000uL
private val author = Key(ByteArray(32) { it.toByte() })
private val attacker = Key(ByteArray(32) { (it + 32).toByte() })
@Test
fun `expired and future-dated posts are rejected`() {
val store = BoardStore(nowMs = { now })
assertEquals(
BoardIngestResult.REJECTED,
store.ingestPost(signedPost(createdAt = now - 100uL, expiresAt = now))
)
assertEquals(
BoardIngestResult.REJECTED,
store.ingestPost(
signedPost(
createdAt = now + BoardStore.Limits.CLOCK_SKEW_MS + 1uL,
expiresAt = now + BoardStore.Limits.CLOCK_SKEW_MS + 2uL
)
)
)
}
@Test
fun `per-author cap evicts oldest posts`() {
val store = BoardStore(nowMs = { now })
repeat(BoardStore.Limits.MAX_POSTS_PER_AUTHOR + 1) { index ->
val post = signedPost(
idByte = index.toByte(),
createdAt = now + index.toULong(),
expiresAt = now + 86_400_000uL
)
assertEquals(BoardIngestResult.ACCEPTED, store.ingestPost(post))
}
val posts = store.posts("")
assertEquals(BoardStore.Limits.MAX_POSTS_PER_AUTHOR, posts.size)
assertTrue(posts.none { it.postID[0] == 0.toByte() })
}
@Test
fun `foreign tombstone does not delete known post`() {
val store = BoardStore(nowMs = { now })
val post = signedPost()
assertEquals(BoardIngestResult.ACCEPTED, store.ingestPost(post))
val forged = signedTombstone(post, attacker)
assertEquals(BoardIngestResult.ACCEPTED, store.ingestTombstone(forged))
assertEquals(1, store.posts("").size)
val valid = signedTombstone(post, author, deletedAt = now + 1uL)
assertEquals(BoardIngestResult.ACCEPTED, store.ingestTombstone(valid))
assertTrue(store.posts("").isEmpty())
assertEquals(2, store.syncCandidates().size)
}
@Test
fun `post id collision cannot suppress another author's post or deletion`() {
val store = BoardStore(nowMs = { now })
val genuine = signedPost()
val collision = signedPost(key = attacker)
assertEquals(BoardIngestResult.ACCEPTED, store.ingestPost(collision))
assertEquals(BoardIngestResult.ACCEPTED, store.ingestPost(genuine))
assertEquals(2, store.posts("").size)
assertEquals(
BoardIngestResult.ACCEPTED,
store.ingestTombstone(signedTombstone(genuine, author))
)
val remaining = store.posts("")
assertEquals(1, remaining.size)
assertTrue(remaining.single().authorSigningKey.contentEquals(attacker.publicKey))
}
@Test
fun `rewrapped duplicate payload is stored but never relayed again`() {
val store = BoardStore(nowMs = { now })
val wire = BoardWire.Post(signedPost())
val firstOuterPacket = packet(wire)
assertTrue(store.ingestRemoteForRelay(wire, firstOuterPacket))
assertFalse(
store.ingestRemoteForRelay(
wire,
firstOuterPacket.copy(timestamp = firstOuterPacket.timestamp + 1uL)
)
)
assertEquals(1, store.posts("").size)
}
@Test
fun `attacker orphan tombstone cannot block author deletion`() {
val store = BoardStore(nowMs = { now })
val post = signedPost()
val attackerTombstone = signedTombstone(post, attacker)
assertEquals(
BoardIngestResult.ACCEPTED,
store.ingestTombstone(attackerTombstone)
)
assertEquals(BoardIngestResult.ACCEPTED, store.ingestPost(post))
val authorTombstone = signedTombstone(post, author, deletedAt = now + 1uL)
assertEquals(
BoardIngestResult.ACCEPTED,
store.ingestTombstone(authorTombstone)
)
assertTrue(store.posts("").isEmpty())
assertEquals(2, store.syncCandidates().size)
assertEquals(
BoardIngestResult.DUPLICATE,
store.ingestTombstone(attackerTombstone)
)
}
@Test
fun `tombstone suppresses a stale copy until original expiry`() {
val store = BoardStore(nowMs = { now })
val post = signedPost(expiresAt = now + 10_000uL)
store.ingestPost(post)
store.ingestTombstone(signedTombstone(post, author))
assertEquals(BoardIngestResult.REJECTED, store.ingestPost(post))
now += 10_001uL
store.pruneExpired()
assertTrue(store.syncCandidates().isEmpty())
}
@Test
fun `signed packets persist and are reverified on load`() {
val file = temporaryFolder.newFile("posts.json")
file.delete()
val store = BoardStore(file = file, nowMs = { now })
val post = signedPost(geohash = "u33dc1")
assertEquals(BoardIngestResult.ACCEPTED, store.ingestPost(post))
val restored = BoardStore(file = file, nowMs = { now })
assertEquals(listOf(post), restored.posts("u33dc1"))
assertEquals(1, restored.syncCandidates().size)
}
private fun BoardStore.ingestPost(post: BoardPostPacket): BoardIngestResult {
val wire = BoardWire.Post(post)
return ingest(wire, packet(wire), BoardIngestSource.REMOTE)
}
private fun BoardStore.ingestTombstone(
tombstone: BoardTombstonePacket
): BoardIngestResult {
val wire = BoardWire.Tombstone(tombstone)
return ingest(wire, packet(wire), BoardIngestSource.REMOTE)
}
private fun packet(wire: BoardWire) = BitchatPacket(
type = MessageType.BOARD_POST.value,
senderID = ByteArray(8) { 1 },
timestamp = now,
payload = BoardWireCodec.encode(wire),
ttl = 7u
)
private fun signedPost(
idByte: Byte = 1,
geohash: String = "",
createdAt: ULong = now,
expiresAt: ULong = now + 86_400_000uL,
key: Key = author
): BoardPostPacket {
val postID = ByteArray(16).also { it[0] = idByte }
val content = "notice-$idByte"
val signingBytes = BoardPostPacket.signingBytes(
postID,
geohash,
content,
key.publicKey,
"alice",
createdAt,
expiresAt,
0u
)
return BoardPostPacket(
postID,
geohash,
content,
key.publicKey,
"alice",
createdAt,
expiresAt,
0u,
key.sign(signingBytes)
)
}
private fun signedTombstone(
post: BoardPostPacket,
key: Key,
deletedAt: ULong = now
): BoardTombstonePacket {
val signingBytes = BoardTombstonePacket.signingBytes(post.postID, deletedAt)
return BoardTombstonePacket(
post.postID,
key.publicKey,
deletedAt,
key.sign(signingBytes)
)
}
private class Key(seed: ByteArray) {
private val privateKey = Ed25519PrivateKeyParameters(seed, 0)
val publicKey: ByteArray = privateKey.generatePublicKey().encoded
fun sign(message: ByteArray): ByteArray = Ed25519Signer().run {
init(true, privateKey)
update(message, 0, message.size)
generateSignature()
}
}
}

View File

@ -0,0 +1,118 @@
package com.bitchat.android.board
import com.bitchat.android.nostr.LocationNotesManager
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class UnifiedNoticesTest {
private val baseSeconds = 1_700_000_000
private val baseMs = baseSeconds.toULong() * 1_000uL
@Test
fun `bridged relay copy is deduplicated in favor of board post`() {
val post = post(content = "water at the gate")
val relayCopy = note(content = "water at the gate", createdAt = baseSeconds + 30)
val result = UnifiedNotices.merge("u33dc", listOf(post), listOf(relayCopy))
assertEquals(1, result.size)
assertEquals(NoticeSource.MESH, result.single().source)
}
@Test
fun `same content from neighbor cell is retained`() {
val post = post(content = "free tent")
val neighbor = note(content = "free tent", geohash = "u33dd")
val result = UnifiedNotices.merge("u33dc", listOf(post), listOf(neighbor))
assertEquals(2, result.size)
assertTrue(result.any { it.source == NoticeSource.NOSTR })
}
@Test
fun `different author and out of window notes remain`() {
val post = post(content = "meet at six")
val otherAuthor = note(content = "meet at six", nickname = "bob")
val oldCopy = note(
content = "meet at six",
createdAt = baseSeconds - 16 * 60
)
val result = UnifiedNotices.merge(
"u33dc",
listOf(post),
listOf(otherAuthor, oldCopy)
)
assertEquals(3, result.size)
}
@Test
fun `anonymous copies deduplicate and urgent sorts first`() {
val anonymous = post(content = "hello", nickname = "")
val bridged = note(content = "hello", nickname = null)
val urgentRelay = note(
content = "road closed",
nickname = "carol",
createdAt = baseSeconds - 60,
urgent = true
)
val result = UnifiedNotices.merge(
"u33dc",
listOf(anonymous),
listOf(bridged, urgentRelay)
)
assertEquals(listOf("road closed", "hello"), result.map { it.content })
assertTrue(result.first().urgent)
assertFalse(result.last().urgent)
}
@Test
fun `colliding post ids from different authors retain unique row ids`() {
val first = post(content = "first")
val second = post(content = "second", authorKeyByte = 2)
val result = UnifiedNotices.merge("u33dc", listOf(first, second), emptyList())
assertEquals(2, result.map { it.id }.distinct().size)
}
private fun post(
content: String,
nickname: String = "alice",
createdAt: ULong = baseMs,
urgent: Boolean = false,
authorKeyByte: Byte = 1
) = BoardPostPacket(
postID = ByteArray(16) { 7 },
geohash = "u33dc",
content = content,
authorSigningKey = ByteArray(32) { authorKeyByte },
authorNickname = nickname,
createdAt = createdAt,
expiresAt = createdAt + 86_400_000uL,
flags = if (urgent) BoardPostPacket.URGENT_FLAG else 0u,
signature = ByteArray(64) { 2 }
)
private fun note(
content: String,
nickname: String? = "alice",
createdAt: Int = baseSeconds,
geohash: String = "u33dc",
urgent: Boolean = false
) = LocationNotesManager.Note(
id = "$content-$nickname-$createdAt-$geohash",
pubkey = "deadbeef",
content = content,
createdAt = createdAt,
nickname = nickname,
geohash = geohash,
isUrgent = urgent
)
}

View File

@ -0,0 +1,206 @@
package com.bitchat.android.sync
import com.bitchat.android.model.IdentityAnnouncement
import com.bitchat.android.model.PeerCapabilities
import com.bitchat.android.model.RequestSyncPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import kotlinx.coroutines.cancel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.junit.Assert.assertArrayEquals
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.TimeUnit
class BoardSyncTest {
@Test
fun `board flag widens to second little-endian byte`() {
assertArrayEquals(byteArrayOf(0, 1), SyncTypeFlags.BOARD.encode())
val decoded = SyncTypeFlags.decode(byteArrayOf(0, 1))!!
assertTrue(decoded.contains(MessageType.BOARD_POST))
assertFalse(decoded.contains(MessageType.MESSAGE))
}
@Test
fun `legacy one-byte flags and unknown bits remain compatible`() {
val legacy = SyncTypeFlags.decode(byteArrayOf(0x03))!!
assertTrue(legacy.contains(MessageType.ANNOUNCE))
assertTrue(legacy.contains(MessageType.MESSAGE))
assertFalse(legacy.contains(MessageType.BOARD_POST))
val unknownOnly = SyncTypeFlags.decode(byteArrayOf(0, 0, 0x40))!!
assertNull(unknownOnly.encode())
}
@Test
fun `request sync round trips board types and cursor`() {
val request = RequestSyncPacket(
p = 7,
m = 1234,
data = byteArrayOf(1, 2, 3),
types = SyncTypeFlags.BOARD,
sinceTimestamp = 1_700_000_000_000uL
)
val decoded = RequestSyncPacket.decode(request.encode())!!
assertEquals(7, decoded.p)
assertEquals(1234, decoded.m)
assertArrayEquals(byteArrayOf(1, 2, 3), decoded.data)
assertTrue(decoded.types!!.contains(MessageType.BOARD_POST))
assertEquals(1_700_000_000_000uL, decoded.sinceTimestamp)
}
@Test
fun `legacy request without type field defaults at handler not decoder`() {
val request = RequestSyncPacket(p = 7, m = 1, data = ByteArray(0))
val decoded = RequestSyncPacket.decode(request.encode())!!
assertNull(decoded.types)
assertNull(decoded.sinceTimestamp)
}
@Test
fun `board round is served only from provider`() {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
val manager = GossipSyncManager(
myPeerID = "0102030405060708",
scope = scope,
configProvider = config()
)
val boardPacket = packet(MessageType.BOARD_POST, timestamp = 200uL)
val messagePacket = packet(MessageType.MESSAGE, timestamp = 300uL)
manager.boardPacketsProvider = { listOf(boardPacket) }
manager.onPublicPacketSeen(messagePacket)
val sent = mutableListOf<BitchatPacket>()
manager.delegate = object : GossipSyncManager.Delegate {
override fun sendPacket(packet: BitchatPacket) = Unit
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) {
sent += packet
}
override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket = packet
}
manager.handleRequestSync(
fromPeerID = "1111111111111111",
request = RequestSyncPacket(
p = 7,
m = 1,
data = ByteArray(0),
types = SyncTypeFlags.BOARD
)
)
assertEquals(listOf(MessageType.BOARD_POST.value), sent.map { it.type })
assertEquals(0u.toUByte(), sent.single().ttl)
scope.cancel()
}
@Test
fun `initial sync omits board for a legacy peer`() {
val requestedTypes = initialSyncTypes(
announcementCapabilities = null,
observeBoardRequest = false
)
assertTrue(requestedTypes.contains(MessageType.ANNOUNCE))
assertTrue(requestedTypes.contains(MessageType.MESSAGE))
assertFalse(requestedTypes.contains(MessageType.BOARD_POST))
}
@Test
fun `initial sync includes board for an advertising peer`() {
val requestedTypes = initialSyncTypes(
announcementCapabilities = PeerCapabilities.BOARD,
observeBoardRequest = false
)
assertTrue(requestedTypes.contains(MessageType.BOARD_POST))
}
@Test
fun `a board request opts current iOS peers into future board sync`() {
val requestedTypes = initialSyncTypes(
announcementCapabilities = null,
observeBoardRequest = true
)
assertTrue(requestedTypes.contains(MessageType.BOARD_POST))
}
private fun config() = object : GossipSyncManager.ConfigProvider {
override fun seenCapacity(): Int = 500
override fun gcsMaxBytes(): Int = 400
override fun gcsTargetFpr(): Double = 0.01
}
private fun packet(type: MessageType, timestamp: ULong) = BitchatPacket(
type = type.value,
senderID = ByteArray(8) { 1 },
timestamp = timestamp,
payload = byteArrayOf(1, 2, 3),
ttl = 7u
)
private fun initialSyncTypes(
announcementCapabilities: PeerCapabilities?,
observeBoardRequest: Boolean
): SyncTypeFlags {
val peerID = "1111111111111111"
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
val manager = GossipSyncManager(
myPeerID = "0102030405060708",
scope = scope,
configProvider = config()
)
manager.boardPacketsProvider = { emptyList() }
manager.onPublicPacketSeen(
BitchatPacket(
type = MessageType.ANNOUNCE.value,
senderID = ByteArray(8) { 0x11 },
timestamp = System.currentTimeMillis().toULong(),
payload = IdentityAnnouncement(
nickname = "peer",
noisePublicKey = ByteArray(32) { 1 },
signingPublicKey = ByteArray(32) { 2 },
capabilities = announcementCapabilities
).encode()!!,
ttl = 7u
)
)
if (observeBoardRequest) {
manager.handleRequestSync(
fromPeerID = peerID,
request = RequestSyncPacket(
p = 7,
m = 1,
data = ByteArray(0),
types = SyncTypeFlags.BOARD
)
)
}
val latch = CountDownLatch(1)
var sent: BitchatPacket? = null
manager.delegate = object : GossipSyncManager.Delegate {
override fun sendPacket(packet: BitchatPacket) = Unit
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) {
sent = packet
latch.countDown()
}
override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket = packet
}
manager.scheduleInitialSyncToPeer(peerID, delayMs = 0)
assertTrue("initial sync was not sent", latch.await(2, TimeUnit.SECONDS))
val types = RequestSyncPacket.decode(requireNotNull(sent).payload)?.types
scope.cancel()
return requireNotNull(types)
}
}

View File

@ -0,0 +1,40 @@
package com.bitchat.android.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class NoticeComposerPolicyTest {
@Test
fun `geo offers permanent and finite board lifetimes`() {
assertEquals(listOf(0, 1, 3, 7), NoticeComposerPolicy.expiryOptions(isGeo = true))
}
@Test
fun `mesh offers only finite board lifetimes`() {
assertEquals(listOf(1, 3, 7), NoticeComposerPolicy.expiryOptions(isGeo = false))
}
@Test
fun `only permanent geo selection skips the board`() {
assertTrue(
NoticeComposerPolicy.isPermanentRelayOnlyGeo(
isGeo = true,
expiryDays = 0
)
)
assertFalse(
NoticeComposerPolicy.isPermanentRelayOnlyGeo(
isGeo = true,
expiryDays = 1
)
)
assertFalse(
NoticeComposerPolicy.isPermanentRelayOnlyGeo(
isGeo = false,
expiryDays = 1
)
)
}
}

View File

@ -92,7 +92,7 @@ class IdentityAnnouncementTest {
val encoded = IdentityAnnouncement.forLocalPeer(nickname, noiseKey, signingKey).encode()!!
assertArrayEquals(
byteArrayOf(0x05, 0x02, 0x08, 0x01),
byteArrayOf(0x05, 0x02, 0x18, 0x01),
encoded.takeLast(4).toByteArray()
)
val capabilities = IdentityAnnouncement.decode(encoded)!!.capabilities!!

View File

@ -3,7 +3,9 @@ package com.bitchat.android.nostr
import com.google.gson.Gson
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlinx.coroutines.runBlocking
class NostrProtocolTest {
private val gson = Gson()
@ -41,6 +43,38 @@ class NostrProtocolTest {
assertNull(decrypted)
}
@Test
fun createGeohashTextNote_addsExpirationAndUrgentTags() = runBlocking {
val identity = NostrIdentity.generate()
val event = NostrProtocol.createGeohashTextNote(
content = "road closed",
geohash = "u33dc",
senderIdentity = identity,
nickname = "alice",
expiresAt = 1_700_086_400,
urgent = true
)
assertEquals(NostrKind.TEXT_NOTE, event.kind)
assertTrue(event.tags.contains(listOf("g", "u33dc")))
assertTrue(event.tags.contains(listOf("n", "alice")))
assertTrue(event.tags.contains(listOf("expiration", "1700086400")))
assertTrue(event.tags.contains(listOf("t", "urgent")))
assertTrue(event.isValidSignature())
}
@Test
fun createDeleteEvent_isSignedNip09Request() = runBlocking {
val identity = NostrIdentity.generate()
val event = NostrProtocol.createDeleteEvent("event-id", identity)
assertEquals(NostrKind.DELETION, event.kind)
assertEquals(listOf(listOf("e", "event-id")), event.tags)
assertTrue(event.isValidSignature())
}
private fun forgedGiftWrap(
content: String,
claimedSender: NostrIdentity,

View File

@ -81,6 +81,8 @@ composeCompiler {
// onboarding, nostr (except pure-Kotlin Bech32), net, geohash, wifi-aware, hotspot, voice
// features, and the phone's foreground service.
val sharedSourceIncludes = listOf(
"com/bitchat/android/board/BoardPackets.kt",
"com/bitchat/android/board/BoardStore.kt",
"com/bitchat/android/protocol/**",
"com/bitchat/android/noise/**",
"com/bitchat/android/crypto/**",