mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-11-01 07:51:15 +00:00
feat: add private groups
This commit is contained in:
parent
61588db474
commit
188993c2ca
336
app/src/main/java/com/bitchat/android/groups/GroupCoordinator.kt
Normal file
336
app/src/main/java/com/bitchat/android/groups/GroupCoordinator.kt
Normal file
@ -0,0 +1,336 @@
|
||||
package com.bitchat.android.groups
|
||||
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
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
|
||||
)
|
||||
|
||||
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 peerIDForNickname(nickname: String): String?
|
||||
fun isPeerConnected(peerID: String): Boolean
|
||||
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) {
|
||||
fun createGroup(rawName: String): GroupCommandResult {
|
||||
val name = rawName.trim()
|
||||
if (name.isEmpty()) return error("usage: /group create <name>")
|
||||
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}")
|
||||
}
|
||||
|
||||
fun inviteMember(rawNickname: String): GroupCommandResult {
|
||||
val nickname = normalizeNickname(rawNickname)
|
||||
if (nickname.isEmpty()) return error("usage: /group invite <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 = context.peerIDForNickname(nickname)
|
||||
?: return error("user '$nickname' was not found")
|
||||
if (!context.isPeerConnected(peerID)) return error("$nickname is not connected")
|
||||
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}")
|
||||
}
|
||||
|
||||
fun removeMember(rawNickname: String): GroupCommandResult {
|
||||
val nickname = normalizeNickname(rawNickname)
|
||||
if (nickname.isEmpty()) return error("usage: /group remove <nickname>")
|
||||
val group = selectedGroup() ?: return error("open a private group first")
|
||||
if (!isCreator(group)) return error("only the group creator can change members")
|
||||
val member = group.members.firstOrNull {
|
||||
it.nickname.equals(nickname, ignoreCase = true)
|
||||
} ?: return error("$nickname is not in this group")
|
||||
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")
|
||||
}
|
||||
|
||||
fun leaveGroup(): GroupCommandResult {
|
||||
val group = selectedGroup() ?: return error("open a private group first")
|
||||
context.closeGroupConversation()
|
||||
context.removeGroupConversation(group.peerID)
|
||||
context.groupStore.removeGroup(group.groupID)
|
||||
return success("left #${group.name}")
|
||||
}
|
||||
|
||||
fun listGroups(): GroupCommandResult {
|
||||
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")
|
||||
}
|
||||
|
||||
fun sendMessage(content: String, groupPeerID: String) {
|
||||
if (content.isEmpty() ||
|
||||
content.codePointCount(0, content.length) > MAX_MESSAGE_LENGTH
|
||||
) {
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
val signingKey = context.mySigningPublicKey()
|
||||
if (signingKey?.size != 32) {
|
||||
context.addGroupSystemMessage(groupPeerID, "your signing identity is unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
)
|
||||
context.broadcastGroupMessage(payload)
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun handleMessage(payload: ByteArray, receivedAtMs: Long) {
|
||||
val envelope = GroupMessageEnvelope.decode(payload) ?: return
|
||||
val group = context.groupStore.group(envelope.groupID) ?: return
|
||||
if (envelope.epoch != group.epoch) 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleInvite(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray
|
||||
) {
|
||||
applyState(peerID, authenticatedRemoteStaticKey, payload)
|
||||
}
|
||||
|
||||
fun handleKeyUpdate(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray
|
||||
) {
|
||||
applyState(peerID, authenticatedRemoteStaticKey, payload)
|
||||
}
|
||||
|
||||
private fun applyState(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray
|
||||
) {
|
||||
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)
|
||||
if (state.members.none { it.fingerprint == ownFingerprint }) {
|
||||
if (existing != null) {
|
||||
if (context.selectedConversationID == existing.peerID) {
|
||||
context.closeGroupConversation()
|
||||
}
|
||||
context.removeGroupConversation(existing.peerID)
|
||||
context.groupStore.removeGroup(existing.groupID)
|
||||
context.addSystemMessage("you were removed from #${existing.name}")
|
||||
}
|
||||
return
|
||||
}
|
||||
if (existing != null && state.epoch < existing.epoch) return
|
||||
if (!context.groupStore.upsert(state.asGroup(), state.key)) return
|
||||
|
||||
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<String>
|
||||
) {
|
||||
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 normalizeNickname(raw: String): String =
|
||||
raw.trim().removePrefix("@")
|
||||
|
||||
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 val FINGERPRINT = Regex("^[0-9a-fA-F]{64}$")
|
||||
}
|
||||
}
|
||||
642
app/src/main/java/com/bitchat/android/groups/GroupProtocol.kt
Normal file
642
app/src/main/java/com/bitchat/android/groups/GroupProtocol.kt
Normal file
@ -0,0 +1,642 @@
|
||||
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 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<GroupMember>,
|
||||
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<Int, ByteArray>): ByteArray {
|
||||
val output = ByteArrayOutputStream()
|
||||
fields.forEach { (type, value) -> put(type, value, output) }
|
||||
return output.toByteArray()
|
||||
}
|
||||
|
||||
fun parse(data: ByteArray): List<Field>? {
|
||||
val fields = mutableListOf<Field>()
|
||||
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<GroupMember>): 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<GroupMember>? {
|
||||
if (data.isEmpty()) return null
|
||||
val count = data[0].toInt() and 0xff
|
||||
if (count > BitchatGroup.MAX_MEMBERS) return null
|
||||
val members = mutableListOf<GroupMember>()
|
||||
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<GroupMember>,
|
||||
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<GroupMember>? = 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 {
|
||||
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 { it.isNotEmpty() }
|
||||
?: 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
|
||||
)
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
283
app/src/main/java/com/bitchat/android/groups/GroupStore.kt
Normal file
283
app/src/main/java/com/bitchat/android/groups/GroupStore.kt
Normal file
@ -0,0 +1,283 @@
|
||||
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.reflect.TypeToken
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
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
|
||||
}
|
||||
|
||||
@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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
class GroupStore private constructor(
|
||||
private val keyStorage: GroupKeyStorage,
|
||||
private val metadataFile: File?
|
||||
) {
|
||||
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<StoredMember>,
|
||||
val creatorFingerprint: String
|
||||
)
|
||||
|
||||
private val lock = Any()
|
||||
private val gson = Gson()
|
||||
private val random = SecureRandom()
|
||||
private val _groups = MutableStateFlow<List<BitchatGroup>>(emptyList())
|
||||
val groups: StateFlow<List<BitchatGroup>> = _groups.asStateFlow()
|
||||
|
||||
constructor(context: Context) : this(
|
||||
EncryptedPreferencesGroupKeyStorage(context.applicationContext),
|
||||
File(context.noBackupFilesDir, "groups/groups.json")
|
||||
)
|
||||
|
||||
internal constructor(
|
||||
keyStorage: GroupKeyStorage,
|
||||
metadataFile: File? = null,
|
||||
testOnly: Boolean
|
||||
) : this(keyStorage, metadataFile) {
|
||||
require(testOnly)
|
||||
}
|
||||
|
||||
init {
|
||||
loadFromDisk()
|
||||
}
|
||||
|
||||
fun group(groupID: ByteArray): BitchatGroup? = synchronized(lock) {
|
||||
_groups.value.firstOrNull { it.groupID.contentEquals(groupID) }
|
||||
}
|
||||
|
||||
fun group(peerID: String): BitchatGroup? =
|
||||
GroupIds.groupID(peerID)?.let(::group)
|
||||
|
||||
fun key(groupID: ByteArray): ByteArray? =
|
||||
keyStorage.get(keyName(groupID))?.takeIf { it.size == BitchatGroup.KEY_LENGTH }
|
||||
|
||||
fun createGroup(name: String, creator: GroupMember): BitchatGroup? {
|
||||
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) {
|
||||
if (!isValid(group, key)) return@synchronized false
|
||||
if (!keyStorage.put(keyName(group.groupID), key.copyOf())) {
|
||||
Log.e(TAG, "Failed to store private-group epoch key")
|
||||
return@synchronized false
|
||||
}
|
||||
val updated = _groups.value.toMutableList()
|
||||
val index = updated.indexOfFirst { it.groupID.contentEquals(group.groupID) }
|
||||
if (index >= 0) {
|
||||
updated[index] = group.deepCopy()
|
||||
} else {
|
||||
updated += group.deepCopy()
|
||||
}
|
||||
_groups.value = updated
|
||||
persistLocked()
|
||||
true
|
||||
}
|
||||
|
||||
fun rotateKey(
|
||||
groupID: ByteArray,
|
||||
members: List<GroupMember>
|
||||
): Pair<BitchatGroup, ByteArray>? = synchronized(lock) {
|
||||
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 (!upsert(rotated, newKey)) return@synchronized null
|
||||
rotated to newKey
|
||||
}
|
||||
|
||||
fun removeGroup(groupID: ByteArray) = synchronized(lock) {
|
||||
_groups.value = _groups.value.filterNot { it.groupID.contentEquals(groupID) }
|
||||
keyStorage.remove(keyName(groupID))
|
||||
persistLocked()
|
||||
}
|
||||
|
||||
fun wipe() = synchronized(lock) {
|
||||
_groups.value.forEach { keyStorage.remove(keyName(it.groupID)) }
|
||||
_groups.value = emptyList()
|
||||
try {
|
||||
metadataFile?.delete()
|
||||
metadataFile?.parentFile?.takeIf { it.listFiles().isNullOrEmpty() }?.delete()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
val file = metadataFile ?: return
|
||||
try {
|
||||
if (_groups.value.isEmpty()) {
|
||||
file.delete()
|
||||
return
|
||||
}
|
||||
file.parentFile?.mkdirs()
|
||||
val stored = _groups.value.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
|
||||
)
|
||||
}
|
||||
val temporary = File(file.parentFile, "${file.name}.tmp")
|
||||
FileOutputStream(temporary).use { output ->
|
||||
output.write(gson.toJson(stored).toByteArray(Charsets.UTF_8))
|
||||
output.fd.sync()
|
||||
}
|
||||
if (!temporary.renameTo(file)) {
|
||||
temporary.copyTo(file, overwrite = true)
|
||||
temporary.delete()
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
Log.e(TAG, "Failed to persist private-group metadata: ${error.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadFromDisk() = synchronized(lock) {
|
||||
val file = metadataFile ?: return@synchronized
|
||||
val stored = try {
|
||||
if (!file.exists()) return@synchronized
|
||||
val type = object : TypeToken<List<StoredGroup>>() {}.type
|
||||
gson.fromJson<List<StoredGroup>>(file.readText(Charsets.UTF_8), type)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: return@synchronized
|
||||
|
||||
_groups.value = stored.mapNotNull { item ->
|
||||
try {
|
||||
val group = BitchatGroup(
|
||||
groupID = Base64.decode(item.groupID, Base64.NO_WRAP),
|
||||
name = item.name,
|
||||
epoch = item.epoch,
|
||||
members = item.members.map { member ->
|
||||
GroupMember(
|
||||
member.fingerprint,
|
||||
Base64.decode(member.signingKey, Base64.NO_WRAP),
|
||||
member.nickname
|
||||
)
|
||||
},
|
||||
creatorFingerprint = item.creatorFingerprint
|
||||
)
|
||||
val storedKey = key(group.groupID) ?: return@mapNotNull null
|
||||
group.takeIf { isValid(it, storedKey) }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun keyName(groupID: ByteArray): String =
|
||||
"groupKey-${groupID.hexEncodedString()}"
|
||||
|
||||
private fun BitchatGroup.deepCopy(): BitchatGroup = copy(
|
||||
groupID = groupID.copyOf(),
|
||||
members = members.map { it.deepCopy() }
|
||||
)
|
||||
|
||||
private fun GroupMember.deepCopy(): GroupMember =
|
||||
copy(signingKey = signingKey.copyOf())
|
||||
|
||||
companion object {
|
||||
private const val TAG = "GroupStore"
|
||||
}
|
||||
}
|
||||
@ -546,6 +546,26 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs)
|
||||
}
|
||||
|
||||
override fun onGroupInviteReceived(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray
|
||||
) {
|
||||
delegate?.didReceiveGroupInvite(peerID, authenticatedRemoteStaticKey, payload)
|
||||
}
|
||||
|
||||
override fun onGroupKeyUpdateReceived(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray
|
||||
) {
|
||||
delegate?.didReceiveGroupKeyUpdate(peerID, authenticatedRemoteStaticKey, payload)
|
||||
}
|
||||
|
||||
override fun onGroupMessageReceived(payload: ByteArray, timestampMs: Long) {
|
||||
delegate?.didReceiveGroupMessage(payload, timestampMs)
|
||||
}
|
||||
}
|
||||
|
||||
// PacketProcessor delegates
|
||||
@ -634,6 +654,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
override fun handleGroupMessage(routed: RoutedPacket) {
|
||||
messageHandler.handleGroupMessage(routed)
|
||||
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
override fun handleLeave(routed: RoutedPacket) {
|
||||
serviceScope.launch { messageHandler.handleLeave(routed) }
|
||||
@ -1192,6 +1217,42 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
sendNoisePayloadToPeer(payload, peerID, "verify response")
|
||||
}
|
||||
|
||||
fun sendGroupInvite(payload: ByteArray, recipientPeerID: String) {
|
||||
sendNoisePayloadToPeer(
|
||||
NoisePayload(NoisePayloadType.GROUP_INVITE, payload),
|
||||
recipientPeerID,
|
||||
"group invite"
|
||||
)
|
||||
}
|
||||
|
||||
fun sendGroupKeyUpdate(payload: ByteArray, recipientPeerID: String) {
|
||||
sendNoisePayloadToPeer(
|
||||
NoisePayload(NoisePayloadType.GROUP_KEY_UPDATE, payload),
|
||||
recipientPeerID,
|
||||
"group key update"
|
||||
)
|
||||
}
|
||||
|
||||
fun broadcastGroupMessage(payload: ByteArray) {
|
||||
if (payload.isEmpty()) return
|
||||
serviceScope.launch {
|
||||
val packet = BitchatPacket(
|
||||
version = if (payload.size > 0xffff) 2u else 1u,
|
||||
type = MessageType.GROUP_MESSAGE.value,
|
||||
senderID = hexStringToByteArray(myPeerID),
|
||||
recipientID = SpecialRecipients.BROADCAST,
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload,
|
||||
signature = null,
|
||||
ttl = MAX_TTL
|
||||
)
|
||||
// The outer packet is intentionally unsigned. Authenticity is
|
||||
// verified from the Ed25519 signature inside the ciphertext.
|
||||
broadcastRoutedPacket(RoutedPacket(packet))
|
||||
try { gossipSyncManager.onPublicPacketSeen(packet) } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendNoisePayloadToPeer(payload: NoisePayload, recipientPeerID: String, label: String) {
|
||||
serviceScope.launch {
|
||||
try {
|
||||
@ -1455,6 +1516,12 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
fun getStaticNoisePublicKey(): ByteArray? {
|
||||
return encryptionService.getStaticPublicKey()
|
||||
}
|
||||
|
||||
fun getSigningPublicKey(): ByteArray? =
|
||||
encryptionService.getSigningPublicKey()?.copyOf()
|
||||
|
||||
fun signData(data: ByteArray): ByteArray? =
|
||||
encryptionService.signData(data)
|
||||
|
||||
/**
|
||||
* Check if encryption icon should be shown for a peer
|
||||
|
||||
@ -418,6 +418,26 @@ class MeshCore(
|
||||
override fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long) {
|
||||
delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs)
|
||||
}
|
||||
|
||||
override fun onGroupInviteReceived(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray
|
||||
) {
|
||||
delegate?.didReceiveGroupInvite(peerID, authenticatedRemoteStaticKey, payload)
|
||||
}
|
||||
|
||||
override fun onGroupKeyUpdateReceived(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray
|
||||
) {
|
||||
delegate?.didReceiveGroupKeyUpdate(peerID, authenticatedRemoteStaticKey, payload)
|
||||
}
|
||||
|
||||
override fun onGroupMessageReceived(payload: ByteArray, timestampMs: Long) {
|
||||
delegate?.didReceiveGroupMessage(payload, timestampMs)
|
||||
}
|
||||
}
|
||||
|
||||
packetProcessor.delegate = object : PacketProcessorDelegate {
|
||||
@ -468,6 +488,11 @@ class MeshCore(
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
override fun handleGroupMessage(routed: RoutedPacket) {
|
||||
messageHandler.handleGroupMessage(routed)
|
||||
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
|
||||
}
|
||||
|
||||
override fun handleLeave(routed: RoutedPacket) {
|
||||
scope.launch { messageHandler.handleLeave(routed) }
|
||||
}
|
||||
@ -722,6 +747,38 @@ class MeshCore(
|
||||
sendNoisePayloadToPeer(payload, peerID)
|
||||
}
|
||||
|
||||
fun sendGroupInvite(payload: ByteArray, recipientPeerID: String) {
|
||||
sendNoisePayloadToPeer(
|
||||
NoisePayload(NoisePayloadType.GROUP_INVITE, payload),
|
||||
recipientPeerID
|
||||
)
|
||||
}
|
||||
|
||||
fun sendGroupKeyUpdate(payload: ByteArray, recipientPeerID: String) {
|
||||
sendNoisePayloadToPeer(
|
||||
NoisePayload(NoisePayloadType.GROUP_KEY_UPDATE, payload),
|
||||
recipientPeerID
|
||||
)
|
||||
}
|
||||
|
||||
fun broadcastGroupMessage(payload: ByteArray) {
|
||||
if (payload.isEmpty()) return
|
||||
scope.launch {
|
||||
val packet = BitchatPacket(
|
||||
version = if (payload.size > 0xffff) 2u else 1u,
|
||||
type = MessageType.GROUP_MESSAGE.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
recipientID = SpecialRecipients.BROADCAST,
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload,
|
||||
signature = null,
|
||||
ttl = maxTtl
|
||||
)
|
||||
dispatchGlobal(RoutedPacket(packet))
|
||||
try { gossipSyncManager.onPublicPacketSeen(packet) } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendNoisePayloadToPeer(payload: NoisePayload, recipientPeerID: String) {
|
||||
scope.launch {
|
||||
try {
|
||||
@ -952,6 +1009,12 @@ class MeshCore(
|
||||
|
||||
fun getStaticNoisePublicKey(): ByteArray? = encryptionService.getStaticPublicKey()
|
||||
|
||||
fun getSigningPublicKey(): ByteArray? =
|
||||
encryptionService.getSigningPublicKey()?.copyOf()
|
||||
|
||||
fun signData(data: ByteArray): ByteArray? =
|
||||
encryptionService.signData(data)
|
||||
|
||||
fun shouldShowEncryptionIcon(peerID: String): Boolean = encryptionService.hasEstablishedSession(peerID)
|
||||
|
||||
fun getEncryptedPeers(): List<String> = emptyList()
|
||||
|
||||
@ -13,6 +13,17 @@ interface MeshDelegate {
|
||||
fun didReceiveReadReceipt(messageID: String, recipientPeerID: String)
|
||||
fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {}
|
||||
fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {}
|
||||
fun didReceiveGroupInvite(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray
|
||||
) {}
|
||||
fun didReceiveGroupKeyUpdate(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray
|
||||
) {}
|
||||
fun didReceiveGroupMessage(payload: ByteArray, timestampMs: Long) {}
|
||||
/** Current Noise generation either proved peer state or exhausted its 5-second watchdog. */
|
||||
fun didResolvePrivateMediaPolicy(peerID: String) {}
|
||||
fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?
|
||||
|
||||
@ -19,6 +19,9 @@ interface MeshService {
|
||||
fun sendFavoriteNotification(peerID: String, isFavorite: Boolean) {}
|
||||
fun sendVerifyChallenge(peerID: String, noiseKeyHex: String, nonceA: ByteArray)
|
||||
fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray)
|
||||
fun sendGroupInvite(payload: ByteArray, recipientPeerID: String)
|
||||
fun sendGroupKeyUpdate(payload: ByteArray, recipientPeerID: String)
|
||||
fun broadcastGroupMessage(payload: ByteArray)
|
||||
fun sendFileBroadcast(file: BitchatFilePacket)
|
||||
fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket)
|
||||
fun prepareFilePrivate(
|
||||
@ -49,6 +52,8 @@ interface MeshService {
|
||||
): Boolean
|
||||
fun getIdentityFingerprint(): String
|
||||
fun getStaticNoisePublicKey(): ByteArray?
|
||||
fun getSigningPublicKey(): ByteArray?
|
||||
fun signData(data: ByteArray): ByteArray?
|
||||
fun shouldShowEncryptionIcon(peerID: String): Boolean
|
||||
fun getEncryptedPeers(): List<String>
|
||||
|
||||
|
||||
@ -185,12 +185,33 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
Log.d(TAG, "🔐 Verify response received from $peerID (${noisePayload.data.size} bytes)")
|
||||
delegate?.onVerifyResponseReceived(peerID, noisePayload.data, packet.timestamp.toLong())
|
||||
}
|
||||
com.bitchat.android.model.NoisePayloadType.GROUP_INVITE -> {
|
||||
delegate?.onGroupInviteReceived(
|
||||
peerID,
|
||||
decryption.authenticatedSession.remoteStaticKey.copyOf(),
|
||||
noisePayload.data
|
||||
)
|
||||
}
|
||||
com.bitchat.android.model.NoisePayloadType.GROUP_KEY_UPDATE -> {
|
||||
delegate?.onGroupKeyUpdateReceived(
|
||||
peerID,
|
||||
decryption.authenticatedSession.remoteStaticKey.copyOf(),
|
||||
noisePayload.data
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error processing Noise encrypted message from $peerID: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun handleGroupMessage(routed: RoutedPacket) {
|
||||
delegate?.onGroupMessageReceived(
|
||||
routed.packet.payload,
|
||||
routed.packet.timestamp.toLong()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send delivery ACK for a received private message - exactly like iOS
|
||||
@ -681,4 +702,15 @@ interface MessageHandlerDelegate {
|
||||
fun onReadReceiptReceived(messageID: String, peerID: String)
|
||||
fun onVerifyChallengeReceived(peerID: String, payload: ByteArray, timestampMs: Long)
|
||||
fun onVerifyResponseReceived(peerID: String, payload: ByteArray, timestampMs: Long)
|
||||
fun onGroupInviteReceived(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray
|
||||
) {}
|
||||
fun onGroupKeyUpdateReceived(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray
|
||||
) {}
|
||||
fun onGroupMessageReceived(payload: ByteArray, timestampMs: Long) {}
|
||||
}
|
||||
|
||||
@ -146,6 +146,7 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
MessageType.ANNOUNCE -> validPacket = handleAnnounce(routed)
|
||||
MessageType.MESSAGE -> handleMessage(routed)
|
||||
MessageType.FILE_TRANSFER -> handleMessage(routed) // treat same routing path; parsing happens in handler
|
||||
MessageType.GROUP_MESSAGE -> handleGroupMessage(routed)
|
||||
MessageType.LEAVE -> handleLeave(routed)
|
||||
MessageType.FRAGMENT -> handleFragment(routed)
|
||||
MessageType.REQUEST_SYNC -> handleRequestSync(routed)
|
||||
@ -211,6 +212,12 @@ class PacketProcessor(private val myPeerID: String) {
|
||||
Log.d(TAG, "Processing message from ${formatPeerForLog(peerID)}")
|
||||
delegate?.handleMessage(routed)
|
||||
}
|
||||
|
||||
private fun handleGroupMessage(routed: RoutedPacket) {
|
||||
val peerID = routed.peerID ?: "unknown"
|
||||
Log.d(TAG, "Processing private-group message from ${formatPeerForLog(peerID)}")
|
||||
delegate?.handleGroupMessage(routed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle leave message
|
||||
@ -323,6 +330,7 @@ interface PacketProcessorDelegate {
|
||||
fun handleNoiseEncrypted(routed: RoutedPacket)
|
||||
suspend fun handleAnnounce(routed: RoutedPacket): Boolean
|
||||
fun handleMessage(routed: RoutedPacket)
|
||||
fun handleGroupMessage(routed: RoutedPacket) {}
|
||||
fun handleLeave(routed: RoutedPacket)
|
||||
fun handleFragment(packet: BitchatPacket): BitchatPacket?
|
||||
fun handleRequestSync(routed: RoutedPacket)
|
||||
|
||||
@ -113,6 +113,30 @@ class UnifiedMeshService(
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendGroupInvite(payload: ByteArray, recipientPeerID: String) {
|
||||
when {
|
||||
isBleReady(recipientPeerID) -> bluetooth.sendGroupInvite(payload, recipientPeerID)
|
||||
isWifiReady(recipientPeerID) ->
|
||||
wifiService()?.sendGroupInvite(payload, recipientPeerID)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendGroupKeyUpdate(payload: ByteArray, recipientPeerID: String) {
|
||||
when {
|
||||
isBleReady(recipientPeerID) ->
|
||||
bluetooth.sendGroupKeyUpdate(payload, recipientPeerID)
|
||||
isWifiReady(recipientPeerID) ->
|
||||
wifiService()?.sendGroupKeyUpdate(payload, recipientPeerID)
|
||||
}
|
||||
}
|
||||
|
||||
override fun broadcastGroupMessage(payload: ByteArray) {
|
||||
when {
|
||||
isBleEnabled() -> bluetooth.broadcastGroupMessage(payload)
|
||||
else -> wifiService()?.broadcastGroupMessage(payload)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendFileBroadcast(file: BitchatFilePacket) {
|
||||
when {
|
||||
isBleEnabled() -> bluetooth.sendFileBroadcast(file)
|
||||
@ -273,6 +297,12 @@ class UnifiedMeshService(
|
||||
return bluetooth.getStaticNoisePublicKey() ?: wifiService()?.getStaticNoisePublicKey()
|
||||
}
|
||||
|
||||
override fun getSigningPublicKey(): ByteArray? =
|
||||
bluetooth.getSigningPublicKey() ?: wifiService()?.getSigningPublicKey()
|
||||
|
||||
override fun signData(data: ByteArray): ByteArray? =
|
||||
bluetooth.signData(data) ?: wifiService()?.signData(data)
|
||||
|
||||
override fun shouldShowEncryptionIcon(peerID: String): Boolean {
|
||||
return hasEstablishedSession(peerID)
|
||||
}
|
||||
@ -359,6 +389,26 @@ class UnifiedMeshService(
|
||||
delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs)
|
||||
}
|
||||
|
||||
override fun didReceiveGroupInvite(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray
|
||||
) {
|
||||
delegate?.didReceiveGroupInvite(peerID, authenticatedRemoteStaticKey, payload)
|
||||
}
|
||||
|
||||
override fun didReceiveGroupKeyUpdate(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray
|
||||
) {
|
||||
delegate?.didReceiveGroupKeyUpdate(peerID, authenticatedRemoteStaticKey, payload)
|
||||
}
|
||||
|
||||
override fun didReceiveGroupMessage(payload: ByteArray, timestampMs: Long) {
|
||||
delegate?.didReceiveGroupMessage(payload, timestampMs)
|
||||
}
|
||||
|
||||
override fun didResolvePrivateMediaPolicy(peerID: String) {
|
||||
delegate?.didResolvePrivateMediaPolicy(peerID)
|
||||
}
|
||||
|
||||
@ -21,6 +21,8 @@ enum class NoisePayloadType(val value: UByte) {
|
||||
PRIVATE_MESSAGE(0x01u), // Private chat message with TLV encoding
|
||||
READ_RECEIPT(0x02u), // Message was read
|
||||
DELIVERED(0x03u), // Message was delivered
|
||||
GROUP_INVITE(0x06u), // Creator-signed private-group state
|
||||
GROUP_KEY_UPDATE(0x07u), // Creator-signed roster/key rotation
|
||||
VERIFY_CHALLENGE(0x10u), // Verification challenge
|
||||
VERIFY_RESPONSE(0x11u), // Verification response
|
||||
FILE_TRANSFER(0x20u),
|
||||
|
||||
@ -29,11 +29,14 @@ data class PeerCapabilities(val rawValue: Long) : Parcelable {
|
||||
companion object {
|
||||
val NONE = PeerCapabilities(0)
|
||||
|
||||
/** Creator-managed encrypted private groups (iOS capability bit 3). */
|
||||
val GROUPS = PeerCapabilities(1L shl 3)
|
||||
|
||||
/** Noise-encrypted private BitchatFilePacket using payload type 0x20. */
|
||||
val PRIVATE_MEDIA = PeerCapabilities(1L shl 8)
|
||||
|
||||
/** Capabilities implemented by this Android build. */
|
||||
val LOCAL_SUPPORTED = PRIVATE_MEDIA
|
||||
val LOCAL_SUPPORTED = PeerCapabilities(PRIVATE_MEDIA.rawValue or GROUPS.rawValue)
|
||||
|
||||
/**
|
||||
* Decode the low 64 bits and ignore any future extension bytes, which
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package com.bitchat.android.model
|
||||
|
||||
import com.bitchat.android.sync.SyncDefaults
|
||||
import com.bitchat.android.sync.SyncTypeFlags
|
||||
|
||||
/**
|
||||
* REQUEST_SYNC payload using GCS (Golomb-Coded Set) parameters.
|
||||
@ -12,7 +13,8 @@ import com.bitchat.android.sync.SyncDefaults
|
||||
data class RequestSyncPacket(
|
||||
val p: Int,
|
||||
val m: Long,
|
||||
val data: ByteArray
|
||||
val data: ByteArray,
|
||||
val types: SyncTypeFlags? = null
|
||||
) {
|
||||
fun encode(): ByteArray {
|
||||
val out = ArrayList<Byte>()
|
||||
@ -38,6 +40,7 @@ data class RequestSyncPacket(
|
||||
)
|
||||
// data
|
||||
putTLV(0x03, data)
|
||||
types?.encoded()?.let { putTLV(0x04, it) }
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
@ -50,6 +53,7 @@ data class RequestSyncPacket(
|
||||
var p: Int? = null
|
||||
var m: Long? = null
|
||||
var payload: ByteArray? = null
|
||||
var types: SyncTypeFlags? = null
|
||||
|
||||
while (off + 3 <= data.size) {
|
||||
val t = (data[off].toInt() and 0xFF); off += 1
|
||||
@ -69,6 +73,9 @@ data class RequestSyncPacket(
|
||||
if (v.size > MAX_ACCEPT_FILTER_BYTES) return null
|
||||
payload = v
|
||||
}
|
||||
0x04 -> {
|
||||
SyncTypeFlags.decode(v)?.let { types = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -76,7 +83,7 @@ data class RequestSyncPacket(
|
||||
val mm = m ?: return null
|
||||
val dd = payload ?: return null
|
||||
if (pp < 1 || mm <= 0L) return null
|
||||
return RequestSyncPacket(pp, mm, dd)
|
||||
return RequestSyncPacket(pp, mm, dd, types)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -217,6 +217,8 @@ class NostrDirectMessageHandler(
|
||||
}
|
||||
NoisePayloadType.VERIFY_CHALLENGE,
|
||||
NoisePayloadType.VERIFY_RESPONSE,
|
||||
NoisePayloadType.GROUP_INVITE,
|
||||
NoisePayloadType.GROUP_KEY_UPDATE,
|
||||
NoisePayloadType.PEER_STATE -> Unit // Peer state is bound to a live mesh Noise generation.
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,7 +17,8 @@ enum class MessageType(val value: UByte) {
|
||||
NOISE_ENCRYPTED(0x11u), // Noise encrypted transport message
|
||||
FRAGMENT(0x20u), // Fragmentation for large packets
|
||||
REQUEST_SYNC(0x21u), // GCS-based sync request
|
||||
FILE_TRANSFER(0x22u); // New: File transfer packet (BLE voice notes, etc.)
|
||||
FILE_TRANSFER(0x22u), // New: File transfer packet (BLE voice notes, etc.)
|
||||
GROUP_MESSAGE(0x25u); // Opaque private-group ciphertext broadcast
|
||||
|
||||
companion object {
|
||||
fun fromValue(value: UByte): MessageType? {
|
||||
|
||||
@ -107,6 +107,19 @@ object AppStateStore {
|
||||
}
|
||||
}
|
||||
|
||||
fun removePrivateConversation(peerID: String) {
|
||||
synchronized(this) {
|
||||
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
||||
val map = _privateMessages.value.toMutableMap()
|
||||
val removedPeer = map.remove(peerID) != null
|
||||
val removedConversation = map.remove(conversationID) != null
|
||||
val changed = removedPeer || removedConversation
|
||||
if (changed) {
|
||||
_privateMessages.value = map
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun statusPriority(status: DeliveryStatus?): Int = when (status) {
|
||||
null -> 0
|
||||
is DeliveryStatus.Sending -> 1
|
||||
|
||||
@ -33,6 +33,7 @@ class GossipSyncManager(
|
||||
|
||||
companion object {
|
||||
private const val TAG = "GossipSyncManager"
|
||||
private const val GROUP_MESSAGE_CAPACITY = 200
|
||||
}
|
||||
|
||||
var delegate: Delegate? = null
|
||||
@ -44,6 +45,9 @@ class GossipSyncManager(
|
||||
// Stored packets for sync:
|
||||
// - broadcast messages: keep up to seenCapacity() most recent, keyed by packetId
|
||||
private val messages = LinkedHashMap<String, BitchatPacket>()
|
||||
// Opaque group ciphertexts are kept separately so sync filters can
|
||||
// request them without mixing their IDs into public-message rounds.
|
||||
private val groupMessages = LinkedHashMap<String, BitchatPacket>()
|
||||
// - announcements: only keep latest per sender peerID
|
||||
private val latestAnnouncementByPeer = ConcurrentHashMap<String, Pair<String, BitchatPacket>>()
|
||||
|
||||
@ -94,23 +98,33 @@ class GossipSyncManager(
|
||||
}
|
||||
|
||||
fun onPublicPacketSeen(packet: BitchatPacket) {
|
||||
// Only ANNOUNCE or broadcast MESSAGE
|
||||
val mt = MessageType.fromValue(packet.type)
|
||||
val isBroadcastMessage = (mt == MessageType.MESSAGE && (packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)))
|
||||
val isGroupMessage = (mt == MessageType.GROUP_MESSAGE &&
|
||||
(packet.recipientID == null || packet.recipientID.contentEquals(SpecialRecipients.BROADCAST)))
|
||||
val isAnnouncement = (mt == MessageType.ANNOUNCE)
|
||||
if (!isBroadcastMessage && !isAnnouncement) return
|
||||
if (!isBroadcastMessage && !isGroupMessage && !isAnnouncement) return
|
||||
|
||||
val idBytes = PacketIdUtil.computeIdBytes(packet)
|
||||
val id = idBytes.joinToString("") { b -> "%02x".format(b) }
|
||||
|
||||
if (isBroadcastMessage) {
|
||||
synchronized(messages) {
|
||||
messages[id] = packet
|
||||
// Enforce capacity (remove oldest when exceeded)
|
||||
val cap = configProvider.seenCapacity().coerceAtLeast(1)
|
||||
while (messages.size > cap) {
|
||||
val it = messages.entries.iterator()
|
||||
if (it.hasNext()) { it.next(); it.remove() } else break
|
||||
if (isBroadcastMessage || isGroupMessage) {
|
||||
val store = if (isGroupMessage) groupMessages else messages
|
||||
synchronized(store) {
|
||||
store[id] = packet
|
||||
val cap = if (isGroupMessage) {
|
||||
GROUP_MESSAGE_CAPACITY
|
||||
} else {
|
||||
configProvider.seenCapacity().coerceAtLeast(1)
|
||||
}
|
||||
while (store.size > cap) {
|
||||
val iterator = store.entries.iterator()
|
||||
if (iterator.hasNext()) {
|
||||
iterator.next()
|
||||
iterator.remove()
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (isAnnouncement) {
|
||||
@ -134,7 +148,11 @@ class GossipSyncManager(
|
||||
}
|
||||
|
||||
private fun sendRequestSync() {
|
||||
val payload = buildGcsPayload()
|
||||
sendRequestSync(SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.GROUP_MESSAGE))
|
||||
}
|
||||
|
||||
private fun sendRequestSync(types: SyncTypeFlags) {
|
||||
val payload = buildGcsPayload(types)
|
||||
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.REQUEST_SYNC.value,
|
||||
@ -149,7 +167,14 @@ class GossipSyncManager(
|
||||
}
|
||||
|
||||
private fun sendRequestSyncToPeer(peerID: String) {
|
||||
val payload = buildGcsPayload()
|
||||
sendRequestSyncToPeer(
|
||||
peerID,
|
||||
SyncTypeFlags.PUBLIC_MESSAGES.union(SyncTypeFlags.GROUP_MESSAGE)
|
||||
)
|
||||
}
|
||||
|
||||
private fun sendRequestSyncToPeer(peerID: String, types: SyncTypeFlags) {
|
||||
val payload = buildGcsPayload(types)
|
||||
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.REQUEST_SYNC.value,
|
||||
@ -174,26 +199,43 @@ class GossipSyncManager(
|
||||
return GCSFilter.contains(sorted, nonZeroV)
|
||||
}
|
||||
|
||||
// 1) Announcements: send latest per peerID if remote doesn't have them
|
||||
for ((_, pair) in latestAnnouncementByPeer.entries) {
|
||||
val (id, pkt) = pair
|
||||
val idBytes = hexToBytes(id)
|
||||
if (!mightContain(idBytes)) {
|
||||
// Send original packet unchanged to requester only (keep local TTL)
|
||||
val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)
|
||||
delegate?.sendPacketToPeer(fromPeerID, toSend)
|
||||
Log.d(TAG, "Sent sync announce: Type ${toSend.type} from ${toSend.senderID.toHexString()} to $fromPeerID packet id ${idBytes.toHexString()}")
|
||||
val requestedTypes = request.types ?: SyncTypeFlags.PUBLIC_MESSAGES
|
||||
|
||||
if (requestedTypes.contains(MessageType.ANNOUNCE)) {
|
||||
for ((_, pair) in latestAnnouncementByPeer.entries) {
|
||||
val (id, pkt) = pair
|
||||
val idBytes = hexToBytes(id)
|
||||
if (!mightContain(idBytes)) {
|
||||
val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)
|
||||
delegate?.sendPacketToPeer(fromPeerID, toSend)
|
||||
Log.d(TAG, "Sent sync announce: Type ${toSend.type} from ${toSend.senderID.toHexString()} to $fromPeerID packet id ${idBytes.toHexString()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Broadcast messages: send all they lack
|
||||
val toSendMsgs = synchronized(messages) { messages.values.toList() }
|
||||
for (pkt in toSendMsgs) {
|
||||
val idBytes = PacketIdUtil.computeIdBytes(pkt)
|
||||
if (requestedTypes.contains(MessageType.MESSAGE)) {
|
||||
sendMissingPackets(fromPeerID, synchronized(messages) { messages.values.toList() }, ::mightContain)
|
||||
}
|
||||
if (requestedTypes.contains(MessageType.GROUP_MESSAGE)) {
|
||||
sendMissingPackets(
|
||||
fromPeerID,
|
||||
synchronized(groupMessages) { groupMessages.values.toList() },
|
||||
::mightContain
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendMissingPackets(
|
||||
peerID: String,
|
||||
packets: List<BitchatPacket>,
|
||||
mightContain: (ByteArray) -> Boolean
|
||||
) {
|
||||
packets.forEach { packet ->
|
||||
val idBytes = PacketIdUtil.computeIdBytes(packet)
|
||||
if (!mightContain(idBytes)) {
|
||||
val toSend = pkt.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)
|
||||
delegate?.sendPacketToPeer(fromPeerID, toSend)
|
||||
Log.d(TAG, "Sent sync message: Type ${toSend.type} to $fromPeerID packet id ${idBytes.toHexString()}")
|
||||
val toSend = packet.copy(ttl = com.bitchat.android.util.AppConstants.SYNC_TTL_HOPS)
|
||||
delegate?.sendPacketToPeer(peerID, toSend)
|
||||
Log.d(TAG, "Sent sync message: Type ${toSend.type} to $peerID packet id ${idBytes.toHexString()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -223,16 +265,16 @@ class GossipSyncManager(
|
||||
return out
|
||||
}
|
||||
|
||||
private fun buildGcsPayload(): ByteArray {
|
||||
// Collect candidates: latest announcement per peer + recent broadcast messages
|
||||
private fun buildGcsPayload(types: SyncTypeFlags): ByteArray {
|
||||
val list = ArrayList<BitchatPacket>()
|
||||
// announcements
|
||||
for ((_, pair) in latestAnnouncementByPeer) {
|
||||
list.add(pair.second)
|
||||
if (types.contains(MessageType.ANNOUNCE)) {
|
||||
latestAnnouncementByPeer.values.forEach { list += it.second }
|
||||
}
|
||||
// messages
|
||||
synchronized(messages) {
|
||||
list.addAll(messages.values)
|
||||
if (types.contains(MessageType.MESSAGE)) {
|
||||
synchronized(messages) { list.addAll(messages.values) }
|
||||
}
|
||||
if (types.contains(MessageType.GROUP_MESSAGE)) {
|
||||
synchronized(groupMessages) { list.addAll(groupMessages.values) }
|
||||
}
|
||||
// sort by timestamp desc, then take up to min(seenCapacity, fit capacity)
|
||||
list.sortByDescending { it.timestamp.toLong() }
|
||||
@ -245,12 +287,22 @@ class GossipSyncManager(
|
||||
val takeN = minOf(nMax, cap, list.size)
|
||||
if (takeN <= 0) {
|
||||
val p0 = GCSFilter.deriveP(fpr)
|
||||
return RequestSyncPacket(p = p0, m = 1, data = ByteArray(0)).encode()
|
||||
return RequestSyncPacket(
|
||||
p = p0,
|
||||
m = 1,
|
||||
data = ByteArray(0),
|
||||
types = types
|
||||
).encode()
|
||||
}
|
||||
val ids = list.take(takeN).map { pkt -> PacketIdUtil.computeIdBytes(pkt) }
|
||||
val params = GCSFilter.buildFilter(ids, maxBytes, fpr)
|
||||
val mVal = if (params.m <= 0L) 1 else params.m
|
||||
return RequestSyncPacket(p = params.p, m = mVal, data = params.data).encode()
|
||||
return RequestSyncPacket(
|
||||
p = params.p,
|
||||
m = mVal,
|
||||
data = params.data,
|
||||
types = types
|
||||
).encode()
|
||||
}
|
||||
|
||||
// Periodically remove stale announcements and all their messages
|
||||
@ -306,6 +358,15 @@ class GossipSyncManager(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
synchronized(groupMessages) {
|
||||
for ((id, message) in groupMessages) {
|
||||
val sender = message.senderID.joinToString("") { b -> "%02x".format(b) }
|
||||
if (sender == key) {
|
||||
idsToRemove.add(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now remove the collected IDs
|
||||
synchronized(messages) {
|
||||
@ -313,6 +374,11 @@ class GossipSyncManager(
|
||||
messages.remove(id)
|
||||
}
|
||||
}
|
||||
synchronized(groupMessages) {
|
||||
for (id in idsToRemove) {
|
||||
groupMessages.remove(id)
|
||||
}
|
||||
}
|
||||
|
||||
if (idsToRemove.isNotEmpty()) {
|
||||
Log.d(TAG, "Pruned ${idsToRemove.size} messages with senders without announcements")
|
||||
|
||||
77
app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt
Normal file
77
app/src/main/java/com/bitchat/android/sync/SyncTypeFlags.kt
Normal file
@ -0,0 +1,77 @@
|
||||
package com.bitchat.android.sync
|
||||
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
|
||||
/**
|
||||
* Packet types covered by one REQUEST_SYNC filter.
|
||||
*
|
||||
* The wire format matches iOS: a minimal little-endian bitfield. Unknown bits
|
||||
* are discarded because they do not map to a packet type this build can serve.
|
||||
*/
|
||||
class SyncTypeFlags private constructor(val rawValue: Long) {
|
||||
fun contains(type: MessageType): Boolean {
|
||||
val bit = bitIndex(type) ?: return false
|
||||
return (rawValue and (1L shl bit)) != 0L
|
||||
}
|
||||
|
||||
fun union(other: SyncTypeFlags): SyncTypeFlags =
|
||||
fromRawValue(rawValue or other.rawValue)
|
||||
|
||||
fun encoded(): ByteArray? {
|
||||
if (rawValue == 0L) return null
|
||||
var remaining = rawValue
|
||||
val bytes = mutableListOf<Byte>()
|
||||
while (remaining != 0L && bytes.size < Long.SIZE_BYTES) {
|
||||
bytes += remaining.toByte()
|
||||
remaining = remaining ushr 8
|
||||
}
|
||||
return bytes.toByteArray()
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean =
|
||||
other is SyncTypeFlags && rawValue == other.rawValue
|
||||
|
||||
override fun hashCode(): Int = rawValue.hashCode()
|
||||
|
||||
companion object {
|
||||
val PUBLIC_MESSAGES = of(MessageType.ANNOUNCE, MessageType.MESSAGE)
|
||||
val GROUP_MESSAGE = of(MessageType.GROUP_MESSAGE)
|
||||
|
||||
fun of(vararg types: MessageType): SyncTypeFlags {
|
||||
var raw = 0L
|
||||
types.forEach { type ->
|
||||
bitIndex(type)?.let { raw = raw or (1L shl it) }
|
||||
}
|
||||
return fromRawValue(raw)
|
||||
}
|
||||
|
||||
fun decode(data: ByteArray): SyncTypeFlags? {
|
||||
if (data.size !in 1..Long.SIZE_BYTES) return null
|
||||
var raw = 0L
|
||||
data.forEachIndexed { index, byte ->
|
||||
raw = raw or ((byte.toLong() and 0xffL) shl (index * 8))
|
||||
}
|
||||
return fromRawValue(raw)
|
||||
}
|
||||
|
||||
private fun fromRawValue(rawValue: Long): SyncTypeFlags {
|
||||
var knownMask = 0L
|
||||
MessageType.entries.forEach { type ->
|
||||
bitIndex(type)?.let { knownMask = knownMask or (1L shl it) }
|
||||
}
|
||||
return SyncTypeFlags(rawValue and knownMask)
|
||||
}
|
||||
|
||||
private fun bitIndex(type: MessageType): Int? = when (type) {
|
||||
MessageType.ANNOUNCE -> 0
|
||||
MessageType.MESSAGE -> 1
|
||||
MessageType.LEAVE -> 2
|
||||
MessageType.NOISE_HANDSHAKE -> 3
|
||||
MessageType.NOISE_ENCRYPTED -> 4
|
||||
MessageType.FRAGMENT -> 5
|
||||
MessageType.REQUEST_SYNC -> 6
|
||||
MessageType.FILE_TRANSFER -> 7
|
||||
MessageType.GROUP_MESSAGE -> 10
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -30,6 +30,13 @@ import com.bitchat.android.noise.NoiseSession
|
||||
import com.bitchat.android.services.ContactDirectory
|
||||
import com.bitchat.android.services.ContactIdentityResolver
|
||||
import com.bitchat.android.util.hexEncodedString
|
||||
import com.bitchat.android.groups.BitchatGroup
|
||||
import com.bitchat.android.groups.GroupCommandResult
|
||||
import com.bitchat.android.groups.GroupCoordinator
|
||||
import com.bitchat.android.groups.GroupCoordinatorContext
|
||||
import com.bitchat.android.groups.GroupIds
|
||||
import com.bitchat.android.groups.GroupPeerIdentity
|
||||
import com.bitchat.android.groups.GroupStore
|
||||
|
||||
/**
|
||||
* Refactored ChatViewModel - Main coordinator for bitchat functionality
|
||||
@ -51,6 +58,8 @@ class ChatViewModel(
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ChatViewModel"
|
||||
private const val GROUP_COMMAND_USAGE =
|
||||
"usage: /group create <name> · invite @name · remove @name · leave · list"
|
||||
}
|
||||
|
||||
fun sendVoiceNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) {
|
||||
@ -101,6 +110,8 @@ class ChatViewModel(
|
||||
private val identityManager by lazy { SecureIdentityStateManager(getApplication()) }
|
||||
private val messageManager = MessageManager(state)
|
||||
private val channelManager = ChannelManager(state, messageManager, dataManager, viewModelScope)
|
||||
private val groupStore = GroupStore(application.applicationContext)
|
||||
val groups: StateFlow<List<BitchatGroup>> = groupStore.groups
|
||||
|
||||
// Create Noise session delegate for clean dependency injection
|
||||
private val noiseSessionDelegate = object : NoiseSessionDelegate {
|
||||
@ -117,6 +128,143 @@ class ChatViewModel(
|
||||
NotificationIntervalManager()
|
||||
)
|
||||
|
||||
private val groupCoordinator = GroupCoordinator(object : GroupCoordinatorContext {
|
||||
override val groupStore: GroupStore
|
||||
get() = this@ChatViewModel.groupStore
|
||||
override val nickname: String
|
||||
get() = state.getNicknameValue()
|
||||
override val myPeerID: String
|
||||
get() = mesh.myPeerID
|
||||
override val selectedConversationID: String?
|
||||
get() = state.getSelectedPrivateChatPeerValue()
|
||||
|
||||
override fun myNoiseFingerprint(): String = mesh.getIdentityFingerprint()
|
||||
override fun mySigningPublicKey(): ByteArray? = mesh.getSigningPublicKey()
|
||||
override fun sign(data: ByteArray): ByteArray? = mesh.signData(data)
|
||||
|
||||
override fun peerIDForNickname(nickname: String): String? =
|
||||
mesh.getPeerNicknames().entries.firstOrNull {
|
||||
it.value.equals(nickname, ignoreCase = true)
|
||||
}?.key
|
||||
|
||||
override fun isPeerConnected(peerID: String): Boolean =
|
||||
mesh.getPeerInfo(peerID)?.isConnected == true && mesh.hasEstablishedSession(peerID)
|
||||
|
||||
override fun peerNickname(peerID: String): String? =
|
||||
mesh.getPeerNicknames()[peerID]
|
||||
|
||||
override fun peerIdentity(peerID: String): GroupPeerIdentity? {
|
||||
val info = mesh.getPeerInfo(peerID) ?: return null
|
||||
if (info.signingPublicKey?.size != 32) return null
|
||||
val liveFingerprint = mesh.getPeerFingerprint(peerID) ?: return null
|
||||
val announcedNoiseKey = info.noisePublicKey ?: return null
|
||||
val announcedFingerprint = ContactIdentityResolver.fingerprintHex(announcedNoiseKey)
|
||||
if (!liveFingerprint.equals(announcedFingerprint, ignoreCase = true)) return null
|
||||
return GroupPeerIdentity(
|
||||
liveFingerprint.lowercase(),
|
||||
info.signingPublicKey!!.copyOf()
|
||||
)
|
||||
}
|
||||
|
||||
override fun connectedPeerID(fingerprint: String): String? =
|
||||
mesh.getPeerNicknames().keys.firstOrNull { peerID ->
|
||||
mesh.getPeerInfo(peerID)?.isConnected == true &&
|
||||
mesh.hasEstablishedSession(peerID) &&
|
||||
mesh.getPeerFingerprint(peerID).equals(fingerprint, ignoreCase = true)
|
||||
}
|
||||
|
||||
override fun isFingerprintBlocked(fingerprint: String): Boolean =
|
||||
dataManager.isUserBlocked(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 {
|
||||
val existing = state.getPrivateChatsValue()[groupPeerID].orEmpty()
|
||||
if (existing.any { it.id == message.id }) return false
|
||||
messageManager.addPrivateMessage(groupPeerID, message)
|
||||
if (state.getSelectedPrivateChatPeerValue() == groupPeerID) {
|
||||
try {
|
||||
com.bitchat.android.services.SeenMessageStore
|
||||
.getInstance(getApplication())
|
||||
.markRead(message.id)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun markGroupUnread(groupPeerID: String) {
|
||||
state.setUnreadPrivateMessages(
|
||||
state.getUnreadPrivateMessagesValue() + groupPeerID
|
||||
)
|
||||
}
|
||||
|
||||
override fun removeGroupConversation(groupPeerID: String) {
|
||||
messageManager.removePrivateChat(groupPeerID)
|
||||
}
|
||||
|
||||
override fun openGroupConversation(groupPeerID: String) {
|
||||
privateChatManager.startPrivateChat(groupPeerID, mesh)
|
||||
showPrivateChatSheet(groupPeerID)
|
||||
}
|
||||
|
||||
override fun closeGroupConversation() {
|
||||
endPrivateChat()
|
||||
}
|
||||
|
||||
override fun addSystemMessage(message: String) {
|
||||
messageManager.addSystemMessage(message)
|
||||
}
|
||||
|
||||
override fun addGroupSystemMessage(groupPeerID: String, message: String) {
|
||||
messageManager.addPrivateMessage(
|
||||
groupPeerID,
|
||||
BitchatMessage(
|
||||
sender = "system",
|
||||
content = message,
|
||||
timestamp = Date(),
|
||||
isPrivate = true,
|
||||
recipientNickname = groupStore.group(groupPeerID)?.name
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun notifyGroupMessage(
|
||||
groupPeerID: String,
|
||||
sender: String,
|
||||
message: String
|
||||
) {
|
||||
notificationManager.showPrivateMessageNotification(groupPeerID, sender, message)
|
||||
}
|
||||
})
|
||||
|
||||
fun handleGroupCommand(arguments: List<String>): GroupCommandResult {
|
||||
val subcommand = arguments.firstOrNull()?.lowercase()
|
||||
?: return GroupCommandResult(false, GROUP_COMMAND_USAGE)
|
||||
val value = arguments.drop(1).joinToString(" ")
|
||||
return when (subcommand) {
|
||||
"create" -> groupCoordinator.createGroup(value)
|
||||
"invite" -> groupCoordinator.inviteMember(value)
|
||||
"remove" -> groupCoordinator.removeMember(value)
|
||||
"leave" -> groupCoordinator.leaveGroup()
|
||||
"list" -> groupCoordinator.listGroups()
|
||||
else -> GroupCommandResult(false, GROUP_COMMAND_USAGE)
|
||||
}
|
||||
}
|
||||
|
||||
private val verificationHandler = VerificationHandler(
|
||||
context = application.applicationContext,
|
||||
scope = viewModelScope,
|
||||
@ -529,6 +677,10 @@ class ChatViewModel(
|
||||
val currentChannelValue = state.getCurrentChannelValue()
|
||||
|
||||
if (selectedPeer != null) {
|
||||
if (GroupIds.isGroup(selectedPeer)) {
|
||||
groupCoordinator.sendMessage(content, selectedPeer)
|
||||
return
|
||||
}
|
||||
// If the selected peer is a temporary Nostr alias or a noise-hex identity, resolve to a canonical target
|
||||
selectedPeer = ContactDirectory.canonicalConversationId(
|
||||
com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
|
||||
@ -938,6 +1090,26 @@ class ChatViewModel(
|
||||
override fun didResolvePrivateMediaPolicy(peerID: String) {
|
||||
mediaSendingManager.retryPendingPrivateMedia(peerID)
|
||||
}
|
||||
|
||||
override fun didReceiveGroupInvite(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray
|
||||
) {
|
||||
groupCoordinator.handleInvite(peerID, authenticatedRemoteStaticKey, payload)
|
||||
}
|
||||
|
||||
override fun didReceiveGroupKeyUpdate(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
payload: ByteArray
|
||||
) {
|
||||
groupCoordinator.handleKeyUpdate(peerID, authenticatedRemoteStaticKey, payload)
|
||||
}
|
||||
|
||||
override fun didReceiveGroupMessage(payload: ByteArray, timestampMs: Long) {
|
||||
groupCoordinator.handleMessage(payload, timestampMs)
|
||||
}
|
||||
|
||||
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
|
||||
return meshDelegateHandler.decryptChannelMessage(encryptedContent, channel)
|
||||
@ -964,6 +1136,11 @@ class ChatViewModel(
|
||||
messageManager.clearAllMessages()
|
||||
channelManager.clearAllChannels()
|
||||
privateChatManager.clearAllPrivateChats()
|
||||
try {
|
||||
com.bitchat.android.services.AppStateStore.clear()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
groupStore.wipe()
|
||||
dataManager.clearAllData()
|
||||
|
||||
// Clear seen message store
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import com.bitchat.android.geohash.ChannelID
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import java.util.Date
|
||||
@ -19,6 +20,7 @@ class CommandProcessor(
|
||||
CommandSuggestion("/block", emptyList(), "[nickname]", "block or list blocked peers"),
|
||||
CommandSuggestion("/channels", emptyList(), null, "show all discovered channels"),
|
||||
CommandSuggestion("/clear", emptyList(), null, "clear chat messages"),
|
||||
CommandSuggestion("/group", emptyList(), "<create|invite|remove|leave|list>", "manage private groups"),
|
||||
CommandSuggestion("/hug", emptyList(), "<nickname>", "send someone a warm hug"),
|
||||
CommandSuggestion("/j", listOf("/join"), "<channel>", "join or create a channel"),
|
||||
CommandSuggestion("/m", listOf("/msg"), "<nickname> [message]", "send private message"),
|
||||
@ -45,11 +47,49 @@ class CommandProcessor(
|
||||
"/hug" -> handleActionCommand(parts, "gives", "a warm hug 🫂", meshService, myPeerID, onSendMessage, viewModel)
|
||||
"/slap" -> handleActionCommand(parts, "slaps", "around a bit with a large trout 🐟", meshService, myPeerID, onSendMessage, viewModel)
|
||||
"/channels" -> handleChannelsCommand()
|
||||
"/group" -> handleGroupCommand(parts, viewModel)
|
||||
else -> handleUnknownCommand(cmd)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun handleGroupCommand(parts: List<String>, viewModel: ChatViewModel?) {
|
||||
if (viewModel == null) {
|
||||
addCommandOutput("private groups are unavailable")
|
||||
return
|
||||
}
|
||||
val selectedPeer = state.getSelectedPrivateChatPeerValue()
|
||||
if (viewModel.selectedLocationChannel.value is ChannelID.Location ||
|
||||
selectedPeer?.startsWith("nostr_") == true ||
|
||||
selectedPeer?.startsWith("nostr:") == true
|
||||
) {
|
||||
addCommandOutput("groups are only for mesh peers in #mesh")
|
||||
return
|
||||
}
|
||||
|
||||
val result = viewModel.handleGroupCommand(
|
||||
parts.drop(1).filter(String::isNotBlank)
|
||||
)
|
||||
addCommandOutput(result.message)
|
||||
}
|
||||
|
||||
private fun addCommandOutput(message: String) {
|
||||
val destination = state.getSelectedPrivateChatPeerValue()
|
||||
if (destination != null) {
|
||||
messageManager.addPrivateMessage(
|
||||
destination,
|
||||
BitchatMessage(
|
||||
sender = "system",
|
||||
content = message,
|
||||
timestamp = Date(),
|
||||
isPrivate = true
|
||||
)
|
||||
)
|
||||
} else {
|
||||
messageManager.addSystemMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleJoinCommand(parts: List<String>, myPeerID: String) {
|
||||
if (parts.size > 1) {
|
||||
@ -403,7 +443,17 @@ class CommandProcessor(
|
||||
emptyList()
|
||||
}
|
||||
|
||||
return baseCommands + channelCommands
|
||||
val selectedPeer = state.getSelectedPrivateChatPeerValue()
|
||||
val isGeohashContext =
|
||||
state.selectedLocationChannel.value is ChannelID.Location ||
|
||||
selectedPeer?.startsWith("nostr_") == true ||
|
||||
selectedPeer?.startsWith("nostr:") == true
|
||||
val contextualBase = if (isGeohashContext) {
|
||||
baseCommands.filterNot { it.command == "/group" }
|
||||
} else {
|
||||
baseCommands
|
||||
}
|
||||
return contextualBase + channelCommands
|
||||
}
|
||||
|
||||
private fun filterCommands(commands: List<CommandSuggestion>, input: String): List<CommandSuggestion> {
|
||||
|
||||
@ -20,6 +20,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
@ -34,6 +36,8 @@ import com.bitchat.android.core.ui.component.sheet.BitchatSheetTopBar
|
||||
import com.bitchat.android.favorites.FavoriteRelationship
|
||||
import com.bitchat.android.favorites.FavoritesPersistenceService
|
||||
import com.bitchat.android.geohash.ChannelID
|
||||
import com.bitchat.android.groups.BitchatGroup
|
||||
import com.bitchat.android.groups.GroupIds
|
||||
import com.bitchat.android.identity.SecureIdentityStateManager
|
||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||
import com.bitchat.android.nostr.GeohashAliasRegistry
|
||||
@ -65,6 +69,8 @@ fun MeshPeerListSheet(
|
||||
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
|
||||
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
||||
val unreadChannelMessages by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
|
||||
val unreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
|
||||
val groups by viewModel.groups.collectAsStateWithLifecycle()
|
||||
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
|
||||
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
||||
@ -182,6 +188,21 @@ fun MeshPeerListSheet(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedLocationChannel !is ChannelID.Location && groups.isNotEmpty()) {
|
||||
item(key = "groups_section") {
|
||||
GroupSection(
|
||||
groups = groups,
|
||||
myFingerprint = viewModel.getMyFingerprint(),
|
||||
unreadConversationIDs = unreadPrivateMessages,
|
||||
colorScheme = colorScheme,
|
||||
onGroupClick = { groupPeerID ->
|
||||
viewModel.showPrivateChatSheet(groupPeerID)
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TopBar (animated)
|
||||
@ -213,6 +234,103 @@ fun MeshPeerListSheet(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupSection(
|
||||
groups: List<BitchatGroup>,
|
||||
myFingerprint: String,
|
||||
unreadConversationIDs: Set<String>,
|
||||
colorScheme: ColorScheme,
|
||||
onGroupClick: (String) -> Unit
|
||||
) {
|
||||
Column(modifier = Modifier.padding(top = 16.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.groups).uppercase(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = colorScheme.onSurface.copy(alpha = 0.7f),
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.padding(top = 8.dp, bottom = 4.dp)
|
||||
)
|
||||
|
||||
groups.forEach { group ->
|
||||
val isCreator = group.creatorFingerprint.equals(myFingerprint, ignoreCase = true)
|
||||
val hasUnread = group.peerID in unreadConversationIDs
|
||||
val memberCountLabel = stringResource(R.string.group_member_count, group.members.size)
|
||||
val creatorLabel = stringResource(R.string.group_creator)
|
||||
val unreadLabel = stringResource(R.string.cd_unread_private_messages)
|
||||
val accessibilityDescription = buildList {
|
||||
add(group.name)
|
||||
add(memberCountLabel)
|
||||
if (isCreator) add(creatorLabel)
|
||||
if (hasUnread) add(unreadLabel)
|
||||
}.joinToString()
|
||||
|
||||
Surface(
|
||||
onClick = { onGroupClick(group.peerID) },
|
||||
color = Color.Transparent,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 2.dp)
|
||||
.semantics(mergeDescendants = true) {
|
||||
contentDescription = accessibilityDescription
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = 48.dp)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Groups,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = "#${group.name}",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = memberCountLabel,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace
|
||||
),
|
||||
color = colorScheme.onSurfaceVariant
|
||||
)
|
||||
if (isCreator) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.WorkspacePremium,
|
||||
contentDescription = creatorLabel,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = Color(0xFFFFD700)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
if (hasUnread) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Mail,
|
||||
contentDescription = unreadLabel,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = Color(0xFFFF9500)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelRow(
|
||||
channel: String,
|
||||
@ -762,6 +880,9 @@ fun PrivateChatSheet(
|
||||
val peerSessionStates by viewModel.peerSessionStates.collectAsStateWithLifecycle()
|
||||
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
|
||||
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
|
||||
val groups by viewModel.groups.collectAsStateWithLifecycle()
|
||||
val group = remember(peerID, groups) { groups.firstOrNull { it.peerID == peerID } }
|
||||
val isGroupConversation = GroupIds.isGroup(peerID)
|
||||
|
||||
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
|
||||
val wifiAwareConnected by com.bitchat.android.wifiaware.WifiAwareController.connectedPeers.collectAsStateWithLifecycle()
|
||||
@ -788,6 +909,7 @@ fun PrivateChatSheet(
|
||||
val isConnected = activeMeshPeerID?.let { connectedPeers.contains(it) } == true || connectedPeers.contains(peerID) || isDirect
|
||||
val isNostrReachableFavorite =
|
||||
!isConnected && favoriteRelationship?.isMutual == true && favoriteRelationship.peerNostrPublicKey != null
|
||||
val privateGroupLabel = stringResource(R.string.private_group)
|
||||
|
||||
// Compute display name and title text reactively
|
||||
val displayName = remember(peerID, peerNicknames, favoriteRelationship) {
|
||||
@ -797,8 +919,10 @@ fun PrivateChatSheet(
|
||||
?: favoriteRelationship?.peerNickname?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
|
||||
?: viewModel.resolvePeerDisplayNameForFingerprint(peerID)
|
||||
}
|
||||
val titleText = remember(peerID, peerNicknames, favoriteRelationship) {
|
||||
if (isNostrPeer) {
|
||||
val titleText = remember(peerID, peerNicknames, favoriteRelationship, group) {
|
||||
if (isGroupConversation) {
|
||||
group?.let { "#${it.name} (${it.members.size})" } ?: privateGroupLabel
|
||||
} else if (isNostrPeer) {
|
||||
val gh = GeohashConversationRegistry.get(peerID) ?: "geohash"
|
||||
val fullPubkey = GeohashAliasRegistry.get(peerID) ?: ""
|
||||
val name = if (fullPubkey.isNotEmpty()) {
|
||||
@ -812,21 +936,28 @@ fun PrivateChatSheet(
|
||||
}
|
||||
}
|
||||
|
||||
val conversationID = contactResolution.conversationID
|
||||
val conversationID = if (isGroupConversation) peerID else contactResolution.conversationID
|
||||
val messages = privateChats[conversationID] ?: privateChats[peerID] ?: emptyList()
|
||||
val sessionState = activeMeshPeerID?.let { peerSessionStates[it] } ?: peerSessionStates[peerID]
|
||||
val fingerprint = activeMeshPeerID?.let { peerFingerprints[it] }
|
||||
?: peerFingerprints[peerID]
|
||||
?: ContactIdentityResolver.fingerprintFromContactConversationId(peerID)
|
||||
val isFavorite = remember(favoritePeers, fingerprint, peerID, favoriteRelationship) {
|
||||
if (fingerprint != null) favoritePeers.contains(fingerprint) else viewModel.isFavorite(peerID)
|
||||
val isFavorite = remember(
|
||||
favoritePeers,
|
||||
fingerprint,
|
||||
peerID,
|
||||
favoriteRelationship,
|
||||
isGroupConversation
|
||||
) {
|
||||
!isGroupConversation &&
|
||||
if (fingerprint != null) favoritePeers.contains(fingerprint) else viewModel.isFavorite(peerID)
|
||||
}
|
||||
|
||||
val isVerified = remember(peerID, verifiedFingerprints) {
|
||||
viewModel.isPeerVerified(peerID, verifiedFingerprints)
|
||||
}
|
||||
|
||||
val securityModifier = if (!isNostrPeer && !isNostrReachableFavorite) {
|
||||
val securityModifier = if (!isGroupConversation && !isNostrPeer && !isNostrReachableFavorite) {
|
||||
Modifier.clickable { viewModel.showSecurityVerificationSheet() }
|
||||
} else {
|
||||
Modifier
|
||||
@ -869,6 +1000,30 @@ fun PrivateChatSheet(
|
||||
HorizontalDivider(color = colorScheme.outline.copy(alpha = 0.3f))
|
||||
|
||||
// Input section
|
||||
if (isGroupConversation) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp)
|
||||
.semantics(mergeDescendants = true) {},
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = Color(0xFFFF9500)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.group_encryption_caption),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color(0xFFFF9500)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var messageText by remember {
|
||||
mutableStateOf(
|
||||
androidx.compose.ui.text.input.TextFieldValue(
|
||||
@ -909,7 +1064,7 @@ fun PrivateChatSheet(
|
||||
currentChannel = null,
|
||||
nickname = nickname,
|
||||
colorScheme = colorScheme,
|
||||
showMediaButtons = true
|
||||
showMediaButtons = !isGroupConversation
|
||||
)
|
||||
}
|
||||
|
||||
@ -940,6 +1095,14 @@ fun PrivateChatSheet(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
when {
|
||||
isGroupConversation -> {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Groups,
|
||||
contentDescription = stringResource(R.string.cd_group_chat),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = colorScheme.primary
|
||||
)
|
||||
}
|
||||
isNostrPeer || isNostrReachableFavorite -> {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Public,
|
||||
@ -987,14 +1150,14 @@ fun PrivateChatSheet(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.then(securityModifier)
|
||||
) {
|
||||
if (!isNostrPeer && !isNostrReachableFavorite) {
|
||||
if (!isGroupConversation && !isNostrPeer && !isNostrReachableFavorite) {
|
||||
NoiseSessionIcon(
|
||||
sessionState = sessionState,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
|
||||
if (isVerified) {
|
||||
if (!isGroupConversation && isVerified) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Verified,
|
||||
@ -1005,16 +1168,18 @@ fun PrivateChatSheet(
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = { viewModel.toggleFavorite(peerID) },
|
||||
modifier = Modifier.size(28.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star,
|
||||
contentDescription = if (isFavorite) stringResource(R.string.cd_remove_favorite) else stringResource(R.string.cd_add_favorite),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = if (isFavorite) Color(0xFFFFD700) else colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
if (!isGroupConversation) {
|
||||
IconButton(
|
||||
onClick = { viewModel.toggleFavorite(peerID) },
|
||||
modifier = Modifier.size(28.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star,
|
||||
contentDescription = if (isFavorite) stringResource(R.string.cd_remove_favorite) else stringResource(R.string.cd_add_favorite),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = if (isFavorite) Color(0xFFFFD700) else colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -143,6 +143,22 @@ class MessageManager(private val state: ChatState) {
|
||||
updatedChats[conversationID] = emptyList()
|
||||
state.setPrivateChats(updatedChats)
|
||||
}
|
||||
|
||||
fun removePrivateChat(peerID: String) {
|
||||
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
||||
val updatedChats = state.getPrivateChatsValue().toMutableMap()
|
||||
updatedChats.remove(peerID)
|
||||
updatedChats.remove(conversationID)
|
||||
state.setPrivateChats(updatedChats)
|
||||
val unread = state.getUnreadPrivateMessagesValue().toMutableSet()
|
||||
unread.remove(peerID)
|
||||
unread.remove(conversationID)
|
||||
state.setUnreadPrivateMessages(unread)
|
||||
try {
|
||||
com.bitchat.android.services.AppStateStore.removePrivateConversation(conversationID)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
fun initializePrivateChat(peerID: String) {
|
||||
val conversationID = ContactDirectory.canonicalConversationId(peerID)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import com.bitchat.android.favorites.FavoritesPersistenceService
|
||||
import com.bitchat.android.groups.GroupIds
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import com.bitchat.android.mesh.PeerFingerprintManager
|
||||
@ -375,6 +376,11 @@ class PrivateChatManager(
|
||||
meshService: MeshService
|
||||
) {
|
||||
val canonicalConversationID = ContactDirectory.canonicalConversationId(conversationID)
|
||||
if (GroupIds.isGroup(canonicalConversationID)) {
|
||||
unreadReceivedMessages.remove(canonicalConversationID)
|
||||
messageManager.clearPrivateUnreadMessages(canonicalConversationID)
|
||||
return
|
||||
}
|
||||
|
||||
// Collect candidate messages: all incoming messages from this peer in the conversation
|
||||
val chats = try { state.getPrivateChatsValue() } catch (_: Exception) { emptyMap<String, List<BitchatMessage>>() }
|
||||
|
||||
@ -1525,6 +1525,18 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
meshCore.sendVerifyResponse(peerID, noiseKeyHex, nonceA)
|
||||
}
|
||||
|
||||
override fun sendGroupInvite(payload: ByteArray, recipientPeerID: String) {
|
||||
meshCore.sendGroupInvite(payload, recipientPeerID)
|
||||
}
|
||||
|
||||
override fun sendGroupKeyUpdate(payload: ByteArray, recipientPeerID: String) {
|
||||
meshCore.sendGroupKeyUpdate(payload, recipientPeerID)
|
||||
}
|
||||
|
||||
override fun broadcastGroupMessage(payload: ByteArray) {
|
||||
meshCore.broadcastGroupMessage(payload)
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcasts a file (TLV payload) to all peers. Uses protocol version 2 to support
|
||||
* large payloads and generates a deterministic transferId (sha256 of payload) for UI/state.
|
||||
@ -1641,6 +1653,10 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
|
||||
|
||||
override fun getStaticNoisePublicKey(): ByteArray? = meshCore.getStaticNoisePublicKey()
|
||||
|
||||
override fun getSigningPublicKey(): ByteArray? = meshCore.getSigningPublicKey()
|
||||
|
||||
override fun signData(data: ByteArray): ByteArray? = meshCore.signData(data)
|
||||
|
||||
/**
|
||||
* @return true if the UI should show an “encrypted” indicator for this peer.
|
||||
*/
|
||||
|
||||
@ -14,6 +14,12 @@
|
||||
<string name="back">Back</string>
|
||||
<string name="people">People</string>
|
||||
<string name="channels">Channels</string>
|
||||
<string name="groups" tools:ignore="MissingTranslation">Groups</string>
|
||||
<string name="private_group" tools:ignore="MissingTranslation">Private group</string>
|
||||
<string name="group_member_count" tools:ignore="MissingTranslation">%1$d members</string>
|
||||
<string name="group_creator" tools:ignore="MissingTranslation">Creator</string>
|
||||
<string name="group_encryption_caption" tools:ignore="MissingTranslation">Encrypted to group members</string>
|
||||
<string name="cd_group_chat" tools:ignore="MissingTranslation">Private group chat</string>
|
||||
<string name="online_users">Online Users</string>
|
||||
<string name="no_one_connected">No one connected</string>
|
||||
<string name="emergency_clear_hint">Triple tap to clear all data</string>
|
||||
|
||||
@ -0,0 +1,330 @@
|
||||
package com.bitchat.android.groups
|
||||
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import java.security.MessageDigest
|
||||
import java.util.Date
|
||||
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
|
||||
import org.bouncycastle.crypto.signers.Ed25519Signer
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class GroupCoordinatorTest {
|
||||
@Test
|
||||
fun `creator invite rotates epoch and sends creator-signed state`() {
|
||||
val localKey = privateKey(0x11)
|
||||
val inviteeKey = privateKey(0x22)
|
||||
val context = FakeGroupContext(localKey, "11".repeat(32))
|
||||
context.peerIDs["alice"] = "22".repeat(8)
|
||||
context.connected += "22".repeat(8)
|
||||
context.peerNames["22".repeat(8)] = "alice"
|
||||
context.identities["22".repeat(8)] = GroupPeerIdentity(
|
||||
"22".repeat(32),
|
||||
inviteeKey.generatePublicKey().encoded
|
||||
)
|
||||
|
||||
val created = GroupCoordinator(context).createGroup("trail crew")
|
||||
assertTrue(created.success)
|
||||
val original = context.groupStore.groups.value.single()
|
||||
val originalKey = context.groupStore.key(original.groupID)!!
|
||||
|
||||
val result = GroupCoordinator(context).inviteMember("@alice")
|
||||
assertTrue(result.success)
|
||||
val updated = context.groupStore.group(original.groupID)!!
|
||||
val updatedKey = context.groupStore.key(updated.groupID)!!
|
||||
assertEquals(2, updated.epoch)
|
||||
assertEquals(2, updated.members.size)
|
||||
assertFalse(originalKey.contentEquals(updatedKey))
|
||||
assertEquals(1, context.invites.size)
|
||||
|
||||
val state = GroupStatePayload.decode(context.invites.single().second)!!
|
||||
assertTrue(state.verifyCreatorSignature())
|
||||
assertEquals(updated, state.asGroup())
|
||||
assertArrayEquals(updatedKey, state.key)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invite is accepted only from authenticated creator`() {
|
||||
val creatorKey = privateKey(0x31)
|
||||
val localKey = privateKey(0x32)
|
||||
val creatorStatic = ByteArray(32) { 0x41 }
|
||||
val creatorFingerprint = fingerprint(creatorStatic)
|
||||
val localFingerprint = "52".repeat(32)
|
||||
val context = FakeGroupContext(localKey, localFingerprint)
|
||||
val group = incomingGroup(
|
||||
creatorKey,
|
||||
creatorFingerprint,
|
||||
localKey,
|
||||
localFingerprint
|
||||
)
|
||||
val payload = signedState(group, creatorKey, ByteArray(32) { 0x61 })
|
||||
val coordinator = GroupCoordinator(context)
|
||||
|
||||
coordinator.handleInvite("creator", ByteArray(32) { 0x7f }, payload)
|
||||
assertTrue(context.groupStore.groups.value.isEmpty())
|
||||
|
||||
coordinator.handleInvite("creator", creatorStatic, payload)
|
||||
assertEquals(group, context.groupStore.groups.value.single())
|
||||
assertTrue(group.peerID in context.unread)
|
||||
assertEquals(1, context.notifications.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `creator removal state drops key conversation and membership`() {
|
||||
val creatorKey = privateKey(0x71)
|
||||
val localKey = privateKey(0x72)
|
||||
val creatorStatic = ByteArray(32) { 0x73 }
|
||||
val creatorFingerprint = fingerprint(creatorStatic)
|
||||
val localFingerprint = "74".repeat(32)
|
||||
val context = FakeGroupContext(localKey, localFingerprint)
|
||||
val original = incomingGroup(
|
||||
creatorKey,
|
||||
creatorFingerprint,
|
||||
localKey,
|
||||
localFingerprint
|
||||
)
|
||||
assertTrue(context.groupStore.upsert(original, ByteArray(32) { 0x75 }))
|
||||
context.selected = original.peerID
|
||||
|
||||
val removedState = original.copy(
|
||||
epoch = original.epoch + 1,
|
||||
members = listOf(original.members.first())
|
||||
)
|
||||
val payload = signedState(removedState, creatorKey, ByteArray(32))
|
||||
GroupCoordinator(context).handleKeyUpdate("creator", creatorStatic, payload)
|
||||
|
||||
assertNull(context.groupStore.group(original.groupID))
|
||||
assertNull(context.groupStore.key(original.groupID))
|
||||
assertTrue(original.peerID in context.removedConversations)
|
||||
assertNull(context.selected)
|
||||
assertTrue(context.systemMessages.single().contains("removed"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `group message requires a roster sender and deduplicates`() {
|
||||
val creatorKey = privateKey(0x21)
|
||||
val localKey = privateKey(0x22)
|
||||
val creatorFingerprint = "81".repeat(32)
|
||||
val localFingerprint = "82".repeat(32)
|
||||
val context = FakeGroupContext(localKey, localFingerprint)
|
||||
val group = incomingGroup(
|
||||
creatorKey,
|
||||
creatorFingerprint,
|
||||
localKey,
|
||||
localFingerprint
|
||||
)
|
||||
val key = ByteArray(32) { 0x33 }
|
||||
assertTrue(context.groupStore.upsert(group, key))
|
||||
val coordinator = GroupCoordinator(context)
|
||||
val payload = sealedMessage(group, key, creatorKey, "message-1", "hello")
|
||||
|
||||
coordinator.handleMessage(payload, System.currentTimeMillis())
|
||||
coordinator.handleMessage(payload, System.currentTimeMillis())
|
||||
|
||||
val messages = context.messages[group.peerID].orEmpty()
|
||||
assertEquals(1, messages.size)
|
||||
assertEquals("hello", messages.single().content)
|
||||
assertEquals("creator", messages.single().sender)
|
||||
assertTrue(group.peerID in context.unread)
|
||||
assertEquals(1, context.notifications.size)
|
||||
|
||||
context.blocked += creatorFingerprint
|
||||
coordinator.handleMessage(
|
||||
sealedMessage(group, key, creatorKey, "message-2", "blocked"),
|
||||
System.currentTimeMillis()
|
||||
)
|
||||
assertEquals(1, context.messages[group.peerID].orEmpty().size)
|
||||
}
|
||||
|
||||
private fun incomingGroup(
|
||||
creatorKey: Ed25519PrivateKeyParameters,
|
||||
creatorFingerprint: String,
|
||||
localKey: Ed25519PrivateKeyParameters,
|
||||
localFingerprint: String
|
||||
) = BitchatGroup(
|
||||
groupID = ByteArray(16) { it.toByte() },
|
||||
name = "ops",
|
||||
epoch = 1,
|
||||
members = listOf(
|
||||
GroupMember(
|
||||
creatorFingerprint,
|
||||
creatorKey.generatePublicKey().encoded,
|
||||
"creator"
|
||||
),
|
||||
GroupMember(
|
||||
localFingerprint,
|
||||
localKey.generatePublicKey().encoded,
|
||||
"local"
|
||||
)
|
||||
),
|
||||
creatorFingerprint = creatorFingerprint
|
||||
)
|
||||
|
||||
private fun signedState(
|
||||
group: BitchatGroup,
|
||||
creatorKey: Ed25519PrivateKeyParameters,
|
||||
key: ByteArray
|
||||
): ByteArray =
|
||||
GroupStatePayload.makeSigned(group, key) { sign(creatorKey, it) }
|
||||
?.encode()
|
||||
?: error("state should encode")
|
||||
|
||||
private fun sealedMessage(
|
||||
group: BitchatGroup,
|
||||
key: ByteArray,
|
||||
senderKey: Ed25519PrivateKeyParameters,
|
||||
messageID: String,
|
||||
content: String
|
||||
): ByteArray = GroupCrypto.sealMessage(
|
||||
content = content,
|
||||
messageID = messageID,
|
||||
senderNickname = "untrusted nickname",
|
||||
senderSigningKey = senderKey.generatePublicKey().encoded,
|
||||
timestampMs = System.currentTimeMillis(),
|
||||
groupID = group.groupID,
|
||||
epoch = group.epoch,
|
||||
key = key
|
||||
) { sign(senderKey, it) }
|
||||
|
||||
private fun privateKey(seed: Int) =
|
||||
Ed25519PrivateKeyParameters(ByteArray(32) { seed.toByte() }, 0)
|
||||
|
||||
private fun sign(
|
||||
privateKey: Ed25519PrivateKeyParameters,
|
||||
data: ByteArray
|
||||
): ByteArray {
|
||||
val signer = Ed25519Signer()
|
||||
signer.init(true, privateKey)
|
||||
signer.update(data, 0, data.size)
|
||||
return signer.generateSignature()
|
||||
}
|
||||
|
||||
private fun fingerprint(noiseKey: ByteArray): String =
|
||||
MessageDigest.getInstance("SHA-256")
|
||||
.digest(noiseKey)
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private class FakeGroupContext(
|
||||
private val localKey: Ed25519PrivateKeyParameters,
|
||||
private val localFingerprint: String
|
||||
) : GroupCoordinatorContext {
|
||||
override val groupStore = GroupStore(TestGroupKeys(), testOnly = true)
|
||||
override val nickname = "local"
|
||||
override val myPeerID = localFingerprint.take(16)
|
||||
override val selectedConversationID: String?
|
||||
get() = selected
|
||||
|
||||
var selected: String? = null
|
||||
val peerIDs = mutableMapOf<String, String>()
|
||||
val connected = mutableSetOf<String>()
|
||||
val peerNames = mutableMapOf<String, String>()
|
||||
val identities = mutableMapOf<String, GroupPeerIdentity>()
|
||||
val connectedFingerprints = mutableMapOf<String, String>()
|
||||
val blocked = mutableSetOf<String>()
|
||||
val invites = mutableListOf<Pair<String, ByteArray>>()
|
||||
val updates = mutableListOf<Pair<String, ByteArray>>()
|
||||
val broadcasts = mutableListOf<ByteArray>()
|
||||
val messages = mutableMapOf<String, MutableList<BitchatMessage>>()
|
||||
val unread = mutableSetOf<String>()
|
||||
val removedConversations = mutableSetOf<String>()
|
||||
val systemMessages = mutableListOf<String>()
|
||||
val notifications = mutableListOf<Triple<String, String, String>>()
|
||||
|
||||
override fun myNoiseFingerprint() = localFingerprint
|
||||
override fun mySigningPublicKey(): ByteArray = localKey.generatePublicKey().encoded
|
||||
override fun sign(data: ByteArray): ByteArray {
|
||||
val signer = Ed25519Signer()
|
||||
signer.init(true, localKey)
|
||||
signer.update(data, 0, data.size)
|
||||
return signer.generateSignature()
|
||||
}
|
||||
|
||||
override fun peerIDForNickname(nickname: String) = peerIDs[nickname]
|
||||
override fun isPeerConnected(peerID: String) = peerID in connected
|
||||
override fun peerNickname(peerID: String) = peerNames[peerID]
|
||||
override fun peerIdentity(peerID: String) = identities[peerID]
|
||||
override fun connectedPeerID(fingerprint: String) = connectedFingerprints[fingerprint]
|
||||
override fun isFingerprintBlocked(fingerprint: String) = fingerprint in blocked
|
||||
|
||||
override fun sendGroupInvite(payload: ByteArray, peerID: String) {
|
||||
invites += peerID to payload
|
||||
}
|
||||
|
||||
override fun sendGroupKeyUpdate(payload: ByteArray, peerID: String) {
|
||||
updates += peerID to payload
|
||||
}
|
||||
|
||||
override fun broadcastGroupMessage(payload: ByteArray) {
|
||||
broadcasts += payload
|
||||
}
|
||||
|
||||
override fun appendGroupMessage(
|
||||
groupPeerID: String,
|
||||
message: BitchatMessage
|
||||
): Boolean {
|
||||
val conversation = messages.getOrPut(groupPeerID) { mutableListOf() }
|
||||
if (conversation.any { it.id == message.id }) return false
|
||||
conversation += message
|
||||
return true
|
||||
}
|
||||
|
||||
override fun markGroupUnread(groupPeerID: String) {
|
||||
unread += groupPeerID
|
||||
}
|
||||
|
||||
override fun removeGroupConversation(groupPeerID: String) {
|
||||
removedConversations += groupPeerID
|
||||
messages.remove(groupPeerID)
|
||||
unread.remove(groupPeerID)
|
||||
}
|
||||
|
||||
override fun openGroupConversation(groupPeerID: String) {
|
||||
selected = groupPeerID
|
||||
}
|
||||
|
||||
override fun closeGroupConversation() {
|
||||
selected = null
|
||||
}
|
||||
|
||||
override fun addSystemMessage(message: String) {
|
||||
systemMessages += message
|
||||
}
|
||||
|
||||
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 += Triple(groupPeerID, sender, message)
|
||||
}
|
||||
}
|
||||
|
||||
private class TestGroupKeys : GroupKeyStorage {
|
||||
private val values = mutableMapOf<String, ByteArray>()
|
||||
|
||||
override fun get(key: String): ByteArray? = values[key]?.copyOf()
|
||||
|
||||
override fun put(key: String, value: ByteArray): Boolean {
|
||||
values[key] = value.copyOf()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun remove(key: String): Boolean = values.remove(key) != null
|
||||
}
|
||||
@ -0,0 +1,263 @@
|
||||
package com.bitchat.android.groups
|
||||
|
||||
import java.security.MessageDigest
|
||||
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
|
||||
import org.bouncycastle.crypto.signers.Ed25519Signer
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class GroupProtocolTest {
|
||||
private val creatorPrivate = Ed25519PrivateKeyParameters(ByteArray(32) { 0x11 }, 0)
|
||||
private val memberPrivate = Ed25519PrivateKeyParameters(ByteArray(32) { 0x22 }, 0)
|
||||
private val creator = member(creatorPrivate, "creator", 0x31)
|
||||
private val member = member(memberPrivate, "alice", 0x41)
|
||||
private val group = BitchatGroup(
|
||||
groupID = ByteArray(16) { it.toByte() },
|
||||
name = "hike",
|
||||
epoch = 7,
|
||||
members = listOf(creator, member),
|
||||
creatorFingerprint = creator.fingerprint
|
||||
)
|
||||
private val groupKey = ByteArray(32) { (it + 1).toByte() }
|
||||
|
||||
@Test
|
||||
fun `creator-signed state round trips and verifies`() {
|
||||
val payload = GroupStatePayload.makeSigned(group, groupKey) { sign(creatorPrivate, it) }!!
|
||||
val decoded = GroupStatePayload.decode(payload.encode()!!)!!
|
||||
|
||||
assertEquals(payload, decoded)
|
||||
assertTrue(decoded.verifyCreatorSignature())
|
||||
assertEquals(group, decoded.asGroup())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `creator signature covers name epoch key and roster`() {
|
||||
val payload = GroupStatePayload.makeSigned(group, groupKey) { sign(creatorPrivate, it) }!!
|
||||
|
||||
assertFalse(copyState(payload, name = "ops").verifyCreatorSignature())
|
||||
assertFalse(copyState(payload, epoch = payload.epoch + 1).verifyCreatorSignature())
|
||||
assertFalse(copyState(payload, key = ByteArray(32) { 9 }).verifyCreatorSignature())
|
||||
assertFalse(
|
||||
copyState(
|
||||
payload,
|
||||
members = payload.members.map {
|
||||
if (it == member) it.copy(nickname = "mallory") else it
|
||||
}
|
||||
).verifyCreatorSignature()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `state rejects creator missing from roster and oversized roster`() {
|
||||
val noCreator = copyState(
|
||||
GroupStatePayload.makeSigned(group, groupKey) { sign(creatorPrivate, it) }!!,
|
||||
members = listOf(member)
|
||||
)
|
||||
assertFalse(noCreator.verifyCreatorSignature())
|
||||
|
||||
val tooMany = List(BitchatGroup.MAX_MEMBERS + 1) { index ->
|
||||
member(memberPrivate, "m$index", index)
|
||||
}
|
||||
assertNull(GroupRosterCoding.encode(tooMany))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `roster nickname truncation remains valid UTF-8`() {
|
||||
val longNickname = "€".repeat(40)
|
||||
val encoded = GroupRosterCoding.encode(listOf(creator.copy(nickname = longNickname)))!!
|
||||
val decoded = GroupRosterCoding.decode(encoded)!!
|
||||
|
||||
assertTrue(decoded.single().nickname.toByteArray(Charsets.UTF_8).size <= 64)
|
||||
assertTrue(decoded.single().nickname.all { it != '\uFFFD' })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `group message seals opens and verifies sender`() {
|
||||
val encoded = GroupCrypto.sealMessage(
|
||||
content = "meet at the ridge",
|
||||
messageID = "message-1",
|
||||
senderNickname = member.nickname,
|
||||
senderSigningKey = member.signingKey,
|
||||
timestampMs = 1_725_000_000_123,
|
||||
groupID = group.groupID,
|
||||
epoch = group.epoch,
|
||||
key = groupKey
|
||||
) { sign(memberPrivate, it) }
|
||||
|
||||
val envelope = GroupMessageEnvelope.decode(encoded)!!
|
||||
val opened = GroupCrypto.openMessage(envelope, groupKey)
|
||||
|
||||
assertEquals("message-1", opened.messageID)
|
||||
assertEquals("meet at the ridge", opened.content)
|
||||
assertEquals(member.nickname, opened.senderNickname)
|
||||
assertArrayEquals(member.signingKey, opened.senderSigningKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `message cannot move between keys groups or epochs`() {
|
||||
val encoded = sealedMessage()
|
||||
val envelope = GroupMessageEnvelope.decode(encoded)!!
|
||||
|
||||
assertThrows(GroupCryptoException.DecryptionFailed::class.java) {
|
||||
GroupCrypto.openMessage(envelope, ByteArray(32) { 0x7f })
|
||||
}
|
||||
assertThrows(GroupCryptoException.DecryptionFailed::class.java) {
|
||||
GroupCrypto.openMessage(
|
||||
GroupMessageEnvelope(
|
||||
envelope.groupID,
|
||||
envelope.epoch + 1,
|
||||
envelope.nonce,
|
||||
envelope.ciphertext
|
||||
),
|
||||
groupKey
|
||||
)
|
||||
}
|
||||
assertThrows(GroupCryptoException.DecryptionFailed::class.java) {
|
||||
GroupCrypto.openMessage(
|
||||
GroupMessageEnvelope(
|
||||
ByteArray(16) { 0x55 },
|
||||
envelope.epoch,
|
||||
envelope.nonce,
|
||||
envelope.ciphertext
|
||||
),
|
||||
groupKey
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bad sender signature is rejected after valid AEAD`() {
|
||||
val encoded = GroupCrypto.sealMessage(
|
||||
content = "forged",
|
||||
messageID = "message-2",
|
||||
senderNickname = member.nickname,
|
||||
senderSigningKey = member.signingKey,
|
||||
timestampMs = 42,
|
||||
groupID = group.groupID,
|
||||
epoch = group.epoch,
|
||||
key = groupKey
|
||||
) { ByteArray(64) }
|
||||
|
||||
assertThrows(GroupCryptoException.BadSenderSignature::class.java) {
|
||||
GroupCrypto.openMessage(GroupMessageEnvelope.decode(encoded)!!, groupKey)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed envelopes and state are rejected`() {
|
||||
assertNull(GroupMessageEnvelope.decode(byteArrayOf(1, 0)))
|
||||
assertNull(GroupStatePayload.decode(byteArrayOf(1, 0)))
|
||||
|
||||
val envelope = GroupMessageEnvelope.decode(sealedMessage())!!
|
||||
val tampered = envelope.ciphertext.copyOf().also {
|
||||
it[it.lastIndex] = (it.last().toInt() xor 1).toByte()
|
||||
}
|
||||
assertThrows(GroupCryptoException.DecryptionFailed::class.java) {
|
||||
GroupCrypto.openMessage(
|
||||
GroupMessageEnvelope(envelope.groupID, envelope.epoch, envelope.nonce, tampered),
|
||||
groupKey
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `group IDs use iOS virtual conversation form`() {
|
||||
val peerID = GroupIds.peerID(group.groupID)
|
||||
assertEquals("group_000102030405060708090a0b0c0d0e0f", peerID)
|
||||
assertTrue(GroupIds.isGroup(peerID))
|
||||
assertArrayEquals(group.groupID, GroupIds.groupID(peerID))
|
||||
assertNull(GroupIds.groupID("group_not-hex"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `message signing content covers epoch`() {
|
||||
val first = GroupCrypto.messageSigningContent(group.groupID, 1, "id", 9, "hello")
|
||||
val second = GroupCrypto.messageSigningContent(group.groupID, 2, "id", 9, "hello")
|
||||
assertFalse(MessageDigest.isEqual(first, second))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `opens deterministic vectors emitted by iOS CryptoKit`() {
|
||||
val state = GroupStatePayload.decode(IOS_STATE_VECTOR.hexBytes())
|
||||
assertNotNull(state)
|
||||
assertEquals("hike", state!!.name)
|
||||
assertEquals(7, state.epoch)
|
||||
assertTrue(state.verifyCreatorSignature())
|
||||
|
||||
val plaintext = GroupCrypto.openMessage(
|
||||
GroupMessageEnvelope.decode(IOS_MESSAGE_VECTOR.hexBytes())!!,
|
||||
ByteArray(32) { (it + 1).toByte() }
|
||||
)
|
||||
assertEquals("ios-vector", plaintext.messageID)
|
||||
assertEquals("alice", plaintext.senderNickname)
|
||||
assertEquals("meet at ridge", plaintext.content)
|
||||
assertEquals(1_725_000_000_123, plaintext.timestampMs)
|
||||
assertArrayEquals(IOS_MEMBER_PUBLIC_KEY.hexBytes(), plaintext.senderSigningKey)
|
||||
}
|
||||
|
||||
private fun sealedMessage(): ByteArray = GroupCrypto.sealMessage(
|
||||
content = "hello",
|
||||
messageID = "message",
|
||||
senderNickname = member.nickname,
|
||||
senderSigningKey = member.signingKey,
|
||||
timestampMs = 1234,
|
||||
groupID = group.groupID,
|
||||
epoch = group.epoch,
|
||||
key = groupKey
|
||||
) { sign(memberPrivate, it) }
|
||||
|
||||
private fun member(
|
||||
privateKey: Ed25519PrivateKeyParameters,
|
||||
nickname: String,
|
||||
fingerprintSeed: Int
|
||||
): GroupMember = GroupMember(
|
||||
fingerprint = ByteArray(32) { fingerprintSeed.toByte() }
|
||||
.joinToString("") { "%02x".format(it) },
|
||||
signingKey = privateKey.generatePublicKey().encoded,
|
||||
nickname = nickname
|
||||
)
|
||||
|
||||
private fun sign(privateKey: Ed25519PrivateKeyParameters, data: ByteArray): ByteArray {
|
||||
val signer = Ed25519Signer()
|
||||
signer.init(true, privateKey)
|
||||
signer.update(data, 0, data.size)
|
||||
return signer.generateSignature()
|
||||
}
|
||||
|
||||
private fun copyState(
|
||||
source: GroupStatePayload,
|
||||
groupID: ByteArray = source.groupID,
|
||||
name: String = source.name,
|
||||
key: ByteArray = source.key,
|
||||
epoch: Long = source.epoch,
|
||||
members: List<GroupMember> = source.members,
|
||||
creatorFingerprint: String = source.creatorFingerprint,
|
||||
signature: ByteArray = source.signature
|
||||
): GroupStatePayload = GroupStatePayload(
|
||||
groupID,
|
||||
name,
|
||||
key,
|
||||
epoch,
|
||||
members,
|
||||
creatorFingerprint,
|
||||
signature
|
||||
)
|
||||
|
||||
private fun String.hexBytes(): ByteArray =
|
||||
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
|
||||
companion object {
|
||||
private const val IOS_MEMBER_PUBLIC_KEY =
|
||||
"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0"
|
||||
private const val IOS_STATE_VECTOR =
|
||||
"010010000102030405060708090a0b0c0d0e0f02000468696b650300200102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f200400040000000705008f023131313131313131313131313131313131313131313131313131313131313131d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c97787370763726561746f724141414141414141414141414141414141414141414141414141414141414141a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f005616c69636506002031313131313131313131313131313131313131313131313131313131313131310700407e7dcb5210e48a2edc346f1364c0e3f3a7939a3006f318bd591e94f11b7c6d5a928042f04278bf2e5d612da02258acd686d11a8f8f310b2cc0f8b886bb13ac05"
|
||||
private const val IOS_MESSAGE_VECTOR =
|
||||
"010010000102030405060708090a0b0c0d0e0f0200040000000703000c000102030405060708090a0b0400a6e1abbfb19fd03920bbd627b82b5d575bd8ec48fc57c70d8f7f7c3af33375ae8ac1ed189e8e17acbe8f4c77cda97e44b8084234d2d8e7825ddcfdb492ed01a4eba676a9c28fcae940b33a80a756f27af8758e6dfca33c4184cd5fa2b82fe527de32c79c9a52d8ddcd596c7e3dcc29003184adf571489a8a9a9d0b7301cdf85b45b4521e55aebf6238031af50c81d6dd639b6210e82c0ab70e19ba4d1b04c323c70f6e09b54585"
|
||||
}
|
||||
}
|
||||
113
app/src/test/kotlin/com/bitchat/android/groups/GroupStoreTest.kt
Normal file
113
app/src/test/kotlin/com/bitchat/android/groups/GroupStoreTest.kt
Normal file
@ -0,0 +1,113 @@
|
||||
package com.bitchat.android.groups
|
||||
|
||||
import java.io.File
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
|
||||
class GroupStoreTest {
|
||||
@get:Rule
|
||||
val temporaryFolder = TemporaryFolder()
|
||||
|
||||
@Test
|
||||
fun `create read rotate and remove`() {
|
||||
val keys = MemoryGroupKeys()
|
||||
val store = GroupStore(keys, testOnly = true)
|
||||
val group = store.createGroup("hike", creator())!!
|
||||
val originalKey = store.key(group.groupID)!!
|
||||
|
||||
assertEquals(1, group.epoch)
|
||||
assertEquals(group, store.group(group.groupID))
|
||||
assertArrayEquals(originalKey, store.key(group.groupID))
|
||||
|
||||
val newMember = GroupMember("22".repeat(32), ByteArray(32) { 0x33 }, "alice")
|
||||
val (rotated, newKey) = store.rotateKey(group.groupID, listOf(creator(), newMember))!!
|
||||
|
||||
assertEquals(2, rotated.epoch)
|
||||
assertEquals(2, rotated.members.size)
|
||||
assertFalse(originalKey.contentEquals(newKey))
|
||||
assertArrayEquals(newKey, store.key(group.groupID))
|
||||
|
||||
store.removeGroup(group.groupID)
|
||||
assertNull(store.group(group.groupID))
|
||||
assertNull(store.key(group.groupID))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `persistence reloads metadata only when key survives`() {
|
||||
val keys = MemoryGroupKeys()
|
||||
val file = File(temporaryFolder.root, "groups.json")
|
||||
val first = GroupStore(keys, file, testOnly = true)
|
||||
val group = first.createGroup("ops", creator())!!
|
||||
|
||||
val reloaded = GroupStore(keys, file, testOnly = true)
|
||||
assertEquals(listOf(group), reloaded.groups.value)
|
||||
|
||||
keys.remove("groupKey-${group.groupID.joinToString("") { "%02x".format(it) }}")
|
||||
val withoutKey = GroupStore(keys, file, testOnly = true)
|
||||
assertTrue(withoutKey.groups.value.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `creator and roster cap are enforced`() {
|
||||
val store = GroupStore(MemoryGroupKeys(), testOnly = true)
|
||||
val missingCreator = BitchatGroup(
|
||||
groupID = ByteArray(16),
|
||||
name = "bad",
|
||||
epoch = 1,
|
||||
members = listOf(GroupMember("22".repeat(32), ByteArray(32), "member")),
|
||||
creatorFingerprint = "11".repeat(32)
|
||||
)
|
||||
assertFalse(store.upsert(missingCreator, ByteArray(32)))
|
||||
|
||||
val tooMany = BitchatGroup(
|
||||
groupID = ByteArray(16),
|
||||
name = "full",
|
||||
epoch = 1,
|
||||
members = List(17) { index ->
|
||||
GroupMember("%02x".format(index).repeat(32), ByteArray(32) { index.toByte() }, "m$index")
|
||||
},
|
||||
creatorFingerprint = "00".repeat(32)
|
||||
)
|
||||
assertFalse(store.upsert(tooMany, ByteArray(32)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `panic wipe clears keys metadata and memory`() {
|
||||
val keys = MemoryGroupKeys()
|
||||
val file = File(temporaryFolder.root, "groups.json")
|
||||
val store = GroupStore(keys, file, testOnly = true)
|
||||
val group = store.createGroup("gone", creator())!!
|
||||
assertTrue(file.exists())
|
||||
assertNotNull(store.key(group.groupID))
|
||||
|
||||
store.wipe()
|
||||
|
||||
assertTrue(store.groups.value.isEmpty())
|
||||
assertNull(store.key(group.groupID))
|
||||
assertFalse(file.exists())
|
||||
}
|
||||
|
||||
private fun creator() =
|
||||
GroupMember("11".repeat(32), ByteArray(32) { 0x44 }, "creator")
|
||||
}
|
||||
|
||||
private class MemoryGroupKeys : GroupKeyStorage {
|
||||
private val values = mutableMapOf<String, ByteArray>()
|
||||
|
||||
override fun get(key: String): ByteArray? = values[key]?.copyOf()
|
||||
|
||||
override fun put(key: String, value: ByteArray): Boolean {
|
||||
values[key] = value.copyOf()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun remove(key: String): Boolean = values.remove(key) != null
|
||||
}
|
||||
@ -59,17 +59,15 @@ class IdentityAnnouncementTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local announcement send advertises private media`() {
|
||||
fun `local announcement send advertises private media and groups`() {
|
||||
val encoded = IdentityAnnouncement.forLocalPeer(nickname, noiseKey, signingKey).encode()!!
|
||||
|
||||
assertArrayEquals(
|
||||
byteArrayOf(0x05, 0x02, 0x00, 0x01),
|
||||
byteArrayOf(0x05, 0x02, 0x08, 0x01),
|
||||
encoded.takeLast(4).toByteArray()
|
||||
)
|
||||
assertTrue(
|
||||
IdentityAnnouncement.decode(encoded)!!
|
||||
.capabilities!!
|
||||
.contains(PeerCapabilities.PRIVATE_MEDIA)
|
||||
)
|
||||
val capabilities = IdentityAnnouncement.decode(encoded)!!.capabilities!!
|
||||
assertTrue(capabilities.contains(PeerCapabilities.PRIVATE_MEDIA))
|
||||
assertTrue(capabilities.contains(PeerCapabilities.GROUPS))
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,116 @@
|
||||
package com.bitchat.android.sync
|
||||
|
||||
import com.bitchat.android.model.RequestSyncPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import com.bitchat.android.protocol.SpecialRecipients
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class GossipSyncGroupTest {
|
||||
@Test
|
||||
fun `typed group request serves only opaque group packets`() {
|
||||
val fixture = fixture()
|
||||
val publicPacket = packet(MessageType.MESSAGE, byteArrayOf(1))
|
||||
val groupPacket = packet(MessageType.GROUP_MESSAGE, byteArrayOf(2))
|
||||
fixture.manager.onPublicPacketSeen(publicPacket)
|
||||
fixture.manager.onPublicPacketSeen(groupPacket)
|
||||
|
||||
fixture.manager.handleRequestSync(
|
||||
PEER_ID,
|
||||
RequestSyncPacket(
|
||||
p = 5,
|
||||
m = 1,
|
||||
data = ByteArray(0),
|
||||
types = SyncTypeFlags.GROUP_MESSAGE
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(listOf(MessageType.GROUP_MESSAGE), fixture.delegate.sentTypes)
|
||||
fixture.scope.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial request combines public and group sync bits`() {
|
||||
val fixture = fixture()
|
||||
fixture.manager.scheduleInitialSyncToPeer(PEER_ID, delayMs = 0)
|
||||
|
||||
assertTrue(fixture.delegate.requestLatch.await(2, TimeUnit.SECONDS))
|
||||
val request = RequestSyncPacket.decode(fixture.delegate.lastRequestPayload!!)!!
|
||||
val types = requireNotNull(request.types)
|
||||
assertTrue(types.contains(MessageType.ANNOUNCE))
|
||||
assertTrue(types.contains(MessageType.MESSAGE))
|
||||
assertTrue(types.contains(MessageType.GROUP_MESSAGE))
|
||||
fixture.scope.cancel()
|
||||
}
|
||||
|
||||
private fun fixture(): Fixture {
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val manager = GossipSyncManager(
|
||||
myPeerID = MY_ID,
|
||||
scope = scope,
|
||||
configProvider = object : GossipSyncManager.ConfigProvider {
|
||||
override fun seenCapacity() = 100
|
||||
override fun gcsMaxBytes() = 400
|
||||
override fun gcsTargetFpr() = 0.01
|
||||
}
|
||||
)
|
||||
val delegate = RecordingSyncDelegate()
|
||||
manager.delegate = delegate
|
||||
return Fixture(scope, manager, delegate)
|
||||
}
|
||||
|
||||
private fun packet(type: MessageType, payload: ByteArray) = BitchatPacket(
|
||||
type = type.value,
|
||||
senderID = ByteArray(8) { 0x11 },
|
||||
recipientID = SpecialRecipients.BROADCAST,
|
||||
timestamp = 1u,
|
||||
payload = payload,
|
||||
ttl = 1u
|
||||
)
|
||||
|
||||
private data class Fixture(
|
||||
val scope: CoroutineScope,
|
||||
val manager: GossipSyncManager,
|
||||
val delegate: RecordingSyncDelegate
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val MY_ID = "0011223344556677"
|
||||
private const val PEER_ID = "8899aabbccddeeff"
|
||||
}
|
||||
}
|
||||
|
||||
private class RecordingSyncDelegate : GossipSyncManager.Delegate {
|
||||
val sentTypes = mutableListOf<MessageType>()
|
||||
val requestLatch = CountDownLatch(1)
|
||||
@Volatile
|
||||
var lastRequestPayload: ByteArray? = null
|
||||
|
||||
override fun sendPacket(packet: BitchatPacket) {
|
||||
record(packet)
|
||||
}
|
||||
|
||||
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) {
|
||||
record(packet)
|
||||
}
|
||||
|
||||
override fun signPacketForBroadcast(packet: BitchatPacket) = packet
|
||||
|
||||
private fun record(packet: BitchatPacket) {
|
||||
val type = MessageType.fromValue(packet.type) ?: return
|
||||
if (type == MessageType.REQUEST_SYNC) {
|
||||
lastRequestPayload = packet.payload
|
||||
requestLatch.countDown()
|
||||
} else {
|
||||
sentTypes += type
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
package com.bitchat.android.sync
|
||||
|
||||
import com.bitchat.android.model.RequestSyncPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SyncTypeFlagsGroupTest {
|
||||
@Test
|
||||
fun `group bit ten widens little endian flags to two bytes`() {
|
||||
assertArrayEquals(
|
||||
byteArrayOf(0x00, 0x04),
|
||||
SyncTypeFlags.GROUP_MESSAGE.encoded()
|
||||
)
|
||||
assertTrue(SyncTypeFlags.decode(byteArrayOf(0x00, 0x04))!!.contains(MessageType.GROUP_MESSAGE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown sync bits are ignored`() {
|
||||
val decoded = SyncTypeFlags.decode(byteArrayOf(0x00, 0x0c))!!
|
||||
assertTrue(decoded.contains(MessageType.GROUP_MESSAGE))
|
||||
assertArrayEquals(byteArrayOf(0x00, 0x04), decoded.encoded())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `request sync round trips group types and legacy omission`() {
|
||||
val typed = RequestSyncPacket(5, 100, byteArrayOf(1, 2), SyncTypeFlags.GROUP_MESSAGE)
|
||||
val decoded = RequestSyncPacket.decode(typed.encode())!!
|
||||
val types = requireNotNull(decoded.types)
|
||||
assertTrue(types.contains(MessageType.GROUP_MESSAGE))
|
||||
assertFalse(types.contains(MessageType.MESSAGE))
|
||||
|
||||
val legacy = RequestSyncPacket(5, 100, byteArrayOf(1, 2))
|
||||
assertTrue(RequestSyncPacket.decode(legacy.encode())!!.types == null)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user