mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-29 07:16:08 +00:00
feat: add geohash bulletin board and unified notices
This commit is contained in:
parent
61588db474
commit
771b063094
194
app/src/main/java/com/bitchat/android/board/BoardManager.kt
Normal file
194
app/src/main/java/com/bitchat/android/board/BoardManager.kt
Normal file
@ -0,0 +1,194 @@
|
||||
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 notesManager: LocationNotesManager = LocationNotesManager.getInstance(),
|
||||
private val nowMs: () -> ULong = { System.currentTimeMillis().coerceAtLeast(0).toULong() },
|
||||
private val random: SecureRandom = SecureRandom(),
|
||||
private val onUrgentPosts: (geohash: String, posts: List<BoardPostPacket>) -> Unit = { _, _ -> }
|
||||
) {
|
||||
private val _unseenScopes = MutableStateFlow<Set<String>>(emptySet())
|
||||
private val bridgedEventIDs = mutableMapOf<String, String>()
|
||||
private val handledPostIDs = mutableSetOf<String>()
|
||||
private val pendingUrgent = mutableMapOf<String, MutableList<BoardPostPacket>>()
|
||||
private var alertFlushJob: Job? = null
|
||||
|
||||
val posts: StateFlow<List<BoardPostPacket>> = store.postsSnapshot
|
||||
val unseenScopes: StateFlow<Set<String>> = _unseenScopes.asStateFlow()
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
store.postArrivals.collect { post ->
|
||||
handleArrival(post)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun posts(geohash: String): List<BoardPostPacket> = store.posts(geohash.lowercase())
|
||||
|
||||
fun isOwnPost(post: BoardPostPacket): Boolean =
|
||||
meshProvider().getSigningPublicKey()?.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 signingKey = mesh.getSigningPublicKey()
|
||||
?.takeIf { it.size == BoardWireConstants.SIGNING_KEY_LENGTH }
|
||||
?: return false
|
||||
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 = mesh.signData(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[postID.toHex()] = eventID
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun deletePost(post: BoardPostPacket): Boolean {
|
||||
if (!isOwnPost(post)) return false
|
||||
val deletedAt = nowMs()
|
||||
val signature = meshProvider().signData(
|
||||
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.postID.toHex())
|
||||
}
|
||||
if (eventID != null) notesManager.deleteEvent(eventID, post.geohash)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun markSeen(scopes: Set<String>) {
|
||||
if (scopes.isEmpty()) return
|
||||
_unseenScopes.value = _unseenScopes.value - scopes
|
||||
}
|
||||
|
||||
fun clearTransientState() {
|
||||
_unseenScopes.value = emptySet()
|
||||
synchronized(bridgedEventIDs) { bridgedEventIDs.clear() }
|
||||
handledPostIDs.clear()
|
||||
pendingUrgent.clear()
|
||||
alertFlushJob?.cancel()
|
||||
alertFlushJob = null
|
||||
}
|
||||
|
||||
private fun handleArrival(post: BoardPostPacket) {
|
||||
val postID = post.postID.toHex()
|
||||
if (!handledPostIDs.add(postID) || 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 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
|
||||
}
|
||||
}
|
||||
385
app/src/main/java/com/bitchat/android/board/BoardPackets.kt
Normal file
385
app/src/main/java/com/bitchat/android/board/BoardPackets.kt
Normal file
@ -0,0 +1,385 @@
|
||||
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
|
||||
|
||||
object BoardWireConstants {
|
||||
const val POST_ID_LENGTH = 16
|
||||
const val SIGNING_KEY_LENGTH = 32
|
||||
const val SIGNATURE_LENGTH = 64
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
366
app/src/main/java/com/bitchat/android/board/BoardStore.kt
Normal file
366
app/src/main/java/com/bitchat/android/board/BoardStore.kt
Normal file
@ -0,0 +1,366 @@
|
||||
package com.bitchat.android.board
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.io.File
|
||||
import java.util.Base64
|
||||
|
||||
enum class BoardIngestResult {
|
||||
ACCEPTED,
|
||||
DUPLICATE,
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum class BoardIngestSource {
|
||||
REMOTE,
|
||||
LOCAL,
|
||||
RESTORE
|
||||
}
|
||||
|
||||
class BoardStore(
|
||||
private val file: File? = null,
|
||||
private val nowMs: () -> ULong = { System.currentTimeMillis().coerceAtLeast(0).toULong() }
|
||||
) {
|
||||
object Limits {
|
||||
const val MAX_POSTS = 200
|
||||
const val MAX_POSTS_PER_AUTHOR = 5
|
||||
const val MAX_ORPHAN_TOMBSTONES = 100
|
||||
const val MAX_ORPHAN_TOMBSTONES_PER_AUTHOR = 5
|
||||
const val CLOCK_SKEW_MS: ULong = 3_600_000uL
|
||||
const val ORPHAN_TOMBSTONE_LIFETIME_MS: ULong = BoardWireConstants.MAX_LIFETIME_MS
|
||||
}
|
||||
|
||||
private data class StoredPost(
|
||||
val post: BoardPostPacket,
|
||||
val packet: BitchatPacket,
|
||||
val rawPacket: ByteArray
|
||||
)
|
||||
|
||||
private data class StoredTombstone(
|
||||
val tombstone: BoardTombstonePacket,
|
||||
val packet: BitchatPacket,
|
||||
val rawPacket: ByteArray,
|
||||
val retainUntil: ULong,
|
||||
val isOrphan: Boolean
|
||||
)
|
||||
|
||||
private data class PersistedEntry(
|
||||
val packet: String,
|
||||
val retainUntil: String?
|
||||
)
|
||||
|
||||
private val lock = Any()
|
||||
private val posts = mutableListOf<StoredPost>()
|
||||
private val tombstones = mutableListOf<StoredTombstone>()
|
||||
private val _postsSnapshot = MutableStateFlow<List<BoardPostPacket>>(emptyList())
|
||||
private val _postArrivals = MutableSharedFlow<BoardPostPacket>(extraBufferCapacity = 64)
|
||||
|
||||
val postsSnapshot: StateFlow<List<BoardPostPacket>> = _postsSnapshot.asStateFlow()
|
||||
val postArrivals: SharedFlow<BoardPostPacket> = _postArrivals.asSharedFlow()
|
||||
|
||||
init {
|
||||
loadFromDisk()
|
||||
}
|
||||
|
||||
fun ingest(
|
||||
wire: BoardWire,
|
||||
packet: BitchatPacket,
|
||||
source: BoardIngestSource = BoardIngestSource.REMOTE
|
||||
): BoardIngestResult {
|
||||
if (packet.type != MessageType.BOARD_POST.value || !wire.verifySignature()) {
|
||||
return BoardIngestResult.REJECTED
|
||||
}
|
||||
val rawPacket = packet.toBinaryData(padding = false) ?: return BoardIngestResult.REJECTED
|
||||
val now = nowMs()
|
||||
var arrival: BoardPostPacket? = null
|
||||
val result = synchronized(lock) {
|
||||
val outcome = ingestLocked(
|
||||
wire = wire,
|
||||
packet = packet,
|
||||
rawPacket = rawPacket,
|
||||
now = now,
|
||||
retainUntilOverride = null
|
||||
)
|
||||
if (outcome == BoardIngestResult.ACCEPTED && source != BoardIngestSource.RESTORE) {
|
||||
persistLocked()
|
||||
}
|
||||
if (outcome == BoardIngestResult.ACCEPTED &&
|
||||
source == BoardIngestSource.REMOTE &&
|
||||
wire is BoardWire.Post
|
||||
) {
|
||||
arrival = wire.packet
|
||||
}
|
||||
outcome
|
||||
}
|
||||
arrival?.let(_postArrivals::tryEmit)
|
||||
return result
|
||||
}
|
||||
|
||||
fun posts(forGeohash: String): List<BoardPostPacket> = synchronized(lock) {
|
||||
pruneExpiredLocked(nowMs())
|
||||
posts.asSequence()
|
||||
.map { it.post }
|
||||
.filter { it.geohash == forGeohash }
|
||||
.sortedWith(
|
||||
compareByDescending<BoardPostPacket> { it.isUrgent }
|
||||
.thenByDescending { it.createdAt }
|
||||
)
|
||||
.toList()
|
||||
}
|
||||
|
||||
fun syncCandidates(): List<BitchatPacket> = synchronized(lock) {
|
||||
pruneExpiredLocked(nowMs())
|
||||
posts.map { it.packet } + tombstones.map { it.packet }
|
||||
}
|
||||
|
||||
fun pruneExpired() = synchronized(lock) {
|
||||
val changed = pruneExpiredLocked(nowMs())
|
||||
if (changed) persistLocked()
|
||||
}
|
||||
|
||||
fun wipe() = synchronized(lock) {
|
||||
posts.clear()
|
||||
tombstones.clear()
|
||||
file?.let { runCatching { if (it.exists()) it.delete() } }
|
||||
publishSnapshotLocked()
|
||||
}
|
||||
|
||||
private fun ingestLocked(
|
||||
wire: BoardWire,
|
||||
packet: BitchatPacket,
|
||||
rawPacket: ByteArray,
|
||||
now: ULong,
|
||||
retainUntilOverride: ULong?
|
||||
): BoardIngestResult {
|
||||
pruneExpiredLocked(now)
|
||||
return when (wire) {
|
||||
is BoardWire.Post -> ingestPostLocked(wire.packet, packet, rawPacket, now)
|
||||
is BoardWire.Tombstone -> ingestTombstoneLocked(
|
||||
wire.packet,
|
||||
packet,
|
||||
rawPacket,
|
||||
now,
|
||||
retainUntilOverride
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ingestPostLocked(
|
||||
post: BoardPostPacket,
|
||||
packet: BitchatPacket,
|
||||
rawPacket: ByteArray,
|
||||
now: ULong
|
||||
): BoardIngestResult {
|
||||
if (post.expiresAt <= now) return BoardIngestResult.REJECTED
|
||||
if (post.createdAt > now.saturatedAdd(Limits.CLOCK_SKEW_MS)) {
|
||||
return BoardIngestResult.REJECTED
|
||||
}
|
||||
if (post.expiresAt > now.saturatedAdd(BoardWireConstants.MAX_LIFETIME_MS)
|
||||
.saturatedAdd(Limits.CLOCK_SKEW_MS)
|
||||
) {
|
||||
return BoardIngestResult.REJECTED
|
||||
}
|
||||
if (tombstones.any {
|
||||
it.tombstone.postID.contentEquals(post.postID) &&
|
||||
it.tombstone.authorSigningKey.contentEquals(post.authorSigningKey)
|
||||
}
|
||||
) {
|
||||
return BoardIngestResult.REJECTED
|
||||
}
|
||||
if (posts.any { it.post.postID.contentEquals(post.postID) }) {
|
||||
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) }) {
|
||||
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) }
|
||||
val retainUntil: ULong
|
||||
val isOrphan: Boolean
|
||||
if (matchingPostIndex >= 0) {
|
||||
val target = posts[matchingPostIndex].post
|
||||
if (!target.authorSigningKey.contentEquals(tombstone.authorSigningKey)) {
|
||||
return BoardIngestResult.REJECTED
|
||||
}
|
||||
retainUntil = target.expiresAt
|
||||
isOrphan = false
|
||||
posts.removeAt(matchingPostIndex)
|
||||
publishSnapshotLocked()
|
||||
} else if (retainUntilOverride != null) {
|
||||
retainUntil = minOf(retainUntilOverride, maxRetain)
|
||||
isOrphan = false
|
||||
} else {
|
||||
retainUntil = maxRetain
|
||||
isOrphan = true
|
||||
}
|
||||
if (retainUntil <= now) return BoardIngestResult.REJECTED
|
||||
|
||||
tombstones += StoredTombstone(
|
||||
tombstone = tombstone,
|
||||
packet = packet,
|
||||
rawPacket = rawPacket,
|
||||
retainUntil = retainUntil,
|
||||
isOrphan = isOrphan
|
||||
)
|
||||
if (isOrphan) enforceOrphanTombstoneCapsLocked(tombstone.authorSigningKey)
|
||||
return BoardIngestResult.ACCEPTED
|
||||
}
|
||||
|
||||
private fun enforcePostCapsLocked(author: ByteArray) {
|
||||
val authorPosts = posts.filter { it.post.authorSigningKey.contentEquals(author) }
|
||||
evictOldestPostsLocked(authorPosts, Limits.MAX_POSTS_PER_AUTHOR)
|
||||
evictOldestPostsLocked(posts.toList(), Limits.MAX_POSTS)
|
||||
}
|
||||
|
||||
private fun evictOldestPostsLocked(candidates: List<StoredPost>, keep: Int) {
|
||||
val victimIDs = candidates.sortedBy { it.post.createdAt }
|
||||
.take((candidates.size - keep).coerceAtLeast(0))
|
||||
.map { it.post.postID.toHex() }
|
||||
.toSet()
|
||||
if (victimIDs.isNotEmpty()) {
|
||||
posts.removeAll { it.post.postID.toHex() in victimIDs }
|
||||
}
|
||||
}
|
||||
|
||||
private fun enforceOrphanTombstoneCapsLocked(author: ByteArray) {
|
||||
val authorOrphans = tombstones.filter {
|
||||
it.isOrphan && it.tombstone.authorSigningKey.contentEquals(author)
|
||||
}
|
||||
removeOldestTombstonesLocked(
|
||||
authorOrphans,
|
||||
authorOrphans.size - Limits.MAX_ORPHAN_TOMBSTONES_PER_AUTHOR
|
||||
)
|
||||
val allOrphans = tombstones.filter { it.isOrphan }
|
||||
removeOldestTombstonesLocked(
|
||||
allOrphans,
|
||||
allOrphans.size - Limits.MAX_ORPHAN_TOMBSTONES
|
||||
)
|
||||
}
|
||||
|
||||
private fun removeOldestTombstonesLocked(
|
||||
candidates: List<StoredTombstone>,
|
||||
count: Int
|
||||
) {
|
||||
if (count <= 0) return
|
||||
val victimIDs = candidates.take(count).map { it.tombstone.postID.toHex() }.toSet()
|
||||
tombstones.removeAll { it.tombstone.postID.toHex() in victimIDs }
|
||||
}
|
||||
|
||||
private fun pruneExpiredLocked(now: ULong): Boolean {
|
||||
val postsBefore = posts.size
|
||||
val tombstonesBefore = tombstones.size
|
||||
posts.removeAll { it.post.expiresAt <= now }
|
||||
tombstones.removeAll { it.retainUntil <= now }
|
||||
if (posts.size != postsBefore) publishSnapshotLocked()
|
||||
return posts.size != postsBefore || tombstones.size != tombstonesBefore
|
||||
}
|
||||
|
||||
private fun publishSnapshotLocked() {
|
||||
_postsSnapshot.value = posts.map { it.post }
|
||||
}
|
||||
|
||||
private fun persistLocked() {
|
||||
val target = file ?: return
|
||||
val entries = posts.map {
|
||||
PersistedEntry(
|
||||
packet = Base64.getEncoder().encodeToString(it.rawPacket),
|
||||
retainUntil = null
|
||||
)
|
||||
} + tombstones.map {
|
||||
PersistedEntry(
|
||||
packet = Base64.getEncoder().encodeToString(it.rawPacket),
|
||||
retainUntil = it.retainUntil.toString()
|
||||
)
|
||||
}
|
||||
runCatching {
|
||||
if (entries.isEmpty()) {
|
||||
if (target.exists()) target.delete()
|
||||
return
|
||||
}
|
||||
target.parentFile?.mkdirs()
|
||||
val temporary = File(target.parentFile, "${target.name}.tmp")
|
||||
temporary.writeText(Gson().toJson(entries))
|
||||
if (!temporary.renameTo(target)) {
|
||||
temporary.copyTo(target, overwrite = true)
|
||||
temporary.delete()
|
||||
}
|
||||
}.onFailure {
|
||||
Log.e(TAG, "Failed to persist board store: ${it.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadFromDisk() {
|
||||
val target = file ?: return
|
||||
if (!target.isFile) return
|
||||
val entries = runCatching {
|
||||
val type = object : TypeToken<List<PersistedEntry>>() {}.type
|
||||
Gson().fromJson<List<PersistedEntry>>(target.readText(), type)
|
||||
}.getOrNull() ?: return
|
||||
val now = nowMs()
|
||||
synchronized(lock) {
|
||||
for (entry in entries) {
|
||||
val raw = runCatching { Base64.getDecoder().decode(entry.packet) }.getOrNull() ?: continue
|
||||
val packet = BitchatPacket.fromBinaryData(raw) ?: continue
|
||||
if (packet.type != MessageType.BOARD_POST.value) continue
|
||||
val wire = BoardWireCodec.decode(packet.payload) ?: continue
|
||||
if (!wire.verifySignature()) continue
|
||||
ingestLocked(
|
||||
wire = wire,
|
||||
packet = packet,
|
||||
rawPacket = raw,
|
||||
now = now,
|
||||
retainUntilOverride = entry.retainUntil?.toULongOrNull()
|
||||
)
|
||||
}
|
||||
publishSnapshotLocked()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BoardStore"
|
||||
|
||||
@Volatile
|
||||
private var instance: BoardStore? = null
|
||||
|
||||
fun getInstance(context: Context): BoardStore =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: BoardStore(
|
||||
File(context.applicationContext.filesDir, "board/posts.json")
|
||||
).also { instance = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ULong.saturatedAdd(other: ULong): ULong =
|
||||
if (ULong.MAX_VALUE - this < other) ULong.MAX_VALUE else this + other
|
||||
|
||||
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
||||
@ -0,0 +1,87 @@
|
||||
package com.bitchat.android.board
|
||||
|
||||
import com.bitchat.android.nostr.LocationNotesManager
|
||||
import kotlin.math.abs
|
||||
|
||||
enum class NoticeSource {
|
||||
MESH,
|
||||
NOSTR
|
||||
}
|
||||
|
||||
data class UnifiedNotice(
|
||||
val id: String,
|
||||
val content: String,
|
||||
val nickname: String,
|
||||
val createdAtMs: Long,
|
||||
val geohash: String,
|
||||
val urgent: Boolean,
|
||||
val expiresAtMs: Long?,
|
||||
val source: NoticeSource,
|
||||
val boardPost: BoardPostPacket? = null,
|
||||
val nostrNote: LocationNotesManager.Note? = null
|
||||
)
|
||||
|
||||
object UnifiedNotices {
|
||||
private const val DEDUPLICATION_WINDOW_MS = 15 * 60 * 1_000L
|
||||
|
||||
/**
|
||||
* Combines exact-scope mesh board posts with relay notes. When a relay
|
||||
* bridge copy matches a board post, the signed board copy is authoritative.
|
||||
*/
|
||||
fun merge(
|
||||
geohash: String,
|
||||
boardPosts: List<BoardPostPacket>,
|
||||
relayNotes: List<LocationNotesManager.Note>
|
||||
): List<UnifiedNotice> {
|
||||
val normalized = geohash.lowercase()
|
||||
val scopedPosts = boardPosts.filter { it.geohash == normalized }
|
||||
val boardNotices = scopedPosts.map { post ->
|
||||
UnifiedNotice(
|
||||
id = "mesh:${post.postID.toHex()}",
|
||||
content = post.content,
|
||||
nickname = post.authorNickname,
|
||||
createdAtMs = post.createdAt.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(),
|
||||
geohash = post.geohash,
|
||||
urgent = post.isUrgent,
|
||||
expiresAtMs = post.expiresAt.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(),
|
||||
source = NoticeSource.MESH,
|
||||
boardPost = post
|
||||
)
|
||||
}
|
||||
val relayNotices = relayNotes.asSequence()
|
||||
.filterNot { note ->
|
||||
scopedPosts.any { post ->
|
||||
post.geohash == note.geohash.lowercase() &&
|
||||
post.content == note.content &&
|
||||
post.authorNickname.ifBlank { "anon" } ==
|
||||
note.nickname?.trim()?.takeIf { it.isNotEmpty() }.orEmpty()
|
||||
.ifEmpty { "anon" } &&
|
||||
abs(
|
||||
post.createdAt.coerceAtMost(Long.MAX_VALUE.toULong()).toLong() -
|
||||
note.createdAt.toLong() * 1_000L
|
||||
) <= DEDUPLICATION_WINDOW_MS
|
||||
}
|
||||
}
|
||||
.map { note ->
|
||||
UnifiedNotice(
|
||||
id = "nostr:${note.id}",
|
||||
content = note.content,
|
||||
nickname = note.nickname.orEmpty(),
|
||||
createdAtMs = note.createdAt.toLong() * 1_000L,
|
||||
geohash = note.geohash.lowercase(),
|
||||
urgent = note.isUrgent,
|
||||
expiresAtMs = note.expiresAt?.toLong()?.times(1_000L),
|
||||
source = NoticeSource.NOSTR,
|
||||
nostrNote = note
|
||||
)
|
||||
}
|
||||
.toList()
|
||||
|
||||
return (boardNotices + relayNotices).sortedWith(
|
||||
compareByDescending<UnifiedNotice> { it.urgent }
|
||||
.thenByDescending { it.createdAtMs }
|
||||
)
|
||||
}
|
||||
|
||||
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
@ -108,6 +108,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)
|
||||
@ -157,6 +158,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)
|
||||
@ -674,6 +676,21 @@ 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
|
||||
if (!wire.verifySignature()) return false
|
||||
return when (boardStore.ingest(
|
||||
wire,
|
||||
routed.packet,
|
||||
com.bitchat.android.board.BoardIngestSource.REMOTE
|
||||
)) {
|
||||
com.bitchat.android.board.BoardIngestResult.ACCEPTED,
|
||||
com.bitchat.android.board.BoardIngestResult.DUPLICATE -> true
|
||||
com.bitchat.android.board.BoardIngestResult.REJECTED -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BluetoothConnectionManager delegates
|
||||
@ -907,6 +924,38 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
}
|
||||
|
||||
fun getSigningPublicKey(): ByteArray? = encryptionService.getSigningPublicKey()
|
||||
|
||||
fun signData(data: ByteArray): ByteArray? = encryptionService.signData(data)
|
||||
|
||||
fun sendBoardPayload(payload: ByteArray) {
|
||||
val wire = com.bitchat.android.board.BoardWireCodec.decode(payload) ?: return
|
||||
if (!wire.verifySignature()) return
|
||||
serviceScope.launch {
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.BOARD_POST.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
recipientID = null,
|
||||
timestamp = System.currentTimeMillis().coerceAtLeast(0).toULong(),
|
||||
payload = payload,
|
||||
signature = null,
|
||||
ttl = MAX_TTL
|
||||
)
|
||||
val signed = signPacketBeforeBroadcast(packet)
|
||||
if (signed.signature?.size != com.bitchat.android.board.BoardWireConstants.SIGNATURE_LENGTH) {
|
||||
Log.e(TAG, "Refusing to send board packet without an outer signature")
|
||||
return@launch
|
||||
}
|
||||
boardStore.ingest(
|
||||
wire,
|
||||
signed,
|
||||
com.bitchat.android.board.BoardIngestSource.LOCAL
|
||||
)
|
||||
broadcastRoutedPacket(RoutedPacket(signed))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a file over mesh as a broadcast MESSAGE (public mesh timeline/channels).
|
||||
*/
|
||||
@ -1621,6 +1670,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
securityManager.clearAllData()
|
||||
peerManager.clearAllPeers()
|
||||
peerManager.clearAllFingerprints()
|
||||
boardStore.wipe()
|
||||
Log.d(TAG, "✅ Cleared all mesh service internal data")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "❌ Error clearing mesh service internal data: ${e.message}")
|
||||
|
||||
@ -106,6 +106,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 val directPeers = ConcurrentHashMap.newKeySet<String>()
|
||||
@ -121,6 +122,7 @@ class MeshCore(
|
||||
|
||||
init {
|
||||
messageHandler.packetProcessor = packetProcessor
|
||||
gossipSyncManager.boardPacketsProvider = boardStore::syncCandidates
|
||||
peerManager.isPeerDirectlyConnected = { peerID -> directPeers.contains(peerID) }
|
||||
setupDelegates()
|
||||
|
||||
@ -505,6 +507,21 @@ 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
|
||||
if (!wire.verifySignature()) return false
|
||||
return when (boardStore.ingest(
|
||||
wire,
|
||||
routed.packet,
|
||||
com.bitchat.android.board.BoardIngestSource.REMOTE
|
||||
)) {
|
||||
com.bitchat.android.board.BoardIngestResult.ACCEPTED,
|
||||
com.bitchat.android.board.BoardIngestResult.DUPLICATE -> true
|
||||
com.bitchat.android.board.BoardIngestResult.REJECTED -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -527,6 +544,38 @@ class MeshCore(
|
||||
}
|
||||
}
|
||||
|
||||
fun getSigningPublicKey(): ByteArray? = encryptionService.getSigningPublicKey()
|
||||
|
||||
fun signData(data: ByteArray): ByteArray? = encryptionService.signData(data)
|
||||
|
||||
fun sendBoardPayload(payload: ByteArray) {
|
||||
val wire = com.bitchat.android.board.BoardWireCodec.decode(payload) ?: return
|
||||
if (!wire.verifySignature()) return
|
||||
scope.launch {
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.BOARD_POST.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
recipientID = null,
|
||||
timestamp = System.currentTimeMillis().coerceAtLeast(0).toULong(),
|
||||
payload = payload,
|
||||
signature = null,
|
||||
ttl = maxTtl
|
||||
)
|
||||
val signed = signPacketBeforeBroadcast(packet)
|
||||
if (signed.signature?.size != com.bitchat.android.board.BoardWireConstants.SIGNATURE_LENGTH) {
|
||||
Log.e("MeshCore", "Refusing to send board packet without an outer signature")
|
||||
return@launch
|
||||
}
|
||||
boardStore.ingest(
|
||||
wire,
|
||||
signed,
|
||||
com.bitchat.android.board.BoardIngestSource.LOCAL
|
||||
)
|
||||
dispatchGlobal(RoutedPacket(signed))
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendAuthenticatedPeerState(
|
||||
peerID: String,
|
||||
state: AuthenticatedPeerState,
|
||||
@ -994,6 +1043,7 @@ class MeshCore(
|
||||
securityManager.clearAllData()
|
||||
peerManager.clearAllPeers()
|
||||
peerManager.clearAllFingerprints()
|
||||
boardStore.wipe()
|
||||
}
|
||||
|
||||
fun clearAllEncryptionData() {
|
||||
|
||||
@ -21,6 +21,9 @@ interface MeshService {
|
||||
fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray)
|
||||
fun sendFileBroadcast(file: BitchatFilePacket)
|
||||
fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket)
|
||||
fun sendBoardPayload(payload: ByteArray) {}
|
||||
fun getSigningPublicKey(): ByteArray? = null
|
||||
fun signData(data: ByteArray): ByteArray? = null
|
||||
fun prepareFilePrivate(
|
||||
recipientPeerID: String,
|
||||
file: BitchatFilePacket,
|
||||
|
||||
@ -146,6 +146,7 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
MessageType.ANNOUNCE -> validPacket = handleAnnounce(routed)
|
||||
MessageType.MESSAGE -> handleMessage(routed)
|
||||
MessageType.FILE_TRANSFER -> handleMessage(routed) // treat same routing path; parsing happens in handler
|
||||
MessageType.BOARD_POST -> validPacket = handleBoardPost(routed)
|
||||
MessageType.LEAVE -> handleLeave(routed)
|
||||
MessageType.FRAGMENT -> handleFragment(routed)
|
||||
MessageType.REQUEST_SYNC -> handleRequestSync(routed)
|
||||
@ -249,9 +250,23 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
*/
|
||||
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
|
||||
@ -326,6 +341,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)
|
||||
|
||||
@ -274,7 +274,8 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
MessageType.ANNOUNCE,
|
||||
MessageType.MESSAGE,
|
||||
MessageType.FILE_TRANSFER,
|
||||
MessageType.LEAVE
|
||||
MessageType.LEAVE,
|
||||
MessageType.REQUEST_SYNC
|
||||
)) {
|
||||
return true
|
||||
}
|
||||
|
||||
@ -130,6 +130,19 @@ class UnifiedMeshService(
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendBoardPayload(payload: ByteArray) {
|
||||
when {
|
||||
isBleEnabled() -> bluetooth.sendBoardPayload(payload)
|
||||
else -> wifiService()?.sendBoardPayload(payload)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getSigningPublicKey(): ByteArray? =
|
||||
bluetooth.getSigningPublicKey() ?: wifiService()?.getSigningPublicKey()
|
||||
|
||||
override fun signData(data: ByteArray): ByteArray? =
|
||||
bluetooth.signData(data) ?: wifiService()?.signData(data)
|
||||
|
||||
override fun prepareFilePrivate(
|
||||
recipientPeerID: String,
|
||||
file: BitchatFilePacket,
|
||||
|
||||
@ -29,11 +29,14 @@ data class PeerCapabilities(val rawValue: Long) : Parcelable {
|
||||
companion object {
|
||||
val NONE = PeerCapabilities(0)
|
||||
|
||||
/** Persistent, signed mesh bulletin-board packets (message type 0x23). */
|
||||
val BOARD = PeerCapabilities(1L shl 4)
|
||||
|
||||
/** Noise-encrypted private BitchatFilePacket using payload type 0x20. */
|
||||
val PRIVATE_MEDIA = PeerCapabilities(1L shl 8)
|
||||
|
||||
/** Capabilities implemented by this Android build. */
|
||||
val LOCAL_SUPPORTED = PRIVATE_MEDIA
|
||||
val LOCAL_SUPPORTED = PeerCapabilities(BOARD.rawValue or PRIVATE_MEDIA.rawValue)
|
||||
|
||||
/**
|
||||
* Decode the low 64 bits and ignore any future extension bytes, which
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package com.bitchat.android.model
|
||||
|
||||
import com.bitchat.android.sync.SyncDefaults
|
||||
import com.bitchat.android.sync.SyncTypeFlags
|
||||
|
||||
/**
|
||||
* REQUEST_SYNC payload using GCS (Golomb-Coded Set) parameters.
|
||||
@ -8,11 +9,15 @@ import com.bitchat.android.sync.SyncDefaults
|
||||
* - 0x01: P (uint8) — Golomb-Rice parameter
|
||||
* - 0x02: M (uint32, big-endian) — hash range (N * 2^P)
|
||||
* - 0x03: data (opaque) — GR bitstream bytes
|
||||
* - 0x04: types (1-8 byte little-endian SyncTypeFlags)
|
||||
* - 0x05: sinceTimestamp (uint64, big-endian)
|
||||
*/
|
||||
data class RequestSyncPacket(
|
||||
val p: Int,
|
||||
val m: Long,
|
||||
val data: ByteArray
|
||||
val data: ByteArray,
|
||||
val types: SyncTypeFlags? = null,
|
||||
val sinceTimestamp: ULong? = null
|
||||
) {
|
||||
fun encode(): ByteArray {
|
||||
val out = ArrayList<Byte>()
|
||||
@ -38,6 +43,13 @@ data class RequestSyncPacket(
|
||||
)
|
||||
// data
|
||||
putTLV(0x03, data)
|
||||
types?.encode()?.let { putTLV(0x04, it) }
|
||||
sinceTimestamp?.let { timestamp ->
|
||||
putTLV(
|
||||
0x05,
|
||||
ByteArray(8) { index -> (timestamp shr ((7 - index) * 8)).toByte() }
|
||||
)
|
||||
}
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
@ -50,6 +62,8 @@ data class RequestSyncPacket(
|
||||
var p: Int? = null
|
||||
var m: Long? = null
|
||||
var payload: ByteArray? = null
|
||||
var types: SyncTypeFlags? = null
|
||||
var sinceTimestamp: ULong? = null
|
||||
|
||||
while (off + 3 <= data.size) {
|
||||
val t = (data[off].toInt() and 0xFF); off += 1
|
||||
@ -69,6 +83,14 @@ data class RequestSyncPacket(
|
||||
if (v.size > MAX_ACCEPT_FILTER_BYTES) return null
|
||||
payload = v
|
||||
}
|
||||
0x04 -> SyncTypeFlags.decode(v)?.let { types = it }
|
||||
0x05 -> if (v.size == 8) {
|
||||
var timestamp = 0uL
|
||||
for (byte in v) {
|
||||
timestamp = (timestamp shl 8) or (byte.toULong() and 0xFFuL)
|
||||
}
|
||||
sinceTimestamp = timestamp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -76,7 +98,7 @@ data class RequestSyncPacket(
|
||||
val mm = m ?: return null
|
||||
val dd = payload ?: return null
|
||||
if (pp < 1 || mm <= 0L) return null
|
||||
return RequestSyncPacket(pp, mm, dd)
|
||||
return RequestSyncPacket(pp, mm, dd, types, sinceTimestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,7 +36,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
|
||||
@ -94,6 +97,15 @@ class LocationNotesManager private constructor() {
|
||||
|
||||
// Coroutine scope for background operations
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
delay(60_000)
|
||||
pruneExpiredNotes()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize dependencies
|
||||
@ -124,9 +136,8 @@ class LocationNotesManager private constructor() {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate geohash (building-level precision: 8 chars) - matches iOS
|
||||
if (!isValidBuildingGeohash(normalized)) {
|
||||
Log.w(TAG, "LocationNotesManager: rejecting invalid geohash '$normalized' (expected 8 valid base32 chars)")
|
||||
if (!isValidGeohash(normalized)) {
|
||||
Log.w(TAG, "LocationNotesManager: rejecting invalid geohash '$normalized' (expected 1-12 valid base32 chars)")
|
||||
return
|
||||
}
|
||||
|
||||
@ -158,8 +169,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 }
|
||||
}
|
||||
@ -192,7 +203,12 @@ 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 currentGeohash = _geohash.value
|
||||
if (currentGeohash == null) {
|
||||
Log.w(TAG, "Cannot send note - no geohash set")
|
||||
@ -242,7 +258,9 @@ class LocationNotesManager private constructor() {
|
||||
content = trimmed,
|
||||
geohash = currentGeohash,
|
||||
senderIdentity = identity,
|
||||
nickname = nickname
|
||||
nickname = nickname,
|
||||
expiresAt = expiresAt,
|
||||
urgent = urgent
|
||||
)
|
||||
}
|
||||
|
||||
@ -252,7 +270,10 @@ class LocationNotesManager private constructor() {
|
||||
pubkey = event.pubkey,
|
||||
content = trimmed,
|
||||
createdAt = event.createdAt,
|
||||
nickname = nickname
|
||||
nickname = nickname,
|
||||
geohash = currentGeohash,
|
||||
expiresAt = expiresAt,
|
||||
isUrgent = urgent
|
||||
)
|
||||
|
||||
if (!noteIDs.contains(event.id)) {
|
||||
@ -384,6 +405,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(
|
||||
@ -391,7 +422,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
|
||||
@ -436,6 +470,87 @@ 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 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) }
|
||||
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 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) }
|
||||
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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -108,7 +108,9 @@ object NostrProtocol {
|
||||
content: String,
|
||||
geohash: String,
|
||||
senderIdentity: NostrIdentity,
|
||||
nickname: String? = null
|
||||
nickname: String? = null,
|
||||
expiresAt: Int? = null,
|
||||
urgent: Boolean = false
|
||||
): NostrEvent = withContext(Dispatchers.Default) {
|
||||
val tags = mutableListOf<List<String>>()
|
||||
tags.add(listOf("g", geohash))
|
||||
@ -116,6 +118,12 @@ object NostrProtocol {
|
||||
if (!nickname.isNullOrEmpty()) {
|
||||
tags.add(listOf("n", nickname))
|
||||
}
|
||||
expiresAt?.let {
|
||||
tags.add(listOf("expiration", it.toString()))
|
||||
}
|
||||
if (urgent) {
|
||||
tags.add(listOf("t", "urgent"))
|
||||
}
|
||||
|
||||
val event = NostrEvent(
|
||||
pubkey = senderIdentity.publicKeyHex,
|
||||
@ -128,6 +136,21 @@ object NostrProtocol {
|
||||
return@withContext senderIdentity.signEvent(event)
|
||||
}
|
||||
|
||||
/** Create a NIP-09 deletion request signed by the original event identity. */
|
||||
suspend fun createDeleteEvent(
|
||||
eventID: String,
|
||||
senderIdentity: NostrIdentity
|
||||
): NostrEvent = withContext(Dispatchers.Default) {
|
||||
val event = NostrEvent(
|
||||
pubkey = senderIdentity.publicKeyHex,
|
||||
createdAt = (System.currentTimeMillis() / 1000).toInt(),
|
||||
kind = NostrKind.DELETION,
|
||||
tags = listOf(listOf("e", eventID)),
|
||||
content = ""
|
||||
)
|
||||
senderIdentity.signEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a geohash-scoped presence event (kind 20001)
|
||||
* Has no content and no nickname, used for participant counting
|
||||
|
||||
@ -17,7 +17,8 @@ enum class MessageType(val value: UByte) {
|
||||
NOISE_ENCRYPTED(0x11u), // Noise encrypted transport message
|
||||
FRAGMENT(0x20u), // Fragmentation for large packets
|
||||
REQUEST_SYNC(0x21u), // GCS-based sync request
|
||||
FILE_TRANSFER(0x22u); // New: File transfer packet (BLE voice notes, etc.)
|
||||
FILE_TRANSFER(0x22u), // File transfer packet (BLE voice notes, etc.)
|
||||
BOARD_POST(0x23u); // Persistent signed bulletin-board post or tombstone
|
||||
|
||||
companion object {
|
||||
fun fromValue(value: UByte): MessageType? {
|
||||
|
||||
@ -21,7 +21,8 @@ object GCSFilter {
|
||||
data class Params(
|
||||
val p: Int, // Golomb-Rice parameter (>= 1)
|
||||
val m: Long, // Range M = N * 2^P
|
||||
val data: ByteArray // Encoded GR bitstream
|
||||
val data: ByteArray, // Encoded GR bitstream
|
||||
val includedCount: Int
|
||||
)
|
||||
|
||||
// Derive P from target FPR; FPR ~= 1 / 2^P
|
||||
@ -66,7 +67,7 @@ object GCSFilter {
|
||||
encoded = encode(mapped, p)
|
||||
}
|
||||
|
||||
return Params(p = p, m = finalM, data = encoded)
|
||||
return Params(p = p, m = finalM, data = encoded, includedCount = trimmedN)
|
||||
}
|
||||
|
||||
fun decodeToSortedSet(p: Int, m: Long, data: ByteArray): LongArray {
|
||||
@ -196,4 +197,3 @@ object GCSFilter {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -33,9 +33,15 @@ class GossipSyncManager(
|
||||
|
||||
companion object {
|
||||
private const val TAG = "GossipSyncManager"
|
||||
private const val BOARD_CAPACITY = 200
|
||||
private const val BOARD_SYNC_INTERVAL_MS = 60_000L
|
||||
private const val RESPONSE_WINDOW_MS = 30_000L
|
||||
private const val MAX_RESPONSES_PER_WINDOW = 8
|
||||
}
|
||||
|
||||
var delegate: Delegate? = null
|
||||
/** BoardStore-backed source of live post and tombstone packets. */
|
||||
var boardPacketsProvider: (() -> List<BitchatPacket>)? = null
|
||||
|
||||
// Defaults (configurable constants)
|
||||
private val defaultMaxBytes = SyncDefaults.DEFAULT_FILTER_BYTES
|
||||
@ -46,8 +52,10 @@ class GossipSyncManager(
|
||||
private val messages = LinkedHashMap<String, BitchatPacket>()
|
||||
// - announcements: only keep latest per sender peerID
|
||||
private val latestAnnouncementByPeer = ConcurrentHashMap<String, Pair<String, BitchatPacket>>()
|
||||
private val responseTimesByPeer = ConcurrentHashMap<String, ArrayDeque<Long>>()
|
||||
|
||||
private var periodicJob: Job? = null
|
||||
private var boardPeriodicJob: Job? = null
|
||||
private var cleanupJob: Job? = null
|
||||
fun start() {
|
||||
periodicJob?.cancel()
|
||||
@ -55,11 +63,26 @@ class GossipSyncManager(
|
||||
while (isActive) {
|
||||
try {
|
||||
delay(30_000)
|
||||
sendRequestSync()
|
||||
sendRequestSync(SyncTypeFlags.PUBLIC_MESSAGES)
|
||||
} catch (e: CancellationException) { throw e }
|
||||
catch (e: Exception) { Log.e(TAG, "Periodic sync error: ${e.message}") }
|
||||
}
|
||||
}
|
||||
boardPeriodicJob?.cancel()
|
||||
boardPeriodicJob = scope.launch(Dispatchers.IO) {
|
||||
while (isActive) {
|
||||
try {
|
||||
delay(BOARD_SYNC_INTERVAL_MS)
|
||||
if (boardPacketsProvider != null) {
|
||||
sendRequestSync(SyncTypeFlags.BOARD)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Periodic board sync error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start periodic cleanup of stale announcements and messages
|
||||
cleanupJob?.cancel()
|
||||
@ -76,20 +99,31 @@ class GossipSyncManager(
|
||||
|
||||
fun stop() {
|
||||
periodicJob?.cancel(); periodicJob = null
|
||||
boardPeriodicJob?.cancel(); boardPeriodicJob = null
|
||||
cleanupJob?.cancel(); cleanupJob = null
|
||||
}
|
||||
|
||||
fun scheduleInitialSync(delayMs: Long = 5_000L) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
delay(delayMs)
|
||||
sendRequestSync()
|
||||
val types = if (boardPacketsProvider != null) {
|
||||
SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.BOARD)
|
||||
} else {
|
||||
SyncTypeFlags.PUBLIC_MESSAGES
|
||||
}
|
||||
sendRequestSync(types)
|
||||
}
|
||||
}
|
||||
|
||||
fun scheduleInitialSyncToPeer(peerID: String, delayMs: Long = 5_000L) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
delay(delayMs)
|
||||
sendRequestSyncToPeer(peerID)
|
||||
val types = if (boardPacketsProvider != null) {
|
||||
SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.BOARD)
|
||||
} else {
|
||||
SyncTypeFlags.PUBLIC_MESSAGES
|
||||
}
|
||||
sendRequestSyncToPeer(peerID, types)
|
||||
}
|
||||
}
|
||||
|
||||
@ -133,8 +167,8 @@ class GossipSyncManager(
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendRequestSync() {
|
||||
val payload = buildGcsPayload()
|
||||
private fun sendRequestSync(types: SyncTypeFlags) {
|
||||
val payload = buildGcsPayload(types)
|
||||
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.REQUEST_SYNC.value,
|
||||
@ -148,8 +182,8 @@ class GossipSyncManager(
|
||||
delegate?.sendPacket(signed)
|
||||
}
|
||||
|
||||
private fun sendRequestSyncToPeer(peerID: String) {
|
||||
val payload = buildGcsPayload()
|
||||
private fun sendRequestSyncToPeer(peerID: String, types: SyncTypeFlags) {
|
||||
val payload = buildGcsPayload(types)
|
||||
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.REQUEST_SYNC.value,
|
||||
@ -166,6 +200,11 @@ class GossipSyncManager(
|
||||
}
|
||||
|
||||
fun handleRequestSync(fromPeerID: String, request: RequestSyncPacket) {
|
||||
if (!shouldRespondTo(fromPeerID)) {
|
||||
Log.w(TAG, "Rate-limited REQUEST_SYNC from ${fromPeerID.take(8)}")
|
||||
return
|
||||
}
|
||||
val requestedTypes = request.types ?: SyncTypeFlags.PUBLIC_MESSAGES
|
||||
// Decode GCS into sorted set for membership checks
|
||||
val sorted = GCSFilter.decodeToSortedSet(request.p, request.m, request.data)
|
||||
fun mightContain(id: ByteArray): Boolean {
|
||||
@ -174,32 +213,63 @@ class GossipSyncManager(
|
||||
return GCSFilter.contains(sorted, nonZeroV)
|
||||
}
|
||||
|
||||
// 1) Announcements: send latest per peerID if remote doesn't have them
|
||||
for ((_, pair) in latestAnnouncementByPeer.entries) {
|
||||
val (id, pkt) = pair
|
||||
val idBytes = hexToBytes(id)
|
||||
if (!mightContain(idBytes)) {
|
||||
// Send original packet unchanged to requester only (keep local TTL)
|
||||
val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)
|
||||
delegate?.sendPacketToPeer(fromPeerID, toSend)
|
||||
Log.d(TAG, "Sent sync announce: Type ${toSend.type} from ${toSend.senderID.toHexString()} to $fromPeerID packet id ${idBytes.toHexString()}")
|
||||
// Announces are exempt from the since cursor: they carry verification keys.
|
||||
if (requestedTypes.contains(MessageType.ANNOUNCE)) {
|
||||
for ((_, pair) in latestAnnouncementByPeer.entries) {
|
||||
val (id, pkt) = pair
|
||||
val idBytes = hexToBytes(id)
|
||||
if (!mightContain(idBytes)) {
|
||||
val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)
|
||||
delegate?.sendPacketToPeer(fromPeerID, toSend)
|
||||
Log.d(TAG, "Sent sync announce: Type ${toSend.type} from ${toSend.senderID.toHexString()} to $fromPeerID packet id ${idBytes.toHexString()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Broadcast messages: send all they lack
|
||||
val toSendMsgs = synchronized(messages) { messages.values.toList() }
|
||||
for (pkt in toSendMsgs) {
|
||||
val idBytes = PacketIdUtil.computeIdBytes(pkt)
|
||||
if (!mightContain(idBytes)) {
|
||||
val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)
|
||||
delegate?.sendPacketToPeer(fromPeerID, toSend)
|
||||
Log.d(TAG, "Sent sync message: Type ${toSend.type} to $fromPeerID packet id ${idBytes.toHexString()}")
|
||||
if (requestedTypes.contains(MessageType.MESSAGE)) {
|
||||
val toSendMsgs = synchronized(messages) { messages.values.toList() }
|
||||
for (pkt in toSendMsgs) {
|
||||
if (request.sinceTimestamp != null && pkt.timestamp < request.sinceTimestamp) continue
|
||||
val idBytes = PacketIdUtil.computeIdBytes(pkt)
|
||||
if (!mightContain(idBytes)) {
|
||||
val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)
|
||||
delegate?.sendPacketToPeer(fromPeerID, toSend)
|
||||
Log.d(TAG, "Sent sync message: Type ${toSend.type} to $fromPeerID packet id ${idBytes.toHexString()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (requestedTypes.contains(MessageType.BOARD_POST)) {
|
||||
for (pkt in boardPacketsProvider?.invoke().orEmpty()) {
|
||||
if (request.sinceTimestamp != null && pkt.timestamp < request.sinceTimestamp) continue
|
||||
val idBytes = PacketIdUtil.computeIdBytes(pkt)
|
||||
if (!mightContain(idBytes)) {
|
||||
val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)
|
||||
delegate?.sendPacketToPeer(fromPeerID, toSend)
|
||||
Log.d(TAG, "Sent sync board packet to $fromPeerID packet id ${idBytes.toHexString()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldRespondTo(peerID: String): Boolean {
|
||||
val now = System.currentTimeMillis()
|
||||
val times = responseTimesByPeer.computeIfAbsent(peerID) { ArrayDeque() }
|
||||
return synchronized(times) {
|
||||
while (times.isNotEmpty() && now - times.first() >= RESPONSE_WINDOW_MS) {
|
||||
times.removeFirst()
|
||||
}
|
||||
if (times.size >= MAX_RESPONSES_PER_WINDOW) {
|
||||
false
|
||||
} else {
|
||||
times.addLast(now)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hexStringToByteArray(hexString: String): ByteArray {
|
||||
val result = ByteArray(8) { 0 }
|
||||
val result = ByteArray(8)
|
||||
var tempID = hexString
|
||||
var index = 0
|
||||
while (tempID.length >= 2 && index < 8) {
|
||||
@ -223,16 +293,20 @@ class GossipSyncManager(
|
||||
return out
|
||||
}
|
||||
|
||||
private fun buildGcsPayload(): ByteArray {
|
||||
// Collect candidates: latest announcement per peer + recent broadcast messages
|
||||
private fun buildGcsPayload(types: SyncTypeFlags): ByteArray {
|
||||
val list = ArrayList<BitchatPacket>()
|
||||
// announcements
|
||||
for ((_, pair) in latestAnnouncementByPeer) {
|
||||
list.add(pair.second)
|
||||
if (types.contains(MessageType.ANNOUNCE)) {
|
||||
for ((_, pair) in latestAnnouncementByPeer) {
|
||||
list.add(pair.second)
|
||||
}
|
||||
}
|
||||
// messages
|
||||
synchronized(messages) {
|
||||
list.addAll(messages.values)
|
||||
if (types.contains(MessageType.MESSAGE)) {
|
||||
synchronized(messages) {
|
||||
list.addAll(messages.values)
|
||||
}
|
||||
}
|
||||
if (types.contains(MessageType.BOARD_POST)) {
|
||||
list.addAll(boardPacketsProvider?.invoke().orEmpty())
|
||||
}
|
||||
// sort by timestamp desc, then take up to min(seenCapacity, fit capacity)
|
||||
list.sortByDescending { it.timestamp.toLong() }
|
||||
@ -241,16 +315,38 @@ class GossipSyncManager(
|
||||
val fpr = try { configProvider.gcsTargetFpr() } catch (_: Exception) { defaultFpr }
|
||||
val p = GCSFilter.deriveP(fpr)
|
||||
val nMax = GCSFilter.estimateMaxElementsForSize(maxBytes, p)
|
||||
val cap = configProvider.seenCapacity().coerceAtLeast(1)
|
||||
val cap = if (types == SyncTypeFlags.BOARD) {
|
||||
BOARD_CAPACITY
|
||||
} else {
|
||||
configProvider.seenCapacity().coerceAtLeast(1)
|
||||
}
|
||||
val takeN = minOf(nMax, cap, list.size)
|
||||
if (takeN <= 0) {
|
||||
val p0 = GCSFilter.deriveP(fpr)
|
||||
return RequestSyncPacket(p = p0, m = 1, data = ByteArray(0)).encode()
|
||||
return RequestSyncPacket(
|
||||
p = p0,
|
||||
m = 1,
|
||||
data = ByteArray(0),
|
||||
types = types
|
||||
).encode()
|
||||
}
|
||||
val ids = list.take(takeN).map { pkt -> PacketIdUtil.computeIdBytes(pkt) }
|
||||
val included = list.take(takeN)
|
||||
val ids = included.map { pkt -> PacketIdUtil.computeIdBytes(pkt) }
|
||||
val params = GCSFilter.buildFilter(ids, maxBytes, fpr)
|
||||
val mVal = if (params.m <= 0L) 1 else params.m
|
||||
return RequestSyncPacket(p = params.p, m = mVal, data = params.data).encode()
|
||||
val sinceTimestamp =
|
||||
if (params.includedCount in 1 until list.size) {
|
||||
included[params.includedCount - 1].timestamp
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return RequestSyncPacket(
|
||||
p = params.p,
|
||||
m = mVal,
|
||||
data = params.data,
|
||||
types = types,
|
||||
sinceTimestamp = sinceTimestamp
|
||||
).encode()
|
||||
}
|
||||
|
||||
// Periodically remove stale announcements and all their messages
|
||||
|
||||
67
app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt
Normal file
67
app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt
Normal file
@ -0,0 +1,67 @@
|
||||
package com.bitchat.android.sync
|
||||
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
|
||||
/**
|
||||
* Little-endian variable-width bitfield carried in REQUEST_SYNC TLV 0x04.
|
||||
* The bit-to-message mapping is shared with iOS.
|
||||
*/
|
||||
data class SyncTypeFlags(val rawValue: ULong) {
|
||||
fun contains(type: MessageType): Boolean {
|
||||
val bit = bitIndex(type) ?: return false
|
||||
return (rawValue and (1uL shl bit)) != 0uL
|
||||
}
|
||||
|
||||
fun union(other: SyncTypeFlags): SyncTypeFlags =
|
||||
SyncTypeFlags((rawValue or other.rawValue) and KNOWN_TYPE_MASK)
|
||||
|
||||
fun encode(): ByteArray? {
|
||||
if (rawValue == 0uL) return null
|
||||
var value = rawValue
|
||||
val output = ArrayList<Byte>()
|
||||
while (value != 0uL && output.size < 8) {
|
||||
output += (value and 0xFFuL).toByte()
|
||||
value = value shr 8
|
||||
}
|
||||
return output.toByteArray().takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
val ANNOUNCE = of(MessageType.ANNOUNCE)
|
||||
val MESSAGE = of(MessageType.MESSAGE)
|
||||
val BOARD = of(MessageType.BOARD_POST)
|
||||
val PUBLIC_MESSAGES = of(MessageType.ANNOUNCE, MessageType.MESSAGE)
|
||||
|
||||
private val KNOWN_TYPE_MASK: ULong = MessageType.entries.fold(0uL) { mask, type ->
|
||||
val bit = bitIndex(type)
|
||||
if (bit == null) mask else mask or (1uL shl bit)
|
||||
}
|
||||
|
||||
fun of(vararg types: MessageType): SyncTypeFlags =
|
||||
SyncTypeFlags(types.fold(0uL) { mask, type ->
|
||||
val bit = bitIndex(type)
|
||||
if (bit == null) mask else mask or (1uL shl bit)
|
||||
})
|
||||
|
||||
fun decode(data: ByteArray): SyncTypeFlags? {
|
||||
if (data.size !in 1..8) return null
|
||||
var value = 0uL
|
||||
data.forEachIndexed { index, byte ->
|
||||
value = value or ((byte.toULong() and 0xFFuL) shl (index * 8))
|
||||
}
|
||||
return SyncTypeFlags(value and KNOWN_TYPE_MASK)
|
||||
}
|
||||
|
||||
private fun bitIndex(type: MessageType): Int? = when (type) {
|
||||
MessageType.ANNOUNCE -> 0
|
||||
MessageType.MESSAGE -> 1
|
||||
MessageType.LEAVE -> 2
|
||||
MessageType.NOISE_HANDSHAKE -> 3
|
||||
MessageType.NOISE_ENCRYPTED -> 4
|
||||
MessageType.FRAGMENT -> 5
|
||||
MessageType.REQUEST_SYNC -> 6
|
||||
MessageType.FILE_TRANSFER -> 7
|
||||
MessageType.BOARD_POST -> 8
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -554,9 +554,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
|
||||
)
|
||||
|
||||
@ -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
|
||||
@ -30,6 +31,8 @@ import com.bitchat.android.noise.NoiseSession
|
||||
import com.bitchat.android.services.ContactDirectory
|
||||
import com.bitchat.android.services.ContactIdentityResolver
|
||||
import com.bitchat.android.util.hexEncodedString
|
||||
import com.bitchat.android.board.BoardManager
|
||||
import com.bitchat.android.board.BoardStore
|
||||
|
||||
/**
|
||||
* Refactored ChatViewModel - Main coordinator for bitchat functionality
|
||||
@ -110,6 +113,39 @@ class ChatViewModel(
|
||||
}
|
||||
|
||||
val privateChatManager = PrivateChatManager(state, messageManager, dataManager, noiseSessionDelegate)
|
||||
val boardManager = BoardManager(
|
||||
store = BoardStore.getInstance(application.applicationContext),
|
||||
scope = viewModelScope,
|
||||
meshProvider = { mesh },
|
||||
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 commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager)
|
||||
private val notificationManager = NotificationManager(
|
||||
application.applicationContext,
|
||||
@ -965,6 +1001,7 @@ class ChatViewModel(
|
||||
channelManager.clearAllChannels()
|
||||
privateChatManager.clearAllPrivateChats()
|
||||
dataManager.clearAllData()
|
||||
boardManager.clearTransientState()
|
||||
|
||||
// Clear seen message store
|
||||
try {
|
||||
@ -1206,6 +1243,8 @@ class ChatViewModel(
|
||||
*/
|
||||
fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color {
|
||||
return geohashViewModel.colorForNostrPubkey(pubkeyHex, isDark)
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.truncateNoticeAlert(): String =
|
||||
if (length <= 120) this else take(120) + "…"
|
||||
}
|
||||
|
||||
@ -2,28 +2,22 @@ package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Description
|
||||
import androidx.compose.material.icons.filled.PushPin
|
||||
import androidx.compose.material.icons.outlined.PushPin
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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 component for MainHeader
|
||||
* Shows in mesh mode when location permission granted AND services enabled
|
||||
* Icon turns primary color when notes exist, gray otherwise
|
||||
* Unified notices button for both mesh and geohash timelines.
|
||||
*/
|
||||
@Composable
|
||||
fun LocationNotesButton(
|
||||
@ -31,37 +25,29 @@ fun LocationNotesButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val context = LocalContext.current
|
||||
|
||||
// Get channel and permission state
|
||||
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
|
||||
|
||||
// Check both permission AND location services enabled
|
||||
val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED
|
||||
val locationEnabled = locationPermissionGranted && locationServicesEnabled
|
||||
|
||||
// Get notes count from LocationNotesManager
|
||||
val notesManager = remember { LocationNotesManager.getInstance() }
|
||||
val notes by notesManager.notes.collectAsStateWithLifecycle()
|
||||
val notesCount = notes.size
|
||||
|
||||
// Only show in mesh mode when location is authorized (iOS pattern)
|
||||
if (selectedLocationChannel is ChannelID.Mesh && locationEnabled) {
|
||||
val hasNotes = notesCount > 0
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
modifier = modifier.size(24.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Description, // "long.text.page.and.pencil" equivalent
|
||||
contentDescription = stringResource(R.string.cd_location_notes),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = if (hasNotes) colorScheme.primary else Color.Gray
|
||||
)
|
||||
}
|
||||
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 -> Color(0xFFFF9500)
|
||||
hasNotices -> Color(0xFFFF9500)
|
||||
else -> Color.Gray
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
537
app/src/main/java/com/bitchat/android/ui/NoticesSheet.kt
Normal file
537
app/src/main/java/com/bitchat/android/ui/NoticesSheet.kt
Normal file
@ -0,0 +1,537 @@
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
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 (selectedTab == NoticesTab.GEO && expiryDays == 0) {
|
||||
notesManager.send(
|
||||
content = draft,
|
||||
nickname = nickname,
|
||||
expiresAt = null
|
||||
)
|
||||
draft = ""
|
||||
sendFailed = false
|
||||
} else {
|
||||
val sent = viewModel.boardManager.createPost(
|
||||
content = draft,
|
||||
geohash = scope,
|
||||
nickname = nickname,
|
||||
urgent = selectedTab == NoticesTab.MESH && urgent,
|
||||
expiryDays = expiryDays
|
||||
)
|
||||
if (sent) draft = "" else sendFailed = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NoticesContent(
|
||||
notices: List<UnifiedNotice>,
|
||||
scope: String,
|
||||
isGeo: Boolean,
|
||||
showsSource: Boolean,
|
||||
viewModel: ChatViewModel,
|
||||
notesManager: LocationNotesManager,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp)
|
||||
) {
|
||||
item(key = "description") {
|
||||
Column {
|
||||
Text(
|
||||
text = if (isGeo) {
|
||||
stringResource(R.string.location_notes_description)
|
||||
} else {
|
||||
stringResource(R.string.notices_description_mesh)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
if (scope.isNotEmpty()) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
text = "#$scope",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (notices.isEmpty()) {
|
||||
item(key = "empty") {
|
||||
Column(modifier = Modifier.padding(vertical = 32.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.location_notes_empty_title),
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.location_notes_empty_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(notices, key = { it.id }) { notice ->
|
||||
val ownBoard = notice.boardPost?.let(viewModel.boardManager::isOwnPost) == true
|
||||
val ownRelay = notice.nostrNote?.let(notesManager::isOwnNote) == true
|
||||
NoticeRow(
|
||||
notice = notice,
|
||||
showsSource = showsSource,
|
||||
canDelete = ownBoard || ownRelay,
|
||||
onDelete = {
|
||||
notice.boardPost?.let(viewModel.boardManager::deletePost)
|
||||
?: notice.nostrNote?.let(notesManager::delete)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NoticeRow(
|
||||
notice: UnifiedNotice,
|
||||
showsSource: Boolean,
|
||||
canDelete: Boolean,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
val boardAuthor = notice.boardPost?.let { post ->
|
||||
post.authorNickname.trim().ifEmpty {
|
||||
"anon#${post.authorSigningKey.takeLast(2).toByteArray().toHex()}"
|
||||
}
|
||||
}
|
||||
val relayAuthor = notice.nostrNote?.displayName
|
||||
val author = boardAuthor ?: relayAuthor ?: notice.nickname.ifEmpty { "anon" }
|
||||
val time = remember(notice.createdAtMs) {
|
||||
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT)
|
||||
.format(Date(notice.createdAtMs))
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (notice.urgent) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.PushPin,
|
||||
contentDescription = stringResource(R.string.notices_urgent),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
}
|
||||
Text(
|
||||
text = "@$author",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
if (showsSource) {
|
||||
Text(
|
||||
text = if (notice.source == NoticeSource.MESH) {
|
||||
stringResource(R.string.notices_source_mesh)
|
||||
} else {
|
||||
stringResource(R.string.notices_source_network)
|
||||
},
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 10.sp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
if (canDelete) {
|
||||
IconButton(onClick = onDelete, modifier = Modifier.size(48.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Delete,
|
||||
contentDescription = stringResource(R.string.notices_delete),
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = notice.content,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = notice.expiresAtMs?.let {
|
||||
"$time · ${stringResource(R.string.notices_fades, relativeExpiry(it))}"
|
||||
} ?: time,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 10.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LocationUnavailable(
|
||||
onEnableLocation: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(24.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.notices_location_unavailable),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.notices_location_unavailable_desc),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Button(onClick = onEnableLocation) {
|
||||
Text(stringResource(R.string.notices_enable_location))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Composer(
|
||||
draft: String,
|
||||
onDraftChange: (String) -> Unit,
|
||||
isGeo: Boolean,
|
||||
urgent: Boolean,
|
||||
onUrgentChange: (Boolean) -> Unit,
|
||||
expiryDays: Int,
|
||||
onExpiryChange: (Int) -> Unit,
|
||||
enabled: Boolean,
|
||||
sendFailed: Boolean,
|
||||
onSend: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
) {
|
||||
if (!isGeo) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.notices_urgent),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Switch(checked = urgent, onCheckedChange = onUrgentChange)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.notices_expiry),
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
listOf(1, 3, 7).forEach { days ->
|
||||
val daysDescription = stringResource(R.string.notices_days, days)
|
||||
FilterChip(
|
||||
selected = expiryDays == days,
|
||||
onClick = { onExpiryChange(days) },
|
||||
label = {
|
||||
Text(
|
||||
text = "${days}d",
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription = daysDescription
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
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"
|
||||
}
|
||||
@ -1535,6 +1535,14 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
meshCore.sendFileBroadcast(file)
|
||||
}
|
||||
|
||||
override fun sendBoardPayload(payload: ByteArray) {
|
||||
meshCore.sendBoardPayload(payload)
|
||||
}
|
||||
|
||||
override fun getSigningPublicKey(): ByteArray? = meshCore.getSigningPublicKey()
|
||||
|
||||
override fun signData(data: ByteArray): ByteArray? = meshCore.signData(data)
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
||||
@ -400,4 +400,17 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
|
||||
<string name="cd_notices">إعلانات</string>
|
||||
<string name="notices_title">إعلانات</string>
|
||||
<string name="notices_tab_geo_count">جغرافي · %1$d</string>
|
||||
<string name="notices_tab_mesh_count">mesh · %1$d</string>
|
||||
<string name="notices_description_mesh">ثبّت إعلانات قصيرة لمن حولك. تنتقل من هاتف إلى هاتف حتى دون اتصال، وتختفي وحدها بعد أيام قليلة.</string>
|
||||
<string name="notices_fades">يتلاشى %1$s</string>
|
||||
<string name="notices_source_mesh">mesh</string>
|
||||
<string name="notices_source_network">نت</string>
|
||||
<string name="notices_location_unavailable">الموقع غير متاح</string>
|
||||
<string name="notices_enable_location">تفعيل الموقع</string>
|
||||
<string name="notices_alert_urgent_single">📌 إعلان عاجل من @%1$s: %2$s</string>
|
||||
<string name="notices_alert_urgent_collapsed">📌 %1$d إعلانات عاجلة جديدة — اضغط على الدبوس للعرض</string>
|
||||
</resources>
|
||||
|
||||
@ -387,4 +387,17 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
|
||||
<string name="cd_notices">নোটিশ</string>
|
||||
<string name="notices_title">নোটিশ</string>
|
||||
<string name="notices_tab_geo_count">এলাকা · %1$d</string>
|
||||
<string name="notices_tab_mesh_count">mesh · %1$d</string>
|
||||
<string name="notices_description_mesh">আশেপাশের মানুষের জন্য ছোট নোটিশ পিন করুন। অফলাইনেও ফোন থেকে ফোনে পৌঁছে যায় আর কয়েক দিন পরে নিজে থেকেই মুছে যায়।</string>
|
||||
<string name="notices_fades">%1$s মুছে যাবে</string>
|
||||
<string name="notices_source_mesh">mesh</string>
|
||||
<string name="notices_source_network">নেট</string>
|
||||
<string name="notices_location_unavailable">অবস্থান অনুপলব্ধ</string>
|
||||
<string name="notices_enable_location">লোকেশন চালু করুন</string>
|
||||
<string name="notices_alert_urgent_single">📌 @%1$s-এর জরুরি নোটিশ: %2$s</string>
|
||||
<string name="notices_alert_urgent_collapsed">📌 %1$dটি নতুন জরুরি নোটিশ — দেখতে পিনে ট্যাপ করুন</string>
|
||||
</resources>
|
||||
|
||||
@ -401,4 +401,17 @@
|
||||
<string name="verify_success_title">Verifiziert</string>
|
||||
<string name="verify_success_body">Du hast %1$s verifiziert</string>
|
||||
<string name="verify_success_system_message">verifiziert %1$s</string>
|
||||
|
||||
<string name="cd_notices">hinweise</string>
|
||||
<string name="notices_title">hinweise</string>
|
||||
<string name="notices_tab_geo_count">geo · %1$d</string>
|
||||
<string name="notices_tab_mesh_count">mesh · %1$d</string>
|
||||
<string name="notices_description_mesh">hefte kurze hinweise für leute in deiner nähe an. sie springen von handy zu handy, auch offline, und verschwinden nach ein paar tagen von selbst.</string>
|
||||
<string name="notices_fades">verblasst %1$s</string>
|
||||
<string name="notices_source_mesh">mesh</string>
|
||||
<string name="notices_source_network">netz</string>
|
||||
<string name="notices_location_unavailable">standort nicht verfügbar</string>
|
||||
<string name="notices_enable_location">standort aktivieren</string>
|
||||
<string name="notices_alert_urgent_single">📌 dringender hinweis von @%1$s: %2$s</string>
|
||||
<string name="notices_alert_urgent_collapsed">📌 %1$d neue dringende hinweise — tippe zum ansehen auf den pin</string>
|
||||
</resources>
|
||||
|
||||
@ -400,4 +400,17 @@
|
||||
<string name="verify_success_title">Verificado</string>
|
||||
<string name="verify_success_body">Verificaste a %1$s</string>
|
||||
<string name="verify_success_system_message">verificado %1$s</string>
|
||||
|
||||
<string name="cd_notices">avisos</string>
|
||||
<string name="notices_title">avisos</string>
|
||||
<string name="notices_tab_geo_count">geo · %1$d</string>
|
||||
<string name="notices_tab_mesh_count">mesh · %1$d</string>
|
||||
<string name="notices_description_mesh">fija avisos cortos para la gente cercana. saltan de teléfono a teléfono, incluso sin conexión, y desaparecen solos después de unos días.</string>
|
||||
<string name="notices_fades">se desvanece %1$s</string>
|
||||
<string name="notices_source_mesh">mesh</string>
|
||||
<string name="notices_source_network">red</string>
|
||||
<string name="notices_location_unavailable">ubicación no disponible</string>
|
||||
<string name="notices_enable_location">activar ubicación</string>
|
||||
<string name="notices_alert_urgent_single">📌 aviso urgente de @%1$s: %2$s</string>
|
||||
<string name="notices_alert_urgent_collapsed">📌 %1$d avisos urgentes nuevos — toca el pin para verlos</string>
|
||||
</resources>
|
||||
|
||||
@ -387,4 +387,17 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
|
||||
<string name="cd_notices">اطلاعیهها</string>
|
||||
<string name="notices_title">اطلاعیهها</string>
|
||||
<string name="notices_tab_geo_count">ژئو · %1$d</string>
|
||||
<string name="notices_tab_mesh_count">مش · %1$d</string>
|
||||
<string name="notices_description_mesh">اطلاعیههای کوتاه برای اطرافیانتان سنجاق کنید. گوشی به گوشی جابهجا میشوند، حتی آفلاین، و بعد از چند روز خودبهخود ناپدید میشوند.</string>
|
||||
<string name="notices_fades">%1$s محو میشود</string>
|
||||
<string name="notices_source_mesh">مش</string>
|
||||
<string name="notices_source_network">نت</string>
|
||||
<string name="notices_location_unavailable">موقعیت در دسترس نیست</string>
|
||||
<string name="notices_enable_location">فعالسازی موقعیت</string>
|
||||
<string name="notices_alert_urgent_single">📌 اطلاعیهٔ فوری از @%1$s: %2$s</string>
|
||||
<string name="notices_alert_urgent_collapsed">📌 %1$d اطلاعیهٔ فوری جدید — برای مشاهده روی سنجاق بزنید</string>
|
||||
</resources>
|
||||
|
||||
@ -400,4 +400,17 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
|
||||
<string name="cd_notices">mga paunawa</string>
|
||||
<string name="notices_title">mga paunawa</string>
|
||||
<string name="notices_tab_geo_count">geo · %1$d</string>
|
||||
<string name="notices_tab_mesh_count">mesh · %1$d</string>
|
||||
<string name="notices_description_mesh">mag-pin ng maiikling paunawa para sa mga taong nasa paligid mo. lumilipat ito mula sa isang telepono patungo sa iba, kahit offline, at kusang nawawala pagkatapos ng ilang araw.</string>
|
||||
<string name="notices_fades">maglalaho %1$s</string>
|
||||
<string name="notices_source_mesh">mesh</string>
|
||||
<string name="notices_source_network">net</string>
|
||||
<string name="notices_location_unavailable">walang lokasyon</string>
|
||||
<string name="notices_enable_location">i-on ang lokasyon</string>
|
||||
<string name="notices_alert_urgent_single">📌 agarang paunawa mula kay @%1$s: %2$s</string>
|
||||
<string name="notices_alert_urgent_collapsed">📌 %1$d bagong agarang paunawa — i-tap ang pin para makita</string>
|
||||
</resources>
|
||||
|
||||
@ -414,4 +414,17 @@
|
||||
<string name="verify_success_title">Vérifié</string>
|
||||
<string name="verify_success_body">Vous avez vérifié %1$s</string>
|
||||
<string name="verify_success_system_message">vérifié %1$s</string>
|
||||
|
||||
<string name="cd_notices">annonces</string>
|
||||
<string name="notices_title">annonces</string>
|
||||
<string name="notices_tab_geo_count">géo · %1$d</string>
|
||||
<string name="notices_tab_mesh_count">mesh · %1$d</string>
|
||||
<string name="notices_description_mesh">épingle de courtes annonces pour les gens autour de toi. elles passent de téléphone en téléphone, même hors ligne, et disparaissent d\'elles-mêmes après quelques jours.</string>
|
||||
<string name="notices_fades">s\'efface %1$s</string>
|
||||
<string name="notices_source_mesh">mesh</string>
|
||||
<string name="notices_source_network">net</string>
|
||||
<string name="notices_location_unavailable">localisation indisponible</string>
|
||||
<string name="notices_enable_location">activer la localisation</string>
|
||||
<string name="notices_alert_urgent_single">📌 annonce urgente de @%1$s : %2$s</string>
|
||||
<string name="notices_alert_urgent_collapsed">📌 %1$d nouvelles annonces urgentes — touche l\'épingle pour voir</string>
|
||||
</resources>
|
||||
|
||||
@ -53,5 +53,17 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
</resources>
|
||||
|
||||
<string name="cd_notices">מודעות</string>
|
||||
<string name="notices_title">מודעות</string>
|
||||
<string name="notices_tab_geo_count">אזור · %1$d</string>
|
||||
<string name="notices_tab_mesh_count">mesh · %1$d</string>
|
||||
<string name="notices_description_mesh">הצמד מודעות קצרות לאנשים סביבך. הן עוברות מטלפון לטלפון, גם בלי אינטרנט, ונעלמות מעצמן אחרי כמה ימים.</string>
|
||||
<string name="notices_fades">דוהה %1$s</string>
|
||||
<string name="notices_source_mesh">mesh</string>
|
||||
<string name="notices_source_network">רשת</string>
|
||||
<string name="notices_location_unavailable">המיקום לא זמין</string>
|
||||
<string name="notices_enable_location">הפעל מיקום</string>
|
||||
<string name="notices_alert_urgent_single">📌 מודעה דחופה מאת @%1$s: %2$s</string>
|
||||
<string name="notices_alert_urgent_collapsed">📌 %1$d מודעות דחופות חדשות — הקש על הנעץ לצפייה</string>
|
||||
</resources>
|
||||
|
||||
@ -400,4 +400,17 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
|
||||
<string name="cd_notices">सूचनाएँ</string>
|
||||
<string name="notices_title">सूचनाएँ</string>
|
||||
<string name="notices_tab_geo_count">क्षेत्र · %1$d</string>
|
||||
<string name="notices_tab_mesh_count">mesh · %1$d</string>
|
||||
<string name="notices_description_mesh">आस-पास के लोगों के लिए छोटी सूचनाएँ पिन करें। ये ऑफ़लाइन भी फ़ोन से फ़ोन तक पहुँचती हैं और कुछ दिनों बाद अपने आप मिट जाती हैं।</string>
|
||||
<string name="notices_fades">%1$s मिट जाएगा</string>
|
||||
<string name="notices_source_mesh">mesh</string>
|
||||
<string name="notices_source_network">नेट</string>
|
||||
<string name="notices_location_unavailable">लोकेशन उपलब्ध नहीं</string>
|
||||
<string name="notices_enable_location">लोकेशन सक्षम करें</string>
|
||||
<string name="notices_alert_urgent_single">📌 @%1$s की ज़रूरी सूचना: %2$s</string>
|
||||
<string name="notices_alert_urgent_collapsed">📌 %1$d नई ज़रूरी सूचनाएँ — देखने के लिए पिन टैप करें</string>
|
||||
</resources>
|
||||
|
||||
@ -400,4 +400,17 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
|
||||
<string name="cd_notices">pengumuman</string>
|
||||
<string name="notices_title">pengumuman</string>
|
||||
<string name="notices_tab_geo_count">area · %1$d</string>
|
||||
<string name="notices_tab_mesh_count">mesh · %1$d</string>
|
||||
<string name="notices_description_mesh">sematkan pengumuman singkat untuk orang di sekitarmu. berpindah dari ponsel ke ponsel, bahkan saat offline, dan hilang sendiri setelah beberapa hari.</string>
|
||||
<string name="notices_fades">memudar %1$s</string>
|
||||
<string name="notices_source_mesh">mesh</string>
|
||||
<string name="notices_source_network">net</string>
|
||||
<string name="notices_location_unavailable">lokasi tidak tersedia</string>
|
||||
<string name="notices_enable_location">aktifkan lokasi</string>
|
||||
<string name="notices_alert_urgent_single">📌 pengumuman mendesak dari @%1$s: %2$s</string>
|
||||
<string name="notices_alert_urgent_collapsed">📌 %1$d pengumuman mendesak baru — ketuk pin untuk melihat</string>
|
||||
</resources>
|
||||
|
||||
@ -434,4 +434,17 @@
|
||||
<string name="verify_success_title">Verificato</string>
|
||||
<string name="verify_success_body">Hai verificato %1$s</string>
|
||||
<string name="verify_success_system_message">verificato %1$s</string>
|
||||
|
||||
<string name="cd_notices">avvisi</string>
|
||||
<string name="notices_title">avvisi</string>
|
||||
<string name="notices_tab_geo_count">geo · %1$d</string>
|
||||
<string name="notices_tab_mesh_count">mesh · %1$d</string>
|
||||
<string name="notices_description_mesh">appunta brevi avvisi per chi ti sta intorno. passano da telefono a telefono, anche offline, e spariscono da soli dopo qualche giorno.</string>
|
||||
<string name="notices_fades">svanisce %1$s</string>
|
||||
<string name="notices_source_mesh">mesh</string>
|
||||
<string name="notices_source_network">rete</string>
|
||||
<string name="notices_location_unavailable">posizione non disponibile</string>
|
||||
<string name="notices_enable_location">attiva posizione</string>
|
||||
<string name="notices_alert_urgent_single">📌 avviso urgente da @%1$s: %2$s</string>
|
||||
<string name="notices_alert_urgent_collapsed">📌 %1$d nuovi avvisi urgenti — tocca la puntina per vederli</string>
|
||||
</resources>
|
||||
|
||||
@ -400,4 +400,17 @@
|
||||
<string name="verify_success_title">検証済み</string>
|
||||
<string name="verify_success_body">%1$s を検証しました</string>
|
||||
<string name="verify_success_system_message">%1$s を検証しました</string>
|
||||
|
||||
<string name="cd_notices">お知らせ</string>
|
||||
<string name="notices_title">お知らせ</string>
|
||||
<string name="notices_tab_geo_count">エリア · %1$d</string>
|
||||
<string name="notices_tab_mesh_count">mesh · %1$d</string>
|
||||
<string name="notices_description_mesh">近くの人に向けて短いお知らせをピン留め。オフラインでもスマホからスマホへ伝わり、数日後に自動的に消えます。</string>
|
||||
<string name="notices_fades">%1$sに消えます</string>
|
||||
<string name="notices_source_mesh">mesh</string>
|
||||
<string name="notices_source_network">ネット</string>
|
||||
<string name="notices_location_unavailable">位置情報を取得できません</string>
|
||||
<string name="notices_enable_location">位置情報を有効化</string>
|
||||
<string name="notices_alert_urgent_single">📌 @%1$sからの緊急のお知らせ: %2$s</string>
|
||||
<string name="notices_alert_urgent_collapsed">📌 新しい緊急のお知らせが%1$d件 — ピンをタップして表示</string>
|
||||
</resources>
|
||||
|
||||
@ -400,4 +400,17 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
|
||||
<string name="cd_notices">공지</string>
|
||||
<string name="notices_title">공지</string>
|
||||
<string name="notices_tab_geo_count">지역 · %1$d</string>
|
||||
<string name="notices_tab_mesh_count">mesh · %1$d</string>
|
||||
<string name="notices_description_mesh">주변 사람들을 위해 짧은 공지를 고정하세요. 오프라인에서도 휴대폰에서 휴대폰으로 전달되고 며칠 후 스스로 사라집니다.</string>
|
||||
<string name="notices_fades">%1$s 사라짐</string>
|
||||
<string name="notices_source_mesh">mesh</string>
|
||||
<string name="notices_source_network">넷</string>
|
||||
<string name="notices_location_unavailable">위치 사용 불가</string>
|
||||
<string name="notices_enable_location">위치 활성화</string>
|
||||
<string name="notices_alert_urgent_single">📌 @%1$s님의 긴급 공지: %2$s</string>
|
||||
<string name="notices_alert_urgent_collapsed">📌 새 긴급 공지 %1$d개 — 핀을 탭하여 확인</string>
|
||||
</resources>
|
||||
|
||||
@ -40,5 +40,17 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
</resources>
|
||||
|
||||
<string name="cd_notices">pengumuman</string>
|
||||
<string name="notices_title">pengumuman</string>
|
||||
<string name="notices_tab_geo_count">kawasan · %1$d</string>
|
||||
<string name="notices_tab_mesh_count">mesh · %1$d</string>
|
||||
<string name="notices_description_mesh">semat pengumuman ringkas untuk orang di sekeliling anda. ia berpindah dari telefon ke telefon, walaupun di luar talian, dan hilang sendiri selepas beberapa hari.</string>
|
||||
<string name="notices_fades">pudar %1$s</string>
|
||||
<string name="notices_source_mesh">mesh</string>
|
||||
<string name="notices_source_network">net</string>
|
||||
<string name="notices_location_unavailable">lokasi tidak tersedia</string>
|
||||
<string name="notices_enable_location">aktifkan lokasi</string>
|
||||
<string name="notices_alert_urgent_single">📌 pengumuman segera daripada @%1$s: %2$s</string>
|
||||
<string name="notices_alert_urgent_collapsed">📌 %1$d pengumuman segera baharu — ketik pin untuk melihat</string>
|
||||
</resources>
|
||||
|
||||
@ -400,4 +400,17 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
|
||||
<string name="cd_notices">सूचनाहरू</string>
|
||||
<string name="notices_title">सूचनाहरू</string>
|
||||
<string name="notices_tab_geo_count">क्षेत्र · %1$d</string>
|
||||
<string name="notices_tab_mesh_count">mesh · %1$d</string>
|
||||
<string name="notices_description_mesh">वरपरका मानिसहरूका लागि छोटा सूचना पिन गर। अफलाइनमा पनि फोनबाट फोनमा पुग्छन् र केही दिनपछि आफैँ हराउँछन्।</string>
|
||||
<string name="notices_fades">%1$s मेटिन्छ</string>
|
||||
<string name="notices_source_mesh">mesh</string>
|
||||
<string name="notices_source_network">नेट</string>
|
||||
<string name="notices_location_unavailable">स्थान उपलब्ध छैन</string>
|
||||
<string name="notices_enable_location">स्थान सक्षम गर</string>
|
||||
<string name="notices_alert_urgent_single">📌 @%1$sको जरुरी सूचना: %2$s</string>
|
||||
<string name="notices_alert_urgent_collapsed">📌 %1$d नयाँ जरुरी सूचना — हेर्न पिन ट्याप गर</string>
|
||||
</resources>
|
||||
|
||||
@ -432,4 +432,17 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
|
||||
<string name="cd_notices">mededelingen</string>
|
||||
<string name="notices_title">mededelingen</string>
|
||||
<string name="notices_tab_geo_count">geo · %1$d</string>
|
||||
<string name="notices_tab_mesh_count">mesh · %1$d</string>
|
||||
<string name="notices_description_mesh">prik korte mededelingen voor mensen om je heen. ze springen van telefoon naar telefoon, ook offline, en verdwijnen vanzelf na een paar dagen.</string>
|
||||
<string name="notices_fades">vervaagt %1$s</string>
|
||||
<string name="notices_source_mesh">mesh</string>
|
||||
<string name="notices_source_network">net</string>
|
||||
<string name="notices_location_unavailable">locatie niet beschikbaar</string>
|
||||
<string name="notices_enable_location">locatie inschakelen</string>
|
||||
<string name="notices_alert_urgent_single">📌 dringende mededeling van @%1$s: %2$s</string>
|
||||
<string name="notices_alert_urgent_collapsed">📌 %1$d nieuwe dringende mededelingen — tik op de pin om te bekijken</string>
|
||||
</resources>
|
||||
|
||||
@ -53,5 +53,17 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
</resources>
|
||||
|
||||
<string name="cd_notices">ogłoszenia</string>
|
||||
<string name="notices_title">ogłoszenia</string>
|
||||
<string name="notices_tab_geo_count">geo · %1$d</string>
|
||||
<string name="notices_tab_mesh_count">mesh · %1$d</string>
|
||||
<string name="notices_description_mesh">przypinaj krótkie ogłoszenia dla ludzi w pobliżu. przeskakują z telefonu na telefon, nawet offline, i same znikają po kilku dniach.</string>
|
||||
<string name="notices_fades">zniknie %1$s</string>
|
||||
<string name="notices_source_mesh">mesh</string>
|
||||
<string name="notices_source_network">sieć</string>
|
||||
<string name="notices_location_unavailable">lokalizacja niedostępna</string>
|
||||
<string name="notices_enable_location">włącz lokalizację</string>
|
||||
<string name="notices_alert_urgent_single">📌 pilne ogłoszenie od @%1$s: %2$s</string>
|
||||
<string name="notices_alert_urgent_collapsed">📌 %1$d nowych pilnych ogłoszeń — dotknij pinezki, aby zobaczyć</string>
|
||||
</resources>
|
||||
|
||||
@ -400,4 +400,17 @@
|
||||
<string name="verify_success_title">Verificado</string>
|
||||
<string name="verify_success_body">Você verificou %1$s</string>
|
||||
<string name="verify_success_system_message">verificou %1$s</string>
|
||||
|
||||
<string name="cd_notices">avisos</string>
|
||||
<string name="notices_title">avisos</string>
|
||||
<string name="notices_tab_geo_count">geo · %1$d</string>
|
||||
<string name="notices_tab_mesh_count">mesh · %1$d</string>
|
||||
<string name="notices_description_mesh">fixe avisos curtos para as pessoas por perto. eles pulam de celular em celular, mesmo offline, e somem sozinhos depois de alguns dias.</string>
|
||||
<string name="notices_fades">desaparece %1$s</string>
|
||||
<string name="notices_source_mesh">mesh</string>
|
||||
<string name="notices_source_network">rede</string>
|
||||
<string name="notices_location_unavailable">localização indisponível</string>
|
||||
<string name="notices_enable_location">habilitar localização</string>
|
||||
<string name="notices_alert_urgent_single">📌 aviso urgente de @%1$s: %2$s</string>
|
||||
<string name="notices_alert_urgent_collapsed">📌 %1$d avisos urgentes novos — toque no pin para ver</string>
|
||||
</resources>
|
||||
|
||||
@ -400,4 +400,17 @@
|
||||
<string name="verify_success_title">Verificado</string>
|
||||
<string name="verify_success_body">Você verificou %1$s</string>
|
||||
<string name="verify_success_system_message">verificou %1$s</string>
|
||||
|
||||
<string name="cd_notices">avisos</string>
|
||||
<string name="notices_title">avisos</string>
|
||||
<string name="notices_tab_geo_count">geo · %1$d</string>
|
||||
<string name="notices_tab_mesh_count">mesh · %1$d</string>
|
||||
<string name="notices_description_mesh">afixa avisos curtos para quem está por perto. saltam de telemóvel em telemóvel, mesmo offline, e desaparecem sozinhos após alguns dias.</string>
|
||||
<string name="notices_fades">desvanece %1$s</string>
|
||||
<string name="notices_source_mesh">mesh</string>
|
||||
<string name="notices_source_network">rede</string>
|
||||
<string name="notices_location_unavailable">localização indisponível</string>
|
||||
<string name="notices_enable_location">ativar localização</string>
|
||||
<string name="notices_alert_urgent_single">📌 aviso urgente de @%1$s: %2$s</string>
|
||||
<string name="notices_alert_urgent_collapsed">📌 %1$d novos avisos urgentes — toca no pin para ver</string>
|
||||
</resources>
|
||||
|
||||
@ -390,4 +390,17 @@
|
||||
<string name="verify_success_title">Проверено</string>
|
||||
<string name="verify_success_body">Вы проверили %1$s</string>
|
||||
<string name="verify_success_system_message">проверен %1$s</string>
|
||||
|
||||
<string name="cd_notices">объявления</string>
|
||||
<string name="notices_title">объявления</string>
|
||||
<string name="notices_tab_geo_count">гео · %1$d</string>
|
||||
<string name="notices_tab_mesh_count">mesh · %1$d</string>
|
||||
<string name="notices_description_mesh">закрепляй короткие объявления для людей рядом. они передаются с телефона на телефон, даже офлайн, и сами исчезают через несколько дней.</string>
|
||||
<string name="notices_fades">исчезнет %1$s</string>
|
||||
<string name="notices_source_mesh">mesh</string>
|
||||
<string name="notices_source_network">сеть</string>
|
||||
<string name="notices_location_unavailable">локация недоступна</string>
|
||||
<string name="notices_enable_location">включить локацию</string>
|
||||
<string name="notices_alert_urgent_single">📌 срочное объявление от @%1$s: %2$s</string>
|
||||
<string name="notices_alert_urgent_collapsed">📌 %1$d новых срочных объявлений — нажми на булавку, чтобы посмотреть</string>
|
||||
</resources>
|
||||
|
||||
@ -388,4 +388,17 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
|
||||
<string name="cd_notices">anslag</string>
|
||||
<string name="notices_title">anslag</string>
|
||||
<string name="notices_tab_geo_count">geo · %1$d</string>
|
||||
<string name="notices_tab_mesh_count">mesh · %1$d</string>
|
||||
<string name="notices_description_mesh">nåla upp korta anslag för folk i närheten. de hoppar från telefon till telefon, även offline, och försvinner av sig själva efter några dagar.</string>
|
||||
<string name="notices_fades">tonar bort %1$s</string>
|
||||
<string name="notices_source_mesh">mesh</string>
|
||||
<string name="notices_source_network">nät</string>
|
||||
<string name="notices_location_unavailable">plats ej tillgänglig</string>
|
||||
<string name="notices_enable_location">aktivera plats</string>
|
||||
<string name="notices_alert_urgent_single">📌 brådskande anslag från @%1$s: %2$s</string>
|
||||
<string name="notices_alert_urgent_collapsed">📌 %1$d nya brådskande anslag — tryck på nålen för att visa</string>
|
||||
</resources>
|
||||
|
||||
@ -40,5 +40,17 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
</resources>
|
||||
|
||||
<string name="cd_notices">அறிவிப்புகள்</string>
|
||||
<string name="notices_title">அறிவிப்புகள்</string>
|
||||
<string name="notices_tab_geo_count">பகுதி · %1$d</string>
|
||||
<string name="notices_tab_mesh_count">mesh · %1$d</string>
|
||||
<string name="notices_description_mesh">அருகிலுள்ளவர்களுக்காக சிறிய அறிவிப்புகளைப் பின் செய்யவும். ஆஃப்லைனிலும் ஃபோனிலிருந்து ஃபோனுக்குப் பரவி, சில நாட்களில் தானாக மறைந்துவிடும்.</string>
|
||||
<string name="notices_fades">%1$s மறையும்</string>
|
||||
<string name="notices_source_mesh">mesh</string>
|
||||
<string name="notices_source_network">நெட்</string>
|
||||
<string name="notices_location_unavailable">இடம் கிடைக்கவில்லை</string>
|
||||
<string name="notices_enable_location">இடத்தை இயக்கு</string>
|
||||
<string name="notices_alert_urgent_single">📌 @%1$s இன் அவசர அறிவிப்பு: %2$s</string>
|
||||
<string name="notices_alert_urgent_collapsed">📌 %1$d புதிய அவசர அறிவிப்புகள் — பார்க்க பின்னைத் தட்டவும்</string>
|
||||
</resources>
|
||||
|
||||
@ -387,4 +387,17 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
|
||||
<string name="cd_notices">ประกาศ</string>
|
||||
<string name="notices_title">ประกาศ</string>
|
||||
<string name="notices_tab_geo_count">พื้นที่ · %1$d</string>
|
||||
<string name="notices_tab_mesh_count">mesh · %1$d</string>
|
||||
<string name="notices_description_mesh">ปักประกาศสั้น ๆ ให้คนรอบตัว ส่งต่อจากมือถือสู่มือถือได้แม้ออฟไลน์ และหายไปเองหลังผ่านไปสองสามวัน</string>
|
||||
<string name="notices_fades">เลือนหาย %1$s</string>
|
||||
<string name="notices_source_mesh">mesh</string>
|
||||
<string name="notices_source_network">เน็ต</string>
|
||||
<string name="notices_location_unavailable">ไม่มีข้อมูลตำแหน่ง</string>
|
||||
<string name="notices_enable_location">เปิดใช้งานตำแหน่ง</string>
|
||||
<string name="notices_alert_urgent_single">📌 ประกาศด่วนจาก @%1$s: %2$s</string>
|
||||
<string name="notices_alert_urgent_collapsed">📌 ประกาศด่วนใหม่ %1$d รายการ — แตะหมุดเพื่อดู</string>
|
||||
</resources>
|
||||
|
||||
@ -388,4 +388,17 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
|
||||
<string name="cd_notices">duyurular</string>
|
||||
<string name="notices_title">duyurular</string>
|
||||
<string name="notices_tab_geo_count">bölge · %1$d</string>
|
||||
<string name="notices_tab_mesh_count">mesh · %1$d</string>
|
||||
<string name="notices_description_mesh">çevrendekiler için kısa duyurular sabitle. çevrimdışıyken bile telefondan telefona geçer ve birkaç gün sonra kendiliğinden kaybolur.</string>
|
||||
<string name="notices_fades">%1$s kaybolur</string>
|
||||
<string name="notices_source_mesh">mesh</string>
|
||||
<string name="notices_source_network">ağ</string>
|
||||
<string name="notices_location_unavailable">konum kullanılamıyor</string>
|
||||
<string name="notices_enable_location">konumu etkinleştir</string>
|
||||
<string name="notices_alert_urgent_single">📌 @%1$s kişisinden acil duyuru: %2$s</string>
|
||||
<string name="notices_alert_urgent_collapsed">📌 %1$d yeni acil duyuru — görmek için raptiyeye dokun</string>
|
||||
</resources>
|
||||
|
||||
@ -40,5 +40,17 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
</resources>
|
||||
|
||||
<string name="cd_notices">оголошення</string>
|
||||
<string name="notices_title">оголошення</string>
|
||||
<string name="notices_tab_geo_count">гео · %1$d</string>
|
||||
<string name="notices_tab_mesh_count">mesh · %1$d</string>
|
||||
<string name="notices_description_mesh">закріплюй короткі оголошення для людей поруч. вони передаються з телефона на телефон, навіть офлайн, і самі зникають за кілька днів.</string>
|
||||
<string name="notices_fades">зникне %1$s</string>
|
||||
<string name="notices_source_mesh">mesh</string>
|
||||
<string name="notices_source_network">мережа</string>
|
||||
<string name="notices_location_unavailable">локація недоступна</string>
|
||||
<string name="notices_enable_location">увімкнути локацію</string>
|
||||
<string name="notices_alert_urgent_single">📌 термінове оголошення від @%1$s: %2$s</string>
|
||||
<string name="notices_alert_urgent_collapsed">📌 %1$d нових термінових оголошень — натисни на шпильку, щоб переглянути</string>
|
||||
</resources>
|
||||
|
||||
@ -400,4 +400,17 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
|
||||
<string name="cd_notices">اعلانات</string>
|
||||
<string name="notices_title">اعلانات</string>
|
||||
<string name="notices_tab_geo_count">علاقہ · %1$d</string>
|
||||
<string name="notices_tab_mesh_count">mesh · %1$d</string>
|
||||
<string name="notices_description_mesh">آس پاس کے لوگوں کیلئے مختصر اعلانات پن کریں۔ یہ آف لائن بھی فون سے فون تک پہنچتے ہیں اور کچھ دنوں بعد خود مٹ جاتے ہیں۔</string>
|
||||
<string name="notices_fades">%1$s مٹ جائے گا</string>
|
||||
<string name="notices_source_mesh">mesh</string>
|
||||
<string name="notices_source_network">نیٹ</string>
|
||||
<string name="notices_location_unavailable">لوکیشن دستیاب نہیں</string>
|
||||
<string name="notices_enable_location">لوکیشن فعال کریں</string>
|
||||
<string name="notices_alert_urgent_single">📌 @%1$s کا فوری اعلان: %2$s</string>
|
||||
<string name="notices_alert_urgent_collapsed">📌 %1$d نئے فوری اعلانات — دیکھنے کیلئے پن پر ٹیپ کریں</string>
|
||||
</resources>
|
||||
|
||||
@ -387,4 +387,17 @@
|
||||
<string name="verify_success_title">Verified</string>
|
||||
<string name="verify_success_body">You verified %1$s</string>
|
||||
<string name="verify_success_system_message">verified %1$s</string>
|
||||
|
||||
<string name="cd_notices">thông báo</string>
|
||||
<string name="notices_title">thông báo</string>
|
||||
<string name="notices_tab_geo_count">khu vực · %1$d</string>
|
||||
<string name="notices_tab_mesh_count">mesh · %1$d</string>
|
||||
<string name="notices_description_mesh">ghim thông báo ngắn cho những người quanh bạn. chúng truyền từ điện thoại này sang điện thoại khác, kể cả khi ngoại tuyến, và tự biến mất sau vài ngày.</string>
|
||||
<string name="notices_fades">mờ dần %1$s</string>
|
||||
<string name="notices_source_mesh">mesh</string>
|
||||
<string name="notices_source_network">mạng</string>
|
||||
<string name="notices_location_unavailable">không có dữ liệu vị trí</string>
|
||||
<string name="notices_enable_location">bật vị trí</string>
|
||||
<string name="notices_alert_urgent_single">📌 thông báo khẩn từ @%1$s: %2$s</string>
|
||||
<string name="notices_alert_urgent_collapsed">📌 %1$d thông báo khẩn mới — chạm vào ghim để xem</string>
|
||||
</resources>
|
||||
|
||||
@ -53,5 +53,17 @@
|
||||
<string name="verify_success_title">已验证</string>
|
||||
<string name="verify_success_body">你已验证 %1$s</string>
|
||||
<string name="verify_success_system_message">已验证 %1$s</string>
|
||||
</resources>
|
||||
|
||||
<string name="cd_notices">公告</string>
|
||||
<string name="notices_title">公告</string>
|
||||
<string name="notices_tab_geo_count">区域 · %1$d</string>
|
||||
<string name="notices_tab_mesh_count">mesh · %1$d</string>
|
||||
<string name="notices_description_mesh">为周围的人钉上简短公告。即使离线也能在手机间传递,几天后自动消失。</string>
|
||||
<string name="notices_fades">%1$s消失</string>
|
||||
<string name="notices_source_mesh">mesh</string>
|
||||
<string name="notices_source_network">网络</string>
|
||||
<string name="notices_location_unavailable">位置不可用</string>
|
||||
<string name="notices_enable_location">启用位置</string>
|
||||
<string name="notices_alert_urgent_single">📌 来自 @%1$s 的紧急公告:%2$s</string>
|
||||
<string name="notices_alert_urgent_collapsed">📌 %1$d 条新紧急公告 — 点按图钉查看</string>
|
||||
</resources>
|
||||
|
||||
@ -53,5 +53,17 @@
|
||||
<string name="verify_success_title">已验证</string>
|
||||
<string name="verify_success_body">你已验证 %1$s</string>
|
||||
<string name="verify_success_system_message">已验证 %1$s</string>
|
||||
</resources>
|
||||
|
||||
<string name="cd_notices">公告</string>
|
||||
<string name="notices_title">公告</string>
|
||||
<string name="notices_tab_geo_count">區域 · %1$d</string>
|
||||
<string name="notices_tab_mesh_count">mesh · %1$d</string>
|
||||
<string name="notices_description_mesh">為周圍的人釘上簡短公告。即使離線也能在手機間傳遞,幾天後自動消失。</string>
|
||||
<string name="notices_fades">%1$s消失</string>
|
||||
<string name="notices_source_mesh">mesh</string>
|
||||
<string name="notices_source_network">網路</string>
|
||||
<string name="notices_location_unavailable">位置不可用</string>
|
||||
<string name="notices_enable_location">啟用位置</string>
|
||||
<string name="notices_alert_urgent_single">📌 來自 @%1$s 的緊急公告:%2$s</string>
|
||||
<string name="notices_alert_urgent_collapsed">📌 %1$d 則新緊急公告 — 點按圖釘查看</string>
|
||||
</resources>
|
||||
|
||||
@ -413,4 +413,17 @@
|
||||
<string name="verify_success_title">已验证</string>
|
||||
<string name="verify_success_body">你已验证 %1$s</string>
|
||||
<string name="verify_success_system_message">已验证 %1$s</string>
|
||||
|
||||
<string name="cd_notices">公告</string>
|
||||
<string name="notices_title">公告</string>
|
||||
<string name="notices_tab_geo_count">区域 · %1$d</string>
|
||||
<string name="notices_tab_mesh_count">mesh · %1$d</string>
|
||||
<string name="notices_description_mesh">为周围的人钉上简短公告。即使离线也能在手机间传递,几天后自动消失。</string>
|
||||
<string name="notices_fades">%1$s消失</string>
|
||||
<string name="notices_source_mesh">mesh</string>
|
||||
<string name="notices_source_network">网络</string>
|
||||
<string name="notices_location_unavailable">位置不可用</string>
|
||||
<string name="notices_enable_location">启用位置</string>
|
||||
<string name="notices_alert_urgent_single">📌 来自 @%1$s 的紧急公告:%2$s</string>
|
||||
<string name="notices_alert_urgent_collapsed">📌 %1$d 条新紧急公告 — 点按图钉查看</string>
|
||||
</resources>
|
||||
|
||||
@ -247,6 +247,26 @@
|
||||
<string name="location_notes_empty_desc">be the first to add one for this spot.</string>
|
||||
<string name="dismiss">dismiss</string>
|
||||
<string name="location_notes_input_placeholder">add a note for this place</string>
|
||||
<string name="cd_notices" tools:ignore="MissingTranslation">Notices</string>
|
||||
<string name="notices_title" tools:ignore="MissingTranslation">notices</string>
|
||||
<string name="notices_tab_geo_count" tools:ignore="MissingTranslation">geo · %1$d</string>
|
||||
<string name="notices_tab_mesh_count" tools:ignore="MissingTranslation">mesh · %1$d</string>
|
||||
<string name="notices_description_mesh" tools:ignore="MissingTranslation">pin short notices for people around you. they hop phone to phone, even offline, and disappear on their own after a few days.</string>
|
||||
<string name="notices_urgent" tools:ignore="MissingTranslation">urgent</string>
|
||||
<string name="notices_placeholder" tools:ignore="MissingTranslation">add a notice</string>
|
||||
<string name="notices_post" tools:ignore="MissingTranslation">Post notice</string>
|
||||
<string name="notices_delete" tools:ignore="MissingTranslation">Delete notice</string>
|
||||
<string name="notices_expiry" tools:ignore="MissingTranslation">expires</string>
|
||||
<string name="notices_days" tools:ignore="MissingTranslation,PluralsCandidate">%1$d days</string>
|
||||
<string name="notices_fades" tools:ignore="MissingTranslation">fades %1$s</string>
|
||||
<string name="notices_source_mesh" tools:ignore="MissingTranslation">mesh</string>
|
||||
<string name="notices_source_network" tools:ignore="MissingTranslation">net</string>
|
||||
<string name="notices_location_unavailable" tools:ignore="MissingTranslation">location unavailable</string>
|
||||
<string name="notices_location_unavailable_desc" tools:ignore="MissingTranslation">enable location to view persistent notices near you.</string>
|
||||
<string name="notices_enable_location" tools:ignore="MissingTranslation">enable location</string>
|
||||
<string name="notices_send_failed" tools:ignore="MissingTranslation">could not sign and send this notice</string>
|
||||
<string name="notices_alert_urgent_single" tools:ignore="MissingTranslation">📌 urgent notice from @%1$s: %2$s</string>
|
||||
<string name="notices_alert_urgent_collapsed" tools:ignore="MissingTranslation,PluralsCandidate">📌 %1$d new urgent notices — tap the pin to view</string>
|
||||
|
||||
<!-- Debug / Diagnostics -->
|
||||
<string name="debug_tools">debug tools</string>
|
||||
|
||||
151
app/src/test/java/com/bitchat/android/board/BoardManagerTest.kt
Normal file
151
app/src/test/java/com/bitchat/android/board/BoardManagerTest.kt
Normal file
@ -0,0 +1,151 @@
|
||||
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.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
|
||||
|
||||
@Test
|
||||
fun `create and delete emit iOS-compatible signed wire payloads`() = runTest {
|
||||
val mesh = mock<MeshService>()
|
||||
val notes = mock<LocationNotesManager>()
|
||||
whenever(mesh.getSigningPublicKey()).thenReturn(publicKey)
|
||||
whenever(mesh.signData(any())).thenAnswer { invocation ->
|
||||
sign(invocation.getArgument(0))
|
||||
}
|
||||
val manager = BoardManager(
|
||||
store = BoardStore(nowMs = { NOW }),
|
||||
scope = backgroundScope,
|
||||
meshProvider = { mesh },
|
||||
notesManager = notes,
|
||||
nowMs = { NOW },
|
||||
random = SecureRandom(byteArrayOf(7))
|
||||
)
|
||||
|
||||
assertTrue(
|
||||
manager.createPost(
|
||||
content = " water at gate two ",
|
||||
geohash = "U33DC",
|
||||
nickname = "alice",
|
||||
urgent = false,
|
||||
expiryDays = 3
|
||||
)
|
||||
)
|
||||
val payloads = argumentCaptor<ByteArray>()
|
||||
verify(mesh).sendBoardPayload(payloads.capture())
|
||||
val post = (BoardWireCodec.decode(payloads.firstValue) as BoardWire.Post).packet
|
||||
assertEquals("water at gate two", post.content)
|
||||
assertEquals("u33dc", post.geohash)
|
||||
assertEquals(NOW + 3uL * DAY_MS, post.expiresAt)
|
||||
assertTrue(post.verifySignature())
|
||||
|
||||
assertTrue(manager.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
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun `remote urgent arrivals badge their scope and collapse into an alert`() = runTest {
|
||||
val mesh = mock<MeshService>()
|
||||
whenever(mesh.getSigningPublicKey()).thenReturn(ByteArray(32) { 9 })
|
||||
val store = BoardStore(nowMs = { NOW })
|
||||
val alerts = mutableListOf<Pair<String, List<BoardPostPacket>>>()
|
||||
val manager = BoardManager(
|
||||
store = store,
|
||||
scope = backgroundScope,
|
||||
meshProvider = { mesh },
|
||||
notesManager = mock(),
|
||||
nowMs = { NOW },
|
||||
onUrgentPosts = { geohash, posts -> alerts += geohash to posts }
|
||||
)
|
||||
runCurrent()
|
||||
val post = signedPost(urgent = true)
|
||||
val wire = BoardWire.Post(post)
|
||||
|
||||
assertEquals(
|
||||
BoardIngestResult.ACCEPTED,
|
||||
store.ingest(
|
||||
wire = wire,
|
||||
packet = BitchatPacket(
|
||||
type = MessageType.BOARD_POST.value,
|
||||
senderID = ByteArray(8) { 1 },
|
||||
timestamp = NOW,
|
||||
payload = BoardWireCodec.encode(wire),
|
||||
ttl = 7u
|
||||
),
|
||||
source = BoardIngestSource.REMOTE
|
||||
)
|
||||
)
|
||||
runCurrent()
|
||||
assertTrue("u33dc" in manager.unseenScopes.value)
|
||||
advanceTimeBy(4_000)
|
||||
runCurrent()
|
||||
assertEquals(listOf("u33dc"), alerts.map { it.first })
|
||||
assertEquals(listOf(post), alerts.single().second)
|
||||
|
||||
manager.markSeen(setOf("u33dc"))
|
||||
assertFalse("u33dc" in manager.unseenScopes.value)
|
||||
}
|
||||
|
||||
private fun signedPost(urgent: Boolean): BoardPostPacket {
|
||||
val postID = ByteArray(16) { 4 }
|
||||
val flags: UByte = if (urgent) BoardPostPacket.URGENT_FLAG else 0u
|
||||
val signingBytes = BoardPostPacket.signingBytes(
|
||||
postID,
|
||||
"u33dc",
|
||||
"road closed",
|
||||
publicKey,
|
||||
"alice",
|
||||
NOW,
|
||||
NOW + DAY_MS,
|
||||
flags
|
||||
)
|
||||
return BoardPostPacket(
|
||||
postID,
|
||||
"u33dc",
|
||||
"road closed",
|
||||
publicKey,
|
||||
"alice",
|
||||
NOW,
|
||||
NOW + DAY_MS,
|
||||
flags,
|
||||
sign(signingBytes)
|
||||
)
|
||||
}
|
||||
|
||||
private fun sign(message: ByteArray): ByteArray = Ed25519Signer().run {
|
||||
init(true, privateKey)
|
||||
update(message, 0, message.size)
|
||||
generateSignature()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val NOW: ULong = 1_700_000_000_000uL
|
||||
const val DAY_MS: ULong = 86_400_000uL
|
||||
}
|
||||
}
|
||||
182
app/src/test/java/com/bitchat/android/board/BoardPacketsTest.kt
Normal file
182
app/src/test/java/com/bitchat/android/board/BoardPacketsTest.kt
Normal file
@ -0,0 +1,182 @@
|
||||
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())
|
||||
}
|
||||
|
||||
private fun signedPost(
|
||||
postID: ByteArray = ByteArray(16) { (it + 1).toByte() },
|
||||
geohash: String = "u33dc1",
|
||||
content: String = "notice",
|
||||
nickname: String = "alice",
|
||||
createdAt: ULong = 1_700_000_000_000uL,
|
||||
expiresAt: ULong = createdAt + 86_400_000uL,
|
||||
urgent: Boolean = false
|
||||
): BoardPostPacket {
|
||||
val flags = if (urgent) BoardPostPacket.URGENT_FLAG else 0u.toUByte()
|
||||
val signingBytes = BoardPostPacket.signingBytes(
|
||||
postID = postID,
|
||||
geohash = geohash,
|
||||
content = content,
|
||||
authorSigningKey = publicKey,
|
||||
authorNickname = nickname,
|
||||
createdAt = createdAt,
|
||||
expiresAt = expiresAt,
|
||||
flags = flags
|
||||
)
|
||||
return BoardPostPacket(
|
||||
postID = postID,
|
||||
geohash = geohash,
|
||||
content = content,
|
||||
authorSigningKey = publicKey,
|
||||
authorNickname = nickname,
|
||||
createdAt = createdAt,
|
||||
expiresAt = expiresAt,
|
||||
flags = flags,
|
||||
signature = sign(signingBytes)
|
||||
)
|
||||
}
|
||||
|
||||
private fun sign(message: ByteArray): ByteArray = Ed25519Signer().run {
|
||||
init(true, privateKey)
|
||||
update(message, 0, message.size)
|
||||
generateSignature()
|
||||
}
|
||||
|
||||
private fun replaceTlv(encoded: ByteArray, type: Int, value: ByteArray): ByteArray {
|
||||
val output = ArrayList<Byte>()
|
||||
var offset = 0
|
||||
while (offset + 3 <= encoded.size) {
|
||||
val currentType = encoded[offset].toInt() and 0xFF
|
||||
val length =
|
||||
((encoded[offset + 1].toInt() and 0xFF) shl 8) or
|
||||
(encoded[offset + 2].toInt() and 0xFF)
|
||||
val end = offset + 3 + length
|
||||
if (currentType == type) {
|
||||
output += type.toByte()
|
||||
output += ((value.size ushr 8) and 0xFF).toByte()
|
||||
output += (value.size and 0xFF).toByte()
|
||||
output += value.toList()
|
||||
} else {
|
||||
output += encoded.copyOfRange(offset, end).toList()
|
||||
}
|
||||
offset = end
|
||||
}
|
||||
return output.toByteArray()
|
||||
}
|
||||
}
|
||||
175
app/src/test/java/com/bitchat/android/board/BoardStoreTest.kt
Normal file
175
app/src/test/java/com/bitchat/android/board/BoardStoreTest.kt
Normal file
@ -0,0 +1,175 @@
|
||||
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.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 `only author tombstone deletes known post`() {
|
||||
val store = BoardStore(nowMs = { now })
|
||||
val post = signedPost()
|
||||
assertEquals(BoardIngestResult.ACCEPTED, store.ingestPost(post))
|
||||
|
||||
val forged = signedTombstone(post, attacker)
|
||||
assertEquals(BoardIngestResult.REJECTED, 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(1, store.syncCandidates().size)
|
||||
}
|
||||
|
||||
@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
|
||||
): BoardPostPacket {
|
||||
val postID = ByteArray(16).also { it[0] = idByte }
|
||||
val content = "notice-$idByte"
|
||||
val signingBytes = BoardPostPacket.signingBytes(
|
||||
postID,
|
||||
geohash,
|
||||
content,
|
||||
author.publicKey,
|
||||
"alice",
|
||||
createdAt,
|
||||
expiresAt,
|
||||
0u
|
||||
)
|
||||
return BoardPostPacket(
|
||||
postID,
|
||||
geohash,
|
||||
content,
|
||||
author.publicKey,
|
||||
"alice",
|
||||
createdAt,
|
||||
expiresAt,
|
||||
0u,
|
||||
author.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,107 @@
|
||||
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)
|
||||
}
|
||||
|
||||
private fun post(
|
||||
content: String,
|
||||
nickname: String = "alice",
|
||||
createdAt: ULong = baseMs,
|
||||
urgent: Boolean = false
|
||||
) = BoardPostPacket(
|
||||
postID = ByteArray(16) { content.hashCode().toByte() },
|
||||
geohash = "u33dc",
|
||||
content = content,
|
||||
authorSigningKey = ByteArray(32) { 1 },
|
||||
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
|
||||
)
|
||||
}
|
||||
112
app/src/test/java/com/bitchat/android/sync/BoardSyncTest.kt
Normal file
112
app/src/test/java/com/bitchat/android/sync/BoardSyncTest.kt
Normal file
@ -0,0 +1,112 @@
|
||||
package com.bitchat.android.sync
|
||||
|
||||
import com.bitchat.android.model.RequestSyncPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
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
|
||||
|
||||
class BoardSyncTest {
|
||||
@Test
|
||||
fun `board flag widens to second little-endian byte`() {
|
||||
assertArrayEquals(byteArrayOf(0, 1), SyncTypeFlags.BOARD.encode())
|
||||
val decoded = SyncTypeFlags.decode(byteArrayOf(0, 1))!!
|
||||
assertTrue(decoded.contains(MessageType.BOARD_POST))
|
||||
assertFalse(decoded.contains(MessageType.MESSAGE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy one-byte flags and unknown bits remain compatible`() {
|
||||
val legacy = SyncTypeFlags.decode(byteArrayOf(0x03))!!
|
||||
assertTrue(legacy.contains(MessageType.ANNOUNCE))
|
||||
assertTrue(legacy.contains(MessageType.MESSAGE))
|
||||
assertFalse(legacy.contains(MessageType.BOARD_POST))
|
||||
|
||||
val unknownOnly = SyncTypeFlags.decode(byteArrayOf(0, 0, 0x40))!!
|
||||
assertNull(unknownOnly.encode())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `request sync round trips board types and cursor`() {
|
||||
val request = RequestSyncPacket(
|
||||
p = 7,
|
||||
m = 1234,
|
||||
data = byteArrayOf(1, 2, 3),
|
||||
types = SyncTypeFlags.BOARD,
|
||||
sinceTimestamp = 1_700_000_000_000uL
|
||||
)
|
||||
|
||||
val decoded = RequestSyncPacket.decode(request.encode())!!
|
||||
|
||||
assertEquals(7, decoded.p)
|
||||
assertEquals(1234, decoded.m)
|
||||
assertArrayEquals(byteArrayOf(1, 2, 3), decoded.data)
|
||||
assertTrue(decoded.types!!.contains(MessageType.BOARD_POST))
|
||||
assertEquals(1_700_000_000_000uL, decoded.sinceTimestamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy request without type field defaults at handler not decoder`() {
|
||||
val request = RequestSyncPacket(p = 7, m = 1, data = ByteArray(0))
|
||||
val decoded = RequestSyncPacket.decode(request.encode())!!
|
||||
|
||||
assertNull(decoded.types)
|
||||
assertNull(decoded.sinceTimestamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `board round is served only from provider`() {
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
|
||||
val manager = GossipSyncManager(
|
||||
myPeerID = "0102030405060708",
|
||||
scope = scope,
|
||||
configProvider = config()
|
||||
)
|
||||
val boardPacket = packet(MessageType.BOARD_POST, timestamp = 200uL)
|
||||
val messagePacket = packet(MessageType.MESSAGE, timestamp = 300uL)
|
||||
manager.boardPacketsProvider = { listOf(boardPacket) }
|
||||
manager.onPublicPacketSeen(messagePacket)
|
||||
val sent = mutableListOf<BitchatPacket>()
|
||||
manager.delegate = object : GossipSyncManager.Delegate {
|
||||
override fun sendPacket(packet: BitchatPacket) = Unit
|
||||
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) {
|
||||
sent += packet
|
||||
}
|
||||
override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket = packet
|
||||
}
|
||||
|
||||
manager.handleRequestSync(
|
||||
fromPeerID = "1111111111111111",
|
||||
request = RequestSyncPacket(
|
||||
p = 7,
|
||||
m = 1,
|
||||
data = ByteArray(0),
|
||||
types = SyncTypeFlags.BOARD
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(listOf(MessageType.BOARD_POST.value), sent.map { it.type })
|
||||
assertEquals(0u.toUByte(), sent.single().ttl)
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
@ -59,11 +59,11 @@ class IdentityAnnouncementTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local announcement send advertises private media`() {
|
||||
fun `local announcement send advertises board and private media`() {
|
||||
val encoded = IdentityAnnouncement.forLocalPeer(nickname, noiseKey, signingKey).encode()!!
|
||||
|
||||
assertArrayEquals(
|
||||
byteArrayOf(0x05, 0x02, 0x00, 0x01),
|
||||
byteArrayOf(0x05, 0x02, 0x10, 0x01),
|
||||
encoded.takeLast(4).toByteArray()
|
||||
)
|
||||
assertTrue(
|
||||
@ -71,5 +71,10 @@ class IdentityAnnouncementTest {
|
||||
.capabilities!!
|
||||
.contains(PeerCapabilities.PRIVATE_MEDIA)
|
||||
)
|
||||
assertTrue(
|
||||
IdentityAnnouncement.decode(encoded)!!
|
||||
.capabilities!!
|
||||
.contains(PeerCapabilities.BOARD)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user