diff --git a/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt b/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt
index 7379b5ef..4bdc2701 100644
--- a/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt
+++ b/app/src/debug/java/com/bitchat/android/testhook/TestHookDriver.kt
@@ -1,6 +1,7 @@
package com.bitchat.android.testhook
import android.content.Context
+import android.content.ContextWrapper
import android.content.Intent
import android.util.Log
import com.bitchat.android.favorites.FavoritesPersistenceService
@@ -15,7 +16,10 @@ import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.mesh.PrivateMediaPreparation
import com.bitchat.android.mesh.TransferProgressManager
+import com.bitchat.android.mesh.CourierDepositTier
+import com.bitchat.android.mesh.CourierStore
import com.bitchat.android.model.BitchatFilePacket
+import com.bitchat.android.model.CourierEnvelope
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.noise.NoiseSession
import com.bitchat.android.protocol.BitchatPacket
@@ -23,8 +27,11 @@ import com.bitchat.android.service.MeshForegroundService
import com.bitchat.android.service.MeshServiceHolder
import com.bitchat.android.service.TransportBridgeService
import com.bitchat.android.services.AppStateStore
+import com.bitchat.android.services.ConversationStorageCipher
+import com.bitchat.android.services.MessageRouter
import com.bitchat.android.ui.DataManager
import com.bitchat.android.ui.PrivateMediaRecipientResolver
+import com.bitchat.android.ui.debug.DebugSettingsManager
import com.bitchat.android.util.AppConstants
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
@@ -69,6 +76,13 @@ object TestHookDriver {
"announce" -> announce(context)
"broadcast_msg" -> broadcastMsg(context, intent.requiredString("content"), intent.getStringExtra("channel"))
"dm_send" -> dmSend(context, intent.requiredString("peer"), intent.requiredString("content"), intent.getStringExtra("msg_id"))
+ "router_private_send" -> routerPrivateSend(
+ context,
+ intent.requiredString("peer"),
+ intent.requiredString("content"),
+ intent.getStringExtra("msg_id")
+ )
+ "router_resume" -> routerResume(context)
"dm_recv" -> dmRecv(context, intent)
"msg_recv" -> msgRecv(context, intent)
"favorite_set" -> favoriteSet(
@@ -89,7 +103,11 @@ object TestHookDriver {
"ptt_send" -> pttSend(context, intent)
"ptt_recv" -> pttRecv(context, intent)
"raw_send" -> rawSend(context, intent)
+ "courier_contract" -> courierContract(context)
+ "cache_peer_identity" -> cachePeerIdentity(context, intent.requiredString("peer"))
+ "sync_request" -> syncRequest(intent.requiredString("peer"))
"ble" -> setBle(intent.getBooleanExtra("enabled", true))
+ "wifi_aware" -> setWifiAware(intent.getBooleanExtra("enabled", true))
"inject_peers" -> injectPeers(intent.getStringExtra("peers"))
"state" -> state(context)
"clear_results" -> clearResults(context)
@@ -243,6 +261,27 @@ object TestHookDriver {
return ok("dm_send").put("peer", peerID).put("msg_id", id)
}
+ /**
+ * Drives the same durable outbox/router path used by private-chat sends instead of the
+ * lower-level direct mesh API that backs `dm_send`.
+ */
+ private fun routerPrivateSend(context: Context, peerID: String, content: String, msgID: String?): JSONObject {
+ val mesh = mesh(context)
+ val nickname = mesh.getPeerNicknames()[peerID] ?: peerID
+ val id = msgID ?: "testhook-router-${System.currentTimeMillis()}"
+ val route = MessageRouter.getInstance(context, mesh).sendPrivate(content, peerID, nickname, id)
+ return ok("router_private_send")
+ .put("peer", peerID)
+ .put("msg_id", id)
+ .put("route", route.name)
+ }
+
+ /** Recreates the durable router after a process restart without adding another message. */
+ private fun routerResume(context: Context): JSONObject {
+ MessageRouter.getInstance(context, mesh(context))
+ return ok("router_resume")
+ }
+
private suspend fun dmRecv(context: Context, intent: Intent): JSONObject {
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS)
val fromPeer = intent.getStringExtra("peer")
@@ -276,10 +315,11 @@ object TestHookDriver {
val timeoutMs = intent.getLongExtra("timeout_ms", DEFAULT_RECV_TIMEOUT_MS)
val contains = intent.getStringExtra("contains")
val channel = intent.getStringExtra("channel")
+ val includeExisting = intent.getBooleanExtra("include_existing", false)
val startTime = System.currentTimeMillis()
val mesh = mesh(context)
val matches: (com.bitchat.android.model.BitchatMessage) -> Boolean = { msg ->
- msg.timestamp.time >= startTime &&
+ (includeExisting || msg.timestamp.time >= startTime) &&
msg.senderPeerID != mesh.myPeerID &&
(contains == null || msg.content.contains(contains)) &&
(channel == null || msg.channel == channel)
@@ -653,6 +693,128 @@ object TestHookDriver {
.put("peer", peerID)
}
+ /**
+ * Exercises the real courier wire/store implementation on a physical debug build.
+ * This is intentionally local: the two-phone harness has no third identity to act as
+ * both recipient and an independent courier, so transport scenarios cannot observe the
+ * spray budget without weakening the assertion.
+ */
+ private fun courierContract(context: Context): JSONObject {
+ val filesDir = File(context.cacheDir, "testhook/courier-contract-files")
+ filesDir.deleteRecursively()
+ filesDir.mkdirs()
+ val labContext = LabStorageContext(context, filesDir)
+ val cipher = LabCipher(0x5a)
+ val now = System.currentTimeMillis()
+ val recipientKey = ByteArray(32) { 7 }
+ val depositorKey = ByteArray(32) { 8 }
+ val firstCourier = ByteArray(32) { 11 }
+ val secondCourier = ByteArray(32) { 12 }
+ val thirdCourier = ByteArray(32) { 13 }
+ val fourthCourier = ByteArray(32) { 14 }
+ val tag = CourierEnvelope.recipientTag(recipientKey, CourierEnvelope.epochDay(now))
+ val store = CourierStore(labContext, cipher) { now }
+ try {
+ val firstEnvelope = CourierEnvelope(
+ recipientTag = tag,
+ expiry = (now + CourierEnvelope.MAX_LIFETIME_MS).toULong(),
+ ciphertext = ByteArray(32) { (it + 1).toByte() },
+ copies = 4u,
+ prekeyID = 0x11223344u
+ )
+ require(store.deposit(firstEnvelope, depositorKey, CourierDepositTier.VERIFIED))
+ val wire = requireNotNull(firstEnvelope.encode())
+ val decoded = requireNotNull(CourierEnvelope.decode(wire))
+ val first = store.sprayCopiesFor(firstCourier).single()
+ val second = store.sprayCopiesFor(secondCourier).single()
+ val sameCourierEmpty = store.sprayCopiesFor(firstCourier).isEmpty()
+ val secondCommitted = store.commitSpray(second, secondCourier)
+ val firstCommitted = store.commitSpray(first, firstCourier)
+ val thirdCourierEmpty = store.sprayCopiesFor(thirdCourier).isEmpty()
+
+ val secondEnvelope = firstEnvelope.copy(ciphertext = ByteArray(32) { (it + 65).toByte() })
+ require(store.deposit(secondEnvelope, depositorKey, CourierDepositTier.VERIFIED))
+ val cancelledPreview = store.sprayCopiesFor(fourthCourier).single()
+ val cancelled = store.cancelSpray(cancelledPreview, fourthCourier)
+ val retry = store.sprayCopiesFor(fourthCourier).single()
+ val retryCommitted = store.commitSpray(retry, fourthCourier)
+
+ val reloaded = CourierStore(labContext, LabCipher(0x5a)) { now }
+ val persistedSprayHistory = reloaded.sprayCopiesFor(fourthCourier)
+ .none { it.ciphertext.contentEquals(secondEnvelope.ciphertext) }
+ val remainingCopies = reloaded.sprayCopiesFor(thirdCourier)
+ .firstOrNull { it.ciphertext.contentEquals(secondEnvelope.ciphertext) }
+ ?.copies
+ ?.toInt()
+ reloaded.wipe()
+
+ require(decoded.prekeyID == firstEnvelope.prekeyID)
+ require(decoded.encode()?.contentEquals(wire) == true)
+ require(first.prekeyID == firstEnvelope.prekeyID)
+ require(first.copies == 2u.toUByte())
+ require(second.copies == 1u.toUByte())
+ require(sameCourierEmpty)
+ require(secondCommitted && firstCommitted && thirdCourierEmpty)
+ require(cancelled && retry.copies == 2u.toUByte() && retryCommitted)
+ require(persistedSprayHistory && remainingCopies == 1)
+
+ return ok("courier_contract")
+ .put("wire_prekey_id_preserved", true)
+ .put("stored_prekey_id_preserved", true)
+ .put("first_reserved_copies", first.copies.toInt())
+ .put("second_reserved_copies", second.copies.toInt())
+ .put("same_courier_second_reservation_empty", sameCourierEmpty)
+ .put("reverse_order_commits", true)
+ .put("cancel_restored_eligibility", cancelled)
+ .put("persisted_spray_history", persistedSprayHistory)
+ .put("remaining_copies_after_restart", remainingCopies)
+ } finally {
+ store.wipe()
+ filesDir.deleteRecursively()
+ }
+ }
+
+ /** Persist the currently authenticated peer key exactly as the normal UI session observer does. */
+ private fun cachePeerIdentity(context: Context, peerID: String): JSONObject {
+ val info = mesh(context).getPeerInfo(peerID)
+ ?: return err("cache_peer_identity", "peer is not known")
+ val noiseKey = info.noisePublicKey
+ ?: return err("cache_peer_identity", "peer Noise key is unavailable")
+ val noiseKeyHex = noiseKey.toHex()
+ val identityManager = SecureIdentityStateManager(context)
+ identityManager.cachePeerNoiseKey(peerID, noiseKeyHex)
+ identityManager.cacheNoiseFingerprint(noiseKeyHex, com.bitchat.android.services.ContactIdentityResolver.fingerprintHex(noiseKey))
+ info.nickname.takeIf { it.isNotBlank() }?.let { nickname ->
+ identityManager.cacheFingerprintNickname(
+ com.bitchat.android.services.ContactIdentityResolver.fingerprintHex(noiseKey),
+ nickname
+ )
+ }
+ return ok("cache_peer_identity").put("peer", peerID)
+ }
+
+ private fun syncRequest(peerID: String): JSONObject {
+ val manager = MeshServiceHolder.sharedGossipSyncManager
+ ?: return err("sync_request", "gossip sync manager is unavailable")
+ manager.scheduleInitialSyncToPeer(peerID, 0)
+ return ok("sync_request").put("peer", peerID)
+ }
+
+ private class LabStorageContext(base: Context, private val labFilesDir: File) : ContextWrapper(base) {
+ override fun getApplicationContext(): Context = this
+ override fun getFilesDir(): File = labFilesDir
+ }
+
+ private class LabCipher(private val mask: Int) : ConversationStorageCipher {
+ override fun encrypt(plaintext: ByteArray, associatedData: ByteArray): ByteArray =
+ plaintext.map { (it.toInt() xor mask).toByte() }.toByteArray()
+
+ override fun decrypt(envelope: ByteArray, associatedData: ByteArray): ByteArray =
+ encrypt(envelope, associatedData)
+
+ override fun destroyKey() = Unit
+ }
+
// MARK: - Transport / state
private fun setBle(enabled: Boolean): JSONObject {
@@ -661,6 +823,14 @@ object TestHookDriver {
return ok("ble").put("enabled", enabled)
}
+ private fun setWifiAware(enabled: Boolean): JSONObject {
+ val previous = com.bitchat.android.wifiaware.WifiAwareController.enabled.value
+ DebugSettingsManager.getInstance().setWifiAwareEnabled(enabled)
+ return ok("wifi_aware")
+ .put("enabled", enabled)
+ .put("previous_enabled", previous)
+ }
+
private fun state(context: Context): JSONObject {
val mesh = mesh(context)
val peersJson = peerInfosJson(mesh, AppStateStore.peers.value)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1f9a8c49..9599ecf8 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -126,6 +126,12 @@
+
+
+
+
+
+
diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt
index 7b5926ef..2493e3a3 100644
--- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt
+++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt
@@ -73,6 +73,20 @@ class BitchatApplication : Application() {
// Initialize mesh service preferences
try { com.bitchat.android.service.MeshServicePreferences.init(this) } catch (_: Exception) { }
+ // Bridge policy is process-scoped so rendezvous and courier delivery
+ // continue while the activity is backgrounded.
+ try {
+ com.bitchat.android.services.bridge.MeshBridgeService.initialize(this)
+ com.bitchat.android.mesh.BridgeMeshPort.install(
+ com.bitchat.android.services.bridge.MeshBridgeService
+ )
+ } catch (_: Exception) { }
+
+ com.bitchat.android.services.bridge.MeshGatewayService.initialize(this)
+ com.bitchat.android.groups.GroupRuntime.getInstance(this)
+ com.bitchat.android.services.PrivateMediaOutbox.initialize(this)
+ com.bitchat.android.model.PeerCapabilities.setPhoneFeaturesEnabled(true)
+
// Proactively start the foreground service to keep mesh alive
try { com.bitchat.android.service.MeshForegroundService.start(this) } catch (_: Exception) { }
diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt
index 6f80052a..d73d9341 100644
--- a/app/src/main/java/com/bitchat/android/MainActivity.kt
+++ b/app/src/main/java/com/bitchat/android/MainActivity.kt
@@ -705,6 +705,7 @@ class MainActivity : OrientationAwareActivity() {
// Handle any notification intent
handleNotificationIntent(intent)
handleVerificationIntent(intent)
+ handleShareIntent(intent)
// Small delay to ensure mesh service is fully initialized
delay(500)
@@ -734,6 +735,7 @@ class MainActivity : OrientationAwareActivity() {
if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) {
handleNotificationIntent(intent)
handleVerificationIntent(intent)
+ handleShareIntent(intent)
}
}
@@ -848,6 +850,21 @@ class MainActivity : OrientationAwareActivity() {
}
}
+ private fun handleShareIntent(intent: Intent) {
+ if (intent.action == Intent.ACTION_SEND && intent.type?.startsWith("text/") == true) {
+ intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()?.takeIf { it.isNotBlank() }?.let {
+ chatViewModel.receiveSharedText(it)
+ intent.removeExtra(Intent.EXTRA_TEXT)
+ }
+ }
+ if (intent.action == Intent.ACTION_VIEW) {
+ val cell = intent.data?.toString()?.let(com.bitchat.android.services.ChannelInvitation::decode) ?: return
+ val channel = com.bitchat.android.ui.channelForManualGeohash(cell) ?: return
+ LocationChannelManager.getInstance(applicationContext).selectManual(channel)
+ intent.data = null
+ }
+ }
+
private fun handleVerificationIntent(intent: Intent) {
val uri = intent.data ?: return
if (uri.scheme != "bitchat" || uri.host != "verify") return
diff --git a/app/src/main/java/com/bitchat/android/board/BoardManager.kt b/app/src/main/java/com/bitchat/android/board/BoardManager.kt
new file mode 100644
index 00000000..93288c32
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/board/BoardManager.kt
@@ -0,0 +1,212 @@
+package com.bitchat.android.board
+
+import com.bitchat.android.mesh.MeshService
+import com.bitchat.android.nostr.LocationNotesManager
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import java.security.SecureRandom
+
+/**
+ * Creates and removes signed board entries while keeping UI-only state out of
+ * the transport layer.
+ */
+class BoardManager(
+ private val store: BoardStore,
+ private val scope: CoroutineScope,
+ private val meshProvider: () -> MeshService,
+ private val geoIdentityProvider: (String) -> BoardSigningIdentity? = { null },
+ private val notesManager: LocationNotesManager = LocationNotesManager.getInstance(),
+ private val nowMs: () -> ULong = { System.currentTimeMillis().coerceAtLeast(0).toULong() },
+ private val random: SecureRandom = SecureRandom(),
+ private val onUrgentPosts: (geohash: String, posts: List) -> Unit = { _, _ -> }
+) {
+ private val _unseenScopes = MutableStateFlow>(emptySet())
+ private val bridgedEventIDs = mutableMapOf()
+ private val handledPostIDs = mutableSetOf()
+ private val pendingUrgent = mutableMapOf>()
+ private var alertFlushJob: Job? = null
+
+ val posts: StateFlow> = store.postsSnapshot
+ val unseenScopes: StateFlow> = _unseenScopes.asStateFlow()
+
+ init {
+ scope.launch {
+ store.postArrivals.collect { post ->
+ handleArrival(post)
+ }
+ }
+ }
+
+ fun posts(geohash: String): List = store.posts(geohash.lowercase())
+
+ fun isOwnPost(post: BoardPostPacket): Boolean =
+ signingIdentityFor(post.geohash)
+ ?.publicKey
+ ?.contentEquals(post.authorSigningKey) == true
+
+ fun createPost(
+ content: String,
+ geohash: String,
+ nickname: String?,
+ urgent: Boolean,
+ expiryDays: Int
+ ): Boolean {
+ val trimmed = content.trim()
+ val contentBytes = trimmed.toByteArray(Charsets.UTF_8)
+ val normalizedGeohash = geohash.lowercase()
+ if (contentBytes.size !in 1..BoardWireConstants.CONTENT_MAX_BYTES ||
+ expiryDays !in 1..7 ||
+ !isValidGeohash(normalizedGeohash)
+ ) {
+ return false
+ }
+
+ val mesh = meshProvider()
+ val identity = signingIdentityFor(normalizedGeohash) ?: return false
+ val signingKey = identity.publicKey.copyOf()
+ val postID = ByteArray(BoardWireConstants.POST_ID_LENGTH).also(random::nextBytes)
+ val createdAt = nowMs()
+ val expiresAt = createdAt + expiryDays.toULong() * DAY_MS
+ val authorNickname = truncateUtf8(nickname.orEmpty(), BoardWireConstants.NICKNAME_MAX_BYTES)
+ val flags: UByte = if (urgent) BoardPostPacket.URGENT_FLAG else 0u
+ val signingBytes = BoardPostPacket.signingBytes(
+ postID = postID,
+ geohash = normalizedGeohash,
+ content = trimmed,
+ authorSigningKey = signingKey,
+ authorNickname = authorNickname,
+ createdAt = createdAt,
+ expiresAt = expiresAt,
+ flags = flags
+ )
+ val signature = identity.sign(signingBytes)
+ ?.takeIf { it.size == BoardWireConstants.SIGNATURE_LENGTH }
+ ?: return false
+ val post = BoardPostPacket(
+ postID = postID,
+ geohash = normalizedGeohash,
+ content = trimmed,
+ authorSigningKey = signingKey,
+ authorNickname = authorNickname,
+ createdAt = createdAt,
+ expiresAt = expiresAt,
+ flags = flags,
+ signature = signature
+ )
+ mesh.sendBoardPayload(BoardWireCodec.encode(BoardWire.Post(post)))
+
+ if (normalizedGeohash.isNotEmpty()) {
+ notesManager.publishBoardBridge(
+ content = trimmed,
+ geohash = normalizedGeohash,
+ nickname = authorNickname,
+ expiresAtSeconds = (expiresAt / 1_000u).coerceAtMost(Int.MAX_VALUE.toULong()).toInt(),
+ urgent = urgent
+ ) { eventID ->
+ synchronized(bridgedEventIDs) {
+ bridgedEventIDs[post.identityKey()] = eventID
+ }
+ }
+ }
+ return true
+ }
+
+ fun deletePost(post: BoardPostPacket): Boolean {
+ val identity = signingIdentityFor(post.geohash)
+ ?.takeIf { it.publicKey.contentEquals(post.authorSigningKey) }
+ ?: return false
+ val deletedAt = nowMs()
+ val signature = identity.sign(
+ BoardTombstonePacket.signingBytes(post.postID, deletedAt)
+ )?.takeIf { it.size == BoardWireConstants.SIGNATURE_LENGTH } ?: return false
+ val tombstone = BoardTombstonePacket(
+ postID = post.postID,
+ authorSigningKey = post.authorSigningKey,
+ deletedAt = deletedAt,
+ signature = signature
+ )
+ meshProvider().sendBoardPayload(BoardWireCodec.encode(BoardWire.Tombstone(tombstone)))
+
+ if (post.geohash.isNotEmpty()) {
+ val eventID = synchronized(bridgedEventIDs) {
+ bridgedEventIDs.remove(post.identityKey())
+ }
+ if (eventID != null) notesManager.deleteEvent(eventID, post.geohash)
+ }
+ return true
+ }
+
+ fun markSeen(scopes: Set) {
+ if (scopes.isEmpty()) return
+ _unseenScopes.value = _unseenScopes.value - scopes
+ }
+
+ fun clearTransientState() {
+ _unseenScopes.value = emptySet()
+ synchronized(bridgedEventIDs) { bridgedEventIDs.clear() }
+ handledPostIDs.clear()
+ pendingUrgent.clear()
+ alertFlushJob?.cancel()
+ alertFlushJob = null
+ }
+
+ private fun handleArrival(post: BoardPostPacket) {
+ if (!handledPostIDs.add(post.identityKey()) || isOwnPost(post)) return
+ _unseenScopes.value = _unseenScopes.value + post.geohash
+ val age = nowMs().toLong() -
+ post.createdAt.coerceAtMost(Long.MAX_VALUE.toULong()).toLong()
+ if (!post.isUrgent || age > URGENT_RECENCY_MS) return
+
+ pendingUrgent.getOrPut(post.geohash) { mutableListOf() } += post
+ if (alertFlushJob == null) {
+ alertFlushJob = scope.launch {
+ delay(ALERT_COLLAPSE_MS)
+ val pending = pendingUrgent.mapValues { it.value.toList() }
+ pendingUrgent.clear()
+ alertFlushJob = null
+ pending.forEach { (geohash, posts) -> onUrgentPosts(geohash, posts) }
+ }
+ }
+ }
+
+ private fun isValidGeohash(value: String): Boolean =
+ value.isEmpty() ||
+ (value.length <= BoardWireConstants.GEOHASH_MAX_LENGTH &&
+ value.all { it in BoardWireConstants.GEOHASH_ALPHABET })
+
+ private fun truncateUtf8(value: String, maxBytes: Int): String {
+ var result = value
+ while (result.toByteArray(Charsets.UTF_8).size > maxBytes && result.isNotEmpty()) {
+ result = result.dropLast(1)
+ }
+ return result
+ }
+
+ private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
+
+ private fun BoardPostPacket.identityKey(): String =
+ "${authorSigningKey.toHex()}:${postID.toHex()}"
+
+ private fun signingIdentityFor(geohash: String): BoardSigningIdentity? {
+ if (geohash.isNotEmpty()) {
+ // Never fall back to the stable mesh identity for a location scope.
+ return runCatching { geoIdentityProvider(geohash) }.getOrNull()
+ }
+ val mesh = meshProvider()
+ val publicKey = mesh.getSigningPublicKey()
+ ?.takeIf { it.size == BoardWireConstants.SIGNING_KEY_LENGTH }
+ ?: return null
+ return BoardSigningIdentity(publicKey, mesh::signData)
+ }
+
+ private companion object {
+ const val DAY_MS: ULong = 86_400_000uL
+ const val URGENT_RECENCY_MS = 30 * 60 * 1_000L
+ const val ALERT_COLLAPSE_MS = 4_000L
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/board/BoardPackets.kt b/app/src/main/java/com/bitchat/android/board/BoardPackets.kt
new file mode 100644
index 00000000..62ba7464
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/board/BoardPackets.kt
@@ -0,0 +1,402 @@
+package com.bitchat.android.board
+
+import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
+import org.bouncycastle.crypto.signers.Ed25519Signer
+import java.io.ByteArrayOutputStream
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import java.nio.charset.CodingErrorAction
+import java.security.MessageDigest
+
+object BoardWireConstants {
+ const val POST_ID_LENGTH = 16
+ const val SIGNING_KEY_LENGTH = 32
+ const val SIGNATURE_LENGTH = 64
+ const val TRANSPORT_SENDER_ID_LENGTH = 8
+ const val CONTENT_MAX_BYTES = 512
+ const val NICKNAME_MAX_BYTES = 64
+ const val GEOHASH_MAX_LENGTH = 12
+ const val MAX_LIFETIME_MS: ULong = 604_800_000uL
+ const val POST_SIGNING_CONTEXT = "bitchat-board-v1"
+ const val TOMBSTONE_SIGNING_CONTEXT = "bitchat-board-del-v1"
+ const val GEOHASH_ALPHABET = "0123456789bcdefghjkmnpqrstuvwxyz"
+}
+
+class BoardPostPacket(
+ val postID: ByteArray,
+ val geohash: String,
+ val content: String,
+ val authorSigningKey: ByteArray,
+ val authorNickname: String,
+ val createdAt: ULong,
+ val expiresAt: ULong,
+ val flags: UByte,
+ val signature: ByteArray
+) {
+ val isUrgent: Boolean
+ get() = (flags.toInt() and URGENT_FLAG.toInt()) != 0
+
+ val signingBytes: ByteArray
+ get() = signingBytes(
+ postID = postID,
+ geohash = geohash,
+ content = content,
+ authorSigningKey = authorSigningKey,
+ authorNickname = authorNickname,
+ createdAt = createdAt,
+ expiresAt = expiresAt,
+ flags = flags
+ )
+
+ fun verifySignature(): Boolean =
+ BoardWireCodec.verify(signature, signingBytes, authorSigningKey)
+
+ override fun equals(other: Any?): Boolean =
+ other is BoardPostPacket &&
+ postID.contentEquals(other.postID) &&
+ geohash == other.geohash &&
+ content == other.content &&
+ authorSigningKey.contentEquals(other.authorSigningKey) &&
+ authorNickname == other.authorNickname &&
+ createdAt == other.createdAt &&
+ expiresAt == other.expiresAt &&
+ flags == other.flags &&
+ signature.contentEquals(other.signature)
+
+ override fun hashCode(): Int {
+ var result = postID.contentHashCode()
+ result = 31 * result + geohash.hashCode()
+ result = 31 * result + content.hashCode()
+ result = 31 * result + authorSigningKey.contentHashCode()
+ result = 31 * result + authorNickname.hashCode()
+ result = 31 * result + createdAt.hashCode()
+ result = 31 * result + expiresAt.hashCode()
+ result = 31 * result + flags.hashCode()
+ return 31 * result + signature.contentHashCode()
+ }
+
+ companion object {
+ const val URGENT_FLAG: UByte = 0x01u
+
+ fun signingBytes(
+ postID: ByteArray,
+ geohash: String,
+ content: String,
+ authorSigningKey: ByteArray,
+ authorNickname: String,
+ createdAt: ULong,
+ expiresAt: ULong,
+ flags: UByte
+ ): ByteArray = ByteArrayOutputStream().apply {
+ appendContext(BoardWireConstants.POST_SIGNING_CONTEXT)
+ write(postID)
+ appendLengthPrefixed(geohash.toByteArray(Charsets.UTF_8))
+ appendLengthPrefixed(content.toByteArray(Charsets.UTF_8))
+ write(authorSigningKey)
+ appendLengthPrefixed(authorNickname.toByteArray(Charsets.UTF_8))
+ appendULong(createdAt)
+ appendULong(expiresAt)
+ write(flags.toInt())
+ }.toByteArray()
+ }
+}
+
+class BoardTombstonePacket(
+ val postID: ByteArray,
+ val authorSigningKey: ByteArray,
+ val deletedAt: ULong,
+ val signature: ByteArray
+) {
+ val signingBytes: ByteArray
+ get() = signingBytes(postID, deletedAt)
+
+ fun verifySignature(): Boolean =
+ BoardWireCodec.verify(signature, signingBytes, authorSigningKey)
+
+ override fun equals(other: Any?): Boolean =
+ other is BoardTombstonePacket &&
+ postID.contentEquals(other.postID) &&
+ authorSigningKey.contentEquals(other.authorSigningKey) &&
+ deletedAt == other.deletedAt &&
+ signature.contentEquals(other.signature)
+
+ override fun hashCode(): Int {
+ var result = postID.contentHashCode()
+ result = 31 * result + authorSigningKey.contentHashCode()
+ result = 31 * result + deletedAt.hashCode()
+ return 31 * result + signature.contentHashCode()
+ }
+
+ companion object {
+ fun signingBytes(postID: ByteArray, deletedAt: ULong): ByteArray =
+ ByteArrayOutputStream().apply {
+ appendContext(BoardWireConstants.TOMBSTONE_SIGNING_CONTEXT)
+ write(postID)
+ appendULong(deletedAt)
+ }.toByteArray()
+ }
+}
+
+sealed interface BoardWire {
+ data class Post(val packet: BoardPostPacket) : BoardWire
+ data class Tombstone(val packet: BoardTombstonePacket) : BoardWire
+
+ fun verifySignature(): Boolean = when (this) {
+ is Post -> packet.verifySignature()
+ is Tombstone -> packet.verifySignature()
+ }
+}
+
+/**
+ * Board payloads authenticate their embedded author key, so their outer mesh
+ * sender must not expose the device's stable peer ID. The pseudonym remains
+ * stable only for one board signing identity.
+ */
+fun BoardWire.transportSenderID(): ByteArray {
+ val authorKey = when (this) {
+ is BoardWire.Post -> packet.authorSigningKey
+ is BoardWire.Tombstone -> packet.authorSigningKey
+ }
+ return MessageDigest.getInstance("SHA-256")
+ .digest(authorKey)
+ .copyOf(BoardWireConstants.TRANSPORT_SENDER_ID_LENGTH)
+}
+
+object BoardWireCodec {
+ private const val TLV_KIND = 0x01
+ private const val TLV_POST_ID = 0x02
+ private const val TLV_GEOHASH = 0x03
+ private const val TLV_CONTENT = 0x04
+ private const val TLV_AUTHOR_SIGNING_KEY = 0x05
+ private const val TLV_AUTHOR_NICKNAME = 0x06
+ private const val TLV_CREATED_AT = 0x07
+ private const val TLV_EXPIRES_AT = 0x08
+ private const val TLV_FLAGS = 0x09
+ private const val TLV_SIGNATURE = 0x0A
+ private const val TLV_DELETED_AT = 0x0B
+ private const val KIND_POST = 0x01
+ private const val KIND_TOMBSTONE = 0x02
+
+ fun encode(wire: BoardWire): ByteArray = ByteArrayOutputStream().apply {
+ when (wire) {
+ is BoardWire.Post -> with(wire.packet) {
+ appendTlv(TLV_KIND, byteArrayOf(KIND_POST.toByte()))
+ appendTlv(TLV_POST_ID, postID)
+ appendTlv(TLV_GEOHASH, geohash.toByteArray(Charsets.UTF_8))
+ appendTlv(TLV_CONTENT, content.toByteArray(Charsets.UTF_8))
+ appendTlv(TLV_AUTHOR_SIGNING_KEY, authorSigningKey)
+ appendTlv(TLV_AUTHOR_NICKNAME, authorNickname.toByteArray(Charsets.UTF_8))
+ appendTlv(TLV_CREATED_AT, createdAt.toBigEndianBytes())
+ appendTlv(TLV_EXPIRES_AT, expiresAt.toBigEndianBytes())
+ appendTlv(TLV_FLAGS, byteArrayOf(flags.toByte()))
+ appendTlv(TLV_SIGNATURE, signature)
+ }
+
+ is BoardWire.Tombstone -> with(wire.packet) {
+ appendTlv(TLV_KIND, byteArrayOf(KIND_TOMBSTONE.toByte()))
+ appendTlv(TLV_POST_ID, postID)
+ appendTlv(TLV_AUTHOR_SIGNING_KEY, authorSigningKey)
+ appendTlv(TLV_DELETED_AT, deletedAt.toBigEndianBytes())
+ appendTlv(TLV_SIGNATURE, signature)
+ }
+ }
+ }.toByteArray()
+
+ fun decode(data: ByteArray): BoardWire? {
+ var offset = 0
+ var kind: Int? = null
+ var postID: ByteArray? = null
+ var geohash: String? = null
+ var content: String? = null
+ var contentBytes = 0
+ var authorSigningKey: ByteArray? = null
+ var authorNickname: String? = null
+ var nicknameBytes = 0
+ var createdAt: ULong? = null
+ var expiresAt: ULong? = null
+ var flags: UByte? = null
+ var signature: ByteArray? = null
+ var deletedAt: ULong? = null
+
+ while (offset + 3 <= data.size) {
+ val type = data[offset].toInt() and 0xFF
+ offset += 1
+ val length =
+ ((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
+ offset += 2
+ if (length > data.size - offset) return null
+ val value = data.copyOfRange(offset, offset + length)
+ offset += length
+
+ when (type) {
+ TLV_KIND -> {
+ if (value.size != 1) return null
+ kind = value[0].toInt() and 0xFF
+ }
+ TLV_POST_ID -> {
+ if (value.size != BoardWireConstants.POST_ID_LENGTH) return null
+ postID = value
+ }
+ TLV_GEOHASH -> {
+ if (value.size > BoardWireConstants.GEOHASH_MAX_LENGTH) return null
+ geohash = decodeUtf8(value) ?: return null
+ }
+ TLV_CONTENT -> {
+ if (value.size > BoardWireConstants.CONTENT_MAX_BYTES) return null
+ contentBytes = value.size
+ content = decodeUtf8(value) ?: return null
+ }
+ TLV_AUTHOR_SIGNING_KEY -> {
+ if (value.size != BoardWireConstants.SIGNING_KEY_LENGTH) return null
+ authorSigningKey = value
+ }
+ TLV_AUTHOR_NICKNAME -> {
+ if (value.size > BoardWireConstants.NICKNAME_MAX_BYTES) return null
+ nicknameBytes = value.size
+ authorNickname = decodeUtf8(value) ?: return null
+ }
+ TLV_CREATED_AT -> createdAt = value.toULongBigEndian() ?: return null
+ TLV_EXPIRES_AT -> expiresAt = value.toULongBigEndian() ?: return null
+ TLV_FLAGS -> {
+ if (value.size != 1) return null
+ flags = value[0].toUByte()
+ }
+ TLV_SIGNATURE -> {
+ if (value.size != BoardWireConstants.SIGNATURE_LENGTH) return null
+ signature = value
+ }
+ TLV_DELETED_AT -> deletedAt = value.toULongBigEndian() ?: return null
+ }
+ }
+
+ val requiredPostID = postID ?: return null
+ val requiredKey = authorSigningKey ?: return null
+ val requiredSignature = signature ?: return null
+ return when (kind) {
+ KIND_POST -> {
+ val requiredGeohash = geohash ?: return null
+ val requiredContent = content ?: return null
+ val requiredNickname = authorNickname ?: return null
+ val requiredCreatedAt = createdAt ?: return null
+ val requiredExpiresAt = expiresAt ?: return null
+ val requiredFlags = flags ?: return null
+ if (contentBytes !in 1..BoardWireConstants.CONTENT_MAX_BYTES) return null
+ if (nicknameBytes > BoardWireConstants.NICKNAME_MAX_BYTES) return null
+ if (!isValidGeohash(requiredGeohash)) return null
+ if (requiredExpiresAt <= requiredCreatedAt) return null
+ if (requiredExpiresAt - requiredCreatedAt > BoardWireConstants.MAX_LIFETIME_MS) return null
+ BoardWire.Post(
+ BoardPostPacket(
+ postID = requiredPostID,
+ geohash = requiredGeohash,
+ content = requiredContent,
+ authorSigningKey = requiredKey,
+ authorNickname = requiredNickname,
+ createdAt = requiredCreatedAt,
+ expiresAt = requiredExpiresAt,
+ flags = requiredFlags,
+ signature = requiredSignature
+ )
+ )
+ }
+
+ KIND_TOMBSTONE -> BoardWire.Tombstone(
+ BoardTombstonePacket(
+ postID = requiredPostID,
+ authorSigningKey = requiredKey,
+ deletedAt = deletedAt ?: return null,
+ signature = requiredSignature
+ )
+ )
+
+ else -> null
+ }
+ }
+
+ fun urgentFlag(data: ByteArray): Boolean {
+ var offset = 0
+ while (offset + 3 <= data.size) {
+ val type = data[offset].toInt() and 0xFF
+ offset += 1
+ val length =
+ ((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
+ offset += 2
+ if (length > data.size - offset) return false
+ if (type == TLV_FLAGS && length == 1) {
+ return (data[offset].toInt() and BoardPostPacket.URGENT_FLAG.toInt()) != 0
+ }
+ offset += length
+ }
+ return false
+ }
+
+ internal fun verify(signature: ByteArray, message: ByteArray, publicKey: ByteArray): Boolean =
+ try {
+ if (signature.size != BoardWireConstants.SIGNATURE_LENGTH ||
+ publicKey.size != BoardWireConstants.SIGNING_KEY_LENGTH
+ ) {
+ false
+ } else {
+ Ed25519Signer().run {
+ init(false, Ed25519PublicKeyParameters(publicKey, 0))
+ update(message, 0, message.size)
+ verifySignature(signature)
+ }
+ }
+ } catch (_: Exception) {
+ false
+ }
+
+ private fun isValidGeohash(value: String): Boolean =
+ value.isEmpty() ||
+ (value.length <= BoardWireConstants.GEOHASH_MAX_LENGTH &&
+ value.all { it in BoardWireConstants.GEOHASH_ALPHABET })
+
+ private fun decodeUtf8(value: ByteArray): String? =
+ try {
+ Charsets.UTF_8.newDecoder()
+ .onMalformedInput(CodingErrorAction.REPORT)
+ .onUnmappableCharacter(CodingErrorAction.REPORT)
+ .decode(ByteBuffer.wrap(value))
+ .toString()
+ } catch (_: Exception) {
+ null
+ }
+}
+
+private fun ByteArrayOutputStream.appendTlv(type: Int, value: ByteArray) {
+ require(value.size <= 0xFFFF)
+ write(type)
+ write((value.size ushr 8) and 0xFF)
+ write(value.size and 0xFF)
+ write(value)
+}
+
+private fun ByteArrayOutputStream.appendContext(context: String) {
+ val value = context.toByteArray(Charsets.UTF_8).take(255).toByteArray()
+ write(value.size)
+ write(value)
+}
+
+private fun ByteArrayOutputStream.appendLengthPrefixed(value: ByteArray) {
+ val limited = value.take(0xFFFF).toByteArray()
+ write((limited.size ushr 8) and 0xFF)
+ write(limited.size and 0xFF)
+ write(limited)
+}
+
+private fun ByteArrayOutputStream.appendULong(value: ULong) {
+ write(value.toBigEndianBytes())
+}
+
+private fun ULong.toBigEndianBytes(): ByteArray =
+ ByteArray(8) { index -> (this shr ((7 - index) * 8)).toByte() }
+
+private fun ByteArray.toULongBigEndian(): ULong? {
+ if (size != 8) return null
+ var value = 0uL
+ for (byte in this) {
+ value = (value shl 8) or (byte.toULong() and 0xFFuL)
+ }
+ return value
+}
diff --git a/app/src/main/java/com/bitchat/android/board/BoardSigningIdentity.kt b/app/src/main/java/com/bitchat/android/board/BoardSigningIdentity.kt
new file mode 100644
index 00000000..a9ab35d1
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/board/BoardSigningIdentity.kt
@@ -0,0 +1,59 @@
+package com.bitchat.android.board
+
+import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
+import org.bouncycastle.crypto.signers.Ed25519Signer
+import java.security.MessageDigest
+
+/**
+ * Signing identity used by board payloads.
+ *
+ * Location boards use a key derived from the already-unlinkable per-geohash
+ * Nostr secret. Domain separation keeps the board Ed25519 identity distinct
+ * from the secp256k1 identity used on relays.
+ */
+class BoardSigningIdentity(
+ publicKey: ByteArray,
+ private val signer: (ByteArray) -> ByteArray?
+) {
+ val publicKey: ByteArray = publicKey.copyOf()
+
+ init {
+ require(publicKey.size == BoardWireConstants.SIGNING_KEY_LENGTH)
+ }
+
+ fun sign(message: ByteArray): ByteArray? = signer(message)?.copyOf()
+
+ companion object {
+ private const val GEO_IDENTITY_CONTEXT = "bitchat-board-geo-identity-v1"
+
+ fun fromNostrPrivateKeyHex(privateKeyHex: String): BoardSigningIdentity? =
+ runCatching {
+ val nostrSecret = privateKeyHex.hexToByteArray()
+ .takeIf { it.size == BoardWireConstants.SIGNING_KEY_LENGTH }
+ ?: return@runCatching null
+ val digest = MessageDigest.getInstance("SHA-256")
+ digest.update(GEO_IDENTITY_CONTEXT.toByteArray(Charsets.UTF_8))
+ digest.update(nostrSecret)
+ fromEd25519Seed(digest.digest())
+ }.getOrNull()
+
+ fun fromEd25519Seed(seed: ByteArray): BoardSigningIdentity {
+ require(seed.size == BoardWireConstants.SIGNING_KEY_LENGTH)
+ val privateKey = Ed25519PrivateKeyParameters(seed.copyOf(), 0)
+ return BoardSigningIdentity(privateKey.generatePublicKey().encoded) { message ->
+ Ed25519Signer().run {
+ init(true, privateKey)
+ update(message, 0, message.size)
+ generateSignature()
+ }
+ }
+ }
+
+ private fun String.hexToByteArray(): ByteArray {
+ require(length % 2 == 0)
+ return ByteArray(length / 2) { index ->
+ substring(index * 2, index * 2 + 2).toInt(16).toByte()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/board/BoardStore.kt b/app/src/main/java/com/bitchat/android/board/BoardStore.kt
new file mode 100644
index 00000000..19bfc1a5
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/board/BoardStore.kt
@@ -0,0 +1,382 @@
+package com.bitchat.android.board
+
+import android.content.Context
+import android.util.Log
+import com.bitchat.android.protocol.BitchatPacket
+import com.bitchat.android.protocol.MessageType
+import com.google.gson.Gson
+import com.google.gson.reflect.TypeToken
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import java.io.File
+import java.util.Base64
+
+enum class BoardIngestResult {
+ ACCEPTED,
+ DUPLICATE,
+ REJECTED
+}
+
+enum class BoardIngestSource {
+ REMOTE,
+ LOCAL,
+ RESTORE
+}
+
+class BoardStore(
+ private val file: File? = null,
+ private val nowMs: () -> ULong = { System.currentTimeMillis().coerceAtLeast(0).toULong() }
+) {
+ object Limits {
+ const val MAX_POSTS = 200
+ const val MAX_POSTS_PER_AUTHOR = 5
+ const val MAX_ORPHAN_TOMBSTONES = 100
+ const val MAX_ORPHAN_TOMBSTONES_PER_AUTHOR = 5
+ const val CLOCK_SKEW_MS: ULong = 3_600_000uL
+ const val ORPHAN_TOMBSTONE_LIFETIME_MS: ULong = BoardWireConstants.MAX_LIFETIME_MS
+ }
+
+ private data class StoredPost(
+ val post: BoardPostPacket,
+ val packet: BitchatPacket,
+ val rawPacket: ByteArray
+ )
+
+ private data class StoredTombstone(
+ val tombstone: BoardTombstonePacket,
+ val packet: BitchatPacket,
+ val rawPacket: ByteArray,
+ val retainUntil: ULong,
+ val isOrphan: Boolean
+ )
+
+ private data class PersistedEntry(
+ val packet: String,
+ val retainUntil: String?
+ )
+
+ private val lock = Any()
+ private val posts = mutableListOf()
+ private val tombstones = mutableListOf()
+ private val _postsSnapshot = MutableStateFlow>(emptyList())
+ private val _postArrivals = MutableSharedFlow(extraBufferCapacity = 64)
+
+ val postsSnapshot: StateFlow> = _postsSnapshot.asStateFlow()
+ val postArrivals: SharedFlow = _postArrivals.asSharedFlow()
+
+ init {
+ loadFromDisk()
+ }
+
+ fun ingest(
+ wire: BoardWire,
+ packet: BitchatPacket,
+ source: BoardIngestSource = BoardIngestSource.REMOTE
+ ): BoardIngestResult {
+ if (packet.type != MessageType.BOARD_POST.value || !wire.verifySignature()) {
+ return BoardIngestResult.REJECTED
+ }
+ val rawPacket = packet.toBinaryData(padding = false) ?: return BoardIngestResult.REJECTED
+ val now = nowMs()
+ var arrival: BoardPostPacket? = null
+ val result = synchronized(lock) {
+ val outcome = ingestLocked(
+ wire = wire,
+ packet = packet,
+ rawPacket = rawPacket,
+ now = now,
+ retainUntilOverride = null
+ )
+ if (outcome == BoardIngestResult.ACCEPTED && source != BoardIngestSource.RESTORE) {
+ persistLocked()
+ }
+ if (outcome == BoardIngestResult.ACCEPTED &&
+ source == BoardIngestSource.REMOTE &&
+ wire is BoardWire.Post
+ ) {
+ arrival = wire.packet
+ }
+ outcome
+ }
+ arrival?.let(_postArrivals::tryEmit)
+ return result
+ }
+
+ /**
+ * Ingests a packet received from the mesh and reports whether this exact
+ * arrival may continue through the live relay path.
+ *
+ * A duplicate board payload must not relay again: the payload signature
+ * does not authenticate mutable outer packet fields such as timestamp, so
+ * accepting duplicates for relay would let one captured notice be wrapped
+ * in infinitely many distinct outer packets.
+ */
+ fun ingestRemoteForRelay(wire: BoardWire, packet: BitchatPacket): Boolean =
+ ingest(wire, packet, BoardIngestSource.REMOTE) == BoardIngestResult.ACCEPTED
+
+ fun posts(forGeohash: String): List = synchronized(lock) {
+ pruneExpiredLocked(nowMs())
+ posts.asSequence()
+ .map { it.post }
+ .filter { it.geohash == forGeohash }
+ .sortedWith(
+ compareByDescending { it.isUrgent }
+ .thenByDescending { it.createdAt }
+ )
+ .toList()
+ }
+
+ fun syncCandidates(): List = synchronized(lock) {
+ pruneExpiredLocked(nowMs())
+ posts.map { it.packet } + tombstones.map { it.packet }
+ }
+
+ fun pruneExpired() = synchronized(lock) {
+ val changed = pruneExpiredLocked(nowMs())
+ if (changed) persistLocked()
+ }
+
+ fun wipe() = synchronized(lock) {
+ posts.clear()
+ tombstones.clear()
+ file?.let { check(!it.exists() || it.delete()) }
+ publishSnapshotLocked()
+ }
+
+ private fun ingestLocked(
+ wire: BoardWire,
+ packet: BitchatPacket,
+ rawPacket: ByteArray,
+ now: ULong,
+ retainUntilOverride: ULong?
+ ): BoardIngestResult {
+ pruneExpiredLocked(now)
+ return when (wire) {
+ is BoardWire.Post -> ingestPostLocked(wire.packet, packet, rawPacket, now)
+ is BoardWire.Tombstone -> ingestTombstoneLocked(
+ wire.packet,
+ packet,
+ rawPacket,
+ now,
+ retainUntilOverride
+ )
+ }
+ }
+
+ private fun ingestPostLocked(
+ post: BoardPostPacket,
+ packet: BitchatPacket,
+ rawPacket: ByteArray,
+ now: ULong
+ ): BoardIngestResult {
+ if (post.expiresAt <= now) return BoardIngestResult.REJECTED
+ if (post.createdAt > now.saturatedAdd(Limits.CLOCK_SKEW_MS)) {
+ return BoardIngestResult.REJECTED
+ }
+ if (post.expiresAt > now.saturatedAdd(BoardWireConstants.MAX_LIFETIME_MS)
+ .saturatedAdd(Limits.CLOCK_SKEW_MS)
+ ) {
+ return BoardIngestResult.REJECTED
+ }
+ if (tombstones.any {
+ it.tombstone.postID.contentEquals(post.postID) &&
+ it.tombstone.authorSigningKey.contentEquals(post.authorSigningKey)
+ }
+ ) {
+ return BoardIngestResult.REJECTED
+ }
+ if (posts.any {
+ it.post.postID.contentEquals(post.postID) &&
+ it.post.authorSigningKey.contentEquals(post.authorSigningKey)
+ }
+ ) {
+ return BoardIngestResult.DUPLICATE
+ }
+
+ posts += StoredPost(post, packet, rawPacket)
+ enforcePostCapsLocked(post.authorSigningKey)
+ publishSnapshotLocked()
+ return BoardIngestResult.ACCEPTED
+ }
+
+ private fun ingestTombstoneLocked(
+ tombstone: BoardTombstonePacket,
+ packet: BitchatPacket,
+ rawPacket: ByteArray,
+ now: ULong,
+ retainUntilOverride: ULong?
+ ): BoardIngestResult {
+ if (tombstones.any {
+ it.tombstone.postID.contentEquals(tombstone.postID) &&
+ it.tombstone.authorSigningKey.contentEquals(tombstone.authorSigningKey)
+ }
+ ) {
+ return BoardIngestResult.DUPLICATE
+ }
+
+ val maxRetain = minOf(
+ tombstone.deletedAt.saturatedAdd(Limits.ORPHAN_TOMBSTONE_LIFETIME_MS),
+ now.saturatedAdd(Limits.ORPHAN_TOMBSTONE_LIFETIME_MS)
+ .saturatedAdd(Limits.CLOCK_SKEW_MS)
+ )
+ val matchingPostIndex = posts.indexOfFirst {
+ it.post.postID.contentEquals(tombstone.postID) &&
+ it.post.authorSigningKey.contentEquals(tombstone.authorSigningKey)
+ }
+ val retainUntil: ULong
+ val isOrphan: Boolean
+ if (matchingPostIndex >= 0) {
+ val target = posts[matchingPostIndex].post
+ retainUntil = target.expiresAt
+ isOrphan = false
+ posts.removeAt(matchingPostIndex)
+ publishSnapshotLocked()
+ } else if (retainUntilOverride != null) {
+ retainUntil = minOf(retainUntilOverride, maxRetain)
+ isOrphan = false
+ } else {
+ retainUntil = maxRetain
+ isOrphan = true
+ }
+ if (retainUntil <= now) return BoardIngestResult.REJECTED
+
+ tombstones += StoredTombstone(
+ tombstone = tombstone,
+ packet = packet,
+ rawPacket = rawPacket,
+ retainUntil = retainUntil,
+ isOrphan = isOrphan
+ )
+ if (isOrphan) enforceOrphanTombstoneCapsLocked(tombstone.authorSigningKey)
+ return BoardIngestResult.ACCEPTED
+ }
+
+ private fun enforcePostCapsLocked(author: ByteArray) {
+ val authorPosts = posts.filter { it.post.authorSigningKey.contentEquals(author) }
+ evictOldestPostsLocked(authorPosts, Limits.MAX_POSTS_PER_AUTHOR)
+ evictOldestPostsLocked(posts.toList(), Limits.MAX_POSTS)
+ }
+
+ private fun evictOldestPostsLocked(candidates: List, keep: Int) {
+ val victims = candidates.sortedBy { it.post.createdAt }
+ .take((candidates.size - keep).coerceAtLeast(0))
+ if (victims.isNotEmpty()) {
+ posts.removeAll { stored -> victims.any { it === stored } }
+ }
+ }
+
+ private fun enforceOrphanTombstoneCapsLocked(author: ByteArray) {
+ val authorOrphans = tombstones.filter {
+ it.isOrphan && it.tombstone.authorSigningKey.contentEquals(author)
+ }
+ removeOldestTombstonesLocked(
+ authorOrphans,
+ authorOrphans.size - Limits.MAX_ORPHAN_TOMBSTONES_PER_AUTHOR
+ )
+ val allOrphans = tombstones.filter { it.isOrphan }
+ removeOldestTombstonesLocked(
+ allOrphans,
+ allOrphans.size - Limits.MAX_ORPHAN_TOMBSTONES
+ )
+ }
+
+ private fun removeOldestTombstonesLocked(
+ candidates: List,
+ count: Int
+ ) {
+ if (count <= 0) return
+ val victims = candidates.take(count)
+ tombstones.removeAll { stored -> victims.any { it === stored } }
+ }
+
+ private fun pruneExpiredLocked(now: ULong): Boolean {
+ val postsBefore = posts.size
+ val tombstonesBefore = tombstones.size
+ posts.removeAll { it.post.expiresAt <= now }
+ tombstones.removeAll { it.retainUntil <= now }
+ if (posts.size != postsBefore) publishSnapshotLocked()
+ return posts.size != postsBefore || tombstones.size != tombstonesBefore
+ }
+
+ private fun publishSnapshotLocked() {
+ _postsSnapshot.value = posts.map { it.post }
+ }
+
+ private fun persistLocked() {
+ val target = file ?: return
+ val entries = posts.map {
+ PersistedEntry(
+ packet = Base64.getEncoder().encodeToString(it.rawPacket),
+ retainUntil = null
+ )
+ } + tombstones.map {
+ PersistedEntry(
+ packet = Base64.getEncoder().encodeToString(it.rawPacket),
+ retainUntil = it.retainUntil.toString()
+ )
+ }
+ runCatching {
+ if (entries.isEmpty()) {
+ if (target.exists()) target.delete()
+ return
+ }
+ target.parentFile?.mkdirs()
+ val temporary = File(target.parentFile, "${target.name}.tmp")
+ temporary.writeText(Gson().toJson(entries))
+ if (!temporary.renameTo(target)) {
+ temporary.copyTo(target, overwrite = true)
+ temporary.delete()
+ }
+ }.onFailure {
+ Log.e(TAG, "Failed to persist board store: ${it.message}")
+ }
+ }
+
+ private fun loadFromDisk() {
+ val target = file ?: return
+ if (!target.isFile) return
+ val entries = runCatching {
+ val type = object : TypeToken>() {}.type
+ Gson().fromJson>(target.readText(), type)
+ }.getOrNull() ?: return
+ val now = nowMs()
+ synchronized(lock) {
+ for (entry in entries) {
+ val raw = runCatching { Base64.getDecoder().decode(entry.packet) }.getOrNull() ?: continue
+ val packet = BitchatPacket.fromBinaryData(raw) ?: continue
+ if (packet.type != MessageType.BOARD_POST.value) continue
+ val wire = BoardWireCodec.decode(packet.payload) ?: continue
+ if (!wire.verifySignature()) continue
+ ingestLocked(
+ wire = wire,
+ packet = packet,
+ rawPacket = raw,
+ now = now,
+ retainUntilOverride = entry.retainUntil?.toULongOrNull()
+ )
+ }
+ publishSnapshotLocked()
+ }
+ }
+
+ companion object {
+ private const val TAG = "BoardStore"
+
+ @Volatile
+ private var instance: BoardStore? = null
+
+ fun getInstance(context: Context): BoardStore =
+ instance ?: synchronized(this) {
+ instance ?: BoardStore(
+ File(context.applicationContext.filesDir, "board/posts.json")
+ ).also { instance = it }
+ }
+ }
+}
+
+private fun ULong.saturatedAdd(other: ULong): ULong =
+ if (ULong.MAX_VALUE - this < other) ULong.MAX_VALUE else this + other
diff --git a/app/src/main/java/com/bitchat/android/board/UnifiedNotices.kt b/app/src/main/java/com/bitchat/android/board/UnifiedNotices.kt
new file mode 100644
index 00000000..c91b127b
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/board/UnifiedNotices.kt
@@ -0,0 +1,87 @@
+package com.bitchat.android.board
+
+import com.bitchat.android.nostr.LocationNotesManager
+import kotlin.math.abs
+
+enum class NoticeSource {
+ MESH,
+ NOSTR
+}
+
+data class UnifiedNotice(
+ val id: String,
+ val content: String,
+ val nickname: String,
+ val createdAtMs: Long,
+ val geohash: String,
+ val urgent: Boolean,
+ val expiresAtMs: Long?,
+ val source: NoticeSource,
+ val boardPost: BoardPostPacket? = null,
+ val nostrNote: LocationNotesManager.Note? = null
+)
+
+object UnifiedNotices {
+ private const val DEDUPLICATION_WINDOW_MS = 15 * 60 * 1_000L
+
+ /**
+ * Combines exact-scope mesh board posts with relay notes. When a relay
+ * bridge copy matches a board post, the signed board copy is authoritative.
+ */
+ fun merge(
+ geohash: String,
+ boardPosts: List,
+ relayNotes: List
+ ): List {
+ val normalized = geohash.lowercase()
+ val scopedPosts = boardPosts.filter { it.geohash == normalized }
+ val boardNotices = scopedPosts.map { post ->
+ UnifiedNotice(
+ id = "mesh:${post.authorSigningKey.toHex()}:${post.postID.toHex()}",
+ content = post.content,
+ nickname = post.authorNickname,
+ createdAtMs = post.createdAt.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(),
+ geohash = post.geohash,
+ urgent = post.isUrgent,
+ expiresAtMs = post.expiresAt.coerceAtMost(Long.MAX_VALUE.toULong()).toLong(),
+ source = NoticeSource.MESH,
+ boardPost = post
+ )
+ }
+ val relayNotices = relayNotes.asSequence()
+ .filterNot { note ->
+ scopedPosts.any { post ->
+ post.geohash == note.geohash.lowercase() &&
+ post.content == note.content &&
+ post.authorNickname.ifBlank { "anon" } ==
+ note.nickname?.trim()?.takeIf { it.isNotEmpty() }.orEmpty()
+ .ifEmpty { "anon" } &&
+ abs(
+ post.createdAt.coerceAtMost(Long.MAX_VALUE.toULong()).toLong() -
+ note.createdAt.toLong() * 1_000L
+ ) <= DEDUPLICATION_WINDOW_MS
+ }
+ }
+ .map { note ->
+ UnifiedNotice(
+ id = "nostr:${note.id}",
+ content = note.content,
+ nickname = note.nickname.orEmpty(),
+ createdAtMs = note.createdAt.toLong() * 1_000L,
+ geohash = note.geohash.lowercase(),
+ urgent = note.isUrgent,
+ expiresAtMs = note.expiresAt?.toLong()?.times(1_000L),
+ source = NoticeSource.NOSTR,
+ nostrNote = note
+ )
+ }
+ .toList()
+
+ return (boardNotices + relayNotices).sortedWith(
+ compareByDescending { it.urgent }
+ .thenByDescending { it.createdAtMs }
+ )
+ }
+
+ private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
+}
diff --git a/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt b/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt
index b15a6254..8f078a3d 100644
--- a/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt
+++ b/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt
@@ -112,6 +112,12 @@ open class EncryptionService(private val context: Context) {
fun getStaticPublicKey(): ByteArray? {
return noiseService.getStaticPublicKeyData()
}
+
+ fun sealCourierPayload(payload: ByteArray, recipientStaticKey: ByteArray): ByteArray =
+ noiseService.sealCourierPayload(payload, recipientStaticKey)
+
+ fun openCourierPayload(ciphertext: ByteArray): Pair =
+ noiseService.openCourierPayload(ciphertext)
/**
* Get our signing public key for Ed25519 signatures (for identity announcements)
diff --git a/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt b/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt
index da6898e8..2d8c04ea 100644
--- a/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt
+++ b/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt
@@ -280,7 +280,7 @@ object FileUtils {
* Recursively delete all media files (incoming and outgoing)
* Used for Panic Mode cleanup
*/
- fun clearAllMedia(context: Context) {
+ fun clearAllMedia(context: Context): Boolean {
try {
// Clear files dir subdirectories (legacy storage and outgoing)
val filesDir = context.filesDir
@@ -295,7 +295,7 @@ object FileUtils {
dirsToClear.forEach { subDir ->
val dir = File(filesDir, subDir)
if (dir.exists()) {
- dir.deleteRecursively()
+ check(dir.deleteRecursively())
Log.d(TAG, "Deleted media directory from filesDir: $subDir")
}
}
@@ -312,17 +312,19 @@ object FileUtils {
cacheDirsToClear.forEach { subDir ->
val dir = File(cacheDir, subDir)
if (dir.exists()) {
- dir.deleteRecursively()
+ check(dir.deleteRecursively())
Log.d(TAG, "Deleted media directory from cacheDir: $subDir")
}
}
// Also clear entire cache dir as a catch-all
- context.cacheDir.deleteRecursively()
+ check(!context.cacheDir.exists() || context.cacheDir.deleteRecursively())
Log.d(TAG, "Cleared entire cache directory")
+ return true
} catch (e: Exception) {
Log.e(TAG, "Failed to clear media files", e)
+ return false
}
}
diff --git a/app/src/main/java/com/bitchat/android/features/voice/LiveVoiceManager.kt b/app/src/main/java/com/bitchat/android/features/voice/LiveVoiceManager.kt
index 05223b48..ce6bc273 100644
--- a/app/src/main/java/com/bitchat/android/features/voice/LiveVoiceManager.kt
+++ b/app/src/main/java/com/bitchat/android/features/voice/LiveVoiceManager.kt
@@ -252,14 +252,22 @@ class LiveVoiceManager private constructor(private val context: Context) {
} ?: return false
val finished = entry.value
val replacement = message.copy(
- id = finished.messageID,
+ id = if (message.isPrivate && com.bitchat.android.model.PrivateMediaMessageIdentity.isStableID(message.id)) message.id else finished.messageID,
timestamp = finished.timestamp,
sender = finished.nickname,
senderPeerID = peerID,
isPrivate = messageScope == LiveVoiceScope.DIRECT_MESSAGE
)
if (messageScope == LiveVoiceScope.DIRECT_MESSAGE) {
- AppStateStore.upsertPrivateMessage(peerID, replacement, isVisible(messageScope, peerID))
+ if (replacement.id != finished.messageID) {
+ val saved = kotlinx.coroutines.runBlocking {
+ AppStateStore.addPrivateMessageDurably(peerID, replacement, isVisible(messageScope, peerID))
+ }
+ if (!saved) return false
+ AppStateStore.removePrivateMessage(finished.messageID)
+ } else {
+ AppStateStore.upsertPrivateMessage(peerID, replacement, isVisible(messageScope, peerID))
+ }
} else {
AppStateStore.upsertPublicMessage(replacement)
}
diff --git a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt
index 46f05950..c793c163 100644
--- a/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt
+++ b/app/src/main/java/com/bitchat/android/geohash/LocationChannelManager.kt
@@ -162,9 +162,12 @@ class LocationChannelManager private constructor(private val context: Context) {
/**
* Refresh available channels from current location
*/
- fun refreshChannels() {
+ fun refreshChannels(
+ forceFresh: Boolean = false,
+ updatePlaceNames: Boolean = true
+ ) {
if (syncPermissionState() == PermissionState.AUTHORIZED && isLocationServicesEnabled()) {
- requestOneShotLocation()
+ requestOneShotLocation(forceFresh, updatePlaceNames)
}
}
@@ -355,7 +358,10 @@ class LocationChannelManager private constructor(private val context: Context) {
// MARK: - Location Operations
- private fun requestOneShotLocation() {
+ private fun requestOneShotLocation(
+ forceFresh: Boolean = false,
+ updatePlaceNames: Boolean = true
+ ) {
if (!isLocationServicesEnabled() ||
syncPermissionState() != PermissionState.AUTHORIZED
) {
@@ -367,37 +373,53 @@ class LocationChannelManager private constructor(private val context: Context) {
val token = LiveLocationPrivacyGate.captureToken() ?: return
_isLoadingLocation.value = true
+ if (forceFresh) {
+ requestFreshLocation(token, updatePlaceNames)
+ return
+ }
+
val started = LiveLocationPrivacyGate.runIfAllowed(token) {
locationProvider.getLastKnownLocation { cached ->
if (!canUseLiveLocation(token)) return@getLastKnownLocation
if (cached != null) {
- onLocationUpdated(cached, token)
+ onLocationUpdated(cached, token, updatePlaceNames)
} else {
- LiveLocationPrivacyGate.runIfAllowed(token) {
- locationProvider.requestFreshLocation { fresh ->
- if (!canUseLiveLocation(token)) return@requestFreshLocation
-
- if (fresh != null) {
- onLocationUpdated(fresh, token)
- } else {
- Log.w(TAG, "Failed to get fresh location")
- _isLoadingLocation.value = false
- }
- }
- }
+ requestFreshLocation(token, updatePlaceNames)
}
}
}
if (!started) _isLoadingLocation.value = false
}
- private fun onLocationUpdated(location: Location, token: Long) {
+ private fun requestFreshLocation(
+ token: Long,
+ updatePlaceNames: Boolean
+ ) {
+ val started = LiveLocationPrivacyGate.runIfAllowed(token) {
+ locationProvider.requestFreshLocation { fresh ->
+ if (!canUseLiveLocation(token)) return@requestFreshLocation
+ if (fresh != null) {
+ onLocationUpdated(fresh, token, updatePlaceNames)
+ } else {
+ Log.w(TAG, "Failed to get fresh location")
+ _isLoadingLocation.value = false
+ }
+ }
+ }
+ if (!started) _isLoadingLocation.value = false
+ }
+
+ private fun onLocationUpdated(
+ location: Location,
+ token: Long,
+ updatePlaceNames: Boolean = true
+ ) {
LiveLocationPrivacyGate.runIfAllowed(token) {
if (!_systemLocationEnabled.value || !hasRuntimeLocationPermission()) return@runIfAllowed
_isLoadingLocation.value = false
computeChannels(location, token)
- reverseGeocodeIfNeeded(location, token)
+ if (updatePlaceNames) reverseGeocodeIfNeeded(location, token)
}
}
@@ -652,6 +674,12 @@ class LocationChannelManager private constructor(private val context: Context) {
_teleported.value = false
}
+ /** Remove exact/transient location state without destroying the singleton. */
+ fun panicReset() {
+ clearLiveLocationState()
+ clearPersistedChannel()
+ }
+
// MARK: - Location Services State Persistence
/**
diff --git a/app/src/main/java/com/bitchat/android/groups/GroupCoordinator.kt b/app/src/main/java/com/bitchat/android/groups/GroupCoordinator.kt
new file mode 100644
index 00000000..c914cf82
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/groups/GroupCoordinator.kt
@@ -0,0 +1,715 @@
+package com.bitchat.android.groups
+
+import com.bitchat.android.model.BitchatMessage
+import com.bitchat.android.model.DeliveryStatus
+import com.bitchat.android.model.PeerCapabilities
+import java.util.ArrayDeque
+import java.util.Date
+import java.util.UUID
+
+data class GroupPeerIdentity(
+ val fingerprint: String,
+ val signingKey: ByteArray
+)
+
+data class GroupCommandResult(
+ val success: Boolean,
+ val message: String
+)
+
+enum class PeerGroupCapability {
+ SUPPORTED,
+ UNSUPPORTED,
+ UNKNOWN;
+
+ companion object {
+ fun fromPeerState(
+ capabilities: PeerCapabilities?,
+ hasVerifiedAnnouncement: Boolean
+ ): PeerGroupCapability = when {
+ capabilities?.contains(PeerCapabilities.GROUPS) == true -> SUPPORTED
+ capabilities != null || hasVerifiedAnnouncement -> UNSUPPORTED
+ else -> UNKNOWN
+ }
+ }
+}
+
+interface GroupCoordinatorContext {
+ val groupStore: GroupStore
+ val nickname: String
+ val myPeerID: String
+ val selectedConversationID: String?
+
+ fun myNoiseFingerprint(): String
+ fun mySigningPublicKey(): ByteArray?
+ fun sign(data: ByteArray): ByteArray?
+
+ fun peerIDsForNickname(nickname: String): List
+ fun isPeerConnected(peerID: String): Boolean
+ fun peerGroupCapability(peerID: String): PeerGroupCapability
+ fun peerNickname(peerID: String): String?
+ fun peerIdentity(peerID: String): GroupPeerIdentity?
+ fun connectedPeerID(fingerprint: String): String?
+ fun isFingerprintBlocked(fingerprint: String): Boolean
+
+ fun sendGroupInvite(payload: ByteArray, peerID: String)
+ fun sendGroupKeyUpdate(payload: ByteArray, peerID: String)
+ fun broadcastGroupMessage(payload: ByteArray)
+
+ fun appendGroupMessage(groupPeerID: String, message: BitchatMessage): Boolean
+ fun markGroupUnread(groupPeerID: String)
+ fun removeGroupConversation(groupPeerID: String)
+ fun openGroupConversation(groupPeerID: String)
+ fun closeGroupConversation()
+ fun addSystemMessage(message: String)
+ fun addGroupSystemMessage(groupPeerID: String, message: String)
+ fun notifyGroupMessage(groupPeerID: String, sender: String, message: String)
+}
+
+/**
+ * Creator-managed private-group state machine matching iOS v1.
+ */
+class GroupCoordinator(private val context: GroupCoordinatorContext) {
+ private data class MemberSelector(
+ val nickname: String,
+ val identitySuffix: String?
+ )
+
+ private sealed class PendingEvent {
+ data class Invite(
+ val peerID: String,
+ val authenticatedRemoteStaticKey: ByteArray,
+ val payload: ByteArray
+ ) : PendingEvent()
+
+ data class KeyUpdate(
+ val peerID: String,
+ val authenticatedRemoteStaticKey: ByteArray,
+ val payload: ByteArray
+ ) : PendingEvent()
+
+ data class Message(
+ val payload: ByteArray,
+ val receivedAtMs: Long
+ ) : PendingEvent()
+
+ data class PeerAuthenticated(val peerID: String) : PendingEvent()
+ }
+
+ private data class FutureMessage(
+ val groupID: ByteArray,
+ val epoch: Long,
+ val payload: ByteArray,
+ val queuedAtMs: Long
+ )
+
+ private val lifecycleLock = Any()
+ private val pendingLock = Any()
+ private val pendingEvents = ArrayDeque()
+ private val futureMessages = ArrayDeque()
+ @Volatile
+ private var acceptsInboundEvents = true
+ @Volatile
+ private var inboundGeneration = 0L
+
+ @Synchronized
+ fun createGroup(rawName: String): GroupCommandResult {
+ if (!acceptsInboundEvents) return error("private groups are paused")
+ if (!context.groupStore.isReady) return loadingError()
+ val name = rawName.trim()
+ if (name.isEmpty()) return error("usage: /group create ")
+ if (name.codePointCount(0, name.length) > MAX_GROUP_NAME_LENGTH) {
+ return error("group name must be $MAX_GROUP_NAME_LENGTH characters or fewer")
+ }
+ val fingerprint = context.myNoiseFingerprint()
+ val signingKey = context.mySigningPublicKey()
+ if (!FINGERPRINT.matches(fingerprint) || signingKey?.size != 32) {
+ return error("your cryptographic identity is not ready")
+ }
+ val creator = GroupMember(fingerprint, signingKey, context.nickname)
+ val group = context.groupStore.createGroup(name, creator)
+ ?: return error("could not create group")
+ context.openGroupConversation(group.peerID)
+ return success("created private group #${group.name}")
+ }
+
+ @Synchronized
+ fun inviteMember(rawNickname: String): GroupCommandResult {
+ if (!acceptsInboundEvents) return error("private groups are paused")
+ if (!context.groupStore.isReady) return loadingError()
+ val selector = parseMemberSelector(rawNickname)
+ ?: return error("usage: /group invite [#identity-suffix]")
+ val nickname = selector.nickname
+ val group = selectedGroup() ?: return error("open a private group first")
+ if (!isCreator(group)) return error("only the group creator can change members")
+ val peerID = resolvePeer(selector) ?: return ambiguousPeerError(selector)
+ if (!context.isPeerConnected(peerID)) return error("$nickname is not connected")
+ when (context.peerGroupCapability(peerID)) {
+ PeerGroupCapability.SUPPORTED -> Unit
+ PeerGroupCapability.UNSUPPORTED ->
+ return error("$nickname does not support private groups")
+ PeerGroupCapability.UNKNOWN ->
+ return error("private-group support for $nickname is not confirmed yet; try again")
+ }
+ val identity = context.peerIdentity(peerID)
+ ?: return error("$nickname does not have a verified mesh identity")
+ if (group.isMember(identity.fingerprint)) return error("$nickname is already a member")
+ if (group.members.size >= BitchatGroup.MAX_MEMBERS) {
+ return error("groups are limited to ${BitchatGroup.MAX_MEMBERS} members")
+ }
+
+ val member = GroupMember(
+ identity.fingerprint,
+ identity.signingKey,
+ context.peerNickname(peerID) ?: nickname
+ )
+ val (updated, key) = context.groupStore.rotateKey(
+ group.groupID,
+ group.members + member
+ ) ?: return error("could not rotate the group key")
+ val payload = signedStatePayload(updated, key)
+ ?: return error("could not sign the group invite")
+
+ context.sendGroupInvite(payload, peerID)
+ distributeState(payload, updated, setOf(identity.fingerprint))
+ return success("invited $nickname to #${updated.name}")
+ }
+
+ @Synchronized
+ fun removeMember(rawNickname: String): GroupCommandResult {
+ if (!acceptsInboundEvents) return error("private groups are paused")
+ if (!context.groupStore.isReady) return loadingError()
+ val selector = parseMemberSelector(rawNickname)
+ ?: return error("usage: /group remove [#identity-suffix]")
+ val nickname = selector.nickname
+ val group = selectedGroup() ?: return error("open a private group first")
+ if (!isCreator(group)) return error("only the group creator can change members")
+ val matchingMembers = group.members.filter {
+ it.nickname.equals(nickname, ignoreCase = true)
+ }.let { members ->
+ selector.identitySuffix?.let { suffix ->
+ members.filter { it.fingerprint.endsWith(suffix, ignoreCase = true) }
+ } ?: members
+ }
+ val member = matchingMembers.singleOrNull() ?: return when {
+ matchingMembers.isEmpty() -> error("$nickname is not in this group")
+ else -> error(
+ "multiple members are named '$nickname'; use ${memberChoices(matchingMembers)}"
+ )
+ }
+ if (member.fingerprint == group.creatorFingerprint) {
+ return error("the creator cannot remove themselves")
+ }
+
+ val remaining = group.members.filterNot { it.fingerprint == member.fingerprint }
+ val (rotated, key) = context.groupStore.rotateKey(group.groupID, remaining)
+ ?: return error("could not rotate the group key")
+ val payload = signedStatePayload(rotated, key)
+ ?: return error("could not sign the group update")
+ distributeState(payload, rotated, emptySet())
+ notifyRemovedMember(member, rotated)
+ return success("removed ${member.nickname} and rotated the group key")
+ }
+
+ @Synchronized
+ fun leaveGroup(): GroupCommandResult {
+ if (!acceptsInboundEvents) return error("private groups are paused")
+ if (!context.groupStore.isReady) return loadingError()
+ val group = selectedGroup() ?: return error("open a private group first")
+ if (isCreator(group) && group.members.size > 1) {
+ return error("remove all other members before leaving this group")
+ }
+ val removed = if (isCreator(group)) {
+ context.groupStore.removeGroup(group.groupID)
+ } else {
+ context.groupStore.departGroup(group.groupID, group.epoch)
+ }
+ if (!removed) return error("could not leave group")
+ synchronized(lifecycleLock) {
+ dropFutureMessages(group.groupID)
+ }
+ context.closeGroupConversation()
+ context.removeGroupConversation(group.peerID)
+ return success("left #${group.name}")
+ }
+
+ @Synchronized
+ fun listGroups(): GroupCommandResult {
+ if (!context.groupStore.isReady) return loadingError()
+ val groups = context.groupStore.groups.value
+ if (groups.isEmpty()) return success("you are not in any private groups")
+ val fingerprint = context.myNoiseFingerprint()
+ val lines = groups.joinToString("\n") { group ->
+ val role = if (group.creatorFingerprint == fingerprint) " (creator)" else ""
+ "#${group.name}$role — ${group.members.size}/${BitchatGroup.MAX_MEMBERS}"
+ }
+ return success("private groups:\n$lines")
+ }
+
+ @Synchronized
+ fun sendMessage(content: String, groupPeerID: String): Boolean {
+ if (!acceptsInboundEvents) return false
+ if (!context.groupStore.isReady) {
+ context.addGroupSystemMessage(groupPeerID, "private groups are still loading")
+ return false
+ }
+ if (content.isEmpty() ||
+ content.codePointCount(0, content.length) > MAX_MESSAGE_LENGTH
+ ) {
+ return false
+ }
+ val group = context.groupStore.group(groupPeerID)
+ val key = group?.let { context.groupStore.key(it.groupID) }
+ if (group == null || key == null) {
+ context.addGroupSystemMessage(groupPeerID, "this private group is unavailable")
+ return false
+ }
+ val signingKey = context.mySigningPublicKey()
+ if (signingKey?.size != 32) {
+ context.addGroupSystemMessage(groupPeerID, "your signing identity is unavailable")
+ return false
+ }
+
+ val messageID = UUID.randomUUID().toString()
+ val timestamp = System.currentTimeMillis()
+ val payload = try {
+ GroupCrypto.sealMessage(
+ content = content,
+ messageID = messageID,
+ senderNickname = context.nickname,
+ senderSigningKey = signingKey,
+ timestampMs = timestamp,
+ groupID = group.groupID,
+ epoch = group.epoch,
+ key = key,
+ sign = context::sign
+ )
+ } catch (_: Exception) {
+ context.addGroupSystemMessage(groupPeerID, "could not encrypt group message")
+ return false
+ }
+
+ val stored = context.appendGroupMessage(
+ groupPeerID,
+ BitchatMessage(
+ id = messageID,
+ sender = context.nickname,
+ content = content,
+ timestamp = Date(timestamp),
+ isPrivate = true,
+ recipientNickname = group.name,
+ senderPeerID = context.myPeerID,
+ deliveryStatus = DeliveryStatus.Sent
+ )
+ )
+ if (!stored) return false
+ context.broadcastGroupMessage(payload)
+ return true
+ }
+
+ @Synchronized
+ fun handleMessage(payload: ByteArray, receivedAtMs: Long) {
+ val generation = inboundGeneration
+ if (!acceptsInboundEvents) return
+ synchronized(lifecycleLock) {
+ if (!acceptsInboundEvents || generation != inboundGeneration) return
+ if (deferIfLoading(PendingEvent.Message(payload.copyOf(), receivedAtMs))) return
+ processMessage(payload)
+ }
+ }
+
+ private fun processMessage(payload: ByteArray, queueFutureEpoch: Boolean = true) {
+ val envelope = GroupMessageEnvelope.decode(payload) ?: return
+ val group = context.groupStore.group(envelope.groupID) ?: return
+ if (envelope.epoch != group.epoch) {
+ if (queueFutureEpoch && envelope.epoch > group.epoch) {
+ queueFutureMessage(envelope, payload)
+ }
+ return
+ }
+ val key = context.groupStore.key(group.groupID) ?: return
+ val plaintext = try {
+ GroupCrypto.openMessage(envelope, key)
+ } catch (_: Exception) {
+ return
+ }
+ val member = group.memberWithSigningKey(plaintext.senderSigningKey) ?: return
+ val ownSigningKey = context.mySigningPublicKey()
+ if (ownSigningKey != null && plaintext.senderSigningKey.contentEquals(ownSigningKey)) return
+ if (context.isFingerprintBlocked(member.fingerprint)) return
+
+ val now = System.currentTimeMillis()
+ val timestamp = plaintext.timestampMs.coerceIn(0, now)
+ val sender = member.nickname.ifBlank { plaintext.senderNickname }
+ val message = BitchatMessage(
+ id = plaintext.messageID,
+ sender = sender,
+ content = plaintext.content,
+ timestamp = Date(timestamp),
+ isPrivate = true,
+ recipientNickname = group.name,
+ senderPeerID = member.fingerprint.take(16)
+ )
+ if (!context.appendGroupMessage(group.peerID, message)) return
+
+ if (context.selectedConversationID != group.peerID) {
+ context.markGroupUnread(group.peerID)
+ if (now - timestamp < RECENT_NOTIFICATION_WINDOW_MS) {
+ context.notifyGroupMessage(group.peerID, "$sender @ ${group.name}", plaintext.content)
+ }
+ }
+ }
+
+ @Synchronized
+ fun handleInvite(
+ peerID: String,
+ authenticatedRemoteStaticKey: ByteArray,
+ payload: ByteArray
+ ) {
+ val generation = inboundGeneration
+ if (!acceptsInboundEvents) return
+ synchronized(lifecycleLock) {
+ if (!acceptsInboundEvents || generation != inboundGeneration) return
+ if (
+ deferIfLoading(
+ PendingEvent.Invite(
+ peerID,
+ authenticatedRemoteStaticKey.copyOf(),
+ payload.copyOf()
+ )
+ )
+ ) {
+ return
+ }
+ applyState(peerID, authenticatedRemoteStaticKey, payload, isInvite = true)
+ }
+ }
+
+ @Synchronized
+ fun handleKeyUpdate(
+ peerID: String,
+ authenticatedRemoteStaticKey: ByteArray,
+ payload: ByteArray
+ ) {
+ val generation = inboundGeneration
+ if (!acceptsInboundEvents) return
+ synchronized(lifecycleLock) {
+ if (!acceptsInboundEvents || generation != inboundGeneration) return
+ if (
+ deferIfLoading(
+ PendingEvent.KeyUpdate(
+ peerID,
+ authenticatedRemoteStaticKey.copyOf(),
+ payload.copyOf()
+ )
+ )
+ ) {
+ return
+ }
+ applyState(peerID, authenticatedRemoteStaticKey, payload, isInvite = false)
+ }
+ }
+
+ /**
+ * Replays the current creator-signed state after an authenticated peer
+ * reconnects. This uses the existing iOS GROUP_KEY_UPDATE payload and
+ * repairs updates that could not be delivered while the member was offline.
+ */
+ @Synchronized
+ fun handlePeerAuthenticated(peerID: String) {
+ val generation = inboundGeneration
+ if (!acceptsInboundEvents) return
+ synchronized(lifecycleLock) {
+ if (!acceptsInboundEvents || generation != inboundGeneration) return
+ if (deferIfLoading(PendingEvent.PeerAuthenticated(peerID))) return
+ if (!context.isPeerConnected(peerID)) return
+ if (context.peerGroupCapability(peerID) != PeerGroupCapability.SUPPORTED) return
+ val identity = context.peerIdentity(peerID) ?: return
+ val ownFingerprint = context.myNoiseFingerprint()
+ context.groupStore.groups.value.forEach { group ->
+ if (group.creatorFingerprint != ownFingerprint ||
+ !group.isMember(identity.fingerprint)
+ ) {
+ return@forEach
+ }
+ val key = context.groupStore.key(group.groupID) ?: return@forEach
+ val payload = signedStatePayload(group, key) ?: return@forEach
+ context.sendGroupKeyUpdate(payload, peerID)
+ }
+ }
+ }
+
+ /**
+ * Drains packets received during asynchronous store initialization.
+ */
+ @Synchronized
+ fun onStoreReady() {
+ val generation = inboundGeneration
+ if (!acceptsInboundEvents) return
+ synchronized(lifecycleLock) {
+ if (!acceptsInboundEvents ||
+ generation != inboundGeneration ||
+ !context.groupStore.isReady
+ ) {
+ return
+ }
+ while (true) {
+ val event = synchronized(pendingLock) {
+ pendingEvents.pollFirst()
+ } ?: return
+ when (event) {
+ is PendingEvent.Invite -> applyState(
+ event.peerID,
+ event.authenticatedRemoteStaticKey,
+ event.payload,
+ isInvite = true
+ )
+ is PendingEvent.KeyUpdate -> applyState(
+ event.peerID,
+ event.authenticatedRemoteStaticKey,
+ event.payload,
+ isInvite = false
+ )
+ is PendingEvent.Message -> processMessage(event.payload)
+ is PendingEvent.PeerAuthenticated ->
+ handlePeerAuthenticated(event.peerID)
+ }
+ }
+ }
+ }
+
+ @Synchronized
+ fun suspendForPanic() {
+ synchronized(lifecycleLock) {
+ acceptsInboundEvents = false
+ inboundGeneration += 1
+ synchronized(pendingLock) {
+ pendingEvents.clear()
+ }
+ futureMessages.clear()
+ }
+ }
+
+ @Synchronized
+ fun resumeAfterPanic() {
+ synchronized(lifecycleLock) {
+ acceptsInboundEvents = true
+ }
+ }
+
+ private fun applyState(
+ peerID: String,
+ authenticatedRemoteStaticKey: ByteArray,
+ payload: ByteArray,
+ isInvite: Boolean
+ ) {
+ val state = GroupStatePayload.decode(payload) ?: return
+ val senderFingerprint = sha256(authenticatedRemoteStaticKey).toHex()
+ if (senderFingerprint != state.creatorFingerprint) return
+ if (!state.verifyCreatorSignature()) return
+
+ val ownFingerprint = context.myNoiseFingerprint()
+ val existing = context.groupStore.group(state.groupID)
+ // Reject stale state before interpreting a missing-self roster as a
+ // removal. Otherwise an old, valid removal notice could delete a
+ // membership restored by a later creator-signed re-invite.
+ if (existing != null && state.epoch < existing.epoch) return
+ val departureEpoch = context.groupStore.departureEpoch(state.groupID)
+ if (departureEpoch != null &&
+ (!isInvite || state.epoch <= departureEpoch)
+ ) {
+ return
+ }
+ if (state.members.none { it.fingerprint == ownFingerprint }) {
+ val removed = context.groupStore.removeGroupForState(state.groupID, state.epoch)
+ ?: return
+ dropFutureMessages(removed.groupID)
+ if (context.selectedConversationID == removed.peerID) {
+ context.closeGroupConversation()
+ }
+ context.removeGroupConversation(removed.peerID)
+ context.addSystemMessage("you were removed from #${removed.name}")
+ return
+ }
+ val stored = if (isInvite && departureEpoch != null) {
+ context.groupStore.acceptInvite(state.asGroup(), state.key)
+ } else {
+ context.groupStore.upsert(state.asGroup(), state.key)
+ }
+ if (!stored) return
+ retryFutureMessages(state.groupID)
+
+ if (existing == null) {
+ val inviter = state.members.firstOrNull {
+ it.fingerprint == state.creatorFingerprint
+ }?.nickname ?: context.peerNickname(peerID) ?: "unknown"
+ val notice = "joined #${state.name}, invited by $inviter"
+ context.addSystemMessage(notice)
+ context.markGroupUnread(state.asGroup().peerID)
+ context.notifyGroupMessage(state.asGroup().peerID, inviter, notice)
+ }
+ }
+
+ private fun signedStatePayload(group: BitchatGroup, key: ByteArray): ByteArray? =
+ GroupStatePayload.makeSigned(group, key, context::sign)?.encode()
+
+ private fun distributeState(
+ payload: ByteArray,
+ group: BitchatGroup,
+ excludedFingerprints: Set
+ ) {
+ val ownFingerprint = context.myNoiseFingerprint()
+ group.members.forEach { member ->
+ if (member.fingerprint == ownFingerprint ||
+ member.fingerprint in excludedFingerprints
+ ) {
+ return@forEach
+ }
+ context.connectedPeerID(member.fingerprint)?.let { peerID ->
+ context.sendGroupKeyUpdate(payload, peerID)
+ }
+ }
+ }
+
+ private fun notifyRemovedMember(member: GroupMember, rotated: BitchatGroup) {
+ val peerID = context.connectedPeerID(member.fingerprint) ?: return
+ val payload = signedStatePayload(rotated, ByteArray(BitchatGroup.KEY_LENGTH)) ?: return
+ context.sendGroupKeyUpdate(payload, peerID)
+ }
+
+ private fun selectedGroup(): BitchatGroup? =
+ context.selectedConversationID?.let(context.groupStore::group)
+
+ private fun isCreator(group: BitchatGroup): Boolean =
+ group.creatorFingerprint == context.myNoiseFingerprint()
+
+ private fun parseMemberSelector(raw: String): MemberSelector? {
+ val normalized = raw.trim().removePrefix("@")
+ if (normalized.isEmpty()) return null
+ val separator = normalized.lastIndexOf('#')
+ if (separator < 0) return MemberSelector(normalized, null)
+ if (separator == 0 || separator == normalized.lastIndex) return null
+ val suffix = normalized.substring(separator + 1)
+ if (!IDENTITY_SUFFIX.matches(suffix)) return null
+ return MemberSelector(
+ nickname = normalized.substring(0, separator),
+ identitySuffix = suffix.lowercase()
+ )
+ }
+
+ private fun resolvePeer(selector: MemberSelector): String? {
+ val matches = context.peerIDsForNickname(selector.nickname).distinct()
+ val narrowed = selector.identitySuffix?.let { suffix ->
+ matches.filter { peerID ->
+ peerID.endsWith(suffix, ignoreCase = true) ||
+ context.peerIdentity(peerID)
+ ?.fingerprint
+ ?.endsWith(suffix, ignoreCase = true) == true
+ }
+ } ?: matches
+ return narrowed.singleOrNull()
+ }
+
+ private fun ambiguousPeerError(selector: MemberSelector): GroupCommandResult {
+ val matches = context.peerIDsForNickname(selector.nickname).distinct()
+ if (matches.isEmpty() || selector.identitySuffix != null) {
+ return error("user '${selector.nickname}' was not found")
+ }
+ return error(
+ "multiple users are named '${selector.nickname}'; use " +
+ matches.joinToString(" or ") { peerID ->
+ val suffix = context.peerIdentity(peerID)
+ ?.fingerprint
+ ?.takeLast(8)
+ ?: peerID.takeLast(8)
+ "@${selector.nickname}#$suffix"
+ }
+ )
+ }
+
+ private fun memberChoices(members: List): String =
+ members.joinToString(" or ") { "@${it.nickname}#${it.fingerprint.takeLast(8)}" }
+
+ private fun queueFutureMessage(envelope: GroupMessageEnvelope, payload: ByteArray) {
+ val now = System.currentTimeMillis()
+ pruneExpiredFutureMessages(now)
+ if (futureMessages.any {
+ it.epoch == envelope.epoch &&
+ it.groupID.contentEquals(envelope.groupID) &&
+ it.payload.contentEquals(payload)
+ }
+ ) {
+ return
+ }
+ if (futureMessages.size >= MAX_FUTURE_MESSAGES) futureMessages.pollFirst()
+ futureMessages.addLast(
+ FutureMessage(
+ envelope.groupID.copyOf(),
+ envelope.epoch,
+ payload.copyOf(),
+ now
+ )
+ )
+ }
+
+ private fun retryFutureMessages(groupID: ByteArray) {
+ val current = context.groupStore.group(groupID) ?: return
+ val now = System.currentTimeMillis()
+ val ready = mutableListOf()
+ val iterator = futureMessages.iterator()
+ while (iterator.hasNext()) {
+ val message = iterator.next()
+ if (now - message.queuedAtMs > FUTURE_MESSAGE_TTL_MS) {
+ iterator.remove()
+ } else if (message.groupID.contentEquals(groupID) &&
+ message.epoch <= current.epoch
+ ) {
+ iterator.remove()
+ if (message.epoch == current.epoch) ready += message.payload
+ }
+ }
+ ready.forEach { processMessage(it, queueFutureEpoch = false) }
+ }
+
+ private fun dropFutureMessages(groupID: ByteArray) {
+ val iterator = futureMessages.iterator()
+ while (iterator.hasNext()) {
+ if (iterator.next().groupID.contentEquals(groupID)) iterator.remove()
+ }
+ }
+
+ private fun pruneExpiredFutureMessages(now: Long) {
+ val iterator = futureMessages.iterator()
+ while (iterator.hasNext()) {
+ if (now - iterator.next().queuedAtMs > FUTURE_MESSAGE_TTL_MS) iterator.remove()
+ }
+ }
+
+ private fun deferIfLoading(event: PendingEvent): Boolean =
+ synchronized(pendingLock) {
+ if (context.groupStore.isReady) return@synchronized false
+ if (pendingEvents.size >= MAX_PENDING_EVENTS) pendingEvents.pollFirst()
+ pendingEvents.addLast(event)
+ true
+ }
+
+ private fun loadingError() =
+ error("private groups are still loading; try again")
+
+ private fun success(message: String) = GroupCommandResult(true, message)
+ private fun error(message: String) = GroupCommandResult(false, message)
+
+ private fun ByteArray.toHex(): String =
+ joinToString("") { "%02x".format(it) }
+
+ companion object {
+ private const val MAX_GROUP_NAME_LENGTH = 40
+ private const val MAX_MESSAGE_LENGTH = 60_000
+ private const val RECENT_NOTIFICATION_WINDOW_MS = 30_000L
+ private const val MAX_PENDING_EVENTS = 64
+ private const val MAX_FUTURE_MESSAGES = 32
+ private const val FUTURE_MESSAGE_TTL_MS = 2 * 60_000L
+ private val FINGERPRINT = Regex("^[0-9a-fA-F]{64}$")
+ private val IDENTITY_SUFFIX = Regex("^[0-9a-fA-F]{4,64}$")
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/groups/GroupProtocol.kt b/app/src/main/java/com/bitchat/android/groups/GroupProtocol.kt
new file mode 100644
index 00000000..2f8a25de
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/groups/GroupProtocol.kt
@@ -0,0 +1,663 @@
+package com.bitchat.android.groups
+
+import com.bitchat.android.util.dataFromHexString
+import com.bitchat.android.util.hexEncodedString
+import java.io.ByteArrayOutputStream
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import java.nio.charset.CodingErrorAction
+import java.security.MessageDigest
+import java.security.SecureRandom
+import java.util.Arrays
+import java.util.UUID
+import org.bouncycastle.crypto.InvalidCipherTextException
+import org.bouncycastle.crypto.modes.ChaCha20Poly1305
+import org.bouncycastle.crypto.params.AEADParameters
+import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
+import org.bouncycastle.crypto.params.KeyParameter
+import org.bouncycastle.crypto.signers.Ed25519Signer
+
+data class GroupMember(
+ val fingerprint: String,
+ val signingKey: ByteArray,
+ val nickname: String
+) {
+ override fun equals(other: Any?): Boolean =
+ this === other ||
+ (other is GroupMember &&
+ fingerprint == other.fingerprint &&
+ signingKey.contentEquals(other.signingKey) &&
+ nickname == other.nickname)
+
+ override fun hashCode(): Int =
+ 31 * (31 * fingerprint.hashCode() + signingKey.contentHashCode()) + nickname.hashCode()
+}
+
+data class BitchatGroup(
+ val groupID: ByteArray,
+ val name: String,
+ val epoch: Long,
+ val members: List,
+ val creatorFingerprint: String
+) {
+ val peerID: String get() = GroupIds.peerID(groupID)
+ val creator: GroupMember? get() = members.firstOrNull { it.fingerprint == creatorFingerprint }
+
+ fun isMember(fingerprint: String): Boolean =
+ members.any { it.fingerprint == fingerprint }
+
+ fun memberWithSigningKey(signingKey: ByteArray): GroupMember? =
+ members.firstOrNull { it.signingKey.contentEquals(signingKey) }
+
+ override fun equals(other: Any?): Boolean =
+ this === other ||
+ (other is BitchatGroup &&
+ groupID.contentEquals(other.groupID) &&
+ name == other.name &&
+ epoch == other.epoch &&
+ members == other.members &&
+ creatorFingerprint == other.creatorFingerprint)
+
+ override fun hashCode(): Int {
+ var result = groupID.contentHashCode()
+ result = 31 * result + name.hashCode()
+ result = 31 * result + epoch.hashCode()
+ result = 31 * result + members.hashCode()
+ result = 31 * result + creatorFingerprint.hashCode()
+ return result
+ }
+
+ companion object {
+ const val MAX_MEMBERS = 16
+ const val GROUP_ID_LENGTH = 16
+ const val KEY_LENGTH = 32
+ const val MAX_EPOCH = 0xffff_ffffL
+ }
+}
+
+object GroupIds {
+ private const val PREFIX = "group_"
+ private val pattern = Regex("^group_[0-9a-f]{32}$")
+
+ fun peerID(groupID: ByteArray): String {
+ require(groupID.size == BitchatGroup.GROUP_ID_LENGTH)
+ return PREFIX + groupID.hexEncodedString()
+ }
+
+ fun groupID(peerID: String): ByteArray? {
+ val normalized = peerID.lowercase()
+ if (!pattern.matches(normalized)) return null
+ return normalized.removePrefix(PREFIX).dataFromHexString()
+ }
+
+ fun isGroup(peerID: String?): Boolean =
+ peerID != null && pattern.matches(peerID.lowercase())
+}
+
+class GroupTlvValueTooLongException : IllegalArgumentException("group TLV value exceeds UInt16")
+
+internal object GroupTLV {
+ data class Field(val type: Int, val value: ByteArray)
+
+ fun put(type: Int, value: ByteArray, output: ByteArrayOutputStream) {
+ if (value.size > 0xffff) throw GroupTlvValueTooLongException()
+ output.write(type and 0xff)
+ output.write((value.size ushr 8) and 0xff)
+ output.write(value.size and 0xff)
+ output.write(value)
+ }
+
+ fun encode(vararg fields: Pair): ByteArray {
+ val output = ByteArrayOutputStream()
+ fields.forEach { (type, value) -> put(type, value, output) }
+ return output.toByteArray()
+ }
+
+ fun parse(data: ByteArray): List? {
+ val fields = mutableListOf()
+ var offset = 0
+ while (offset < data.size) {
+ if (offset + 3 > data.size) return null
+ val type = data[offset].toInt() and 0xff
+ val length =
+ ((data[offset + 1].toInt() and 0xff) shl 8) or
+ (data[offset + 2].toInt() and 0xff)
+ offset += 3
+ if (offset + length > data.size) return null
+ fields += Field(type, data.copyOfRange(offset, offset + length))
+ offset += length
+ }
+ return fields
+ }
+
+ fun epochData(epoch: Long): ByteArray {
+ require(epoch in 0..BitchatGroup.MAX_EPOCH)
+ return ByteBuffer.allocate(Int.SIZE_BYTES)
+ .order(ByteOrder.BIG_ENDIAN)
+ .putInt(epoch.toInt())
+ .array()
+ }
+
+ fun epoch(data: ByteArray): Long? {
+ if (data.size != Int.SIZE_BYTES) return null
+ return ByteBuffer.wrap(data).order(ByteOrder.BIG_ENDIAN).int.toLong() and 0xffff_ffffL
+ }
+
+ fun timestampData(timestampMs: Long): ByteArray =
+ ByteBuffer.allocate(Long.SIZE_BYTES)
+ .order(ByteOrder.BIG_ENDIAN)
+ .putLong(timestampMs)
+ .array()
+
+ fun timestamp(data: ByteArray): Long? {
+ if (data.size != Long.SIZE_BYTES) return null
+ return ByteBuffer.wrap(data).order(ByteOrder.BIG_ENDIAN).long
+ }
+
+ fun strictUtf8(data: ByteArray): String? = try {
+ Charsets.UTF_8.newDecoder()
+ .onMalformedInput(CodingErrorAction.REPORT)
+ .onUnmappableCharacter(CodingErrorAction.REPORT)
+ .decode(ByteBuffer.wrap(data))
+ .toString()
+ } catch (_: Exception) {
+ null
+ }
+}
+
+object GroupRosterCoding {
+ private const val FINGERPRINT_LENGTH = 32
+ private const val SIGNING_KEY_LENGTH = 32
+ private const val MAX_NICKNAME_BYTES = 64
+
+ fun encode(members: List): ByteArray? {
+ if (members.size > BitchatGroup.MAX_MEMBERS) return null
+ val output = ByteArrayOutputStream()
+ output.write(members.size)
+ members.forEach { member ->
+ val fingerprint = member.fingerprint.dataFromHexString()
+ if (fingerprint?.size != FINGERPRINT_LENGTH ||
+ member.signingKey.size != SIGNING_KEY_LENGTH
+ ) {
+ return null
+ }
+ output.write(fingerprint)
+ output.write(member.signingKey)
+ val nickname = truncatedNicknameBytes(member.nickname)
+ output.write(nickname.size)
+ output.write(nickname)
+ }
+ return output.toByteArray()
+ }
+
+ fun decode(data: ByteArray): List? {
+ if (data.isEmpty()) return null
+ val count = data[0].toInt() and 0xff
+ if (count > BitchatGroup.MAX_MEMBERS) return null
+ val members = mutableListOf()
+ var offset = 1
+ repeat(count) {
+ val fixedLength = FINGERPRINT_LENGTH + SIGNING_KEY_LENGTH + 1
+ if (offset + fixedLength > data.size) return null
+ val fingerprint =
+ data.copyOfRange(offset, offset + FINGERPRINT_LENGTH).hexEncodedString()
+ offset += FINGERPRINT_LENGTH
+ val signingKey = data.copyOfRange(offset, offset + SIGNING_KEY_LENGTH)
+ offset += SIGNING_KEY_LENGTH
+ val nicknameLength = data[offset].toInt() and 0xff
+ offset += 1
+ if (offset + nicknameLength > data.size) return null
+ val nickname = GroupTLV.strictUtf8(
+ data.copyOfRange(offset, offset + nicknameLength)
+ ) ?: return null
+ offset += nicknameLength
+ members += GroupMember(fingerprint, signingKey, nickname)
+ }
+ if (offset != data.size) return null
+ return members
+ }
+
+ private fun truncatedNicknameBytes(nickname: String): ByteArray {
+ val output = StringBuilder()
+ var offset = 0
+ while (offset < nickname.length) {
+ val codePoint = nickname.codePointAt(offset)
+ val candidate = output.toString() + String(Character.toChars(codePoint))
+ if (candidate.toByteArray(Charsets.UTF_8).size > MAX_NICKNAME_BYTES) break
+ output.appendCodePoint(codePoint)
+ offset += Character.charCount(codePoint)
+ }
+ return output.toString().toByteArray(Charsets.UTF_8)
+ }
+}
+
+class GroupStatePayload(
+ val groupID: ByteArray,
+ val name: String,
+ val key: ByteArray,
+ val epoch: Long,
+ val members: List,
+ val creatorFingerprint: String,
+ val signature: ByteArray
+) {
+ fun encode(): ByteArray? {
+ val roster = GroupRosterCoding.encode(members) ?: return null
+ val creator = creatorFingerprint.dataFromHexString()
+ if (creator?.size != 32) return null
+ return try {
+ GroupTLV.encode(
+ FIELD_GROUP_ID to groupID,
+ FIELD_NAME to name.toByteArray(Charsets.UTF_8),
+ FIELD_KEY to key,
+ FIELD_EPOCH to GroupTLV.epochData(epoch),
+ FIELD_ROSTER to roster,
+ FIELD_CREATOR_FINGERPRINT to creator,
+ FIELD_SIGNATURE to signature
+ )
+ } catch (_: GroupTlvValueTooLongException) {
+ null
+ }
+ }
+
+ fun verifyCreatorSignature(): Boolean {
+ if (members.size > BitchatGroup.MAX_MEMBERS) return false
+ val creator = members.firstOrNull { it.fingerprint == creatorFingerprint } ?: return false
+ val roster = GroupRosterCoding.encode(members) ?: return false
+ return GroupCrypto.verify(
+ signature,
+ signingContent(groupID, epoch, key, roster, name),
+ creator.signingKey
+ )
+ }
+
+ fun asGroup(): BitchatGroup =
+ BitchatGroup(groupID, name, epoch, members, creatorFingerprint)
+
+ override fun equals(other: Any?): Boolean =
+ this === other ||
+ (other is GroupStatePayload &&
+ groupID.contentEquals(other.groupID) &&
+ name == other.name &&
+ key.contentEquals(other.key) &&
+ epoch == other.epoch &&
+ members == other.members &&
+ creatorFingerprint == other.creatorFingerprint &&
+ signature.contentEquals(other.signature))
+
+ override fun hashCode(): Int {
+ var result = Arrays.hashCode(groupID)
+ result = 31 * result + name.hashCode()
+ result = 31 * result + Arrays.hashCode(key)
+ result = 31 * result + epoch.hashCode()
+ result = 31 * result + members.hashCode()
+ result = 31 * result + creatorFingerprint.hashCode()
+ result = 31 * result + Arrays.hashCode(signature)
+ return result
+ }
+
+ companion object {
+ private const val FIELD_GROUP_ID = 0x01
+ private const val FIELD_NAME = 0x02
+ private const val FIELD_KEY = 0x03
+ private const val FIELD_EPOCH = 0x04
+ private const val FIELD_ROSTER = 0x05
+ private const val FIELD_CREATOR_FINGERPRINT = 0x06
+ private const val FIELD_SIGNATURE = 0x07
+ private val SIGNING_DOMAIN = "bitchat-group-v1".toByteArray(Charsets.UTF_8)
+
+ fun signingContent(
+ groupID: ByteArray,
+ epoch: Long,
+ key: ByteArray,
+ rosterBlob: ByteArray,
+ name: String
+ ): ByteArray = concat(
+ SIGNING_DOMAIN,
+ groupID,
+ GroupTLV.epochData(epoch),
+ sha256(key),
+ sha256(rosterBlob),
+ sha256(name.toByteArray(Charsets.UTF_8))
+ )
+
+ fun makeSigned(
+ group: BitchatGroup,
+ key: ByteArray,
+ sign: (ByteArray) -> ByteArray?
+ ): GroupStatePayload? {
+ val roster = GroupRosterCoding.encode(group.members) ?: return null
+ val signature = sign(
+ signingContent(group.groupID, group.epoch, key, roster, group.name)
+ ) ?: return null
+ if (signature.size != 64) return null
+ return GroupStatePayload(
+ group.groupID,
+ group.name,
+ key,
+ group.epoch,
+ group.members,
+ group.creatorFingerprint,
+ signature
+ )
+ }
+
+ fun decode(data: ByteArray): GroupStatePayload? {
+ val fields = GroupTLV.parse(data) ?: return null
+ var groupID: ByteArray? = null
+ var name: String? = null
+ var key: ByteArray? = null
+ var epoch: Long? = null
+ var members: List? = null
+ var creatorFingerprint: String? = null
+ var signature: ByteArray? = null
+ fields.forEach { field ->
+ when (field.type) {
+ FIELD_GROUP_ID ->
+ if (field.value.size == BitchatGroup.GROUP_ID_LENGTH) groupID = field.value
+ FIELD_NAME -> name = GroupTLV.strictUtf8(field.value)
+ FIELD_KEY ->
+ if (field.value.size == BitchatGroup.KEY_LENGTH) key = field.value
+ FIELD_EPOCH -> epoch = GroupTLV.epoch(field.value)
+ FIELD_ROSTER -> members = GroupRosterCoding.decode(field.value)
+ FIELD_CREATOR_FINGERPRINT ->
+ if (field.value.size == 32) creatorFingerprint = field.value.hexEncodedString()
+ FIELD_SIGNATURE -> if (field.value.size == 64) signature = field.value
+ }
+ }
+ val decodedMembers = members ?: return null
+ if (decodedMembers.isEmpty()) return null
+ return GroupStatePayload(
+ groupID ?: return null,
+ name ?: return null,
+ key ?: return null,
+ epoch ?: return null,
+ decodedMembers,
+ creatorFingerprint ?: return null,
+ signature ?: return null
+ )
+ }
+ }
+}
+
+class GroupMessageEnvelope(
+ val groupID: ByteArray,
+ val epoch: Long,
+ val nonce: ByteArray,
+ val ciphertext: ByteArray
+) {
+ fun encode(): ByteArray = GroupTLV.encode(
+ FIELD_GROUP_ID to groupID,
+ FIELD_EPOCH to GroupTLV.epochData(epoch),
+ FIELD_NONCE to nonce,
+ FIELD_CIPHERTEXT to ciphertext
+ )
+
+ override fun equals(other: Any?): Boolean =
+ this === other ||
+ (other is GroupMessageEnvelope &&
+ groupID.contentEquals(other.groupID) &&
+ epoch == other.epoch &&
+ nonce.contentEquals(other.nonce) &&
+ ciphertext.contentEquals(other.ciphertext))
+
+ override fun hashCode(): Int {
+ var result = groupID.contentHashCode()
+ result = 31 * result + epoch.hashCode()
+ result = 31 * result + nonce.contentHashCode()
+ result = 31 * result + ciphertext.contentHashCode()
+ return result
+ }
+
+ companion object {
+ private const val FIELD_GROUP_ID = 0x01
+ private const val FIELD_EPOCH = 0x02
+ private const val FIELD_NONCE = 0x03
+ private const val FIELD_CIPHERTEXT = 0x04
+
+ fun decode(data: ByteArray): GroupMessageEnvelope? {
+ val fields = GroupTLV.parse(data) ?: return null
+ var groupID: ByteArray? = null
+ var epoch: Long? = null
+ var nonce: ByteArray? = null
+ var ciphertext: ByteArray? = null
+ fields.forEach { field ->
+ when (field.type) {
+ FIELD_GROUP_ID ->
+ if (field.value.size == BitchatGroup.GROUP_ID_LENGTH) groupID = field.value
+ FIELD_EPOCH -> epoch = GroupTLV.epoch(field.value)
+ FIELD_NONCE -> if (field.value.size == 12) nonce = field.value
+ FIELD_CIPHERTEXT -> if (field.value.isNotEmpty()) ciphertext = field.value
+ }
+ }
+ return GroupMessageEnvelope(
+ groupID ?: return null,
+ epoch ?: return null,
+ nonce ?: return null,
+ ciphertext ?: return null
+ )
+ }
+ }
+}
+
+data class GroupMessagePlaintext(
+ val messageID: String,
+ val senderSigningKey: ByteArray,
+ val senderNickname: String,
+ val timestampMs: Long,
+ val content: String
+) {
+ override fun equals(other: Any?): Boolean =
+ this === other ||
+ (other is GroupMessagePlaintext &&
+ messageID == other.messageID &&
+ senderSigningKey.contentEquals(other.senderSigningKey) &&
+ senderNickname == other.senderNickname &&
+ timestampMs == other.timestampMs &&
+ content == other.content)
+
+ override fun hashCode(): Int {
+ var result = messageID.hashCode()
+ result = 31 * result + senderSigningKey.contentHashCode()
+ result = 31 * result + senderNickname.hashCode()
+ result = 31 * result + timestampMs.hashCode()
+ result = 31 * result + content.hashCode()
+ return result
+ }
+}
+
+sealed class GroupCryptoException(message: String) : Exception(message) {
+ class MalformedPayload : GroupCryptoException("malformed group payload")
+ class SigningFailed : GroupCryptoException("group message signing failed")
+ class SealFailed : GroupCryptoException("group message sealing failed")
+ class DecryptionFailed : GroupCryptoException("group message decryption failed")
+ class BadSenderSignature : GroupCryptoException("bad group sender signature")
+}
+
+object GroupCrypto {
+ private const val FIELD_MESSAGE_ID = 0x01
+ private const val FIELD_SENDER_SIGNING_KEY = 0x02
+ private const val FIELD_SENDER_NICKNAME = 0x03
+ private const val FIELD_TIMESTAMP = 0x04
+ private const val FIELD_CONTENT = 0x05
+ private const val FIELD_SIGNATURE = 0x06
+ private val MESSAGE_SIGNING_DOMAIN =
+ "bitchat-group-msg-v1".toByteArray(Charsets.UTF_8)
+ private val random = SecureRandom()
+
+ fun messageSigningContent(
+ groupID: ByteArray,
+ epoch: Long,
+ messageID: String,
+ timestampMs: Long,
+ content: String
+ ): ByteArray = concat(
+ MESSAGE_SIGNING_DOMAIN,
+ groupID,
+ GroupTLV.epochData(epoch),
+ messageID.toByteArray(Charsets.UTF_8),
+ GroupTLV.timestampData(timestampMs),
+ content.toByteArray(Charsets.UTF_8)
+ )
+
+ fun verify(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean {
+ if (signature.size != 64 || publicKey.size != 32) return false
+ return try {
+ val verifier = Ed25519Signer()
+ verifier.init(false, Ed25519PublicKeyParameters(publicKey, 0))
+ verifier.update(data, 0, data.size)
+ verifier.verifySignature(signature)
+ } catch (_: Exception) {
+ false
+ }
+ }
+
+ @Throws(GroupCryptoException::class, GroupTlvValueTooLongException::class)
+ fun sealMessage(
+ content: String,
+ messageID: String,
+ senderNickname: String,
+ senderSigningKey: ByteArray,
+ timestampMs: Long,
+ groupID: ByteArray,
+ epoch: Long,
+ key: ByteArray,
+ sign: (ByteArray) -> ByteArray?
+ ): ByteArray {
+ if (!isCanonicalMessageID(messageID)) {
+ throw GroupCryptoException.MalformedPayload()
+ }
+ val signature = sign(
+ messageSigningContent(groupID, epoch, messageID, timestampMs, content)
+ )
+ if (signature?.size != 64) throw GroupCryptoException.SigningFailed()
+
+ val inner = GroupTLV.encode(
+ FIELD_MESSAGE_ID to messageID.toByteArray(Charsets.UTF_8),
+ FIELD_SENDER_SIGNING_KEY to senderSigningKey,
+ FIELD_SENDER_NICKNAME to senderNickname.toByteArray(Charsets.UTF_8),
+ FIELD_TIMESTAMP to GroupTLV.timestampData(timestampMs),
+ FIELD_CONTENT to content.toByteArray(Charsets.UTF_8),
+ FIELD_SIGNATURE to signature
+ )
+ if (key.size != BitchatGroup.KEY_LENGTH ||
+ groupID.size != BitchatGroup.GROUP_ID_LENGTH
+ ) {
+ throw GroupCryptoException.SealFailed()
+ }
+
+ return try {
+ val nonce = ByteArray(12).also(random::nextBytes)
+ val aad = concat(groupID, GroupTLV.epochData(epoch))
+ val ciphertext = crypt(encrypt = true, key, nonce, aad, inner)
+ GroupMessageEnvelope(groupID, epoch, nonce, ciphertext).encode()
+ } catch (error: GroupTlvValueTooLongException) {
+ throw error
+ } catch (_: Exception) {
+ throw GroupCryptoException.SealFailed()
+ }
+ }
+
+ @Throws(GroupCryptoException::class)
+ fun openMessage(envelope: GroupMessageEnvelope, key: ByteArray): GroupMessagePlaintext {
+ if (key.size != BitchatGroup.KEY_LENGTH || envelope.ciphertext.size <= 16) {
+ throw GroupCryptoException.DecryptionFailed()
+ }
+ val inner = try {
+ crypt(
+ encrypt = false,
+ key,
+ envelope.nonce,
+ concat(envelope.groupID, GroupTLV.epochData(envelope.epoch)),
+ envelope.ciphertext
+ )
+ } catch (_: Exception) {
+ throw GroupCryptoException.DecryptionFailed()
+ }
+
+ val fields = GroupTLV.parse(inner) ?: throw GroupCryptoException.MalformedPayload()
+ var messageID: String? = null
+ var senderSigningKey: ByteArray? = null
+ var senderNickname: String? = null
+ var timestampMs: Long? = null
+ var content: String? = null
+ var signature: ByteArray? = null
+ fields.forEach { field ->
+ when (field.type) {
+ FIELD_MESSAGE_ID -> messageID = GroupTLV.strictUtf8(field.value)
+ FIELD_SENDER_SIGNING_KEY ->
+ if (field.value.size == 32) senderSigningKey = field.value
+ FIELD_SENDER_NICKNAME -> senderNickname = GroupTLV.strictUtf8(field.value)
+ FIELD_TIMESTAMP -> timestampMs = GroupTLV.timestamp(field.value)
+ FIELD_CONTENT -> content = GroupTLV.strictUtf8(field.value)
+ FIELD_SIGNATURE -> if (field.value.size == 64) signature = field.value
+ }
+ }
+ val decodedMessageID = messageID?.takeIf(::isCanonicalMessageID)
+ ?: throw GroupCryptoException.MalformedPayload()
+ val decodedSigningKey = senderSigningKey ?: throw GroupCryptoException.MalformedPayload()
+ val decodedNickname = senderNickname ?: throw GroupCryptoException.MalformedPayload()
+ val decodedTimestamp = timestampMs ?: throw GroupCryptoException.MalformedPayload()
+ val decodedContent = content ?: throw GroupCryptoException.MalformedPayload()
+ val decodedSignature = signature ?: throw GroupCryptoException.MalformedPayload()
+
+ val signingContent = messageSigningContent(
+ envelope.groupID,
+ envelope.epoch,
+ decodedMessageID,
+ decodedTimestamp,
+ decodedContent
+ )
+ if (!verify(decodedSignature, signingContent, decodedSigningKey)) {
+ throw GroupCryptoException.BadSenderSignature()
+ }
+ return GroupMessagePlaintext(
+ decodedMessageID,
+ decodedSigningKey,
+ decodedNickname,
+ decodedTimestamp,
+ decodedContent
+ )
+ }
+
+ private fun isCanonicalMessageID(messageID: String): Boolean {
+ val encoded = messageID.toByteArray(Charsets.UTF_8)
+ if (encoded.size != UUID_TEXT_LENGTH || !UUID_PATTERN.matches(messageID)) return false
+ return try {
+ UUID.fromString(messageID)
+ true
+ } catch (_: IllegalArgumentException) {
+ false
+ }
+ }
+
+ @Throws(InvalidCipherTextException::class)
+ private fun crypt(
+ encrypt: Boolean,
+ key: ByteArray,
+ nonce: ByteArray,
+ aad: ByteArray,
+ input: ByteArray
+ ): ByteArray {
+ val cipher = ChaCha20Poly1305()
+ cipher.init(encrypt, AEADParameters(KeyParameter(key), 128, nonce, aad))
+ val output = ByteArray(cipher.getOutputSize(input.size))
+ var length = cipher.processBytes(input, 0, input.size, output, 0)
+ length += cipher.doFinal(output, length)
+ return output.copyOf(length)
+ }
+
+ private const val UUID_TEXT_LENGTH = 36
+ private val UUID_PATTERN = Regex(
+ "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-" +
+ "[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
+ )
+}
+
+internal fun sha256(data: ByteArray): ByteArray =
+ MessageDigest.getInstance("SHA-256").digest(data)
+
+internal fun concat(vararg arrays: ByteArray): ByteArray {
+ val output = ByteArrayOutputStream(arrays.sumOf(ByteArray::size))
+ arrays.forEach(output::write)
+ return output.toByteArray()
+}
diff --git a/app/src/main/java/com/bitchat/android/groups/GroupRuntime.kt b/app/src/main/java/com/bitchat/android/groups/GroupRuntime.kt
new file mode 100644
index 00000000..d0622531
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/groups/GroupRuntime.kt
@@ -0,0 +1,96 @@
+package com.bitchat.android.groups
+
+import android.content.Context
+import androidx.core.app.NotificationManagerCompat
+import com.bitchat.android.mesh.GroupMessagePort
+import com.bitchat.android.mesh.GroupMessageReceiver
+import com.bitchat.android.model.BitchatMessage
+import com.bitchat.android.service.MeshServiceHolder
+import com.bitchat.android.services.AppStateStore
+import com.bitchat.android.services.ContactIdentityResolver
+import com.bitchat.android.ui.DataManager
+import java.lang.ref.WeakReference
+import java.util.Date
+import kotlinx.coroutines.*
+
+interface GroupUiDelegate {
+ val nickname: String
+ fun markGroupUnread(groupPeerID: String)
+ fun openGroupConversation(groupPeerID: String)
+ fun closeGroupConversation()
+}
+
+/** Keeps group membership, decryption, and durable delivery alive when the Activity closes. */
+class GroupRuntime private constructor(private val application: Context) : GroupMessageReceiver {
+ val store = GroupStore(application)
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ @Volatile private var ui = WeakReference(null)
+ private val mesh get() = MeshServiceHolder.unifiedMeshService
+ private val notifications = com.bitchat.android.ui.NotificationManager(application, NotificationManagerCompat.from(application))
+
+ val coordinator = GroupCoordinator(object : GroupCoordinatorContext {
+ override val groupStore get() = store
+ override val nickname get() = ui.get()?.nickname ?: AppStateStore.nickname.value
+ override val myPeerID get() = mesh?.myPeerID.orEmpty()
+ override val selectedConversationID get() = AppStateStore.selectedPrivateChatPeer.value
+ override fun myNoiseFingerprint() = mesh?.getIdentityFingerprint().orEmpty()
+ override fun mySigningPublicKey() = mesh?.getSigningPublicKey()
+ override fun sign(data: ByteArray) = mesh?.signData(data)
+ override fun peerIDsForNickname(nickname: String) = mesh?.getPeerNicknames().orEmpty()
+ .filterValues { it.equals(nickname, ignoreCase = true) }.keys.toList()
+ override fun isPeerConnected(peerID: String) = mesh?.getPeerInfo(peerID)?.isConnected == true && mesh?.hasEstablishedSession(peerID) == true
+ override fun peerGroupCapability(peerID: String): PeerGroupCapability {
+ val peer = mesh?.getPeerInfo(peerID) ?: return PeerGroupCapability.UNKNOWN
+ return PeerGroupCapability.fromPeerState(peer.capabilities, peer.hasVerifiedAnnouncement)
+ }
+ override fun peerNickname(peerID: String) = mesh?.getPeerNicknames()?.get(peerID)
+ override fun peerIdentity(peerID: String): GroupPeerIdentity? {
+ val info = mesh?.getPeerInfo(peerID) ?: return null
+ val signing = info.signingPublicKey?.takeIf { it.size == 32 } ?: return null
+ val key = info.noisePublicKey ?: return null
+ val fingerprint = ContactIdentityResolver.fingerprintHex(key)
+ if (!fingerprint.equals(mesh?.getPeerFingerprint(peerID), ignoreCase = true)) return null
+ return GroupPeerIdentity(fingerprint, signing.copyOf())
+ }
+ override fun connectedPeerID(fingerprint: String) = mesh?.getPeerNicknames()?.keys?.firstOrNull {
+ isPeerConnected(it) && fingerprint.equals(mesh?.getPeerFingerprint(it), ignoreCase = true)
+ }
+ override fun isFingerprintBlocked(fingerprint: String) = DataManager.isFingerprintBlocked(application, fingerprint)
+ override fun sendGroupInvite(payload: ByteArray, peerID: String) { mesh?.sendGroupInvite(payload, peerID) }
+ override fun sendGroupKeyUpdate(payload: ByteArray, peerID: String) { mesh?.sendGroupKeyUpdate(payload, peerID) }
+ override fun broadcastGroupMessage(payload: ByteArray) { mesh?.broadcastGroupMessage(payload) }
+ override fun appendGroupMessage(groupPeerID: String, message: BitchatMessage): Boolean = runBlocking {
+ AppStateStore.addPrivateMessageDurably(groupPeerID, message, selectedConversationID == groupPeerID || message.senderPeerID == myPeerID)
+ }
+ override fun markGroupUnread(groupPeerID: String) { ui.get()?.markGroupUnread(groupPeerID) }
+ override fun removeGroupConversation(groupPeerID: String) { AppStateStore.deletePrivateConversation(groupPeerID) }
+ override fun openGroupConversation(groupPeerID: String) { scope.launch(Dispatchers.Main) { ui.get()?.openGroupConversation(groupPeerID) } }
+ override fun closeGroupConversation() { scope.launch(Dispatchers.Main) { ui.get()?.closeGroupConversation() } }
+ override fun addSystemMessage(message: String) { AppStateStore.addPublicMessage(BitchatMessage(sender = "system", content = message, timestamp = Date())) }
+ override fun addGroupSystemMessage(groupPeerID: String, message: String) {
+ appendGroupMessage(groupPeerID, BitchatMessage(sender = "system", content = message, timestamp = Date(), isPrivate = true))
+ }
+ override fun notifyGroupMessage(groupPeerID: String, sender: String, message: String) {
+ notifications.showPrivateMessageNotification(groupPeerID, sender, message)
+ }
+ })
+
+ init {
+ GroupMessagePort.receiver = this
+ scope.launch { if (store.initialize()) coordinator.onStoreReady() }
+ }
+
+ fun attach(context: GroupUiDelegate) { ui = WeakReference(context) }
+ fun detach(context: GroupUiDelegate) { if (ui.get() === context) ui.clear() }
+ override fun invite(peerID: String, authenticatedKey: ByteArray, payload: ByteArray) = coordinator.handleInvite(peerID, authenticatedKey, payload)
+ override fun keyUpdate(peerID: String, authenticatedKey: ByteArray, payload: ByteArray) = coordinator.handleKeyUpdate(peerID, authenticatedKey, payload)
+ override fun message(payload: ByteArray, timestampMs: Long) = coordinator.handleMessage(payload, timestampMs)
+ override fun peerAuthenticated(peerID: String) { scope.launch { coordinator.handlePeerAuthenticated(peerID) } }
+
+ companion object {
+ @Volatile private var instance: GroupRuntime? = null
+ fun getInstance(context: Context): GroupRuntime = instance ?: synchronized(this) {
+ instance ?: GroupRuntime(context.applicationContext).also { instance = it }
+ }
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/groups/GroupStore.kt b/app/src/main/java/com/bitchat/android/groups/GroupStore.kt
new file mode 100644
index 00000000..0f63ee9a
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/groups/GroupStore.kt
@@ -0,0 +1,552 @@
+package com.bitchat.android.groups
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.SharedPreferences
+import android.util.Base64
+import android.util.Log
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+import com.bitchat.android.util.hexEncodedString
+import com.google.gson.Gson
+import com.google.gson.JsonParser
+import com.google.gson.reflect.TypeToken
+import java.io.File
+import java.io.FileOutputStream
+import java.nio.file.AtomicMoveNotSupportedException
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+import java.security.SecureRandom
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+internal interface GroupKeyStorage {
+ fun get(key: String): ByteArray?
+ fun put(key: String, value: ByteArray): Boolean
+ fun remove(key: String): Boolean
+ fun clear(): Boolean
+}
+
+internal interface GroupMetadataStorage {
+ fun read(): String?
+ fun write(contents: String): Boolean
+ fun delete(): Boolean
+}
+
+@SuppressLint("ApplySharedPref", "UseKtx")
+private class EncryptedPreferencesGroupKeyStorage(context: Context) : GroupKeyStorage {
+ private val preferences: SharedPreferences
+
+ init {
+ val masterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build()
+ preferences = EncryptedSharedPreferences.create(
+ context,
+ "bitchat_private_groups",
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ )
+ }
+
+ override fun get(key: String): ByteArray? = try {
+ preferences.getString(key, null)?.let {
+ Base64.decode(it, Base64.NO_WRAP)
+ }
+ } catch (_: Exception) {
+ null
+ }
+
+ override fun put(key: String, value: ByteArray): Boolean = try {
+ // The metadata must not advance unless the epoch key is durably stored.
+ preferences.edit()
+ .putString(key, Base64.encodeToString(value, Base64.NO_WRAP))
+ .commit()
+ } catch (_: Exception) {
+ false
+ }
+
+ override fun remove(key: String): Boolean = try {
+ // Removal is a security boundary, so report the synchronous result.
+ preferences.edit().remove(key).commit()
+ } catch (_: Exception) {
+ false
+ }
+
+ override fun clear(): Boolean = try {
+ preferences.edit().clear().commit() && preferences.all.isEmpty()
+ } catch (_: Exception) {
+ false
+ }
+}
+
+private class FileGroupMetadataStorage(private val file: File) : GroupMetadataStorage {
+ override fun read(): String? = try {
+ file.takeIf(File::exists)?.readText(Charsets.UTF_8)
+ } catch (_: Exception) {
+ null
+ }
+
+ override fun write(contents: String): Boolean {
+ var temporary: File? = null
+ return try {
+ val parent = file.parentFile
+ if (parent != null && !parent.exists() && !parent.mkdirs()) return false
+ temporary = File(parent, "${file.name}.tmp")
+ FileOutputStream(temporary).use { output ->
+ output.write(contents.toByteArray(Charsets.UTF_8))
+ output.fd.sync()
+ }
+ try {
+ Files.move(
+ temporary.toPath(),
+ file.toPath(),
+ StandardCopyOption.ATOMIC_MOVE,
+ StandardCopyOption.REPLACE_EXISTING
+ )
+ } catch (_: AtomicMoveNotSupportedException) {
+ Files.move(
+ temporary.toPath(),
+ file.toPath(),
+ StandardCopyOption.REPLACE_EXISTING
+ )
+ }
+ true
+ } catch (_: Exception) {
+ temporary?.delete()
+ false
+ }
+ }
+
+ override fun delete(): Boolean = try {
+ val deleted = !file.exists() || file.delete()
+ if (deleted) {
+ file.parentFile
+ ?.takeIf { it.listFiles().isNullOrEmpty() }
+ ?.delete()
+ }
+ deleted
+ } catch (_: Exception) {
+ false
+ }
+}
+
+/**
+ * Persistent private-group metadata and epoch keys.
+ *
+ * Metadata is kept in app-private no-backup storage. Symmetric group keys are
+ * stored separately in EncryptedSharedPreferences backed by Android Keystore.
+ *
+ * The Android constructor is intentionally inert. [initialize] performs
+ * Keystore and disk access and must be called from a background dispatcher.
+ */
+class GroupStore private constructor(
+ private val keyStorageFactory: () -> GroupKeyStorage,
+ private val metadataStorageFactory: () -> GroupMetadataStorage?,
+ autoInitialize: Boolean
+) {
+ private data class StoredMember(
+ val fingerprint: String,
+ val signingKey: String,
+ val nickname: String
+ )
+
+ private data class StoredGroup(
+ val groupID: String,
+ val name: String,
+ val epoch: Long,
+ val members: List,
+ val creatorFingerprint: String
+ )
+
+ private data class StoredState(
+ val version: Int = 1,
+ val groups: List,
+ val departures: Map
+ )
+
+ private val lock = Any()
+ private val gson = Gson()
+ private val random by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { SecureRandom() }
+ private val _groups = MutableStateFlow>(emptyList())
+ private val departures = mutableMapOf()
+ private var keyStorage: GroupKeyStorage? = null
+ private var metadataStorage: GroupMetadataStorage? = null
+
+ @Volatile
+ private var initialized = false
+
+ val groups: StateFlow> = _groups.asStateFlow()
+ val isReady: Boolean
+ get() = initialized
+
+ constructor(context: Context) : this(
+ keyStorageFactory = {
+ EncryptedPreferencesGroupKeyStorage(context.applicationContext)
+ },
+ metadataStorageFactory = {
+ FileGroupMetadataStorage(
+ File(context.applicationContext.noBackupFilesDir, "groups/groups.json")
+ )
+ },
+ autoInitialize = false
+ )
+
+ internal constructor(
+ keyStorage: GroupKeyStorage,
+ metadataFile: File? = null,
+ testOnly: Boolean,
+ autoInitialize: Boolean = true
+ ) : this(
+ keyStorageFactory = { keyStorage },
+ metadataStorageFactory = {
+ metadataFile?.let(::FileGroupMetadataStorage)
+ },
+ autoInitialize = autoInitialize
+ ) {
+ require(testOnly)
+ }
+
+ internal constructor(
+ keyStorage: GroupKeyStorage,
+ metadataStorage: GroupMetadataStorage,
+ testOnly: Boolean,
+ autoInitialize: Boolean = true
+ ) : this(
+ keyStorageFactory = { keyStorage },
+ metadataStorageFactory = { metadataStorage },
+ autoInitialize = autoInitialize
+ ) {
+ require(testOnly)
+ }
+
+ init {
+ if (autoInitialize) initialize()
+ }
+
+ /**
+ * Initializes encrypted key storage and loads metadata. Callers using the
+ * Android constructor must invoke this away from the main thread.
+ */
+ fun initialize(): Boolean = synchronized(lock) {
+ if (initialized) return@synchronized true
+ val keys = try {
+ keyStorageFactory()
+ } catch (error: Exception) {
+ Log.e(TAG, "Failed to initialize private-group key storage", error)
+ return@synchronized false
+ }
+ val metadata = try {
+ metadataStorageFactory()
+ } catch (error: Exception) {
+ Log.e(TAG, "Failed to initialize private-group metadata storage", error)
+ return@synchronized false
+ }
+ keyStorage = keys
+ metadataStorage = metadata
+ loadLocked(keys, metadata)
+ initialized = true
+ true
+ }
+
+ fun group(groupID: ByteArray): BitchatGroup? = synchronized(lock) {
+ _groups.value.firstOrNull { it.groupID.contentEquals(groupID) }?.deepCopy()
+ }
+
+ fun group(peerID: String): BitchatGroup? =
+ GroupIds.groupID(peerID)?.let(::group)
+
+ fun key(groupID: ByteArray): ByteArray? = synchronized(lock) {
+ keyStorage
+ ?.get(keyName(groupID))
+ ?.takeIf { it.size == BitchatGroup.KEY_LENGTH }
+ ?.copyOf()
+ }
+
+ fun departureEpoch(groupID: ByteArray): Long? = synchronized(lock) {
+ departures[groupID.hexEncodedString()]
+ }
+
+ fun createGroup(name: String, creator: GroupMember): BitchatGroup? {
+ if (!isReady) return null
+ val groupID = ByteArray(BitchatGroup.GROUP_ID_LENGTH).also(random::nextBytes)
+ val key = ByteArray(BitchatGroup.KEY_LENGTH).also(random::nextBytes)
+ val group = BitchatGroup(
+ groupID = groupID,
+ name = name,
+ epoch = 1,
+ members = listOf(creator),
+ creatorFingerprint = creator.fingerprint
+ )
+ return group.takeIf { upsert(it, key) }
+ }
+
+ fun upsert(group: BitchatGroup, key: ByteArray): Boolean = synchronized(lock) {
+ upsertLocked(group, key, clearDeparture = false)
+ }
+
+ fun acceptInvite(group: BitchatGroup, key: ByteArray): Boolean = synchronized(lock) {
+ upsertLocked(group, key, clearDeparture = true)
+ }
+
+ fun rotateKey(
+ groupID: ByteArray,
+ members: List
+ ): Pair? = synchronized(lock) {
+ if (!initialized) return@synchronized null
+ val existing = _groups.value.firstOrNull { it.groupID.contentEquals(groupID) }
+ ?: return@synchronized null
+ val newKey = ByteArray(BitchatGroup.KEY_LENGTH).also(random::nextBytes)
+ val rotated = existing.copy(
+ epoch = (existing.epoch + 1) and BitchatGroup.MAX_EPOCH,
+ members = members.map { it.deepCopy() }
+ )
+ if (!upsertLocked(rotated, newKey, clearDeparture = false)) {
+ return@synchronized null
+ }
+ rotated.deepCopy() to newKey.copyOf()
+ }
+
+ fun removeGroup(groupID: ByteArray): Boolean = synchronized(lock) {
+ removeLocked(groupID, departureEpoch = null)
+ }
+
+ fun removeGroupForState(groupID: ByteArray, stateEpoch: Long): BitchatGroup? =
+ synchronized(lock) {
+ if (stateEpoch !in 0..BitchatGroup.MAX_EPOCH) return@synchronized null
+ val existing = _groups.value.firstOrNull { it.groupID.contentEquals(groupID) }
+ ?: return@synchronized null
+ if (stateEpoch < existing.epoch) return@synchronized null
+ if (!removeLocked(groupID, departureEpoch = null)) return@synchronized null
+ existing.deepCopy()
+ }
+
+ fun departGroup(groupID: ByteArray, epoch: Long): Boolean = synchronized(lock) {
+ if (epoch !in 0..BitchatGroup.MAX_EPOCH) return@synchronized false
+ removeLocked(groupID, departureEpoch = epoch)
+ }
+
+ fun wipe(): Boolean = synchronized(lock) {
+ if (!initialized && !initialize()) return@synchronized false
+ val keys = keyStorage ?: return@synchronized false
+ val keysCleared = keys.clear()
+ val metadataDeleted = metadataStorage?.delete() ?: true
+ _groups.value = emptyList()
+ departures.clear()
+ keysCleared && metadataDeleted
+ }
+
+ private fun upsertLocked(
+ group: BitchatGroup,
+ key: ByteArray,
+ clearDeparture: Boolean
+ ): Boolean {
+ val keys = keyStorage ?: return false
+ if (!initialized || !isValid(group, key)) return false
+
+ val updatedGroups = _groups.value.toMutableList()
+ val index = updatedGroups.indexOfFirst { it.groupID.contentEquals(group.groupID) }
+ if (index >= 0 && group.epoch < updatedGroups[index].epoch) return false
+
+ if (index >= 0) {
+ updatedGroups[index] = group.deepCopy()
+ } else {
+ updatedGroups += group.deepCopy()
+ }
+ val updatedDepartures = departures.toMutableMap()
+ val groupID = group.groupID.hexEncodedString()
+ val departureEpoch = updatedDepartures[groupID]
+ if (clearDeparture) {
+ if (departureEpoch != null && group.epoch <= departureEpoch) return false
+ updatedDepartures.remove(groupID)
+ } else if (departureEpoch != null) {
+ return false
+ }
+
+ val name = keyName(group.groupID)
+ val previousKey = keys.get(name)?.copyOf()
+ if (!keys.put(name, key.copyOf())) {
+ Log.e(TAG, "Failed to store private-group epoch key")
+ return false
+ }
+ if (!persistLocked(updatedGroups, updatedDepartures)) {
+ restoreKey(keys, name, previousKey)
+ return false
+ }
+
+ departures.clear()
+ departures.putAll(updatedDepartures)
+ _groups.value = updatedGroups.map { it.deepCopy() }
+ return true
+ }
+
+ private fun removeLocked(groupID: ByteArray, departureEpoch: Long?): Boolean {
+ val keys = keyStorage ?: return false
+ if (!initialized) return false
+
+ val updatedGroups = _groups.value.filterNot { it.groupID.contentEquals(groupID) }
+ val updatedDepartures = departures.toMutableMap()
+ if (departureEpoch != null) {
+ val id = groupID.hexEncodedString()
+ updatedDepartures[id] = maxOf(updatedDepartures[id] ?: -1, departureEpoch)
+ }
+
+ val name = keyName(groupID)
+ val previousKey = keys.get(name)?.copyOf()
+ if (previousKey != null && !keys.remove(name)) {
+ Log.e(TAG, "Failed to remove private-group epoch key")
+ return false
+ }
+ if (!persistLocked(updatedGroups, updatedDepartures)) {
+ restoreKey(keys, name, previousKey)
+ return false
+ }
+
+ departures.clear()
+ departures.putAll(updatedDepartures)
+ _groups.value = updatedGroups.map { it.deepCopy() }
+ return true
+ }
+
+ private fun restoreKey(
+ storage: GroupKeyStorage,
+ name: String,
+ previousKey: ByteArray?
+ ) {
+ val restored = if (previousKey == null) {
+ storage.remove(name)
+ } else {
+ storage.put(name, previousKey)
+ }
+ if (!restored && previousKey != null) {
+ Log.e(TAG, "Failed to restore private-group epoch key after metadata failure")
+ }
+ }
+
+ private fun isValid(group: BitchatGroup, key: ByteArray): Boolean =
+ group.groupID.size == BitchatGroup.GROUP_ID_LENGTH &&
+ key.size == BitchatGroup.KEY_LENGTH &&
+ group.epoch in 0..BitchatGroup.MAX_EPOCH &&
+ group.members.isNotEmpty() &&
+ group.members.size <= BitchatGroup.MAX_MEMBERS &&
+ group.creator != null &&
+ group.members.all {
+ it.fingerprint.matches(Regex("^[0-9a-fA-F]{64}$")) &&
+ it.signingKey.size == 32
+ }
+
+ private fun persistLocked(
+ groups: List,
+ departures: Map
+ ): Boolean {
+ val storage = metadataStorage ?: return true
+ if (groups.isEmpty() && departures.isEmpty()) return storage.delete()
+
+ val state = StoredState(
+ groups = groups.map { group ->
+ StoredGroup(
+ groupID = Base64.encodeToString(group.groupID, Base64.NO_WRAP),
+ name = group.name,
+ epoch = group.epoch,
+ members = group.members.map { member ->
+ StoredMember(
+ fingerprint = member.fingerprint,
+ signingKey = Base64.encodeToString(
+ member.signingKey,
+ Base64.NO_WRAP
+ ),
+ nickname = member.nickname
+ )
+ },
+ creatorFingerprint = group.creatorFingerprint
+ )
+ },
+ departures = departures.toSortedMap()
+ )
+ val persisted = storage.write(gson.toJson(state))
+ if (!persisted) Log.e(TAG, "Failed to persist private-group metadata")
+ return persisted
+ }
+
+ private fun loadLocked(
+ keys: GroupKeyStorage,
+ metadata: GroupMetadataStorage?
+ ) {
+ val raw = metadata?.read() ?: return
+ val parsed = try {
+ val json = JsonParser.parseString(raw)
+ if (json.isJsonArray) {
+ val type = object : TypeToken>() {}.type
+ StoredState(groups = gson.fromJson(json, type), departures = emptyMap())
+ } else {
+ val objectValue = json.asJsonObject
+ val groupType = object : TypeToken>() {}.type
+ val departureType = object : TypeToken