diff --git a/app/src/main/java/com/bitchat/android/board/BoardManager.kt b/app/src/main/java/com/bitchat/android/board/BoardManager.kt new file mode 100644 index 00000000..93288c32 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/board/BoardManager.kt @@ -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) -> Unit = { _, _ -> } +) { + private val _unseenScopes = MutableStateFlow>(emptySet()) + private val bridgedEventIDs = mutableMapOf() + private val handledPostIDs = mutableSetOf() + private val pendingUrgent = mutableMapOf>() + private var alertFlushJob: Job? = null + + val posts: StateFlow> = store.postsSnapshot + val unseenScopes: StateFlow> = _unseenScopes.asStateFlow() + + init { + scope.launch { + store.postArrivals.collect { post -> + handleArrival(post) + } + } + } + + fun posts(geohash: String): List = 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) { + 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 + } +} diff --git a/app/src/main/java/com/bitchat/android/board/BoardPackets.kt b/app/src/main/java/com/bitchat/android/board/BoardPackets.kt new file mode 100644 index 00000000..62ba7464 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/board/BoardPackets.kt @@ -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 +} diff --git a/app/src/main/java/com/bitchat/android/board/BoardSigningIdentity.kt b/app/src/main/java/com/bitchat/android/board/BoardSigningIdentity.kt new file mode 100644 index 00000000..a9ab35d1 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/board/BoardSigningIdentity.kt @@ -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() + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/board/BoardStore.kt b/app/src/main/java/com/bitchat/android/board/BoardStore.kt new file mode 100644 index 00000000..97aa95d1 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/board/BoardStore.kt @@ -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() + private val tombstones = mutableListOf() + private val _postsSnapshot = MutableStateFlow>(emptyList()) + private val _postArrivals = MutableSharedFlow(extraBufferCapacity = 64) + + val postsSnapshot: StateFlow> = _postsSnapshot.asStateFlow() + val postArrivals: SharedFlow = _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 = synchronized(lock) { + pruneExpiredLocked(nowMs()) + posts.asSequence() + .map { it.post } + .filter { it.geohash == forGeohash } + .sortedWith( + compareByDescending { it.isUrgent } + .thenByDescending { it.createdAt } + ) + .toList() + } + + fun syncCandidates(): List = 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, 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, + 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>() {}.type + Gson().fromJson>(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 diff --git a/app/src/main/java/com/bitchat/android/board/UnifiedNotices.kt b/app/src/main/java/com/bitchat/android/board/UnifiedNotices.kt new file mode 100644 index 00000000..c91b127b --- /dev/null +++ b/app/src/main/java/com/bitchat/android/board/UnifiedNotices.kt @@ -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, + relayNotes: List + ): List { + 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 { it.urgent } + .thenByDescending { it.createdAtMs } + ) + } + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } +} diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index e0ae3743..13d42f5d 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -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 ) diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt index c2edbb44..c625da61 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -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() { diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt index 96408df9..65288fbf 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt @@ -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, diff --git a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt index 51dc40ff..d35a26b4 100644 --- a/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt +++ b/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt @@ -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) diff --git a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt index 40cacbda..d122c3ec 100644 --- a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt @@ -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 } diff --git a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt index 66650ec1..0112a7f1 100644 --- a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt @@ -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, diff --git a/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt b/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt index a60ce71b..7b9b7672 100644 --- a/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt +++ b/app/src/main/java/com/bitchat/android/model/PeerCapabilities.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt index 0edb1fca..4318f1df 100644 --- a/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/LocationNotesManager.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt b/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt index 2cdf75d6..483eeb86 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrEvent.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt index 4376d1e9..965d8f90 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrProtocol.kt @@ -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>() 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 diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt index abd5da7e..1024f244 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -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 { diff --git a/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt b/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt index 614619f9..9963cc45 100644 --- a/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt +++ b/app/src/main/java/com/bitchat/android/sync/GossipSyncManager.kt @@ -43,6 +43,15 @@ class GossipSyncManager( private const val ARCHIVE_FILE = "gossip-public-history.bin" } + var boardPacketsProvider: (() -> List)? = null + private val responseTimesByPeer = ConcurrentHashMap>() + private val observedBoardSyncPeers = ConcurrentHashMap.newKeySet() + 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() + 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) } } diff --git a/app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt b/app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt index 09eb11cd..8c1be788 100644 --- a/app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt +++ b/app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 1ae1c47f..f6c21ef8 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -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 ) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index cdad8afa..de1252bb 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -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, val peerNicknames: Map, @@ -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) + "…" } diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt index 098f190b..0970909e 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -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 + } + ) } } diff --git a/app/src/main/java/com/bitchat/android/ui/NoticeComposerPolicy.kt b/app/src/main/java/com/bitchat/android/ui/NoticeComposerPolicy.kt new file mode 100644 index 00000000..fe6e7e75 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/NoticeComposerPolicy.kt @@ -0,0 +1,11 @@ +package com.bitchat.android.ui + +internal object NoticeComposerPolicy { + private val finiteExpiryDays = listOf(1, 3, 7) + + fun expiryOptions(isGeo: Boolean): List = + if (isGeo) listOf(0) + finiteExpiryDays else finiteExpiryDays + + fun isPermanentRelayOnlyGeo(isGeo: Boolean, expiryDays: Int): Boolean = + isGeo && expiryDays == 0 +} diff --git a/app/src/main/java/com/bitchat/android/ui/NoticesSheet.kt b/app/src/main/java/com/bitchat/android/ui/NoticesSheet.kt new file mode 100644 index 00000000..4ae95a69 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/NoticesSheet.kt @@ -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, + 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" +} diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt index d7fe4904..21cb8fc9 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -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. diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 772e48a5..51d3e6d4 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -385,6 +385,20 @@ Verified You verified %1$s verified %1$s + + إعلانات + إعلانات + جغرافي · %1$d + mesh · %1$d + ثبّت إعلانات قصيرة لمن حولك. تنتقل من هاتف إلى هاتف حتى دون اتصال، وتختفي وحدها بعد أيام قليلة. + دائم + يتلاشى %1$s + mesh + نت + الموقع غير متاح + تفعيل الموقع + 📌 إعلان عاجل من @%1$s: %2$s + 📌 %1$d إعلانات عاجلة جديدة — اضغط على الدبوس للعرض فتح قسم حول تحقّق من الملاحظات المتروكة هنا تُركت ملاحظة واحدة هنا — انقر للقراءة diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index b6557fe2..6f062502 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -372,6 +372,20 @@ Verified You verified %1$s verified %1$s + + নোটিশ + নোটিশ + এলাকা · %1$d + mesh · %1$d + আশেপাশের মানুষের জন্য ছোট নোটিশ পিন করুন। অফলাইনেও ফোন থেকে ফোনে পৌঁছে যায় আর কয়েক দিন পরে নিজে থেকেই মুছে যায়। + স্থায়ী + %1$s মুছে যাবে + mesh + নেট + অবস্থান অনুপলব্ধ + লোকেশন চালু করুন + 📌 @%1$s-এর জরুরি নোটিশ: %2$s + 📌 %1$dটি নতুন জরুরি নোটিশ — দেখতে পিনে ট্যাপ করুন পরিচিতি খুলুন এখানে রাখা নোট আছে কি না দেখুন এখানে 1টি নোট রাখা আছে — পড়তে ট্যাপ করুন diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 75cc8927..808ec6c4 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -386,6 +386,20 @@ Verifiziert Du hast %1$s verifiziert verifiziert %1$s + + hinweise + hinweise + geo · %1$d + mesh · %1$d + 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. + dauerhaft + verblasst %1$s + mesh + netz + standort nicht verfügbar + standort aktivieren + 📌 dringender hinweis von @%1$s: %2$s + 📌 %1$d neue dringende hinweise — tippe zum ansehen auf den pin Info öffnen nachsehen, ob hier notizen hinterlassen wurden 1 notiz hier hinterlassen — tippen zum lesen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 3f08ead0..3e90144e 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -385,6 +385,20 @@ Verificado Verificaste a %1$s verificado %1$s + + avisos + avisos + geo · %1$d + mesh · %1$d + 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. + permanente + se desvanece %1$s + mesh + red + ubicación no disponible + activar ubicación + 📌 aviso urgente de @%1$s: %2$s + 📌 %1$d avisos urgentes nuevos — toca el pin para verlos Abrir Acerca de buscar notas dejadas aquí 1 nota dejada aquí — toca para leer diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 1e092b64..324e6e26 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -372,6 +372,20 @@ Verified You verified %1$s verified %1$s + + اطلاعیه‌ها + اطلاعیه‌ها + ژئو · %1$d + مش · %1$d + اطلاعیه‌های کوتاه برای اطرافیان‌تان سنجاق کنید. گوشی به گوشی جابه‌جا می‌شوند، حتی آفلاین، و بعد از چند روز خودبه‌خود ناپدید می‌شوند. + دائمی + %1$s محو می‌شود + مش + نت + موقعیت در دسترس نیست + فعال‌سازی موقعیت + 📌 اطلاعیهٔ فوری از @%1$s: %2$s + 📌 %1$d اطلاعیهٔ فوری جدید — برای مشاهده روی سنجاق بزنید باز کردن درباره یادداشت‌های باقی‌مانده در اینجا را بررسی کنید ۱ یادداشت اینجا باقی مانده — برای خواندن ضربه بزنید diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index fa4aea0c..0d63bb71 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -383,6 +383,20 @@ Verified You verified %1$s verified %1$s + + mga paunawa + mga paunawa + geo · %1$d + mesh · %1$d + 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. + permanente + maglalaho %1$s + mesh + net + walang lokasyon + i-on ang lokasyon + 📌 agarang paunawa mula kay @%1$s: %2$s + 📌 %1$d bagong agarang paunawa — i-tap ang pin para makita Buksan ang Tungkol tingnan kung may mga note na naiwan dito 1 note ang naiwan dito — i-tap para basahin diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index a884d35e..bf30d007 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -397,6 +397,20 @@ Vérifié Vous avez vérifié %1$s vérifié %1$s + + annonces + annonces + géo · %1$d + mesh · %1$d + é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. + permanent + s\'efface %1$s + mesh + net + localisation indisponible + activer la localisation + 📌 annonce urgente de @%1$s : %2$s + 📌 %1$d nouvelles annonces urgentes — touche l\'épingle pour voir Ouvrir À propos vérifier s\'il y a des notes laissées ici 1 note laissée ici — appuyez pour lire diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml index f742042e..2800a13b 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -392,4 +392,21 @@ שפת האפליקציה ברירת המחדל של המערכת בחירת שפה + מודעות + מודעות + אזור · %1$d + mesh · %1$d + הצמד מודעות קצרות לאנשים סביבך. הן עוברות מטלפון לטלפון, גם בלי אינטרנט, ונעלמות מעצמן אחרי כמה ימים. + קבוע + דוהה %1$s + mesh + רשת + המיקום לא זמין + הפעל מיקום + 📌 מודעה דחופה מאת @%1$s: %2$s + 📌 %1$d מודעות דחופות חדשות — הקש על הנעץ לצפייה + פתיחת אודות + בדיקה אם הושארו כאן פתקים + פתק אחד הושאר כאן — הקש לקריאה + %d פתקים הושארו כאן — הקש לקריאה diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 4cb18e38..3f9a1051 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -385,6 +385,20 @@ Verified You verified %1$s verified %1$s + + सूचनाएँ + सूचनाएँ + क्षेत्र · %1$d + mesh · %1$d + आस-पास के लोगों के लिए छोटी सूचनाएँ पिन करें। ये ऑफ़लाइन भी फ़ोन से फ़ोन तक पहुँचती हैं और कुछ दिनों बाद अपने आप मिट जाती हैं। + स्थायी + %1$s मिट जाएगा + mesh + नेट + लोकेशन उपलब्ध नहीं + लोकेशन सक्षम करें + 📌 @%1$s की ज़रूरी सूचना: %2$s + 📌 %1$d नई ज़रूरी सूचनाएँ — देखने के लिए पिन टैप करें परिचय खोलें देखें कि यहाँ नोट छोड़े गए हैं या नहीं यहाँ 1 नोट छोड़ा गया है — पढ़ने के लिए टैप करें diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 02c86a68..c8832cec 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -385,6 +385,20 @@ Verified You verified %1$s verified %1$s + + pengumuman + pengumuman + area · %1$d + mesh · %1$d + sematkan pengumuman singkat untuk orang di sekitarmu. berpindah dari ponsel ke ponsel, bahkan saat offline, dan hilang sendiri setelah beberapa hari. + permanen + memudar %1$s + mesh + net + lokasi tidak tersedia + aktifkan lokasi + 📌 pengumuman mendesak dari @%1$s: %2$s + 📌 %1$d pengumuman mendesak baru — ketuk pin untuk melihat Buka Tentang periksa catatan yang ditinggalkan di sini 1 catatan ditinggalkan di sini — ketuk untuk membaca diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index af59fad3..50244ec1 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -412,6 +412,20 @@ Verificato Hai verificato %1$s verificato %1$s + + avvisi + avvisi + geo · %1$d + mesh · %1$d + appunta brevi avvisi per chi ti sta intorno. passano da telefono a telefono, anche offline, e spariscono da soli dopo qualche giorno. + permanente + svanisce %1$s + mesh + rete + posizione non disponibile + attiva posizione + 📌 avviso urgente da @%1$s: %2$s + 📌 %1$d nuovi avvisi urgenti — tocca la puntina per vederli Apri Informazioni controlla se ci sono note lasciate qui 1 nota lasciata qui — tocca per leggere diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 17841dcc..6c95e804 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -385,6 +385,20 @@ 検証済み %1$s を検証しました %1$s を検証しました + + お知らせ + お知らせ + エリア · %1$d + mesh · %1$d + 近くの人に向けて短いお知らせをピン留め。オフラインでもスマホからスマホへ伝わり、数日後に自動的に消えます。 + 無期限 + %1$sに消えます + mesh + ネット + 位置情報を取得できません + 位置情報を有効化 + 📌 @%1$sからの緊急のお知らせ: %2$s + 📌 新しい緊急のお知らせが%1$d件 — ピンをタップして表示 このアプリについてを開く ここに残されたメモを確認 ここに1件のメモがあります — タップして読む diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 030165d6..f5780198 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -385,6 +385,20 @@ Verified You verified %1$s verified %1$s + + 공지 + 공지 + 지역 · %1$d + mesh · %1$d + 주변 사람들을 위해 짧은 공지를 고정하세요. 오프라인에서도 휴대폰에서 휴대폰으로 전달되고 며칠 후 스스로 사라집니다. + 영구 + %1$s 사라짐 + mesh + 넷 + 위치 사용 불가 + 위치 활성화 + 📌 @%1$s님의 긴급 공지: %2$s + 📌 새 긴급 공지 %1$d개 — 핀을 탭하여 확인 정보 열기 여기 남겨진 쪽지 확인 여기 남겨진 쪽지 1개 — 탭하여 읽기 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 79bf5b1c..3ff3b858 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -433,4 +433,21 @@ Bahasa aplikasi Lalai sistem Pilih bahasa + pengumuman + pengumuman + kawasan · %1$d + mesh · %1$d + semat pengumuman ringkas untuk orang di sekeliling anda. ia berpindah dari telefon ke telefon, walaupun di luar talian, dan hilang sendiri selepas beberapa hari. + kekal + pudar %1$s + mesh + net + lokasi tidak tersedia + aktifkan lokasi + 📌 pengumuman segera daripada @%1$s: %2$s + 📌 %1$d pengumuman segera baharu — ketik pin untuk melihat + Buka Perihal + semak nota yang ditinggalkan di sini + 1 nota ditinggalkan di sini — ketik untuk baca + %d nota ditinggalkan di sini — ketik untuk baca diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index 59d52853..86247cdb 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -383,6 +383,20 @@ Verified You verified %1$s verified %1$s + + सूचनाहरू + सूचनाहरू + क्षेत्र · %1$d + mesh · %1$d + वरपरका मानिसहरूका लागि छोटा सूचना पिन गर। अफलाइनमा पनि फोनबाट फोनमा पुग्छन् र केही दिनपछि आफैँ हराउँछन्। + स्थायी + %1$s मेटिन्छ + mesh + नेट + स्थान उपलब्ध छैन + स्थान सक्षम गर + 📌 @%1$sको जरुरी सूचना: %2$s + 📌 %1$d नयाँ जरुरी सूचना — हेर्न पिन ट्याप गर परिचय खोल्नुहोस् यहाँ छोडिएका नोटहरू छन् कि हेर्नुहोस् यहाँ 1 नोट छोडिएको छ — पढ्न ट्याप गर्नुहोस् diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 1e46f196..a3dd971c 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -410,6 +410,20 @@ Verified You verified %1$s verified %1$s + + mededelingen + mededelingen + geo · %1$d + mesh · %1$d + prik korte mededelingen voor mensen om je heen. ze springen van telefoon naar telefoon, ook offline, en verdwijnen vanzelf na een paar dagen. + permanent + vervaagt %1$s + mesh + net + locatie niet beschikbaar + locatie inschakelen + 📌 dringende mededeling van @%1$s: %2$s + 📌 %1$d nieuwe dringende mededelingen — tik op de pin om te bekijken Info openen kijk of hier notities zijn achtergelaten 1 notitie hier achtergelaten — tik om te lezen diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 3ec84dd4..204b9a7b 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -398,4 +398,21 @@ Język aplikacji Domyślny systemu Wybierz język + ogłoszenia + ogłoszenia + geo · %1$d + mesh · %1$d + przypinaj krótkie ogłoszenia dla ludzi w pobliżu. przeskakują z telefonu na telefon, nawet offline, i same znikają po kilku dniach. + na stałe + zniknie %1$s + mesh + sieć + lokalizacja niedostępna + włącz lokalizację + 📌 pilne ogłoszenie od @%1$s: %2$s + 📌 %1$d nowych pilnych ogłoszeń — dotknij pinezki, aby zobaczyć + Otwórz informacje + sprawdź, czy zostawiono tutaj notatki + 1 notatka zostawiona tutaj — stuknij, aby przeczytać + %d notatek zostawionych tutaj — stuknij, aby przeczytać diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 42340857..b8977dbe 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -385,6 +385,19 @@ Verificado Você verificou %1$s verificou %1$s + avisos + avisos + geo · %1$d + mesh · %1$d + fixe avisos curtos para as pessoas por perto. eles pulam de celular em celular, mesmo offline, e somem sozinhos depois de alguns dias. + permanente + desaparece %1$s + mesh + rede + localização indisponível + habilitar localização + 📌 aviso urgente de @%1$s: %2$s + 📌 %1$d avisos urgentes novos — toque no pin para ver ver se há notas deixadas aqui 1 nota deixada aqui — toque para ler %d notas deixadas aqui — toque para ler diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 0e01bb07..f7a7ef51 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -385,6 +385,20 @@ Verificado Você verificou %1$s verificou %1$s + + avisos + avisos + geo · %1$d + mesh · %1$d + afixa avisos curtos para quem está por perto. saltam de telemóvel em telemóvel, mesmo offline, e desaparecem sozinhos após alguns dias. + permanente + desvanece %1$s + mesh + rede + localização indisponível + ativar localização + 📌 aviso urgente de @%1$s: %2$s + 📌 %1$d novos avisos urgentes — toca no pin para ver Abrir Sobre ver se há notas deixadas aqui 1 nota deixada aqui — toque para ler diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 02b1175d..7a289d57 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -372,6 +372,20 @@ Проверено Вы проверили %1$s проверен %1$s + + объявления + объявления + гео · %1$d + mesh · %1$d + закрепляй короткие объявления для людей рядом. они передаются с телефона на телефон, даже офлайн, и сами исчезают через несколько дней. + навсегда + исчезнет %1$s + mesh + сеть + локация недоступна + включить локацию + 📌 срочное объявление от @%1$s: %2$s + 📌 %1$d новых срочных объявлений — нажми на булавку, чтобы посмотреть Открыть раздел «О приложении» проверить, есть ли здесь заметки здесь оставлена 1 заметка — нажмите, чтобы прочитать diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index b958faad..4b2dd58d 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -370,6 +370,20 @@ Verified You verified %1$s verified %1$s + + anslag + anslag + geo · %1$d + mesh · %1$d + 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. + permanent + tonar bort %1$s + mesh + nät + plats ej tillgänglig + aktivera plats + 📌 brådskande anslag från @%1$s: %2$s + 📌 %1$d nya brådskande anslag — tryck på nålen för att visa Öppna Om kolla om anteckningar lämnats här 1 anteckning lämnad här — tryck för att läsa diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 243f2112..6cf00d63 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -390,4 +390,21 @@ பயன்பாட்டு மொழி கணினி இயல்புநிலை மொழியைத் தேர்ந்தெடுக்கவும் + அறிவிப்புகள் + அறிவிப்புகள் + பகுதி · %1$d + mesh · %1$d + அருகிலுள்ளவர்களுக்காக சிறிய அறிவிப்புகளைப் பின் செய்யவும். ஆஃப்லைனிலும் ஃபோனிலிருந்து ஃபோனுக்குப் பரவி, சில நாட்களில் தானாக மறைந்துவிடும். + நிரந்தரம் + %1$s மறையும் + mesh + நெட் + இடம் கிடைக்கவில்லை + இடத்தை இயக்கு + 📌 @%1$s இன் அவசர அறிவிப்பு: %2$s + 📌 %1$d புதிய அவசர அறிவிப்புகள் — பார்க்க பின்னைத் தட்டவும் + அறிமுகத்தைத் திற + இங்கே விடப்பட்ட குறிப்புகள் உள்ளதா எனப் பார்க்கவும் + இங்கே 1 குறிப்பு விடப்பட்டுள்ளது — படிக்க தட்டவும் + இங்கே %d குறிப்புகள் விடப்பட்டுள்ளன — படிக்க தட்டவும் diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index b21049c1..c0edbe39 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -372,6 +372,20 @@ Verified You verified %1$s verified %1$s + + ประกาศ + ประกาศ + พื้นที่ · %1$d + mesh · %1$d + ปักประกาศสั้น ๆ ให้คนรอบตัว ส่งต่อจากมือถือสู่มือถือได้แม้ออฟไลน์ และหายไปเองหลังผ่านไปสองสามวัน + ถาวร + เลือนหาย %1$s + mesh + เน็ต + ไม่มีข้อมูลตำแหน่ง + เปิดใช้งานตำแหน่ง + 📌 ประกาศด่วนจาก @%1$s: %2$s + 📌 ประกาศด่วนใหม่ %1$d รายการ — แตะหมุดเพื่อดู เปิดเกี่ยวกับ ดูว่ามีโน้ตทิ้งไว้ที่นี่หรือไม่ มี 1 โน้ตทิ้งไว้ที่นี่ — แตะเพื่ออ่าน diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 251d6a46..c1ec19b9 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -370,6 +370,20 @@ Verified You verified %1$s verified %1$s + + duyurular + duyurular + bölge · %1$d + mesh · %1$d + çevrendekiler için kısa duyurular sabitle. çevrimdışıyken bile telefondan telefona geçer ve birkaç gün sonra kendiliğinden kaybolur. + kalıcı + %1$s kaybolur + mesh + ağ + konum kullanılamıyor + konumu etkinleştir + 📌 @%1$s kişisinden acil duyuru: %2$s + 📌 %1$d yeni acil duyuru — görmek için raptiyeye dokun Hakkında’yı aç buraya bırakılan notlara bak buraya 1 not bırakıldı — okumak için dokun diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index f248ff71..e5ec5bbe 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -400,4 +400,21 @@ Мова застосунку Системна мова Вибрати мову + оголошення + оголошення + гео · %1$d + mesh · %1$d + закріплюй короткі оголошення для людей поруч. вони передаються з телефона на телефон, навіть офлайн, і самі зникають за кілька днів. + назавжди + зникне %1$s + mesh + мережа + локація недоступна + увімкнути локацію + 📌 термінове оголошення від @%1$s: %2$s + 📌 %1$d нових термінових оголошень — натисни на шпильку, щоб переглянути + Відкрити розділ «Про застосунок» + перевірити, чи залишено тут нотатки + тут залишено 1 нотатку — торкніться, щоб прочитати + тут залишено %d нотаток — торкніться, щоб прочитати diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index 785338fb..be6c71da 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -385,6 +385,20 @@ Verified You verified %1$s verified %1$s + + اعلانات + اعلانات + علاقہ · %1$d + mesh · %1$d + آس پاس کے لوگوں کیلئے مختصر اعلانات پن کریں۔ یہ آف لائن بھی فون سے فون تک پہنچتے ہیں اور کچھ دنوں بعد خود مٹ جاتے ہیں۔ + مستقل + %1$s مٹ جائے گا + mesh + نیٹ + لوکیشن دستیاب نہیں + لوکیشن فعال کریں + 📌 @%1$s کا فوری اعلان: %2$s + 📌 %1$d نئے فوری اعلانات — دیکھنے کیلئے پن پر ٹیپ کریں تعارف کھولیں دیکھیں کہ یہاں نوٹ چھوڑے گئے ہیں یا نہیں یہاں 1 نوٹ چھوڑا گیا ہے — پڑھنے کے لیے تھپتھپائیں diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index b97c3fc5..73e648d9 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -372,6 +372,20 @@ Verified You verified %1$s verified %1$s + + thông báo + thông báo + khu vực · %1$d + mesh · %1$d + 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. + vĩnh viễn + mờ dần %1$s + mesh + mạng + không có dữ liệu vị trí + bật vị trí + 📌 thông báo khẩn từ @%1$s: %2$s + 📌 %1$d thông báo khẩn mới — chạm vào ghim để xem Mở phần Giới thiệu kiểm tra ghi chú để lại ở đây có 1 ghi chú để lại ở đây — chạm để đọc diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index fca6b07b..9cb59954 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -388,4 +388,20 @@ 应用语言 跟随系统 选择语言 + 公告 + 公告 + 区域 · %1$d + mesh · %1$d + 为周围的人钉上简短公告。即使离线也能在手机间传递,几天后自动消失。 + 永久 + %1$s消失 + mesh + 网络 + 位置不可用 + 启用位置 + 📌 来自 @%1$s 的紧急公告:%2$s + 📌 %1$d 条新紧急公告 — 点按图钉查看 + 查看这里留下的留言 + 这里留有 1 条留言 — 点按阅读 + 这里留有 %d 条留言 — 点按阅读 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index f427dbaf..e83e90c0 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -389,4 +389,20 @@ 應用程式語言 跟隨系統 選擇語言 + 公告 + 公告 + 區域 · %1$d + mesh · %1$d + 為周圍的人釘上簡短公告。即使離線也能在手機間傳遞,幾天後自動消失。 + 永久 + %1$s消失 + mesh + 網路 + 位置不可用 + 啟用位置 + 📌 來自 @%1$s 的緊急公告:%2$s + 📌 %1$d 則新緊急公告 — 點按圖釘查看 + 查看這裡留下的留言 + 這裡留有 1 則留言 — 點按閱讀 + 這裡留有 %d 則留言 — 點按閱讀 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 35f7477a..00c69754 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -396,6 +396,20 @@ 已验证 你已验证 %1$s 已验证 %1$s + + 公告 + 公告 + 区域 · %1$d + mesh · %1$d + 为周围的人钉上简短公告。即使离线也能在手机间传递,几天后自动消失。 + 永久 + %1$s消失 + mesh + 网络 + 位置不可用 + 启用位置 + 📌 来自 @%1$s 的紧急公告:%2$s + 📌 %1$d 条新紧急公告 — 点按图钉查看 打开“关于” 查看这里留下的留言 这里留有 1 条留言 — 点按阅读 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cc13eeac..9ff033fa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -453,6 +453,27 @@ Be the first to add one for this spot. Dismiss Add a note for this place + Notices + notices + geo · %1$d + mesh · %1$d + pin short notices for people around you. they hop phone to phone, even offline, and disappear on their own after a few days. + urgent + add a notice + Post notice + Delete notice + expires + %1$d days + permanent + fades %1$s + mesh + net + location unavailable + enable location to view persistent notices near you. + enable location + could not sign and send this notice + 📌 urgent notice from @%1$s: %2$s + 📌 %1$d new urgent notices — tap the pin to view Debug Tools diff --git a/app/src/test/java/com/bitchat/android/board/BoardManagerTest.kt b/app/src/test/java/com/bitchat/android/board/BoardManagerTest.kt new file mode 100644 index 00000000..9de2fd1c --- /dev/null +++ b/app/src/test/java/com/bitchat/android/board/BoardManagerTest.kt @@ -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() + val notes = mock() + 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() + 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() + 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() + 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() + whenever(mesh.getSigningPublicKey()).thenReturn(ByteArray(32) { 9 }) + val store = BoardStore(nowMs = { NOW }) + val alerts = mutableListOf>>() + 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 + } +} diff --git a/app/src/test/java/com/bitchat/android/board/BoardPacketsTest.kt b/app/src/test/java/com/bitchat/android/board/BoardPacketsTest.kt new file mode 100644 index 00000000..99762a5f --- /dev/null +++ b/app/src/test/java/com/bitchat/android/board/BoardPacketsTest.kt @@ -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() + 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() + } +} diff --git a/app/src/test/java/com/bitchat/android/board/BoardStoreTest.kt b/app/src/test/java/com/bitchat/android/board/BoardStoreTest.kt new file mode 100644 index 00000000..bda4aa41 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/board/BoardStoreTest.kt @@ -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() + } + } +} diff --git a/app/src/test/java/com/bitchat/android/board/UnifiedNoticesTest.kt b/app/src/test/java/com/bitchat/android/board/UnifiedNoticesTest.kt new file mode 100644 index 00000000..9443a7de --- /dev/null +++ b/app/src/test/java/com/bitchat/android/board/UnifiedNoticesTest.kt @@ -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 + ) +} diff --git a/app/src/test/java/com/bitchat/android/sync/BoardSyncTest.kt b/app/src/test/java/com/bitchat/android/sync/BoardSyncTest.kt new file mode 100644 index 00000000..0f3a294d --- /dev/null +++ b/app/src/test/java/com/bitchat/android/sync/BoardSyncTest.kt @@ -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() + 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) + } +} diff --git a/app/src/test/java/com/bitchat/android/ui/NoticeComposerPolicyTest.kt b/app/src/test/java/com/bitchat/android/ui/NoticeComposerPolicyTest.kt new file mode 100644 index 00000000..553e6813 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/NoticeComposerPolicyTest.kt @@ -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 + ) + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt b/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt index cfc31d02..f945a59f 100644 --- a/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/model/IdentityAnnouncementTest.kt @@ -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!! diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt index a5bd9561..ec3a8679 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt @@ -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, diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index a4d37f68..fd814ba9 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -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/**",