[{"data":1,"prerenderedAt":1726},["ShallowReactive",2],{"i-ph:moon-bold":3,"i-ph:globe-simple":8,"i-ph:caret-down":10,"i-ph:list":12,"i-ph:heart-fill":14,"i-ph:discord-logo-bold":16,"i-ph:github-logo-bold":18,"i-ph:cookie-bold":20,"blog-article-en-chat-architecture":22,"i-ph:arrow-left":1724},{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":7},0,256,false,"\u003Cpath fill=\"currentColor\" d=\"M236.37 139.4a12 12 0 0 0-12-3A84.07 84.07 0 0 1 119.6 31.59a12 12 0 0 0-15-15a108.86 108.86 0 0 0-54.91 38.48A108 108 0 0 0 136 228a107.1 107.1 0 0 0 64.93-21.69a108.86 108.86 0 0 0 38.44-54.94a12 12 0 0 0-3-11.97m-49.88 47.74A84 84 0 0 1 68.86 69.51a84.9 84.9 0 0 1 23.41-21.22Q92 52.13 92 56a108.12 108.12 0 0 0 108 108q3.87 0 7.71-.27a84.8 84.8 0 0 1-21.22 23.41\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":9},"\u003Cpath fill=\"currentColor\" d=\"M128 24a104 104 0 1 0 104 104A104.12 104.12 0 0 0 128 24m87.62 96h-39.83c-1.79-36.51-15.85-62.33-27.38-77.6a88.19 88.19 0 0 1 67.22 77.6ZM96.23 136h63.54c-2.31 41.61-22.23 67.11-31.77 77c-9.55-9.9-29.46-35.4-31.77-77m0-16c2.31-41.61 22.23-67.11 31.77-77c9.55 9.93 29.46 35.43 31.77 77Zm11.36-77.6C96.06 57.67 82 83.49 80.21 120H40.37a88.19 88.19 0 0 1 67.22-77.6M40.37 136h39.84c1.82 36.51 15.85 62.33 27.38 77.6A88.19 88.19 0 0 1 40.37 136m108 77.6c11.53-15.27 25.56-41.09 27.38-77.6h39.84a88.19 88.19 0 0 1-67.18 77.6Z\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":11},"\u003Cpath fill=\"currentColor\" d=\"m213.66 101.66l-80 80a8 8 0 0 1-11.32 0l-80-80a8 8 0 0 1 11.32-11.32L128 164.69l74.34-74.35a8 8 0 0 1 11.32 11.32\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":13},"\u003Cpath fill=\"currentColor\" d=\"M224 128a8 8 0 0 1-8 8H40a8 8 0 0 1 0-16h176a8 8 0 0 1 8 8M40 72h176a8 8 0 0 0 0-16H40a8 8 0 0 0 0 16m176 112H40a8 8 0 0 0 0 16h176a8 8 0 0 0 0-16\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":15},"\u003Cpath fill=\"currentColor\" d=\"M240 102c0 70-103.79 126.66-108.21 129a8 8 0 0 1-7.58 0C119.79 228.66 16 172 16 102a62.07 62.07 0 0 1 62-62c20.65 0 38.73 8.88 50 23.89C139.27 48.88 157.35 40 178 40a62.07 62.07 0 0 1 62 62\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":17},"\u003Cpath fill=\"currentColor\" d=\"M108 136a16 16 0 1 1-16-16a16 16 0 0 1 16 16m56-16a16 16 0 1 0 16 16a16 16 0 0 0-16-16m76.07 76.56l-67 29.71A20.15 20.15 0 0 1 146 214.9l-8.54-23.13c-3.13.14-6.27.24-9.45.24s-6.32-.1-9.45-.24L110 214.9a20.19 20.19 0 0 1-27.08 11.37l-67-29.71a19.93 19.93 0 0 1-11.3-23.15L34.15 57a20 20 0 0 1 16.22-14.81l36.06-5.93a20.26 20.26 0 0 1 22.79 14.84l4.41 17.41c4.74-.33 9.52-.51 14.37-.51s9.63.18 14.37.51l4.41-17.41a20.25 20.25 0 0 1 22.79-14.84l36.06 5.93A20 20 0 0 1 221.85 57l29.53 116.38a19.93 19.93 0 0 1-11.31 23.18M227.28 176L199.23 65.46l-30.07-4.94l-2.84 11.17c2.9.58 5.78 1.2 8.61 1.92a12 12 0 1 1-5.86 23.27A168.4 168.4 0 0 0 128 92a168.4 168.4 0 0 0-41.07 4.88a12 12 0 0 1-5.86-23.27c2.83-.72 5.71-1.34 8.61-1.92l-2.83-11.17l-30.08 4.94L28.72 176l60.22 26.7l5-13.57c-4.37-.76-8.67-1.65-12.88-2.71a12 12 0 0 1 5.86-23.28A168.4 168.4 0 0 0 128 168a168.4 168.4 0 0 0 41.07-4.88a12 12 0 0 1 5.86 23.28c-4.21 1.06-8.51 1.95-12.88 2.71l5 13.57Z\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":19},"\u003Cpath fill=\"currentColor\" d=\"M212.62 75.17A63.7 63.7 0 0 0 206.39 26A12 12 0 0 0 196 20a63.71 63.71 0 0 0-50 24h-20a63.71 63.71 0 0 0-50-24a12 12 0 0 0-10.39 6a63.7 63.7 0 0 0-6.23 49.17A61.5 61.5 0 0 0 52 104v8a60.1 60.1 0 0 0 45.76 58.28A43.66 43.66 0 0 0 92 192v4H76a20 20 0 0 1-20-20a44.05 44.05 0 0 0-44-44a12 12 0 0 0 0 24a20 20 0 0 1 20 20a44.05 44.05 0 0 0 44 44h16v12a12 12 0 0 0 24 0v-40a20 20 0 0 1 40 0v40a12 12 0 0 0 24 0v-40a43.66 43.66 0 0 0-5.76-21.72A60.1 60.1 0 0 0 220 112v-8a61.5 61.5 0 0 0-7.38-28.83M196 112a36 36 0 0 1-36 36h-48a36 36 0 0 1-36-36v-8a37.87 37.87 0 0 1 6.13-20.12a11.65 11.65 0 0 0 1.58-11.49a39.9 39.9 0 0 1-.4-27.72a39.87 39.87 0 0 1 26.41 17.8a12 12 0 0 0 10.1 5.53h32.35a12 12 0 0 0 10.11-5.53a39.84 39.84 0 0 1 26.41-17.8a39.9 39.9 0 0 1-.4 27.72a12 12 0 0 0 1.61 11.53A37.85 37.85 0 0 1 196 104Z\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":21},"\u003Cpath fill=\"currentColor\" d=\"M167.31 160.69a16 16 0 1 1-22.62 0a16 16 0 0 1 22.62 0m-86.62-8a16 16 0 1 0 22.62 0a16 16 0 0 0-22.62 0m14.62-33.38a16 16 0 1 0-22.62 0a16 16 0 0 0 22.62 0m48-6.62a16 16 0 1 0 0 22.62a16 16 0 0 0 0-22.62M236 128A108 108 0 1 1 128 20a12 12 0 0 1 12 12a36 36 0 0 0 36 36a12 12 0 0 1 12 12a36 36 0 0 0 36 36a12 12 0 0 1 12 12m-24.67 10.65A60.17 60.17 0 0 1 165 91a60.17 60.17 0 0 1-47.66-46.32a84 84 0 1 0 94 94Z\"\u002F>",{"id":23,"title":24,"body":25,"category":1711,"date":1712,"description":1713,"extension":1714,"meta":1715,"navigation":1716,"path":1717,"readingTime":1718,"seo":1719,"seoDescription":1720,"seoTitle":1721,"stem":1722,"__hash__":1723},"blog_en\u002Fblog\u002Fchat-architecture.md","Peer & Channel Chat Architecture",{"type":26,"value":27,"toc":1664},"minimark",[28,33,116,119,137,140,194,201,206,213,220,255,258,262,306,310,313,375,381,384,438,441,448,484,499,503,524,541,569,573,591,608,611,618,624,627,706,726,729,738,744,748,762,765,775,783,811,815,821,825,828,835,839,863,867,902,905,919,1028,1032,1066,1088,1094,1098,1120,1123,1129,1132,1142,1180,1186,1190,1214,1217,1221,1227,1249,1263,1266,1280,1286,1299,1302,1309,1315,1319,1355,1358,1369,1375,1379,1397,1400,1643,1647],[29,30,32],"h2",{"id":31},"table-of-contents","Table of Contents",[34,35,36,44,50,56,62,68,74,80,86,92,98,104,110],"ul",{},[37,38,39],"li",{},[40,41,43],"a",{"href":42},"#high-level-architecture","High-Level Architecture",[37,45,46],{},[40,47,49],{"href":48},"#data-model","Data Model",[37,51,52],{},[40,53,55],{"href":54},"#graphql-api-surface","GraphQL API Surface",[37,57,58],{},[40,59,61],{"href":60},"#peer-chat-sending-a-message","Peer Chat: Sending a Message",[37,63,64],{},[40,65,67],{"href":66},"#peer-chat-receiving-a-message","Peer Chat: Receiving a Message",[37,69,70],{},[40,71,73],{"href":72},"#channel-chat-leader-election--fan-out","Channel Chat: Leader Election & Fan-Out",[37,75,76],{},[40,77,79],{"href":78},"#channel-system-messages","Channel System Messages",[37,81,82],{},[40,83,85],{"href":84},"#channel-lifecycle","Channel Lifecycle",[37,87,88],{},[40,89,91],{"href":90},"#peer-transport-layer-lan--wi-fi-aware--ble","Peer Transport Layer (LAN → Wi-Fi Aware → BLE)",[37,93,94],{},[40,95,97],{"href":96},"#peer-status--presence","Peer Status & Presence",[37,99,100],{},[40,101,103],{"href":102},"#caching-layer","Caching Layer",[37,105,106],{},[40,107,109],{"href":108},"#file-downloads","File Downloads",[37,111,112],{},[40,113,115],{"href":114},"#design-patterns-recap","Design Patterns Recap",[29,117,43],{"id":118},"high-level-architecture",[120,121,122,123,127,128,131,132,136],"p",{},"PlainApp chat is ",[124,125,126],"strong",{},"serverless",". Every device runs an embedded Ktor HTTP server,\nand devices talk to each other directly over the local network, Wi-Fi Aware\n(NAN), or Bluetooth Low Energy. There is no relay server, no cloud inbox, no\nphone-number-based identity. Devices are identified by a self-generated\n",[124,129,130],{},"clientId"," and authenticated through an Ed25519 + ECDH handshake performed\nduring ",[40,133,135],{"href":134},"\u002Fblog\u002Fpairing-flow","pairing",".",[120,138,139],{},"Two kinds of conversations exist:",[141,142,143,159],"table",{},[144,145,146],"thead",{},[147,148,149,153,156],"tr",{},[150,151,152],"th",{},"Type",[150,154,155],{},"Constant",[150,157,158],{},"Description",[160,161,162,179],"tbody",{},[147,163,164,171,176],{},[165,166,167],"td",{},[168,169,170],"code",{},"PEER",[165,172,173],{},[168,174,175],{},"ChatTargetType.PEER",[165,177,178],{},"1-to-1 direct chat between two paired devices.",[147,180,181,186,191],{},[165,182,183],{},[168,184,185],{},"CHANNEL",[165,187,188],{},[168,189,190],{},"ChatTargetType.CHANNEL",[165,192,193],{},"Multi-party group chat owned by one device; members fan out messages to each other.",[120,195,196,197,200],{},"A special ",[168,198,199],{},"\"local\""," target is the device's own scratchpad (notes to self) —\nsending to it is a no-op over the wire.",[202,203,205],"h3",{"id":204},"component-map","Component map",[120,207,208],{},[209,210],"img",{"alt":211,"src":212},"Diagram 1","\u002Fblog\u002Fchat-architecture\u002Fdiagram-01.svg",[120,214,215,216,219],{},"The architecture is intentionally ",[124,217,218],{},"layered",":",[221,222,223,229,237,249],"ol",{},[37,224,225,228],{},[124,226,227],{},"UI \u002F GraphQL entry points"," never touch transports or DB directly.",[37,230,231,236],{},[124,232,233],{},[168,234,235],{},"ChatManager"," is a façade — every caller (UI, GraphQL resolver, peer\nreceiver) goes through it.",[37,238,239,244,245,248],{},[124,240,241],{},[168,242,243],{},"ChatSender"," is a dispatcher that branches on ",[168,246,247],{},"ChatTargetType"," and\ndelegates to the peer or channel senders.",[37,250,251,254],{},[124,252,253],{},"The transport layer"," is a pluggable strategy chain with circuit breaking,\nso a flaky Wi-Fi Aware link never blocks a message that could go over BLE.",[29,256,49],{"id":257},"data-model",[202,259,261],{"id":260},"chattarget","ChatTarget",[120,263,264,265,267,268,271,272,275,276,278,279,281,282,285,286,289,290,293,294,297,298,301,302,305],{},"The smallest unit of routing is a ",[168,266,261],{}," — a ",[168,269,270],{},"(toId, type)"," pair where\n",[168,273,274],{},"type"," is either ",[168,277,170],{}," or ",[168,280,185],{},". It exposes an ",[168,283,284],{},"encodedToId"," (",[168,287,288],{},"peer:\u003Cid>","\nor ",[168,291,292],{},"channel:\u003Cid>",") that the UI uses as a stable routing key (e.g.\n",[168,295,296],{},"TempData.activeToId"," so the receiver knows whether to emit a notification),\nan ",[168,299,300],{},"isLocal()"," check (toId == \"local\"), and a ",[168,303,304],{},"parseId"," companion that\nreconstructs the target from a stored string.",[202,307,309],{"id":308},"database-tables","Database tables",[120,311,312],{},"All persistence uses Room. Three tables matter for chat:",[141,314,315,328],{},[144,316,317],{},[147,318,319,322,325],{},[150,320,321],{},"Table",[150,323,324],{},"Entity",[150,326,327],{},"Purpose",[160,329,330,345,360],{},[147,331,332,337,342],{},[165,333,334],{},[168,335,336],{},"chats",[165,338,339],{},[168,340,341],{},"DChat",[165,343,344],{},"One row per message (text \u002F image \u002F file).",[147,346,347,352,357],{},[165,348,349],{},[168,350,351],{},"chat_channels",[165,353,354],{},[168,355,356],{},"DChatChannel",[165,358,359],{},"One row per group channel.",[147,361,362,367,372],{},[165,363,364],{},[168,365,366],{},"peers",[165,368,369],{},[168,370,371],{},"DPeer",[165,373,374],{},"One row per known device (paired or channel-only).",[120,376,377],{},[209,378],{"alt":379,"src":380},"Diagram 2","\u002Fblog\u002Fchat-architecture\u002Fdiagram-02.svg",[120,382,383],{},"A few things worth noting:",[34,385,386,399,416],{},[37,387,388,394,395,398],{},[124,389,390,391,393],{},"Identity is ",[168,392,130],{},", never MAC."," Android randomizes the BLE MAC on\nevery connection, so the database uses a stable 13-character self-generated\nid. Only an 8-byte SHA-256 prefix (",[168,396,397],{},"shortId",") is broadcast over BLE to allow\ndiscovery.",[37,400,401,407,408,411,412,415],{},[124,402,403,406],{},[168,404,405],{},"status=\"channel\""," peers"," are members of a channel that this device has\nnever directly paired with. Their ",[168,409,410],{},"key"," is empty — they authenticate using\nthe ",[124,413,414],{},"channel key"," instead of a pairwise shared key.",[37,417,418,423,424,426,427,430,431,434,435,136],{},[124,419,420],{},[168,421,422],{},"owner=\"me\""," is a sentinel that lets a freshly-installed device act as\nowner before its ",[168,425,130],{}," is stable; ",[168,428,429],{},"isOwnedByMe()"," accepts both ",[168,432,433],{},"\"me\"","\nand ",[168,436,437],{},"TempData.clientId",[29,439,55],{"id":440},"graphql-api-surface",[120,442,443,444,447],{},"PlainApp exposes ",[124,445,446],{},"two"," GraphQL schemas:",[221,449,450,471],{},[37,451,452,285,455,458,459,462,463,466,467,470],{},[124,453,454],{},"Web GraphQL",[168,456,457],{},"addChatChannelSchema"," + ",[168,460,461],{},"addChatMessageSchema"," in\n",[168,464,465],{},"shared\u002Fsrc\u002FcommonMain\u002Fkotlin\u002Fcom\u002Fismartcoding\u002Fplain\u002Fhttpserver\u002F",")\n— served by the local Ktor server to the browser UI and to the\n",[168,468,469],{},"apitest\u002F"," harness. Authenticated by a ChaCha20-encrypted token.",[37,472,473,285,476,479,480,483],{},[124,474,475],{},"Peer GraphQL",[168,477,478],{},"PeerGraphQLService.applyPeerSchema",") — exposed at\n",[168,481,482],{},"\u002Fpeer_graphql"," for other devices over the encrypted peer transport.\nAuthenticated by Ed25519 signature + ChaCha20 body encryption.",[120,485,486,487,490,491,494,495,498],{},"The two schemas share the same business logic singletons (",[168,488,489],{},"ChannelManager",",\n",[168,492,493],{},"ChatMessageReceiver",", …) but expose different surfaces because the ",[124,496,497],{},"trust\nmodel differs",": the web GraphQL trusts the local UI, while the peer GraphQL\nonly trusts cryptographically authenticated peers.",[202,500,502],{"id":501},"web-graphql-surface-chat","Web GraphQL surface (chat)",[120,504,505,506,509,510,513,514,516,517,519,520,523],{},"Queries: ",[168,507,508],{},"chatChannels"," (list all channels), ",[168,511,512],{},"chatItems(id)"," (messages for a\ntarget — id is \"local\", ",[168,515,288],{},", or ",[168,518,292],{},"), and\n",[168,521,522],{},"latestChatItems"," (preview across all chats).",[120,525,526,527,530,531,490,534,537,538,136],{},"Chat mutations: ",[168,528,529],{},"sendChatItem(toId, content)",", ",[168,532,533],{},"deleteChatItem(id)",[168,535,536],{},"deleteChatItems(query)",", and ",[168,539,540],{},"retryChatItem(id)",[120,542,543,544,530,547,490,550,530,553,530,556,530,559,530,562,565,566,136],{},"Channel mutations: ",[168,545,546],{},"createChatChannel(name)",[168,548,549],{},"updateChatChannel(id, name)",[168,551,552],{},"deleteChatChannel(id)",[168,554,555],{},"leaveChatChannel(id)",[168,557,558],{},"addChatChannelMember(id, peerId)",[168,560,561],{},"removeChatChannelMember(id, peerId)",[168,563,564],{},"acceptChatChannelInvite(id)",",\nand ",[168,567,568],{},"declineChatChannelInvite(id)",[202,570,572],{"id":571},"peer-graphql-surface-transport","Peer GraphQL surface (transport)",[120,574,575,576,578,579,582,583,586,587,590],{},"Exposed at ",[168,577,482],{}," and authenticated by Ed25519 signature + ChaCha20\nbody encryption. Only three mutations cross the transport boundary:\n",[168,580,581],{},"createChatItem(content)"," (an incoming peer message), ",[168,584,585],{},"channelSystemMessage(type, payload)"," (channel lifecycle events like invite\u002Fleave), and ",[168,588,589],{},"startAware"," (a\nnudge asking the peer to start its Wi-Fi Aware service so a faster transport\ncan take over).",[120,592,593,594,597,598,600,601,604,605,607],{},"The ",[168,595,596],{},"c-id"," HTTP header carries the sender's ",[168,599,130],{},"; the ",[168,602,603],{},"c-cid"," header\ncarries a channel id when the request is channel-scoped (so the receiver picks\nthe ",[124,606,414],{}," rather than the pairwise peer key for decryption).",[29,609,61],{"id":610},"peer-chat-sending-a-message",[120,612,613,614,617],{},"When the user taps ",[124,615,616],{},"Send"," in a peer conversation, the call chain is:",[120,619,620],{},[209,621],{"alt":622,"src":623},"Diagram 3","\u002Fblog\u002Fchat-architecture\u002Fdiagram-03.svg",[120,625,626],{},"The key invariants enforced at each hop:",[221,628,629,641,657,673,686],{},[37,630,631,636,637,640],{},[124,632,633],{},[168,634,635],{},"ChatManager.createChatItem"," always inserts a row first, ",[124,638,639],{},"then"," sends.\nThis means the UI sees a \"pending\" bubble immediately and the message\nsurvives app crashes even if delivery hasn't happened yet.",[37,642,643,648,649,652,653,656],{},[124,644,645],{},[168,646,647],{},"PeerGraphQLClient.buildSignedRequest"," builds an envelope of the form\n",[168,650,651],{},"signature|timestamp|requestJson",". The signature is Ed25519 over\n",[168,654,655],{},"\"$timestamp$requestJson\"",", binding the timestamp to the body so it cannot\nbe replayed with a fresh timestamp.",[37,658,659,664,665,668,669,672],{},[124,660,661],{},[168,662,663],{},"PeerTransportRouter.send"," iterates transports in order\n",[168,666,667],{},"Lan → WifiAware → Ble",". Each transport can throw ",[168,670,671],{},"TransportUnavailable","\nto let the router try the next one.",[37,674,675,676,681,682,685],{},"On the receiving side, ",[124,677,678],{},[168,679,680],{},"PeerChatParser.decrypt"," checks the timestamp\nis within ",[168,683,684],{},"±5 min"," and verifies the Ed25519 signature before the GraphQL\nmutation is even executed.",[37,687,688,693,694,697,698,701,702,705],{},[124,689,690],{},[168,691,692],{},"ChatMessageReceiver.receive"," keeps a ",[168,695,696],{},"seenSignatures"," set keyed by\n",[168,699,700],{},"\"$fromPeerId|$signature|$timestamp\""," and throws\n",[168,703,704],{},"ReplayedMessageException"," on duplicates — essential because the\ntransport may deliver the same payload twice (LAN + BLE).",[120,707,708,709,712,713,715,716,721,722,725],{},"If ",[168,710,711],{},"PeerChatSender.send"," returns a non-null error string, ",[168,714,243],{}," calls\n",[124,717,718],{},[168,719,720],{},"triggerPeerRediscovery(peerId)",", which fires a directed, encrypted\n",[168,723,724],{},"DISCOVER"," broadcast so the peer can re-announce its current IP\u002Fport.",[29,727,67],{"id":728},"peer-chat-receiving-a-message",[120,730,731,732,734,735,219],{},"Inbound requests land at the local Ktor server's ",[168,733,482],{}," route,\nhandled by ",[168,736,737],{},"PeerGraphQLService",[120,739,740],{},[209,741],{"alt":742,"src":743},"Diagram 4","\u002Fblog\u002Fchat-architecture\u002Fdiagram-04.svg",[202,745,747],{"id":746},"notifications","Notifications",[120,749,750,753,754,757,758,761],{},[168,751,752],{},"emitNotificationIfNeeded"," is the final step. It suppresses the notification\nwhen ",[168,755,756],{},"TempData.activeToId == targetId"," (i.e. the user is currently looking at\nthat conversation) or when ",[168,759,760],{},"canShowNotifications()"," is false. Channel\nnotifications are prefixed with the sender's name.",[29,763,73],{"id":764},"channel-chat-leader-election-fan-out",[120,766,767,768,770,771,774],{},"Channels are multi-party but ",[124,769,126],{},". To avoid every member fanning out\nthe same message N times, the sender side elects a single ",[124,772,773],{},"leader"," whose job\nis to broadcast to all joined members.",[202,776,778,779,782],{"id":777},"leader-election-algorithm-dchatchannelelectleader","Leader election algorithm (",[168,780,781],{},"DChatChannel.electLeader",")",[221,784,785,788,795,804],{},[37,786,787],{},"Filter to joined members that are currently online (the local device is\nalways considered online).",[37,789,790,791,794],{},"If the ",[124,792,793],{},"owner"," is among the online joined members → the owner is the\nleader.",[37,796,797,798,803],{},"Otherwise, the leader is the online joined member with the ",[124,799,800,801],{},"smallest\n",[168,802,130],{}," (deterministic tiebreak, no coordination required).",[37,805,806,807,810],{},"Returns ",[168,808,809],{},"null"," if no online joined members exist.",[202,812,814],{"id":813},"send-flow","Send flow",[120,816,817],{},[209,818],{"alt":819,"src":820},"Diagram 5","\u002Fblog\u002Fchat-architecture\u002Fdiagram-05.svg",[202,822,824],{"id":823},"why-a-leader-at-all","Why a leader at all?",[120,826,827],{},"Imagine a 5-member channel where everyone broadcasts to everyone else: a\nsingle message would generate 20 network round trips and 4 duplicate copies\narriving at each member. By electing one leader, only that device does the\nfan-out — the sender either performs the fan-out itself (if it's the leader)\nor relays a single copy to the leader, which then fans out.",[120,829,830,831,834],{},"If the leader is offline, the sender falls back to ",[168,832,833],{},"Result.NoLeader",",\ntriggers peer rediscovery (so the leader's IP can be found), and clears the\nstatus to let the user retry.",[202,836,838],{"id":837},"channel-key-routing","Channel key routing",[120,840,841,842,845,846,848,849,851,852,855,856,858,859,862],{},"Channel messages are encrypted with the ",[124,843,844],{},"channel's ChaCha20 key",", not the\npairwise peer key. This is what allows a member that has only ever met the\nother members via the channel (never paired 1-to-1) to receive messages —\ntheir ",[168,847,366],{}," row has ",[168,850,405],{}," and ",[168,853,854],{},"key=\"\"",". The sender sets the\n",[168,857,603],{}," HTTP header to the channel id; the receiver looks up\n",[168,860,861],{},"ChannelCacher.getKeyBytes(channelId)"," instead of the pairwise key.",[202,864,866],{"id":865},"per-recipient-retry","Per-recipient retry",[120,868,869,870,873,874,877,878,881,882,885,886,889,890,893,894,897,898,901],{},"Each ",[168,871,872],{},"sendToMember"," returns a ",[168,875,876],{},"DMessageDeliveryResult",". The aggregated\n",[168,879,880],{},"DMessageStatusData"," is persisted as the chat item's ",[168,883,884],{},"status_data"," JSON.\nThe UI shows \"Delivered to Alice, Bob; Failed for Carol\" and lets the user\ntap ",[124,887,888],{},"Retry"," for Carol specifically — ",[168,891,892],{},"ChatManager.sendToChannelMembers","\nre-runs ",[168,895,896],{},"sendToRecipients"," for the retry subset and ",[124,899,900],{},"merges"," the new results\nwith existing ones, replacing only the retried peers.",[29,903,79],{"id":904},"channel-system-messages",[120,906,907,908,911,912,915,916,918],{},"Channel control-plane messages (invite, accept, decline, update, kick, leave)\nare exchanged over the ",[124,909,910],{},"peer GraphQL"," ",[168,913,914],{},"channelSystemMessage"," mutation. They\nare JSON payloads typed by a ",[168,917,274],{}," string:",[141,920,921,935],{},[144,922,923],{},[147,924,925,927,930,933],{},[150,926,152],{},[150,928,929],{},"Direction",[150,931,932],{},"Signed?",[150,934,327],{},[160,936,937,953,969,983,998,1013],{},[147,938,939,944,947,950],{},[165,940,941],{},[168,942,943],{},"channel_invite",[165,945,946],{},"Owner → invitee",[165,948,949],{},"Yes",[165,951,952],{},"Invite a peer; carries channel key + members.",[147,954,955,960,963,966],{},[165,956,957],{},[168,958,959],{},"channel_invite_accept",[165,961,962],{},"Invitee → owner",[165,964,965],{},"No",[165,967,968],{},"Acceptance; carries accepter's public key.",[147,970,971,976,978,980],{},[165,972,973],{},[168,974,975],{},"channel_invite_decline",[165,977,962],{},[165,979,965],{},[165,981,982],{},"Decline; owner removes member.",[147,984,985,990,993,995],{},[165,986,987],{},[168,988,989],{},"channel_update",[165,991,992],{},"Owner → all members",[165,994,949],{},[165,996,997],{},"Membership\u002Fname change broadcast.",[147,999,1000,1005,1008,1010],{},[165,1001,1002],{},[168,1003,1004],{},"channel_kick",[165,1006,1007],{},"Owner → kicked peer",[165,1009,949],{},[165,1011,1012],{},"Targeted kick; also broadcast on channel delete.",[147,1014,1015,1020,1023,1025],{},[165,1016,1017],{},[168,1018,1019],{},"channel_leave",[165,1021,1022],{},"Member → owner",[165,1024,965],{},[165,1026,1027],{},"Member-initiated leave notice.",[202,1029,1031],{"id":1030},"signed-payload-format","Signed payload format",[120,1033,1034,1035,530,1038,530,1041,1044,1045,1048,1049,1052,1053,530,1055,530,1057,537,1059,1062,1063,1065],{},"The three signed types (",[168,1036,1037],{},"invite",[168,1039,1040],{},"update",[168,1042,1043],{},"kick",") use a canonical pipe-\ndelimited string: ",[168,1046,1047],{},"\"$channelId|$version|$action|$target\"",", where ",[168,1050,1051],{},"action"," is\none of ",[168,1054,1037],{},[168,1056,1040],{},[168,1058,1043],{},[168,1060,1061],{},"target"," is the invitee\u002Fkicked peer id\n(empty for broadcast ",[168,1064,1043],{},").",[120,1067,1068,1069,911,1072,1075,1076,1079,1080,1083,1084,1087],{},"The owner signs this string with its Ed25519 key. Receivers reject any\nmessage where ",[168,1070,1071],{},"channel.owner != fromId",[124,1073,1074],{},"before"," even checking the\nsignature, and reject ",[168,1077,1078],{},"ChannelUpdate"," payloads whose ",[168,1081,1082],{},"version"," is\n",[168,1085,1086],{},"≤"," the local version (stale-version guard against out-of-order delivery).",[120,1089,1090],{},[209,1091],{"alt":1092,"src":1093},"Diagram 6","\u002Fblog\u002Fchat-architecture\u002Fdiagram-06.svg",[202,1095,1097],{"id":1096},"lazy-peer-hydration","Lazy peer hydration",[120,1099,1100,851,1103,1105,1106,1109,1110,1113,1114,1116,1117,1119],{},[168,1101,1102],{},"ChannelInvite",[168,1104,1078],{}," carry a ",[168,1107,1108],{},"memberPeers: List\u003CMemberPeerInfo>","\nlist — lightweight peer info (id, name, publicKey, deviceType, ip, port) for\nevery member. The receiver's ",[168,1111,1112],{},"ensureChannelPeer"," creates a ",[168,1115,371],{}," row with\n",[168,1118,405],{}," for any member it has never seen before. This is critical\nbecause fan-out routing needs every member's peer record to send messages.",[29,1121,85],{"id":1122},"channel-lifecycle",[120,1124,1125],{},[209,1126],{"alt":1127,"src":1128},"Diagram 7","\u002Fblog\u002Fchat-architecture\u002Fdiagram-07.svg",[29,1130,91],{"id":1131},"peer-transport-layer-lan-wi-fi-aware-ble",[120,1133,1134,1137,1138,1141],{},[168,1135,1136],{},"PeerTransportRouter"," is a ",[124,1139,1140],{},"strategy chain with circuit breaking",". The\nordered list of transports is:",[221,1143,1144,1156,1172],{},[37,1145,1146,1151,1152,1155],{},[124,1147,1148],{},[168,1149,1150],{},"LanTransport"," — first choice. Uses OkHttp with a ChaCha20 crypto\ninterceptor over HTTPS. Skipped entirely when ",[168,1153,1154],{},"peer.ip"," is empty (cross-\nsubnet peer we haven't discovered yet).",[37,1157,1158,1163,1164,1167,1168,1171],{},[124,1159,1160],{},[168,1161,1162],{},"WifiAwareTransport"," (Android 13+ only) — uses Wi-Fi Aware (NAN) data\npaths. Fast-skip when the peer's ",[168,1165,1166],{},"awareRunning"," flag is false (refreshed by\nthe BLE prewarmer scan). The peer's IPv6 is resolved via a custom DNS that\nmaps the hostname ",[168,1169,1170],{},"plain-aware-peer"," to the link-local address.",[37,1173,1174,1179],{},[124,1175,1176],{},[168,1177,1178],{},"BleTransport"," — guaranteed fallback for any paired peer. Streams\nchunked RPC over GATT. Slower but works without any IP connectivity.",[120,1181,1182],{},[209,1183],{"alt":1184,"src":1185},"Diagram 8","\u002Fblog\u002Fchat-architecture\u002Fdiagram-08.svg",[202,1187,1189],{"id":1188},"why-this-order","Why this order?",[34,1191,1192,1198,1208],{},[37,1193,1194,1197],{},[124,1195,1196],{},"LAN is the fastest"," (single HTTPS round trip, ~10 ms timeout).",[37,1199,1200,1203,1204,1207],{},[124,1201,1202],{},"Wi-Fi Aware is medium"," (data-path setup ~5 s, then ~10 ms round trips)\nand works cross-subnet (e.g. one device on guest Wi-Fi, another on IoT\nWi-Fi). Tuned to skip fast when the peer's Aware service isn't running,\navoiding a 10 s ",[168,1205,1206],{},"buildLink"," timeout.",[37,1209,1210,1213],{},[124,1211,1212],{},"BLE is slowest"," but works without any IP connectivity at all — even\nwith no Wi-Fi, the message still gets through. Used as the guaranteed\nfallback for paired peers.",[120,1215,1216],{},"The circuit breaker ensures that a flaky transport (especially Wi-Fi Aware\nduring network churn) is skipped for 30 s after 2 failures, so the fallback\nhappens quickly instead of waiting for repeated 10 s timeouts.",[202,1218,1220],{"id":1219},"wi-fi-aware-handshake","Wi-Fi Aware handshake",[120,1222,593,1223,1226],{},[168,1224,1225],{},"AwareSession"," does a two-message handshake before opening a data path:",[34,1228,1229,1237],{},[37,1230,1231,1236],{},[124,1232,1233],{},[168,1234,1235],{},"MSG_HELLO"," (subscriber → publisher): \"I see you, here is my peer\nhandle.\"",[37,1238,1239,1244,1245,1248],{},[124,1240,1241],{},[168,1242,1243],{},"MSG_READY"," (publisher → subscriber): \"I've registered my network\nspecifier, you can ",[168,1246,1247],{},"requestNetwork"," now.\"",[120,1250,1251,1252,1255,1256,1259,1260,1262],{},"This synchronizes both sides' ",[168,1253,1254],{},"connectivityManager.requestNetwork(...)"," calls\nwithin the Android framework's ~500 ms window. The ",[124,1257,1258],{},"subscriber"," is the side\nwith the smaller ",[168,1261,130],{}," (deterministic role split — both sides agree\nwithout coordination), and it owns the retry loop.",[29,1264,97],{"id":1265},"peer-status-presence",[120,1267,1268,1269,1272,1273,1276,1277,136],{},"Presence is tracked via ",[124,1270,1271],{},"long-lived WebSocket connections",". Only one side\nof each pair opens the socket — decided by the deterministic rule\n",[168,1274,1275],{},"TempData.clientId \u003C peer.id",". The other side accepts the inbound connection\nat ",[168,1278,1279],{},"\u002Fpeer_status",[120,1281,1282],{},[209,1283],{"alt":1284,"src":1285},"Diagram 9","\u002Fblog\u002Fchat-architecture\u002Fdiagram-09.svg",[120,1287,1288,1291,1292,1295,1296,1065],{},[168,1289,1290],{},"PeerCacher.onlineMap"," is the source of truth for presence. It is exposed as\n",[168,1293,1294],{},"onlinePeerIds: StateFlow\u003CSet\u003CString>>",", which is consumed by the channel\nleader election (",[168,1297,1298],{},"electLeader(onlinePeerIds, myId)",[29,1300,103],{"id":1301},"caching-layer",[120,1303,1304,1305,1308],{},"Two caches mirror the database tables in memory and expose ",[168,1306,1307],{},"StateFlow","s that\nCompose collects directly:",[120,1310,1311],{},[209,1312],{"alt":1313,"src":1314},"Diagram 10","\u002Fblog\u002Fchat-architecture\u002Fdiagram-10.svg",[202,1316,1318],{"id":1317},"why-copy-on-write","Why copy-on-write?",[120,1320,1321,1322,1325,1326,1328,1329,1332,1333,911,1336,1338,1339,1342,1343,1346,1347,1350,1351,1354],{},"Kotlin's ",[168,1323,1324],{},"MutableStateFlow.distinctUntilChanged"," uses structural equality. If\nwe mutated the ",[168,1327,371],{}," in place, the derived ",[168,1330,1331],{},"pairedPeers"," list would contain\nthe ",[124,1334,1335],{},"same",[168,1337,371],{}," reference before and after, and ",[168,1340,1341],{},"distinctUntilChanged","\nwould see no difference and suppress emission. By copying the entity first,\nmutating the copy, and ",[124,1344,1345],{},"replacing"," the map entry with a new\n",[168,1348,1349],{},"PeerRuntime","\u002F",[168,1352,1353],{},"ChannelRuntime",", the derived list gets a new list-of-new-\nreferences and the flow fires.",[29,1356,109],{"id":1357},"file-downloads",[120,1359,1360,1361,1364,1365,1368],{},"Inbound file\u002Fimage messages are downloaded automatically by a bounded\nworker pool. Each download streams through whatever transport is available\n(",[168,1362,1363],{},"PeerTransportRouter.downloadFile",") and writes to a temp file, then imports\ninto the app's media store and patches the chat item's ",[168,1366,1367],{},"uri"," field.",[120,1370,1371],{},[209,1372],{"alt":1373,"src":1374},"Diagram 11","\u002Fblog\u002Fchat-architecture\u002Fdiagram-11.svg",[202,1376,1378],{"id":1377},"transport-agnostic-streaming","Transport-agnostic streaming",[120,1380,593,1381,1384,1385,1388,1389,1392,1393,1396],{},[168,1382,1383],{},"DownloadedResponse(status, ByteReadChannel, onClose): AutoCloseable","\nabstraction lets LAN and Wi-Fi Aware stream the live HTTP body, while BLE\nstreams chunked RPC (16 KiB chunks via ",[168,1386,1387],{},"GET \u002Ffs?id=…&offset=…&length=…",")\nthrough the same ",[168,1390,1391],{},"ByteReadChannel",". The ",[168,1394,1395],{},"onClose"," callback lets BLE cancel\nits background download coroutine when the consumer closes the response\nearly (e.g. on pause).",[29,1398,115],{"id":1399},"design-patterns-recap",[141,1401,1402,1415],{},[144,1403,1404],{},[147,1405,1406,1409,1412],{},[150,1407,1408],{},"Pattern",[150,1410,1411],{},"Where",[150,1413,1414],{},"Why",[160,1416,1417,1431,1454,1469,1487,1506,1521,1539,1557,1579,1596,1610,1628],{},[147,1418,1419,1424,1428],{},[165,1420,1421],{},[124,1422,1423],{},"Façade",[165,1425,1426],{},[168,1427,235],{},[165,1429,1430],{},"Single entry point; callers never touch DB\u002Ftransport directly.",[147,1432,1433,1438,1448],{},[165,1434,1435],{},[124,1436,1437],{},"Strategy + Chain of Resp.",[165,1439,1440,458,1442,1350,1444,1350,1446],{},[168,1441,1136],{},[168,1443,1150],{},[168,1445,1162],{},[168,1447,1178],{},[165,1449,1450,1451,1453],{},"Pluggable transports with ",[168,1452,671],{}," as the fall-through signal.",[147,1455,1456,1461,1466],{},[165,1457,1458],{},[124,1459,1460],{},"Circuit Breaker",[165,1462,1463],{},[168,1464,1465],{},"PeerCircuitBreaker",[165,1467,1468],{},"2 fails \u002F 30 s opens a (peer, transport) leg so Wi-Fi Aware doesn't block fallback.",[147,1470,1471,1476,1484],{},[165,1472,1473],{},[124,1474,1475],{},"State Machine",[165,1477,1478,530,1481],{},[168,1479,1480],{},"PeerStatusManager.PeerState",[168,1482,1483],{},"AwarePeerLink.LinkState",[165,1485,1486],{},"Explicit transitions for socket lifecycle and NDP link lifecycle.",[147,1488,1489,1494,1503],{},[165,1490,1491],{},[124,1492,1493],{},"Producer\u002FConsumer + Pool",[165,1495,1496,1499,1500,782],{},[168,1497,1498],{},"DownloadQueue"," (3 workers, ",[168,1501,1502],{},"Channel.BUFFERED",[165,1504,1505],{},"Bounded concurrency for file downloads.",[147,1507,1508,1513,1518],{},[165,1509,1510],{},[124,1511,1512],{},"Observer \u002F Reactive",[165,1514,1515,1517],{},[168,1516,1307],{}," everywhere",[165,1519,1520],{},"Compose collects directly; no manual refresh.",[147,1522,1523,1528,1536],{},[165,1524,1525],{},[124,1526,1527],{},"Replay Protection",[165,1529,1530,530,1533],{},[168,1531,1532],{},"ChatMessageReceiver.seenSignatures",[168,1534,1535],{},"PeerChatParser.MAX_TIMESTAMP_DIFF_MS",[165,1537,1538],{},"Drop duplicates from LAN+BLE dual delivery; reject out-of-window timestamps.",[147,1540,1541,1546,1551],{},[165,1542,1543],{},[124,1544,1545],{},"Exponential Backoff",[165,1547,1548],{},[168,1549,1550],{},"PeerStatusManager.scheduleReconnect",[165,1552,1553,1556],{},[168,1554,1555],{},"min(60 s, 1 s × 2^min(n-1, 6))"," — caps at 64 s.",[147,1558,1559,1564,1572],{},[165,1560,1561],{},[124,1562,1563],{},"Copy-on-Write",[165,1565,1566,530,1569],{},[168,1567,1568],{},"PeerCacher.mutatePeer",[168,1570,1571],{},"ChannelCacher.mutateChannel",[165,1573,1574,1575,1578],{},"Forces ",[168,1576,1577],{},"StateFlow.distinctUntilChanged"," to fire on every mutation.",[147,1580,1581,1586,1590],{},[165,1582,1583],{},[124,1584,1585],{},"Signed Envelope",[165,1587,1588],{},[168,1589,647],{},[165,1591,1592,1595],{},[168,1593,1594],{},"signature|timestamp|body"," — binds timestamp to body to prevent replay.",[147,1597,1598,1603,1607],{},[165,1599,1600],{},[124,1601,1602],{},"Deterministic Role Split",[165,1604,1605],{},[168,1606,1275],{},[165,1608,1609],{},"Decides WebSocket client vs server, and Wi-Fi Aware subscriber vs publisher.",[147,1611,1612,1617,1622],{},[165,1613,1614],{},[124,1615,1616],{},"Lazy Hydration",[165,1618,1619,1621],{},[168,1620,1112],{}," on invite\u002Fupdate",[165,1623,1624,1625,1627],{},"Creates ",[168,1626,366],{}," rows for unseen channel members so fan-out routing works.",[147,1629,1630,1635,1640],{},[165,1631,1632],{},[124,1633,1634],{},"Encrypted Identity",[165,1636,1637],{},[168,1638,1639],{},"LANDiscoverManager.discoverSpecificDevice",[165,1641,1642],{},"Directed DISCOVER encrypts target id with peer key — only the target recognizes it.",[29,1644,1646],{"id":1645},"further-reading","Further Reading",[34,1648,1649,1655],{},[37,1650,1651,1654],{},[40,1652,1653],{"href":134},"Pairing Flow"," — how two devices establish trust and\nexchange the shared ChaCha20 key used by every transport in this article.",[37,1656,1657,851,1660,1663],{},[168,1658,1659],{},"apitest\u002Fgroups\u002Fchat-messages.sh",[168,1661,1662],{},"apitest\u002Fgroups\u002Fchat-channels.sh"," —\nexecutable test plan exercising every GraphQL mutation end-to-end.",{"title":1665,"searchDepth":1666,"depth":1666,"links":1667},"",3,[1668,1670,1673,1677,1681,1682,1685,1693,1697,1698,1702,1703,1706,1709,1710],{"id":31,"depth":1669,"text":32},2,{"id":118,"depth":1669,"text":43,"children":1671},[1672],{"id":204,"depth":1666,"text":205},{"id":257,"depth":1669,"text":49,"children":1674},[1675,1676],{"id":260,"depth":1666,"text":261},{"id":308,"depth":1666,"text":309},{"id":440,"depth":1669,"text":55,"children":1678},[1679,1680],{"id":501,"depth":1666,"text":502},{"id":571,"depth":1666,"text":572},{"id":610,"depth":1669,"text":61},{"id":728,"depth":1669,"text":67,"children":1683},[1684],{"id":746,"depth":1666,"text":747},{"id":764,"depth":1669,"text":73,"children":1686},[1687,1689,1690,1691,1692],{"id":777,"depth":1666,"text":1688},"Leader election algorithm (DChatChannel.electLeader)",{"id":813,"depth":1666,"text":814},{"id":823,"depth":1666,"text":824},{"id":837,"depth":1666,"text":838},{"id":865,"depth":1666,"text":866},{"id":904,"depth":1669,"text":79,"children":1694},[1695,1696],{"id":1030,"depth":1666,"text":1031},{"id":1096,"depth":1666,"text":1097},{"id":1122,"depth":1669,"text":85},{"id":1131,"depth":1669,"text":91,"children":1699},[1700,1701],{"id":1188,"depth":1666,"text":1189},{"id":1219,"depth":1666,"text":1220},{"id":1265,"depth":1669,"text":97},{"id":1301,"depth":1669,"text":103,"children":1704},[1705],{"id":1317,"depth":1666,"text":1318},{"id":1357,"depth":1669,"text":109,"children":1707},[1708],{"id":1377,"depth":1666,"text":1378},{"id":1399,"depth":1669,"text":115},{"id":1645,"depth":1669,"text":1646},"Architecture","2025-01-20","This article explains how PlainApp's offline-first chat works end-to-end: how a message travels from a tap in the UI all the way to another device over the peer transport, how group channels fan out messages to many members, and how the system stays resilient when networks disappear. Pairing (the trust and key exchange that bootstraps two devices) is covered in the separate Pairing Flow article.","md",{},true,"\u002Fblog\u002Fchat-architecture","12 min read",{"title":24,"description":1713},"How do you build chat that works without internet? See how PlainApp routes messages peer-to-peer, fans out group channels, and stays resilient when networks disappear.","Building Offline Chat: P2P Messaging Architecture Explained","blog\u002Fchat-architecture","QDOBiqiBoyI48jZtPAfLHbCwZ8ojnfTMH2uDRzeMeXA",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":1725},"\u003Cpath fill=\"currentColor\" d=\"M224 128a8 8 0 0 1-8 8H59.31l58.35 58.34a8 8 0 0 1-11.32 11.32l-72-72a8 8 0 0 1 0-11.32l72-72a8 8 0 0 1 11.32 11.32L59.31 120H216a8 8 0 0 1 8 8\"\u002F>",1788009009935]