1package com.sophax.sophaxchat.protocol23import kotlinx.serialization.KSerializer4import kotlinx.serialization.SerialName5import kotlinx.serialization.Serializable6import kotlinx.serialization.descriptors.PrimitiveKind7import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor8import kotlinx.serialization.descriptors.SerialDescriptor9import kotlinx.serialization.encoding.Decoder10import kotlinx.serialization.encoding.Encoder11import android.util.Base6412import java.text.SimpleDateFormat13import java.util.Date14import java.util.Locale15import java.util.TimeZone1617// ---------------------------------------------------------------------------18// Custom serializers — match iOS JSONEncoder defaults19// ---------------------------------------------------------------------------2021/** Serializes ByteArray as base64 (standard encoding, no wrap) — matches Swift Data Codable. */22object ByteArraySerializer : KSerializer<ByteArray>{23 override val descriptor: SerialDescriptor =24 PrimitiveSerialDescriptor("ByteArray", PrimitiveKind.STRING)25 override fun serialize(encoder: Encoder, value: ByteArray)=26 encoder.encodeString(Base64.encodeToString(value, Base64.NO_WRAP))27 override fun deserialize(decoder: Decoder): ByteArray =28 Base64.decode(decoder.decodeString(), Base64.NO_WRAP)29}3031/** Serializes Date as ISO8601 string — matches Swift's .iso8601 date strategy.32 *33 * Swift JSONEncoder emits the UTC suffix as literal "Z" (e.g. "2024-01-15T10:30:00Z").34 * Java SimpleDateFormat pattern "Z" matches "+0000" but NOT the literal "Z", so we35 * normalise the incoming string before parsing: replace a trailing "Z" with "+0000".36 * Output always uses "+0000" which is valid ISO8601 and round-trips correctly.37 */38object DateSerializer : KSerializer<Date>{39 private val fmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US).also {40 it.timeZone = TimeZone.getTimeZone("UTC")41}42 override val descriptor: SerialDescriptor =43 PrimitiveSerialDescriptor("Date", PrimitiveKind.STRING)44 override fun serialize(encoder: Encoder, value: Date)=45 encoder.encodeString(fmt.format(value))46 override fun deserialize(decoder: Decoder): Date {47// Normalise "Z" → "+0000" so SimpleDateFormat can parse Swift-generated timestamps.48 val raw = decoder.decodeString().let {49 if (it.endsWith("Z")) it.dropLast(1)+"+0000" else it50}51 return fmt.parse(raw)?: Date()52}53}5455typealias ByteArrayBase64 =@Serializable(ByteArraySerializer::class) ByteArray56typealias SerDate =@Serializable(DateSerializer::class) Date5758// ---------------------------------------------------------------------------59// Wire message envelope60// ---------------------------------------------------------------------------6162enum class WireMessageType {63 hello, initiateSession, message, ack, relay, typing,64 sealed, readReceipt, reaction,65 groupMessage, groupReaction, groupMemberLeft, groupDeleted, groupReadReceipt,66 storeAndForward, storeAndForwardDelivery, channelAnnouncement,67// Added to match iOS wire protocol:68 deadDrop,// sealed mesh flood for offline recipient69 editMessage,// author edits a 1:1 message in-place70 groupEditMessage,// author edits a group message, fanned out to members71 senderKeyRequest,// request peer to re-send their SenderKeyDistributionMessage72 deviceLinkRequest,// QR-based device link: share PreKeyBundle with other device73 deviceSyncMessage,// forward a received/sent message to all linked devices74 remoteWipe,// trusted contact requests account wipe75// Kademlia DHT (peer discovery over Tor)76 dhtPing,// liveness check77 dhtPong,// reply to dhtPing78 dhtFindNode,// iterative lookup: return k closest nodes to targetNodeID79 dhtFindNodeResp,// response with up to k closest known contacts80 dhtStore,// store a PreKeyBundle at this node81 dhtFindValue,// lookup: return stored bundle or closest nodes82 dhtFindValueResp // response: bundle (hit) or closest nodes (miss)83}8485@Serializable86data class WireMessage(87 val type: String,// WireMessageType.name88 val payload: ByteArrayBase64,89 val senderID: String,90 val timestamp: SerDate,91 val signature: ByteArrayBase6492){93/** Canonical bytes for signing — must match iOS signingBytes(). */94 fun signingBytes(): ByteArray =95 type.toByteArray()+ payload +96 senderID.toByteArray()+97 SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US).let {98 it.timeZone = TimeZone.getTimeZone("UTC")99 it.format(timestamp).toByteArray()100}101}102103// ---------------------------------------------------------------------------104// Handshake105// ---------------------------------------------------------------------------106107@Serializable108data class PreKeyBundle(109 val peerID: String,110 val username: String,111 val signingKeyPublic: ByteArrayBase64,112 val dhIdentityKeyPublic: ByteArrayBase64,113 val signedPreKeyPublic: ByteArrayBase64,114 val signedPreKeyId: Long,115 val signedPreKeySignature: ByteArrayBase64,116 val oneTimePreKeyPublic: ByteArrayBase64?,117 val oneTimePreKeyId: Long?,118 val timestamp: SerDate,119 val tcpAddress: String?120)121122@Serializable123data class HelloMessage(val bundle: PreKeyBundle)124125// ---------------------------------------------------------------------------126// Session initiation (X3DH)127// ---------------------------------------------------------------------------128129@Serializable130data class RatchetHeader(131 val senderRatchetKey: ByteArrayBase64,132 val previousChainLength: Long,133 val messageNumber: Long134)135136@Serializable137data class RatchetMessage(138 val encryptedHeader: ByteArrayBase64,139 val ciphertext: ByteArrayBase64140)141142@Serializable143data class InitiateSessionMessage(144 val senderBundle: PreKeyBundle,145 val ephemeralPublicKey: ByteArrayBase64,146 val usedSignedPreKeyId: Long,147 val usedOneTimePreKeyId: Long?,148 val initialMessage: RatchetMessage149)150151// ---------------------------------------------------------------------------152// Chat message153// ---------------------------------------------------------------------------154155@Serializable156data class ChatMessagePayload(157 val ratchetMessage: RatchetMessage,158 val messageID: String159)160161@Serializable162data class MessageContent(163 val body: String,164 val type: String ="text",// text | image | audio | groupInvite | senderKeyDistribution165 val replyToID: String?= null,166 val timestamp: SerDate,167 val expiresAt: SerDate?= null,168 val attachmentData: ByteArrayBase64?= null,169 val attachmentMimeType: String?= null,170 val audioDuration: Double?= null,171 val groupInviteData: ByteArrayBase64?= null,172 val senderKeyData: ByteArrayBase64?= null173)174175// ---------------------------------------------------------------------------176// Ack177// ---------------------------------------------------------------------------178179@Serializable180data class AckMessage(val messageID: String, val status: String)// "delivered" | "failed"181182// ---------------------------------------------------------------------------183// Typing184// ---------------------------------------------------------------------------185186@Serializable187data class TypingMessage(val isTyping: Boolean)188189// ---------------------------------------------------------------------------190// Read receipt191// ---------------------------------------------------------------------------192193@Serializable194data class ReadReceiptMessage(val messageIDs: List<String>)195196// ---------------------------------------------------------------------------197// Reactions198// ---------------------------------------------------------------------------199200@Serializable201data class ReactionMessage(val targetMessageID: String, val emoji: String?)202203@Serializable204data class GroupReactionMessage(val groupID: String, val targetMessageID: String, val emoji: String?)205206// ---------------------------------------------------------------------------207// Multihop relay208// ---------------------------------------------------------------------------209210@Serializable211data class RelayEnvelope(212 val id: String,213 val targetPeerID: String,214 val originPeerID: String,215 val ttl: Int,216 val hopCount: Int,217 val message: WireMessage218){219 companion object { const val MAX_TTL = 6 }220221 fun forwarded()= copy(ttl = if (ttl > 0) ttl - 1 else 0, hopCount = hopCount + 1)222}223224// ---------------------------------------------------------------------------225// Sealed sender226// ---------------------------------------------------------------------------227228@Serializable229data class SealedMessage(230 val ephemeralPublicKey: ByteArrayBase64,231 val encryptedPayload: ByteArrayBase64232)233234// ---------------------------------------------------------------------------235// Group messages236// ---------------------------------------------------------------------------237238@Serializable239data class GroupWireMessage(240 val groupID: String,241 val messageID: String,242 val senderPeerID: String,243 val senderUsername: String,244 val timestamp: SerDate,245 val ciphertext: ByteArrayBase64,246 val attachmentCiphertext: ByteArrayBase64?= null,247 val attachmentMimeType: String?= null,248 val audioDuration: Double?= null,249 val senderKeyIteration: Long?= null,250 val expiresAt: SerDate?= null,251 val replyToID: String?= null,252/** ≤8KB JPEG avatar piggyback — used for avatar sync with relay-only contacts. */253 val senderAvatarData: ByteArrayBase64?= null254)255256@Serializable257data class GroupDeletedMessage(258 val groupID: String,259 val deletedByPeerID: String260)261262@Serializable263data class GroupMemberLeftMessage(264 val groupID: String,265 val leavingPeerID: String,266 val remainingMemberIDs: List<String>267)268269@Serializable270data class GroupReadReceiptMessage(val groupID: String, val targetMessageID: String)271272// ---------------------------------------------------------------------------273// Store-and-forward274// ---------------------------------------------------------------------------275276@Serializable277data class StoreAndForwardRequest(278 val targetPeerID: String,279 val messageID: String,280 val sealed: SealedMessage,281 val expiresAt: SerDate282)283284@Serializable285data class StoreAndForwardDelivery(val items: List<StoreAndForwardItem>)286287@Serializable288data class StoreAndForwardItem(val messageID: String, val sealed: SealedMessage)289290// ---------------------------------------------------------------------------291// Channel discovery292// ---------------------------------------------------------------------------293294@Serializable295data class ChannelAnnouncement(296 val groupID: String,297 val groupName: String,298 val creatorID: String,299 val memberCount: Int,300 val timestamp: SerDate301)302303// ---------------------------------------------------------------------------304// Sender key request (matches iOS SenderKeyRequestMessage)305// ---------------------------------------------------------------------------306307/** Sent to a group member whose sender key is missing or stale.308 * The recipient responds by re-sending their current SenderKeyDistributionMessage. */309@Serializable310data class SenderKeyRequestMessage(311 val groupID: String,312 val targetPeerID: String313)314315// ---------------------------------------------------------------------------316// Edit message (matches iOS EditMessagePayload)317// ---------------------------------------------------------------------------318319/** Sent when the author edits a previously-sent 1:1 message. DR-encrypted. */320@Serializable321data class EditMessagePayload(322 val messageID: String,323 val newBody: String,324 val editedAt: SerDate325)326327// ---------------------------------------------------------------------------328// Group edit message (matches iOS GroupEditMessagePayload)329// ---------------------------------------------------------------------------330331/** Fanned out to all group members when the original author edits a message. */332@Serializable333data class GroupEditMessagePayload(334 val groupID: String,335 val messageID: String,336 val newBody: String,337 val editedAt: SerDate338)339340// ---------------------------------------------------------------------------341// Dead drop (matches iOS DeadDropEnvelope)342// ---------------------------------------------------------------------------343344/** Sealed message flooded over the mesh for an offline recipient.345 * Relay nodes see only targetPeerID and expiresAt; content is sealed. */346@Serializable347data class DeadDropEnvelope(348 val id: String,349 val targetPeerID: String,350 val sealed: SealedMessage,351 val expiresAt: SerDate352)353354// ---------------------------------------------------------------------------355// Device linking (multi-device)356// ---------------------------------------------------------------------------357358/**359 * QR payload: device A broadcasts its PreKeyBundle so device B can open a DR session.360 * Transmitted via the `deviceLinkRequest` wire message type.361 */362@Serializable363data class DeviceLinkRequestMessage(364/** Human-readable label for this device, e.g. "Pixel 9 Pro" */365 val deviceLabel: String,366/** The full PreKeyBundle of this device for X3DH session initiation */367 val bundle: PreKeyBundle368)369370/**371 * Forwarded copy of a message from one linked device to all others.372 * The raw message JSON is serialised so both sides can decode it without373 * re-running the original crypto.374 */375@Serializable376data class DeviceSyncMessage(377/** JSON-encoded StoredMessage (avoids re-running crypto) */378 val messageJSON: ByteArrayBase64379)380381// ---------------------------------------------------------------------------382// Known peer (local store)383// ---------------------------------------------------------------------------384385data class KnownPeer(386 val id: String,387 val username: String,388 val signingKeyPublic: ByteArray,389 val dhKeyPublic: ByteArray,390 val safetyNumber: String,391 var lastSeen: Date?= null,392 var isOnline: Boolean = false,393 var isDirectlyConnected: Boolean = false,394 var tcpAddress: String?= null395)396397// ---------------------------------------------------------------------------398// Remote Wipe399// ---------------------------------------------------------------------------400401/** Signed request to wipe the receiver's account.402 * Sender must be in receiver's trustedWipePeers list.403 * requestID prevents replay attacks.404 */405@Serializable406data class RemoteWipeRequest(407 val requestID: String = java.util.UUID.randomUUID().toString(),408 val issuedAt: SerDate = java.util.Date()409)410411// ---------------------------------------------------------------------------412// Kademlia DHT payloads413// Field names must match iOS counterparts exactly for cross-platform JSON interop.414// ---------------------------------------------------------------------------415416/** Contact info published in DHT routing tables and lookup responses. */417@Serializable418data class DHTNodeInfo(419/** Full 256-bit SHA256(signingKey || dhKey) as lowercase hex (64 chars). */420 val nodeID: String,421/** Tor v3 .onion hostname without port (62 chars). */422 val onionAddress: String,423/** TCP port — always 25519 in practice. */424 val port: Int = 25519425)426427@Serializable data class DHTPingPayload(val senderNodeID: String)428@Serializable data class DHTPongPayload(val senderNodeID: String)429430/** Ask the receiver for up to k nodes closest to targetNodeID. */431@Serializable data class DHTFindNodePayload(val targetNodeID: String)432433/** Up to k closest DHT contacts known to the responder. */434@Serializable data class DHTFindNodeRespPayload(val closestNodes: List<DHTNodeInfo>)435436/** Ask the receiver to store a PreKeyBundle (we computed they are among the k closest). */437@Serializable438data class DHTStorePayload(439/** Full 256-bit node ID (hex 64 chars) — DHT key under which the bundle is stored. */440 val nodeID: String,441 val bundle: PreKeyBundle,442/** Bundle expires after this date (typically now + 25h). */443 val expiresAt: SerDate444)445446/** Lookup a stored PreKeyBundle by full nodeID or 16-char peerID prefix. */447@Serializable448data class DHTFindValuePayload(449/** Full 256-bit node ID (hex) for XOR routing. */450 val targetNodeID: String,451/** Optional 16-char prefix when full ID is unknown — receivers scan local store. */452 val peerIDPrefix: String?= null453)454455/** Response to dhtFindValue: bundle (hit) or k closest nodes (miss). */456@Serializable457data class DHTFindValueRespPayload(458/** Non-null when the receiver holds the bundle for the requested node. */459 val bundle: PreKeyBundle?,460/** Populated when bundle is null — k closest nodes to query next. */461 val closestNodes: List<DHTNodeInfo>= emptyList()462)