[{"data":1,"prerenderedAt":12416},["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-list-en":22,"i-ph:arrow-right":12412,"i-ph:notebook-duotone":12414},{"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>",[23,271,510,787,3622,6742,6759,8505,9878,11480],{"id":24,"title":25,"body":26,"category":258,"date":259,"description":260,"extension":261,"meta":262,"navigation":263,"path":264,"readingTime":265,"seo":266,"seoDescription":267,"seoTitle":268,"stem":269,"__hash__":270},"blog_en\u002Fblog\u002Fairdroid-web-alternatives.md","AirDroid Web: What It Is and 3 Better Alternatives",{"type":27,"value":28,"toc":247},"minimark",[29,33,38,73,76,79,101,104,107,110,113,126,129,141,144,151,154,237,240],[30,31,32],"p",{},"Search \"airdroid web\" and you land on web.airdroid.com — a browser\ndashboard that mirrors your Android phone: files, messages, photos,\nnotifications. It's a genuinely useful idea. But how it works underneath\nshapes everything about the experience: speed, limits, and who handles\nyour data. This is a quick tour of that, plus four alternatives worth\nknowing.",[34,35,37],"h2",{"id":36},"table-of-contents","Table of Contents",[39,40,41,49,55,61,67],"ul",{},[42,43,44],"li",{},[45,46,48],"a",{"href":47},"#what-airdroid-web-actually-is","What AirDroid Web actually is",[42,50,51],{},[45,52,54],{"href":53},"#alternative-1-plainapp-local-open-source-no-account","Alternative 1: PlainApp — local, open source, no account",[42,56,57],{},[45,58,60],{"href":59},"#alternative-2-localsend-cross-platform-file-sharing","Alternative 2: LocalSend — cross-platform file sharing",[42,62,63],{},[45,64,66],{"href":65},"#alternative-3-kde-connect-desktop-first-integration","Alternative 3: KDE Connect — desktop-first integration",[42,68,69],{},[45,70,72],{"href":71},"#which-one-fits-you","Which one fits you?",[34,74,48],{"id":75},"what-airdroid-web-actually-is",[30,77,78],{},"AirDroid Web is the browser face of the AirDroid service. Your phone\ndoesn't talk to your browser directly — it talks to AirDroid's cloud,\nand your browser talks to the same cloud. Three practical consequences:",[80,81,82,89,95],"ol",{},[42,83,84,88],{},[85,86,87],"strong",{},"You need an account."," The cloud can't route to an anonymous phone,\nso signup comes first.",[42,90,91,94],{},[85,92,93],{},"Free tier caps."," The free plan limits remote file transfers —\naround 30 MB per file and a monthly quota — with the desktop client\npushing you toward premium for full functionality.",[42,96,97,100],{},[85,98,99],{},"Your data transits servers you don't control."," Even on your own\nWi-Fi, file transfers pass through AirDroid's infrastructure rather\nthan phone-to-browser.",[30,102,103],{},"None of this is a scandal — it's the cost of remote access from anywhere.\nBut if you mostly sit on the same network as your phone (home, office,\nhotspot), you're paying that cost for a capability you rarely use.",[34,105,54],{"id":106},"alternative-1-plainapp-local-open-source-no-account",[30,108,109],{},"PlainApp is built on the opposite assumption: same network, direct\nconnection. The Android app starts a secure HTTPS server on your phone;\nany browser on the same Wi-Fi connects to it. Nothing routes through an\nexternal server, there is no account, and there are no file-size or\nmonthly limits.",[30,111,112],{},"You get the same core feature set — file management, SMS and call logs,\ncontacts, screen mirroring, notifications — plus extras AirDroid doesn't\nhave, like an RSS reader and Markdown notes. The catch is symmetric to\nAirDroid's: PlainApp is local-network by design. If you need to reach\nyour phone from another city, AirDroid (or a VPN) still makes sense.",[30,114,115,116,120,121,125],{},"The full feature-by-feature breakdown is in ",[45,117,119],{"href":118},"\u002Fairdroid-alternative","AirDroid alternative:\nunlimited, private, open source",". For how the\nencryption stack works, see ",[45,122,124],{"href":123},"\u002Fblog\u002Fencrypted-android-phone-manager","the PlainApp security deep\ndive",".",[34,127,60],{"id":128},"alternative-2-localsend-cross-platform-file-sharing",[30,130,131,132,136,137,125],{},"If your actual need is \"move files between whatever devices I have,\"\nLocalSend is the focused answer: open source, peer-to-peer, with native\napps for Android, iOS, Windows, macOS, and Linux. No account, no cloud.\nIt doesn't manage your phone — no SMS, no screen mirror — but as a\ndrop-in replacement for AirDroid's ",[133,134,135],"em",{},"transfer"," feature it's excellent.\nA side-by-side look is in ",[45,138,140],{"href":139},"\u002Flocalsend-alternative","LocalSend alternative: file transfer plus\nphone management",[34,142,66],{"id":143},"alternative-3-kde-connect-desktop-first-integration",[30,145,146,147,150],{},"KDE Connect is the power-user option: pairs your phone with a Linux,\nWindows, or macOS desktop app and syncs notifications, clipboard, files,\nand even uses the phone as a remote. It's open source and local-network.\nThe trade-off: it's a desktop ",[133,148,149],{},"application"," ecosystem rather than a\nbrowser portal, and iOS support is limited.",[34,152,72],{"id":153},"which-one-fits-you",[155,156,157,173],"table",{},[158,159,160],"thead",{},[161,162,163,167,170],"tr",{},[164,165,166],"th",{},"Need",[164,168,169],{},"Best pick",[164,171,172],{},"Why",[174,175,176,188,199,215,226],"tbody",{},[161,177,178,182,185],{},[179,180,181],"td",{},"Manage Android from any browser, privately",[179,183,184],{},"PlainApp",[179,186,187],{},"No account, no limits, local-only",[161,189,190,193,196],{},[179,191,192],{},"Transfer files across mixed OSes",[179,194,195],{},"LocalSend",[179,197,198],{},"Native apps everywhere, P2P",[161,200,201,204,212],{},[179,202,203],{},"Just move files, nothing installed anywhere",[179,205,206],{},[45,207,211],{"href":208,"rel":209},"https:\u002F\u002Fplain.icu",[210],"nofollow","PlainICU",[179,213,214],{},"Both devices are just a browser tab",[161,216,217,220,223],{},[179,218,219],{},"Deep desktop ↔ phone integration",[179,221,222],{},"KDE Connect",[179,224,225],{},"Notifications, clipboard, remote",[161,227,228,231,234],{},[179,229,230],{},"Reach your phone from another network",[179,232,233],{},"AirDroid",[179,235,236],{},"Cloud routing is the point",[30,238,239],{},"If your use of AirDroid Web is really just \"push this file over,\" there\nis an even lighter option than all of the above: PlainICU — a\nbrowser-only transfer page from the PlainApp team. No app, no account;\nfiles travel directly between the two devices over WebRTC.",[30,241,242,243,246],{},"The pattern to notice: four of the five options chose ",[133,244,245],{},"local network","\nas the foundation, because that's where speed and privacy are cheapest.\nOnly you can decide whether remote access is worth routing your files\nthrough someone else's servers — but it should be a decision, not a\ndefault.",{"title":248,"searchDepth":249,"depth":249,"links":250},"",3,[251,253,254,255,256,257],{"id":36,"depth":252,"text":37},2,{"id":75,"depth":252,"text":48},{"id":106,"depth":252,"text":54},{"id":128,"depth":252,"text":60},{"id":143,"depth":252,"text":66},{"id":153,"depth":252,"text":72},"Comparison","2026-08-26","AirDroid's browser client is convenient but cloud-routed and account-gated. Here's how it really works, and three alternatives — including one with no account and no limits.","md",{},true,"\u002Fblog\u002Fairdroid-web-alternatives","8 min read",{"title":25,"description":260},"Using AirDroid's web client? See how it routes your data, its free-tier limits, and open-source alternatives with no account and no transfer caps.","AirDroid Web: What It Is & Better Alternatives","blog\u002Fairdroid-web-alternatives","XJ6k4l1HqxKvPHep1DxCbz3HjuKfeCki5zuDseo7QJU",{"id":272,"title":273,"body":274,"category":501,"date":259,"description":502,"extension":261,"meta":503,"navigation":263,"path":504,"readingTime":265,"seo":505,"seoDescription":506,"seoTitle":507,"stem":508,"__hash__":509},"blog_en\u002Fblog\u002Fairdrop-android-to-iphone.md","How to AirDrop From Android to iPhone: 4 Working Ways",{"type":27,"value":275,"toc":493},[276,283,285,317,320,327,332,353,360,363,366,370,381,387,390,401,405,420,423,426,429,432,490],[30,277,278,279,282],{},"Apple's AirDrop is a delight — between Apple devices. Ask it to move a\nphoto from an Android phone to an iPhone and it simply won't: AirDrop is\nhard-wired to Apple's ecosystem. The good news is that \"AirDrop\" has\nbecome shorthand for ",[133,280,281],{},"quick local file sharing",", and that problem has\nseveral real solutions. Here are four, ranked by how little you have to\ninstall.",[34,284,37],{"id":36},[39,286,287,293,299,305,311],{},[42,288,289],{},[45,290,292],{"href":291},"#method-1-plainapp-no-app-on-the-iphone","Method 1: PlainApp — no app on the iPhone",[42,294,295],{},[45,296,298],{"href":297},"#method-2-localsend-an-app-on-both-sides","Method 2: LocalSend — an app on both sides",[42,300,301],{},[45,302,304],{"href":303},"#method-3-plainicu-no-app-on-either-device","Method 3: PlainICU — no app on either device",[42,306,307],{},[45,308,310],{"href":309},"#method-4-a-cloud-drive-when-nothing-else-is-possible","Method 4: A cloud drive — when nothing else is possible",[42,312,313],{},[45,314,316],{"href":315},"#choosing-between-them","Choosing between them",[34,318,292],{"id":319},"method-1-plainapp-no-app-on-the-iphone",[30,321,322,323,326],{},"PlainApp runs on the ",[85,324,325],{},"Android"," phone only. It starts a secure web\nserver on your phone; the iPhone connects to it with Safari over the\nsame Wi-Fi. Nothing is installed on the iPhone, and because it's a web\nportal, an iPad, a PC, or a Mac can join exactly the same way.",[30,328,329],{},[85,330,331],{},"Steps:",[80,333,334,340,347,350],{},[42,335,336,337,125],{},"Install PlainApp on the Android phone (Google Play, F-Droid, or\nGitHub) and tap ",[85,338,339],{},"Start",[42,341,342,343,125],{},"Note the address it shows, like ",[344,345,346],"code",{},"https:\u002F\u002F192.168.1.5:8443",[42,348,349],{},"On the iPhone, open Safari and go to that address. Accept the\ncertificate warning — the connection is encrypted with a self-signed\ncertificate.",[42,351,352],{},"Android → iPhone: browse the phone's files in Safari and download\nwhat you need. iPhone → Android: use the upload area to push photos\nor files the other way.",[30,354,355,356,125],{},"Both directions work, transfers stay on your local network, and there\nare no file-size limits. The full walkthrough (with what else the web\nportal does) is in ",[45,357,359],{"href":358},"\u002Fairdrop-for-android","AirDrop for Android: send files to PC in\nseconds",[34,361,298],{"id":362},"method-2-localsend-an-app-on-both-sides",[30,364,365],{},"LocalSend is an open-source, peer-to-peer file sharing app with real\niOS support, plus Windows, macOS, Linux, and Android. If you move files\nbetween many combinations of devices and want one consistent tool,\nthis is the sturdier pick — discovery is handled inside the apps, with\nno account and no cloud.",[30,367,368],{},[85,369,331],{},[80,371,372,375,378],{},[42,373,374],{},"Install LocalSend from the App Store on the iPhone and from Google\nPlay on the Android phone.",[42,376,377],{},"Open the app on both devices; they find each other on the same\nWi-Fi automatically.",[42,379,380],{},"Pick the target device, attach files, send.",[30,382,383,384,125],{},"The cost is symmetric: an app on every device. A closer comparison of\nwhat you give up and gain is in ",[45,385,386],{"href":139},"LocalSend alternative: file transfer\nplus phone management",[34,388,304],{"id":389},"method-3-plainicu-no-app-on-either-device",[30,391,392,393,396,397,400],{},"Sometimes you don't want to install anything at all — a friend's phone,\na borrowed laptop, a one-off send. ",[45,394,211],{"href":208,"rel":395},[210]," (from\nthe PlainApp team) is a browser-only transfer page: open\n",[85,398,399],{},"plain.icu"," in a browser on both devices, and they discover each\nother on the same network. Files then travel directly between the two\ndevices over an encrypted WebRTC connection — the site's server only\nintroduces the two devices and never touches the file bytes.",[30,402,403],{},[85,404,331],{},[80,406,407,414,417],{},[42,408,409,410,413],{},"Open ",[45,411,208],{"href":208,"rel":412},[210]," in a browser on the Android phone.",[42,415,416],{},"Open the same address on the iPhone (Safari works fine).",[42,418,419],{},"Once both devices appear, drag and drop or pick your files — they\ntransfer straight to the other device.",[30,421,422],{},"No install, no account, no file-size limit. The trade-offs: it needs\nboth devices on the same network, and it moves files only — it is a\ntransfer page, not a phone manager.",[34,424,310],{"id":425},"method-4-a-cloud-drive-when-nothing-else-is-possible",[30,427,428],{},"When the two devices aren't on the same network at all, a cloud drive\n(Google Drive, Dropbox, OneDrive) is the fallback: upload from the\nAndroid, open on the iPhone. It works everywhere, but notice what you\ntrade for it — the file leaves both devices, uploads over the internet,\nand lands on storage you don't control. For sensitive photos or large\nvideos, it's also the slowest option of the four.",[34,430,316],{"id":431},"choosing-between-them",[155,433,434,446],{},[158,435,436],{},[161,437,438,441,444],{},[164,439,440],{},"Situation",[164,442,443],{},"Use",[164,445,172],{},[174,447,448,459,469,479],{},[161,449,450,453,456],{},[179,451,452],{},"One-off transfer, nothing on the iPhone",[179,454,455],{},"PlainApp via Safari",[179,457,458],{},"Full phone access from the browser",[161,460,461,464,466],{},[179,462,463],{},"Regular sharing across many devices",[179,465,195],{},[179,467,468],{},"One consistent native app everywhere",[161,470,471,474,476],{},[179,472,473],{},"Nothing installed anywhere",[179,475,211],{},[179,477,478],{},"Both sides are just a browser tab",[161,480,481,484,487],{},[179,482,483],{},"Devices on different networks",[179,485,486],{},"Cloud drive",[179,488,489],{},"The only option that crosses networks",[30,491,492],{},"A note on the phrase \"AirDrop for Android online\": web-based relays\nlike Snapdrop once filled this niche, but the original project is no\nlonger maintained. PlainICU is the same browser-based idea — any\ndevice, nothing to install — kept current. PlainApp goes further: your\nown phone is the server, so nothing passes through anyone else's\ninfrastructure and you get full phone management, not just transfers.",{"title":248,"searchDepth":249,"depth":249,"links":494},[495,496,497,498,499,500],{"id":36,"depth":252,"text":37},{"id":319,"depth":252,"text":292},{"id":362,"depth":252,"text":298},{"id":389,"depth":252,"text":304},{"id":425,"depth":252,"text":310},{"id":431,"depth":252,"text":316},"How-To","AirDrop refuses to talk to Android by design. Here are four methods that actually move files between an Android phone and an iPhone — including one that needs no app on the iPhone, and one that needs no app anywhere.",{},"\u002Fblog\u002Fairdrop-android-to-iphone",{"title":273,"description":502},"AirDrop doesn't work between Android and iPhone. Here are 4 ways that do — no app on the iPhone, or no app at all, plus a cloud fallback for different networks.","How to AirDrop From Android to iPhone (4 Working Ways)","blog\u002Fairdrop-android-to-iphone","7F_HKRCUzqMksHCSIu6Xr19Lc8SXSRml_qTENtv33A0",{"id":511,"title":512,"body":513,"category":777,"date":259,"description":778,"extension":261,"meta":779,"navigation":263,"path":780,"readingTime":781,"seo":782,"seoDescription":783,"seoTitle":784,"stem":785,"__hash__":786},"blog_en\u002Fblog\u002Fsnapdrop-not-working.md","Snapdrop Not Working? Common Causes and How to Fix It",{"type":27,"value":514,"toc":764},[515,518,520,582,585,598,601,608,611,614,625,628,631,634,637,640,643,646,649,652,655,658,661,664,671,682,685,757],[30,516,517],{},"Snapdrop is one of those tools people discover exactly when they need it:\ntwo devices, one file, no cable. So when the page loads but nothing\nconnects — or the site itself is down — it's uniquely frustrating. This\nguide walks through what actually goes wrong, the fixes worth trying, and\nwhen to give up and switch tools.",[34,519,37],{"id":36},[39,521,522,528,534,540,546,552,558,564,570,576],{},[42,523,524],{},[45,525,527],{"href":526},"#first-the-context-snapdrop-is-no-longer-maintained","First, the context: Snapdrop is no longer maintained",[42,529,530],{},[45,531,533],{"href":532},"#cause-1-the-signaling-server-is-having-a-bad-day","Cause 1: The signaling server is having a bad day",[42,535,536],{},[45,537,539],{"href":538},"#cause-2-the-devices-are-not-on-the-same-network","Cause 2: The devices are not on the same network",[42,541,542],{},[45,543,545],{"href":544},"#cause-3-ap-isolation-is-blocking-peer-to-peer-traffic","Cause 3: AP isolation is blocking peer-to-peer traffic",[42,547,548],{},[45,549,551],{"href":550},"#cause-4-browser-webrtc-is-restricted-or-blocked","Cause 4: Browser WebRTC is restricted or blocked",[42,553,554],{},[45,555,557],{"href":556},"#cause-5-corporate-firewall-or-proxy-interference","Cause 5: Corporate firewall or proxy interference",[42,559,560],{},[45,561,563],{"href":562},"#cause-6-stale-page-state","Cause 6: Stale page state",[42,565,566],{},[45,567,569],{"href":568},"#cause-7-youre-asking-it-to-do-more-than-it-can","Cause 7: You're asking it to do more than it can",[42,571,572],{},[45,573,575],{"href":574},"#when-to-switch-an-alternative-with-no-relay-dependency","When to switch: an alternative with no relay dependency",[42,577,578],{},[45,579,581],{"href":580},"#quick-decision-table","Quick decision table",[34,583,527],{"id":584},"first-the-context-snapdrop-is-no-longer-maintained",[30,586,587,588,593,594,597],{},"The original Snapdrop project has stopped receiving updates. The community\nfork ",[45,589,592],{"href":590,"rel":591},"https:\u002F\u002Fpairdrop.net",[210],"PairDrop"," carries the idea forward, but the\nunderlying architecture is the same: devices discover each other through a\n",[85,595,596],{},"signaling server on the internet",", then try to open a direct WebRTC\nconnection for the actual transfer. Most \"Snapdrop not working\" reports\ntrace back to one link in that chain.",[34,599,533],{"id":600},"cause-1-the-signaling-server-is-having-a-bad-day",[30,602,603,604,607],{},"Because discovery depends on a public server, an outage on their side\nlooks exactly like a bug on yours: the page loads, but no other device\nappears. Fix: check whether both devices show ",[133,605,606],{},"someone"," in the list. If\nneither device sees anything, wait or retry later — nothing on your end\nis broken.",[34,609,539],{"id":610},"cause-2-the-devices-are-not-on-the-same-network",[30,612,613],{},"Snapdrop needs both devices on the same Wi-Fi (or at least on networks\nthat allow peer-to-peer traffic between them). Common traps:",[39,615,616,619,622],{},[42,617,618],{},"One device is on the guest Wi-Fi, the other on the main network",[42,620,621],{},"One device silently fell back to mobile data",[42,623,624],{},"A VPN on either device routes traffic elsewhere",[30,626,627],{},"Fix: verify both IPs look alike, disable VPNs, and reconnect both devices\nto the same SSID.",[34,629,545],{"id":630},"cause-3-ap-isolation-is-blocking-peer-to-peer-traffic",[30,632,633],{},"Many public and hotel networks enable \"client isolation,\" which lets you\nreach the internet but blocks devices from reaching each other. WebRTC\nthen fails even though discovery worked. Fix: test on a phone hotspot —\nif transfers work there, the router is the culprit. On your own router,\ndisable AP\u002Fclient isolation.",[34,635,551],{"id":636},"cause-4-browser-webrtc-is-restricted-or-blocked",[30,638,639],{},"Snapdrop is WebRTC-only. If your browser (or an extension) blocks WebRTC,\npairing never completes. Fix: try another browser with no extensions, and\ncheck that WebRTC isn't disabled in browser flags or by privacy plugins.",[34,641,557],{"id":642},"cause-5-corporate-firewall-or-proxy-interference",[30,644,645],{},"Corporate networks often allow HTTP out but block the UDP ports WebRTC\nprefers. Fixes on that network are usually out of your hands — a phone\nhotspot sidesteps the problem entirely.",[34,647,563],{"id":648},"cause-6-stale-page-state",[30,650,651],{},"Long-lived Snapdrop tabs drift out of sync with the signaling server.\nFix: hard-refresh (Ctrl+Shift+R) on both devices, or close and reopen\nthe tab.",[34,653,569],{"id":654},"cause-7-youre-asking-it-to-do-more-than-it-can",[30,656,657],{},"Snapdrop moves files and nothing else. If you came looking for phone\nmanagement — browsing storage, replying to SMS, mirroring the screen —\nno fix will get you there. That's a different tool.",[34,659,575],{"id":660},"when-to-switch-an-alternative-with-no-relay-dependency",[30,662,663],{},"If outages keep eating your transfers, the structural fix is to stop\ndepending on an external server at all. PlainApp takes the opposite\napproach from Snapdrop: your Android phone itself runs a secure local\nweb server, and any device on the same Wi-Fi connects to it directly\nthrough a browser — no signaling server, no relay, no account.",[30,665,666,667,125],{},"The trade-off is honest: PlainApp serves Android as the hub (the other\nside just needs a browser), while Snapdrop is OS-agnostic on both ends.\nIf your workflow is \"Android phone + whatever computer I'm at,\" the\ndirect connection is faster and works even when the internet doesn't.\nSee the full comparison in ",[45,668,670],{"href":669},"\u002Fsnapdrop-alternative","Snapdrop alternative: private sharing with\nno relay server",[30,672,673,674,677,678,681],{},"If what you liked about Snapdrop was the ",[133,675,676],{},"nothing-to-install"," browser\nexperience itself — and you don't need phone management —\n",[45,679,211],{"href":208,"rel":680},[210]," (from the PlainApp team) is that idea\nkept current: open the page on both devices, files transfer directly\nover WebRTC, no account and no install on either side.",[34,683,581],{"id":684},"quick-decision-table",[155,686,687,700],{},[158,688,689],{},[161,690,691,694,697],{},[164,692,693],{},"Symptom",[164,695,696],{},"Likely cause",[164,698,699],{},"First fix",[174,701,702,713,724,735,746],{},[161,703,704,707,710],{},[179,705,706],{},"No devices appear at all",[179,708,709],{},"Signaling server or network",[179,711,712],{},"Hotspot test, retry later",[161,714,715,718,721],{},[179,716,717],{},"Devices appear, transfer fails",[179,719,720],{},"AP isolation \u002F WebRTC blocked",[179,722,723],{},"Hotspot test, disable isolation",[161,725,726,729,732],{},[179,727,728],{},"Works at home, fails at hotel",[179,730,731],{},"Client isolation",[179,733,734],{},"Use phone hotspot",[161,736,737,740,743],{},[179,738,739],{},"Works then stalls mid-transfer",[179,741,742],{},"UDP blocked mid-session",[179,744,745],{},"Retry on hotspot",[161,747,748,751,754],{},[179,749,750],{},"Only need it once a month",[179,752,753],{},"—",[179,755,756],{},"Any of the above, patiently",[30,758,759,760,763],{},"Whatever you pick, the one thing worth knowing is ",[133,761,762],{},"why"," it broke: with a\nrelay-based web tool, half the failures aren't yours to fix.",{"title":248,"searchDepth":249,"depth":249,"links":765},[766,767,768,769,770,771,772,773,774,775,776],{"id":36,"depth":252,"text":37},{"id":584,"depth":252,"text":527},{"id":600,"depth":252,"text":533},{"id":610,"depth":252,"text":539},{"id":630,"depth":252,"text":545},{"id":636,"depth":252,"text":551},{"id":642,"depth":252,"text":557},{"id":648,"depth":252,"text":563},{"id":654,"depth":252,"text":569},{"id":660,"depth":252,"text":575},{"id":684,"depth":252,"text":581},"Troubleshooting","Snapdrop won't load, devices can't see each other, or transfers stall? Here are the real reasons it breaks — and what to switch to when fixes don't hold.",{},"\u002Fblog\u002Fsnapdrop-not-working","7 min read",{"title":512,"description":778},"Snapdrop won't load or devices won't pair? Here are the common causes, quick fixes, and a private alternative that works without a relay server.","Snapdrop Not Working? 7 Fixes & a Reliable Alternative","blog\u002Fsnapdrop-not-working","IpGnFHtCgFvokS1U76YJP9za99uPT-ty-pjnTOEPwYE",{"id":788,"title":789,"body":790,"category":3611,"date":3612,"description":3613,"extension":261,"meta":3614,"navigation":263,"path":3615,"readingTime":3616,"seo":3617,"seoDescription":3618,"seoTitle":3619,"stem":3620,"__hash__":3621},"blog_en\u002Fblog\u002Fdlna-cast.md","DLNA Cast: Building a UPnP Sender and Receiver From Scratch",{"type":27,"value":791,"toc":3592},[792,807,810,812,907,910,917,920,988,1003,1006,1017,1023,1070,1192,1228,1232,1247,1433,1440,1443,1458,1464,1469,1600,1617,1668,1671,1688,1914,1938,1959,2112,2127,2130,2143,2149,2163,2271,2276,2483,2497,2518,2521,2528,2687,2715,2819,2822,2861,2864,2873,2903,2954,2963,3042,3067,3070,3080,3086,3236,3249,3252,3258,3291,3294,3312,3318,3321,3324,3418,3421,3568,3571,3588],[30,793,794,795,798,799,802,803,806],{},"DLNA (built on UPnP AV) is the protocol behind \"Cast to TV\" buttons on\nsmart TVs. It runs entirely on the local network, with no cloud account\nand no pairing step. PlainApp implements ",[85,796,797],{},"both directions"," of it: it\ncan push a local video, song, or photo to any DLNA-compatible smart TV,\nand it can turn the phone itself into a ",[344,800,801],{},"MediaRenderer"," so a TV remote\napp, VLC, or another PlainApp can cast ",[133,804,805],{},"to"," it.",[30,808,809],{},"This article covers the wire protocol (SSDP + SOAP + DIDL-Lite), the local\nHTTP server that streams media with the headers TVs actually require, the\nGENA event subscription for playback state, and the sender-IP trust model\nthat keeps an unauthenticated 1990s-era protocol safe to run on a modern\nphone.",[34,811,37],{"id":36},[39,813,814,819,825,839,845,851,857,871,877,883,889,895,901],{},[42,815,816],{},[45,817,37],{"href":818},"#table-of-contents",[42,820,821],{},[45,822,824],{"href":823},"#high-level-architecture","High-Level Architecture",[42,826,827,831],{},[45,828,830],{"href":829},"#ssdp-discovery-finding-devices-without-a-server","SSDP Discovery: Finding Devices Without a Server",[39,832,833],{},[42,834,835],{},[45,836,838],{"href":837},"#port-fallback","Port fallback",[42,840,841],{},[45,842,844],{"href":843},"#avtransport-control-the-soap-protocol","AVTransport Control: The SOAP Protocol",[42,846,847],{},[45,848,850],{"href":849},"#didl-lite-metadata-and-the-double-escaping-quirk","DIDL-Lite Metadata and the Double-Escaping Quirk",[42,852,853],{},[45,854,856],{"href":855},"#serving-media-to-the-tv-range-requests--dlna-headers","Serving Media to the TV: Range Requests & DLNA Headers",[42,858,859,863],{},[45,860,862],{"href":861},"#gena-events-playback-state-callbacks","GENA Events: Playback State Callbacks",[39,864,865],{},[42,866,867],{},[45,868,870],{"href":869},"#the-duplicate-callback-guard","The duplicate-callback guard",[42,872,873],{},[45,874,876],{"href":875},"#receiver-mode-becoming-a-upnp-mediarenderer","Receiver Mode: Becoming a UPnP MediaRenderer",[42,878,879],{},[45,880,882],{"href":881},"#security-sender-trust-via-allowdeny-lists","Security: Sender Trust via Allow\u002FDeny Lists",[42,884,885],{},[45,886,888],{"href":887},"#platform-split-commonmain-orchestration-androidmain-sockets","Platform Split: commonMain Orchestration, androidMain Sockets",[42,890,891],{},[45,892,894],{"href":893},"#pure-kotlin-engineering-notes","Pure-Kotlin Engineering Notes",[42,896,897],{},[45,898,900],{"href":899},"#design-patterns-recap","Design Patterns Recap",[42,902,903],{},[45,904,906],{"href":905},"#further-reading","Further Reading",[34,908,824],{"id":909},"high-level-architecture",[30,911,912],{},[913,914],"img",{"alt":915,"src":916},"Diagram 1","\u002Fblog\u002Fdlna-cast\u002Fdiagram-01.svg",[30,918,919],{},"DLNA\u002FUPnP AV has no central server and no cloud component — everything\nhappens over local-network UDP multicast (discovery) and HTTP (control +\nmedia). PlainApp implements two independent roles that happen to share the\nsame commonMain protocol code:",[155,921,922,935],{},[158,923,924],{},[161,925,926,929,932],{},[164,927,928],{},"Role",[164,930,931],{},"What it does",[164,933,934],{},"Key classes",[174,936,937,960],{},[161,938,939,945,948],{},[179,940,941,944],{},[85,942,943],{},"Sender"," (\"Cast to TV\")",[179,946,947],{},"Scans for renderers, tells one to fetch a URL, controls playback",[179,949,950,953,954,953,957],{},[344,951,952],{},"DlnaDeviceScanner",", ",[344,955,956],{},"DlnaTransportController",[344,958,959],{},"CastPlayer",[161,961,962,968,974],{},[179,963,964,967],{},[85,965,966],{},"Receiver"," (\"Wireless Cast\")",[179,969,970,971,973],{},"Advertises itself as a ",[344,972,801],{},", accepts control from any UPnP controller",[179,975,976,953,979,953,982,953,985],{},[344,977,978],{},"DlnaReceiverEngine",[344,980,981],{},"DlnaHttpRouter",[344,983,984],{},"DlnaSoapHandler",[344,986,987],{},"DlnaReceiverViewModel",[30,989,990,991,994,995,998,999,1002],{},"Both roles reuse ",[344,992,993],{},"DlnaSoap"," (SOAP envelope builders) and ",[344,996,997],{},"DlnaDevice"," (the\nUPnP device model) from ",[344,1000,1001],{},"features\u002Fdlna\u002Fcommon\u002F",". The DLNA specification\nhas no authentication of any kind — anyone on the LAN who knows a\nrenderer's control URL can send it commands. This shapes almost every\ndesign decision described below, especially the receiver's trust model.",[34,1004,830],{"id":1005},"ssdp-discovery-finding-devices-without-a-server",[30,1007,1008,1009,1012,1013,1016],{},"Discovery uses ",[85,1010,1011],{},"SSDP"," (Simple Service Discovery Protocol), a thin layer\nover UDP multicast to ",[344,1014,1015],{},"239.255.255.250:1900",". There is no directory\nserver — devices announce themselves and answer search queries directly.",[30,1018,1019],{},[913,1020],{"alt":1021,"src":1022},"Diagram 2","\u002Fblog\u002Fdlna-cast\u002Fdiagram-02.svg",[30,1024,1025,953,1028,1030,1031,1034,1035,1038,1039,1042,1043,1046,1047,1050,1051,1054,1055,1058,1059,1061,1062,1065,1066,1069],{},[85,1026,1027],{},"As a sender",[344,1029,952],{}," broadcasts an ",[344,1032,1033],{},"M-SEARCH"," datagram\ntargeting ",[344,1036,1037],{},"urn:schemas-upnp-org:service:AVTransport:1"," and collects\nunicast ",[344,1040,1041],{},"200 OK"," replies, each carrying a ",[344,1044,1045],{},"LOCATION"," header pointing at\nthe renderer's ",[344,1048,1049],{},"description.xml",". The scanner de-duplicates by\n",[344,1052,1053],{},"hostAddress"," — it doesn't parse the device XML itself; that's left to\n",[344,1056,1057],{},"CastViewModel.searchAsync()",", which fetches ",[344,1060,1045],{},", calls\n",[344,1063,1064],{},"device.update(xml)",", and only surfaces devices where\n",[344,1067,1068],{},"device.isAVTransport()"," returns true:",[1071,1072,1076],"pre",{"className":1073,"code":1074,"language":1075,"meta":248,"style":248},"language-kotlin shiki shiki-themes github-light github-dark","fun search(): Flow\u003CDlnaDevice> = searchDlnaDevicesRaw().transform { ssdp ->\n    if (devices.none { it.hostAddress == ssdp.hostAddress }) {\n        val device = DlnaDevice(ssdp.hostAddress, ssdp.header)\n        devices.add(device)\n        emit(device)\n    }\n}\n","kotlin",[344,1077,1078,1124,1144,1160,1172,1180,1186],{"__ignoreMap":248},[1079,1080,1083,1087,1091,1095,1098,1101,1103,1106,1109,1112,1115,1118,1121],"span",{"class":1081,"line":1082},"line",1,[1079,1084,1086],{"class":1085},"szBVR","fun",[1079,1088,1090],{"class":1089},"sScJk"," search",[1079,1092,1094],{"class":1093},"sVt8B","(): ",[1079,1096,1097],{"class":1089},"Flow",[1079,1099,1100],{"class":1093},"\u003C",[1079,1102,997],{"class":1089},[1079,1104,1105],{"class":1093},"> ",[1079,1107,1108],{"class":1085},"=",[1079,1110,1111],{"class":1089}," searchDlnaDevicesRaw",[1079,1113,1114],{"class":1093},"().",[1079,1116,1117],{"class":1089},"transform",[1079,1119,1120],{"class":1093}," { ssdp ",[1079,1122,1123],{"class":1085},"->\n",[1079,1125,1126,1129,1132,1135,1138,1141],{"class":1081,"line":252},[1079,1127,1128],{"class":1085},"    if",[1079,1130,1131],{"class":1093}," (devices.",[1079,1133,1134],{"class":1089},"none",[1079,1136,1137],{"class":1093}," { it.hostAddress ",[1079,1139,1140],{"class":1085},"==",[1079,1142,1143],{"class":1093}," ssdp.hostAddress }) {\n",[1079,1145,1146,1149,1152,1154,1157],{"class":1081,"line":249},[1079,1147,1148],{"class":1085},"        val",[1079,1150,1151],{"class":1093}," device ",[1079,1153,1108],{"class":1085},[1079,1155,1156],{"class":1089}," DlnaDevice",[1079,1158,1159],{"class":1093},"(ssdp.hostAddress, ssdp.header)\n",[1079,1161,1163,1166,1169],{"class":1081,"line":1162},4,[1079,1164,1165],{"class":1093},"        devices.",[1079,1167,1168],{"class":1089},"add",[1079,1170,1171],{"class":1093},"(device)\n",[1079,1173,1175,1178],{"class":1081,"line":1174},5,[1079,1176,1177],{"class":1089},"        emit",[1079,1179,1171],{"class":1093},[1079,1181,1183],{"class":1081,"line":1182},6,[1079,1184,1185],{"class":1093},"    }\n",[1079,1187,1189],{"class":1081,"line":1188},7,[1079,1190,1191],{"class":1093},"}\n",[30,1193,1194,953,1197,1200,1201,1204,1205,1208,1209,1212,1213,1216,1217,1219,1220,1223,1224,1227],{},[85,1195,1196],{},"As a receiver",[344,1198,1199],{},"DlnaReceiverEngine.runSsdpLoop()"," sends three\n",[344,1202,1203],{},"NOTIFY ssdp:alive"," datagrams on start (root device, ",[344,1206,1207],{},"MediaRenderer:1","\ndevice type, ",[344,1210,1211],{},"AVTransport:1"," service type), re-announces every 30 seconds\n(",[344,1214,1215],{},"CACHE-CONTROL: max-age=1800","), and answers incoming ",[344,1218,1033],{}," requests\nwith unicast responses. On ",[344,1221,1222],{},"stop()",", it sends ",[344,1225,1226],{},"ssdp:byebye"," immediately\nrather than waiting for the 30-minute cache to expire — so a TV remote\napp stops listing PlainApp the moment \"Wireless Cast\" is turned off.",[1229,1230,838],"h3",{"id":1231},"port-fallback",[30,1233,1234,1235,1238,1239,1242,1243,1246],{},"The receiver's HTTP server tries port ",[344,1236,1237],{},"7878"," first, then ",[344,1240,1241],{},"7879",", then\n",[344,1244,1245],{},"7880",", preferring whichever port worked last time:",[1071,1248,1250],{"className":1073,"code":1249,"language":1075,"meta":248,"style":248},"private val CANDIDATE_PORTS = listOf(7878, 7879, 7880)\nprivate fun openServerSocket(): DlnaServerSocket? {\n    val candidates = lastPort\n        ?.let { listOf(it) + CANDIDATE_PORTS.filter { p -> p != it } }\n        ?: CANDIDATE_PORTS\n    for (port in candidates) {\n        val ss = createDlnaServerSocket(port)\n        if (ss != null) return ss\n    }\n    return null\n}\n",[344,1251,1252,1285,1303,1316,1357,1362,1376,1391,1414,1419,1428],{"__ignoreMap":248},[1079,1253,1254,1257,1260,1263,1265,1268,1271,1274,1276,1278,1280,1282],{"class":1081,"line":1082},[1079,1255,1256],{"class":1085},"private",[1079,1258,1259],{"class":1085}," val",[1079,1261,1262],{"class":1093}," CANDIDATE_PORTS ",[1079,1264,1108],{"class":1085},[1079,1266,1267],{"class":1089}," listOf",[1079,1269,1270],{"class":1093},"(",[1079,1272,1237],{"class":1273},"sj4cs",[1079,1275,953],{"class":1093},[1079,1277,1241],{"class":1273},[1079,1279,953],{"class":1093},[1079,1281,1245],{"class":1273},[1079,1283,1284],{"class":1093},")\n",[1079,1286,1287,1289,1292,1295,1297,1300],{"class":1081,"line":252},[1079,1288,1256],{"class":1085},[1079,1290,1291],{"class":1085}," fun",[1079,1293,1294],{"class":1089}," openServerSocket",[1079,1296,1094],{"class":1093},[1079,1298,1299],{"class":1089},"DlnaServerSocket",[1079,1301,1302],{"class":1093},"? {\n",[1079,1304,1305,1308,1311,1313],{"class":1081,"line":249},[1079,1306,1307],{"class":1085},"    val",[1079,1309,1310],{"class":1093}," candidates ",[1079,1312,1108],{"class":1085},[1079,1314,1315],{"class":1093}," lastPort\n",[1079,1317,1318,1321,1324,1327,1330,1333,1336,1339,1342,1345,1348,1351,1354],{"class":1081,"line":1162},[1079,1319,1320],{"class":1093},"        ?.",[1079,1322,1323],{"class":1089},"let",[1079,1325,1326],{"class":1093}," { ",[1079,1328,1329],{"class":1089},"listOf",[1079,1331,1332],{"class":1093},"(it) ",[1079,1334,1335],{"class":1085},"+",[1079,1337,1338],{"class":1093}," CANDIDATE_PORTS.",[1079,1340,1341],{"class":1089},"filter",[1079,1343,1344],{"class":1093}," { p ",[1079,1346,1347],{"class":1085},"->",[1079,1349,1350],{"class":1093}," p ",[1079,1352,1353],{"class":1085},"!=",[1079,1355,1356],{"class":1093}," it } }\n",[1079,1358,1359],{"class":1081,"line":1174},[1079,1360,1361],{"class":1093},"        ?: CANDIDATE_PORTS\n",[1079,1363,1364,1367,1370,1373],{"class":1081,"line":1182},[1079,1365,1366],{"class":1085},"    for",[1079,1368,1369],{"class":1093}," (port ",[1079,1371,1372],{"class":1085},"in",[1079,1374,1375],{"class":1093}," candidates) {\n",[1079,1377,1378,1380,1383,1385,1388],{"class":1081,"line":1188},[1079,1379,1148],{"class":1085},[1079,1381,1382],{"class":1093}," ss ",[1079,1384,1108],{"class":1085},[1079,1386,1387],{"class":1089}," createDlnaServerSocket",[1079,1389,1390],{"class":1093},"(port)\n",[1079,1392,1394,1397,1400,1402,1405,1408,1411],{"class":1081,"line":1393},8,[1079,1395,1396],{"class":1085},"        if",[1079,1398,1399],{"class":1093}," (ss ",[1079,1401,1353],{"class":1085},[1079,1403,1404],{"class":1273}," null",[1079,1406,1407],{"class":1093},") ",[1079,1409,1410],{"class":1085},"return",[1079,1412,1413],{"class":1093}," ss\n",[1079,1415,1417],{"class":1081,"line":1416},9,[1079,1418,1185],{"class":1093},[1079,1420,1422,1425],{"class":1081,"line":1421},10,[1079,1423,1424],{"class":1085},"    return",[1079,1426,1427],{"class":1273}," null\n",[1079,1429,1431],{"class":1081,"line":1430},11,[1079,1432,1191],{"class":1093},[30,1434,1435,1436,1439],{},"If all three ports are taken (rare, but possible with other DLNA apps\nrunning), ",[344,1437,1438],{},"startError"," is set and surfaced in the UI rather than failing\nsilently.",[34,1441,844],{"id":1442},"avtransport-control-the-soap-protocol",[30,1444,1445,1446,1449,1450,1453,1454,1457],{},"Once a renderer is found, playback is controlled through ",[85,1447,1448],{},"UPnP\nAVTransport",", a SOAP-over-HTTP service. Every action is a ",[344,1451,1452],{},"POST"," to the\nrenderer's control URL with a ",[344,1455,1456],{},"SOAPAction"," header and an XML body wrapped\nin a SOAP envelope.",[30,1459,1460],{},[913,1461],{"alt":1462,"src":1463},"Diagram 3","\u002Fblog\u002Fdlna-cast\u002Fdiagram-03.svg",[30,1465,1466,1468],{},[344,1467,956],{}," builds each request with a shared helper:",[1071,1470,1472],{"className":1073,"code":1471,"language":1075,"meta":248,"style":248},"private suspend fun executeAVTransportCommand(\n    device: DlnaDevice,\n    action: String,\n    parameters: String = \"\u003CInstanceID>0\u003C\u002FInstanceID>\",\n): String {\n    val st = device.getAVTransportService()?.serviceType ?: return \"\"\n    return executeSOAPRequest(device, action, \"\u003Cu:$action xmlns:u=\\\"$st\\\">$parameters\u003C\u002Fu:$action>\")\n}\n",[344,1473,1474,1489,1499,1509,1525,1535,1558,1596],{"__ignoreMap":248},[1079,1475,1476,1478,1481,1483,1486],{"class":1081,"line":1082},[1079,1477,1256],{"class":1085},[1079,1479,1480],{"class":1085}," suspend",[1079,1482,1291],{"class":1085},[1079,1484,1485],{"class":1089}," executeAVTransportCommand",[1079,1487,1488],{"class":1093},"(\n",[1079,1490,1491,1494,1496],{"class":1081,"line":252},[1079,1492,1493],{"class":1093},"    device: ",[1079,1495,997],{"class":1089},[1079,1497,1498],{"class":1093},",\n",[1079,1500,1501,1504,1507],{"class":1081,"line":249},[1079,1502,1503],{"class":1093},"    action: ",[1079,1505,1506],{"class":1089},"String",[1079,1508,1498],{"class":1093},[1079,1510,1511,1514,1516,1519,1523],{"class":1081,"line":1162},[1079,1512,1513],{"class":1093},"    parameters: ",[1079,1515,1506],{"class":1089},[1079,1517,1518],{"class":1085}," =",[1079,1520,1522],{"class":1521},"sZZnC"," \"\u003CInstanceID>0\u003C\u002FInstanceID>\"",[1079,1524,1498],{"class":1093},[1079,1526,1527,1530,1532],{"class":1081,"line":1174},[1079,1528,1529],{"class":1093},"): ",[1079,1531,1506],{"class":1089},[1079,1533,1534],{"class":1093}," {\n",[1079,1536,1537,1539,1542,1544,1547,1550,1553,1555],{"class":1081,"line":1182},[1079,1538,1307],{"class":1085},[1079,1540,1541],{"class":1093}," st ",[1079,1543,1108],{"class":1085},[1079,1545,1546],{"class":1093}," device.",[1079,1548,1549],{"class":1089},"getAVTransportService",[1079,1551,1552],{"class":1093},"()?.serviceType ?: ",[1079,1554,1410],{"class":1085},[1079,1556,1557],{"class":1521}," \"\"\n",[1079,1559,1560,1562,1565,1568,1571,1574,1577,1580,1583,1586,1589,1591,1594],{"class":1081,"line":1188},[1079,1561,1424],{"class":1085},[1079,1563,1564],{"class":1089}," executeSOAPRequest",[1079,1566,1567],{"class":1093},"(device, action, ",[1079,1569,1570],{"class":1521},"\"\u003Cu:",[1079,1572,1573],{"class":1273},"$action",[1079,1575,1576],{"class":1521}," xmlns:u=",[1079,1578,1579],{"class":1273},"\\\"$st\\\"",[1079,1581,1582],{"class":1521},">",[1079,1584,1585],{"class":1273},"$parameters",[1079,1587,1588],{"class":1521},"\u003C\u002Fu:",[1079,1590,1573],{"class":1273},[1079,1592,1593],{"class":1521},">\"",[1079,1595,1284],{"class":1093},[1079,1597,1598],{"class":1081,"line":1393},[1079,1599,1191],{"class":1093},[30,1601,1602,1605,1606,1609,1610,1613,1614,125],{},[344,1603,1604],{},"executeSOAPRequest"," sets ",[344,1607,1608],{},"SOAPAction: \"\u003CserviceType>#\u003Caction>\""," and posts\n",[344,1611,1612],{},"DlnaSoap.requestEnvelope(soapBody)"," — the same envelope constants used by\nthe receiver side to build responses, so the wire format only needs to be\ndefined once in ",[344,1615,1616],{},"commonMain",[30,1618,1619,1620,1623,1624,1627,1628,1631,1632,953,1635,953,1638,953,1641,1498,1644,953,1647,953,1650,953,1653,1498,1656,1659,1660,1663,1664,1667],{},"The receiver's ",[344,1621,1622],{},"DlnaHttpRouter.handleSoap()"," mirrors this on the other\nend: it reads the ",[344,1625,1626],{},"soapaction"," header, extracts the action name after the\n",[344,1629,1630],{},"#",", and dispatches on it —",[344,1633,1634],{},"SetAVTransportURI",[344,1636,1637],{},"Play",[344,1639,1640],{},"Pause",[344,1642,1643],{},"Stop",[344,1645,1646],{},"Seek",[344,1648,1649],{},"GetTransportInfo",[344,1651,1652],{},"GetPositionInfo",[344,1654,1655],{},"GetMediaInfo",[344,1657,1658],{},"GetDeviceCapabilities",". ",[344,1661,1662],{},"RenderingControl"," (volume) is stubbed with a\nstatic ",[344,1665,1666],{},"100"," — PlainApp doesn't expose device volume through UPnP.",[34,1669,850],{"id":1670},"didl-lite-metadata-and-the-double-escaping-quirk",[30,1672,1673,1675,1676,1679,1680,1683,1684,1687],{},[344,1674,1634],{}," carries two parameters: ",[344,1677,1678],{},"CurrentURI"," (the media URL)\nand ",[344,1681,1682],{},"CurrentURIMetaData"," — a ",[85,1685,1686],{},"DIDL-Lite"," XML fragment describing the\ntitle, media class, and album art, embedded as an XML-escaped string\ninside the outer SOAP body:",[1071,1689,1691],{"className":1073,"code":1690,"language":1075,"meta":248,"style":248},"private fun buildDidlLiteMetadata(mediaUrl: String, title: String, albumArtUri: String): String {\n    val upnpClass = when {\n        ext in setOf(\"mp3\", \"m4a\", \"flac\", ...) -> \"object.item.audioItem.musicTrack\"\n        ext in setOf(\"jpg\", \"jpeg\", \"png\", ...) -> \"object.item.imageItem\"\n        else -> \"object.item.videoItem\"\n    }\n    val didl = \"\"\"\u003CDIDL-Lite xmlns=\"...\">\u003Citem id=\"0\" parentID=\"-1\" restricted=\"0\">\n        \u003Cdc:title>$escapedTitle\u003C\u002Fdc:title>\u003Cupnp:class>$upnpClass\u003C\u002Fupnp:class>$albumArtTag\u003C\u002Fitem>\u003C\u002FDIDL-Lite>\"\"\"\n    return didl.replace(\"&\", \"&amp;\").replace(\"\u003C\", \"&lt;\").replace(\">\", \"&gt;\")\n}\n",[344,1692,1693,1723,1737,1775,1809,1820,1824,1836,1859,1910],{"__ignoreMap":248},[1079,1694,1695,1697,1699,1702,1705,1707,1710,1712,1715,1717,1719,1721],{"class":1081,"line":1082},[1079,1696,1256],{"class":1085},[1079,1698,1291],{"class":1085},[1079,1700,1701],{"class":1089}," buildDidlLiteMetadata",[1079,1703,1704],{"class":1093},"(mediaUrl: ",[1079,1706,1506],{"class":1089},[1079,1708,1709],{"class":1093},", title: ",[1079,1711,1506],{"class":1089},[1079,1713,1714],{"class":1093},", albumArtUri: ",[1079,1716,1506],{"class":1089},[1079,1718,1529],{"class":1093},[1079,1720,1506],{"class":1089},[1079,1722,1534],{"class":1093},[1079,1724,1725,1727,1730,1732,1735],{"class":1081,"line":252},[1079,1726,1307],{"class":1085},[1079,1728,1729],{"class":1093}," upnpClass ",[1079,1731,1108],{"class":1085},[1079,1733,1734],{"class":1085}," when",[1079,1736,1534],{"class":1093},[1079,1738,1739,1742,1744,1747,1749,1752,1754,1757,1759,1762,1764,1767,1770,1772],{"class":1081,"line":249},[1079,1740,1741],{"class":1093},"        ext ",[1079,1743,1372],{"class":1085},[1079,1745,1746],{"class":1089}," setOf",[1079,1748,1270],{"class":1093},[1079,1750,1751],{"class":1521},"\"mp3\"",[1079,1753,953],{"class":1093},[1079,1755,1756],{"class":1521},"\"m4a\"",[1079,1758,953],{"class":1093},[1079,1760,1761],{"class":1521},"\"flac\"",[1079,1763,953],{"class":1093},[1079,1765,1766],{"class":1085},"..",[1079,1768,1769],{"class":1093},".) ",[1079,1771,1347],{"class":1085},[1079,1773,1774],{"class":1521}," \"object.item.audioItem.musicTrack\"\n",[1079,1776,1777,1779,1781,1783,1785,1788,1790,1793,1795,1798,1800,1802,1804,1806],{"class":1081,"line":1162},[1079,1778,1741],{"class":1093},[1079,1780,1372],{"class":1085},[1079,1782,1746],{"class":1089},[1079,1784,1270],{"class":1093},[1079,1786,1787],{"class":1521},"\"jpg\"",[1079,1789,953],{"class":1093},[1079,1791,1792],{"class":1521},"\"jpeg\"",[1079,1794,953],{"class":1093},[1079,1796,1797],{"class":1521},"\"png\"",[1079,1799,953],{"class":1093},[1079,1801,1766],{"class":1085},[1079,1803,1769],{"class":1093},[1079,1805,1347],{"class":1085},[1079,1807,1808],{"class":1521}," \"object.item.imageItem\"\n",[1079,1810,1811,1814,1817],{"class":1081,"line":1174},[1079,1812,1813],{"class":1085},"        else",[1079,1815,1816],{"class":1085}," ->",[1079,1818,1819],{"class":1521}," \"object.item.videoItem\"\n",[1079,1821,1822],{"class":1081,"line":1182},[1079,1823,1185],{"class":1093},[1079,1825,1826,1828,1831,1833],{"class":1081,"line":1188},[1079,1827,1307],{"class":1085},[1079,1829,1830],{"class":1093}," didl ",[1079,1832,1108],{"class":1085},[1079,1834,1835],{"class":1521}," \"\"\"\u003CDIDL-Lite xmlns=\"...\">\u003Citem id=\"0\" parentID=\"-1\" restricted=\"0\">\n",[1079,1837,1838,1841,1844,1847,1850,1853,1856],{"class":1081,"line":1393},[1079,1839,1840],{"class":1521},"        \u003Cdc:title>",[1079,1842,1843],{"class":1273},"$escapedTitle",[1079,1845,1846],{"class":1521},"\u003C\u002Fdc:title>\u003Cupnp:class>",[1079,1848,1849],{"class":1273},"$upnpClass",[1079,1851,1852],{"class":1521},"\u003C\u002Fupnp:class>",[1079,1854,1855],{"class":1273},"$albumArtTag",[1079,1857,1858],{"class":1521},"\u003C\u002Fitem>\u003C\u002FDIDL-Lite>\"\"\"\n",[1079,1860,1861,1863,1866,1869,1871,1874,1876,1879,1882,1884,1886,1889,1891,1894,1896,1898,1900,1903,1905,1908],{"class":1081,"line":1416},[1079,1862,1424],{"class":1085},[1079,1864,1865],{"class":1093}," didl.",[1079,1867,1868],{"class":1089},"replace",[1079,1870,1270],{"class":1093},[1079,1872,1873],{"class":1521},"\"&\"",[1079,1875,953],{"class":1093},[1079,1877,1878],{"class":1521},"\"&amp;\"",[1079,1880,1881],{"class":1093},").",[1079,1883,1868],{"class":1089},[1079,1885,1270],{"class":1093},[1079,1887,1888],{"class":1521},"\"\u003C\"",[1079,1890,953],{"class":1093},[1079,1892,1893],{"class":1521},"\"&lt;\"",[1079,1895,1881],{"class":1093},[1079,1897,1868],{"class":1089},[1079,1899,1270],{"class":1093},[1079,1901,1902],{"class":1521},"\">\"",[1079,1904,953],{"class":1093},[1079,1906,1907],{"class":1521},"\"&gt;\"",[1079,1909,1284],{"class":1093},[1079,1911,1912],{"class":1081,"line":1421},[1079,1913,1191],{"class":1093},[30,1915,1916,1917,1920,1921,1924,1925,1927,1928,1930,1931,1934,1935,1937],{},"The DIDL-Lite XML is escaped ",[85,1918,1919],{},"twice",": once for the title text itself\n(so a song called ",[344,1922,1923],{},"Fire & Ice"," doesn't break the DIDL-Lite tags), and once\nfor the entire DIDL-Lite document (so its own ",[344,1926,1100],{},"\u002F",[344,1929,1582],{}," don't break the\n",[133,1932,1933],{},"outer"," SOAP envelope it's embedded in as text). This is a well-known\nUPnP quirk, not a bug — ",[344,1936,1682],{}," is defined as string content,\nnot as nested XML elements.",[30,1939,1940,1941,1943,1944,1947,1948,1927,1951,1954,1955,1958],{},"On the receiver side, ",[344,1942,984],{}," reverses this: ",[344,1945,1946],{},"parseSoapAction","\nun-escapes the SOAP body once to get the DIDL-Lite text, then\n",[344,1949,1950],{},"extractTitleFromDidlMeta",[344,1952,1953],{},"extractMediaTypeFromDidlMeta","\u002F\n",[344,1956,1957],{},"extractAlbumArtUriFromDidlMeta"," each do a second pass of entity\nunescaping and tag extraction on that inner string:",[1071,1960,1962],{"className":1073,"code":1961,"language":1075,"meta":248,"style":248},"fun extractMediaTypeFromDidlMeta(meta: String, fallbackUri: String = \"\"): DlnaMediaType {\n    val cls = meta.substring(classStart + 12, classEnd).lowercase()\n    return when {\n        \"audioitem\" in cls || \"musictrack\" in cls -> DlnaMediaType.AUDIO\n        \"imageitem\" in cls || \"photo\" in cls -> DlnaMediaType.IMAGE\n        \"videoitem\" in cls -> DlnaMediaType.VIDEO\n        else -> DlnaMediaType.UNKNOWN\n    }\n}\n",[344,1963,1964,1993,2025,2033,2058,2081,2095,2104,2108],{"__ignoreMap":248},[1079,1965,1966,1968,1971,1974,1976,1979,1981,1983,1986,1988,1991],{"class":1081,"line":1082},[1079,1967,1086],{"class":1085},[1079,1969,1970],{"class":1089}," extractMediaTypeFromDidlMeta",[1079,1972,1973],{"class":1093},"(meta: ",[1079,1975,1506],{"class":1089},[1079,1977,1978],{"class":1093},", fallbackUri: ",[1079,1980,1506],{"class":1089},[1079,1982,1518],{"class":1085},[1079,1984,1985],{"class":1521}," \"\"",[1079,1987,1529],{"class":1093},[1079,1989,1990],{"class":1089},"DlnaMediaType",[1079,1992,1534],{"class":1093},[1079,1994,1995,1997,2000,2002,2005,2008,2011,2013,2016,2019,2022],{"class":1081,"line":252},[1079,1996,1307],{"class":1085},[1079,1998,1999],{"class":1093}," cls ",[1079,2001,1108],{"class":1085},[1079,2003,2004],{"class":1093}," meta.",[1079,2006,2007],{"class":1089},"substring",[1079,2009,2010],{"class":1093},"(classStart ",[1079,2012,1335],{"class":1085},[1079,2014,2015],{"class":1273}," 12",[1079,2017,2018],{"class":1093},", classEnd).",[1079,2020,2021],{"class":1089},"lowercase",[1079,2023,2024],{"class":1093},"()\n",[1079,2026,2027,2029,2031],{"class":1081,"line":249},[1079,2028,1424],{"class":1085},[1079,2030,1734],{"class":1085},[1079,2032,1534],{"class":1093},[1079,2034,2035,2038,2041,2043,2046,2049,2051,2053,2055],{"class":1081,"line":1162},[1079,2036,2037],{"class":1521},"        \"audioitem\"",[1079,2039,2040],{"class":1085}," in",[1079,2042,1999],{"class":1093},[1079,2044,2045],{"class":1085},"||",[1079,2047,2048],{"class":1521}," \"musictrack\"",[1079,2050,2040],{"class":1085},[1079,2052,1999],{"class":1093},[1079,2054,1347],{"class":1085},[1079,2056,2057],{"class":1093}," DlnaMediaType.AUDIO\n",[1079,2059,2060,2063,2065,2067,2069,2072,2074,2076,2078],{"class":1081,"line":1174},[1079,2061,2062],{"class":1521},"        \"imageitem\"",[1079,2064,2040],{"class":1085},[1079,2066,1999],{"class":1093},[1079,2068,2045],{"class":1085},[1079,2070,2071],{"class":1521}," \"photo\"",[1079,2073,2040],{"class":1085},[1079,2075,1999],{"class":1093},[1079,2077,1347],{"class":1085},[1079,2079,2080],{"class":1093}," DlnaMediaType.IMAGE\n",[1079,2082,2083,2086,2088,2090,2092],{"class":1081,"line":1182},[1079,2084,2085],{"class":1521},"        \"videoitem\"",[1079,2087,2040],{"class":1085},[1079,2089,1999],{"class":1093},[1079,2091,1347],{"class":1085},[1079,2093,2094],{"class":1093}," DlnaMediaType.VIDEO\n",[1079,2096,2097,2099,2101],{"class":1081,"line":1188},[1079,2098,1813],{"class":1085},[1079,2100,1816],{"class":1085},[1079,2102,2103],{"class":1093}," DlnaMediaType.UNKNOWN\n",[1079,2105,2106],{"class":1081,"line":1393},[1079,2107,1185],{"class":1093},[1079,2109,2110],{"class":1081,"line":1416},[1079,2111,1191],{"class":1093},[30,2113,2114,2115,2118,2119,2122,2123,2126],{},"If ",[344,2116,2117],{},"\u003Cupnp:class>"," is missing (some senders omit it), ",[344,2120,2121],{},"cleanMediaTitle()","\nfalls back to the file extension of the URI itself — media type\ndetection never hard-fails, it just degrades to ",[344,2124,2125],{},"UNKNOWN"," which routes to\nthe video player as a safe default.",[34,2128,856],{"id":2129},"serving-media-to-the-tv-range-requests-dlna-headers",[30,2131,2132,2133,2135,2136,2139,2140,125],{},"A ",[344,2134,1634],{}," call only tells the renderer ",[133,2137,2138],{},"where"," to fetch the\nmedia from — the actual bytes are served by PlainApp's own local HTTP\nserver, at ",[344,2141,2142],{},"\u002Fmedia\u002F{id}",[30,2144,2145],{},[913,2146],{"alt":2147,"src":2148},"Diagram 4","\u002Fblog\u002Fdlna-cast\u002Fdiagram-04.svg",[30,2150,2151,2154,2155,2158,2159,2162],{},[344,2152,2153],{},"UrlHelper.getMediaHttpUrl(path)"," registers the real path (which might be\na ",[344,2156,2157],{},"content:\u002F\u002F"," URI, a remote URL, or a plain file path) under a short id\nand returns ",[344,2160,2161],{},"http:\u002F\u002F\u003Cdevice-ip>:\u003Cport>\u002Fmedia\u002F\u003Cid>.\u003Cext>",". The route then\nbranches on what kind of source it actually is:",[1071,2164,2166],{"className":1073,"code":2165,"language":1075,"meta":248,"style":248},"when {\n    path.isUrl() -> call.proxyUrl(path)                 \u002F\u002F remote URL: stream upstream response\n    isContentUri(path) -> call.respondStream { sink -> streamContentUri(path, sink) }\n    path.isImageFast() -> call.respondFile(path)         \u002F\u002F images: plain static serve\n    else -> call.respondDlnaFile(path)                   \u002F\u002F audio\u002Fvideo: DLNA-aware serving\n}\n",[344,2167,2168,2175,2201,2227,2249,2267],{"__ignoreMap":248},[1079,2169,2170,2173],{"class":1081,"line":1082},[1079,2171,2172],{"class":1085},"when",[1079,2174,1534],{"class":1093},[1079,2176,2177,2180,2183,2186,2188,2191,2194,2197],{"class":1081,"line":252},[1079,2178,2179],{"class":1093},"    path.",[1079,2181,2182],{"class":1089},"isUrl",[1079,2184,2185],{"class":1093},"() ",[1079,2187,1347],{"class":1085},[1079,2189,2190],{"class":1093}," call.",[1079,2192,2193],{"class":1089},"proxyUrl",[1079,2195,2196],{"class":1093},"(path)                 ",[1079,2198,2200],{"class":2199},"sJ8bj","\u002F\u002F remote URL: stream upstream response\n",[1079,2202,2203,2206,2209,2211,2213,2216,2219,2221,2224],{"class":1081,"line":249},[1079,2204,2205],{"class":1089},"    isContentUri",[1079,2207,2208],{"class":1093},"(path) ",[1079,2210,1347],{"class":1085},[1079,2212,2190],{"class":1093},[1079,2214,2215],{"class":1089},"respondStream",[1079,2217,2218],{"class":1093}," { sink ",[1079,2220,1347],{"class":1085},[1079,2222,2223],{"class":1089}," streamContentUri",[1079,2225,2226],{"class":1093},"(path, sink) }\n",[1079,2228,2229,2231,2234,2236,2238,2240,2243,2246],{"class":1081,"line":1162},[1079,2230,2179],{"class":1093},[1079,2232,2233],{"class":1089},"isImageFast",[1079,2235,2185],{"class":1093},[1079,2237,1347],{"class":1085},[1079,2239,2190],{"class":1093},[1079,2241,2242],{"class":1089},"respondFile",[1079,2244,2245],{"class":1093},"(path)         ",[1079,2247,2248],{"class":2199},"\u002F\u002F images: plain static serve\n",[1079,2250,2251,2254,2256,2258,2261,2264],{"class":1081,"line":1174},[1079,2252,2253],{"class":1085},"    else",[1079,2255,1816],{"class":1085},[1079,2257,2190],{"class":1093},[1079,2259,2260],{"class":1089},"respondDlnaFile",[1079,2262,2263],{"class":1093},"(path)                   ",[1079,2265,2266],{"class":2199},"\u002F\u002F audio\u002Fvideo: DLNA-aware serving\n",[1079,2268,2269],{"class":1081,"line":1182},[1079,2270,1191],{"class":1093},[30,2272,2273,2275],{},[344,2274,2260],{}," is the interesting case — many smart TVs and DLNA\nrenderers refuse to play a stream unless it looks like a proper DLNA\nmedia server response:",[1071,2277,2279],{"className":1073,"code":2278,"language":1075,"meta":248,"style":248},"override suspend fun respondDlnaFile(path: String): Boolean {\n    val file = java.io.File(path)\n    if (!file.exists()) return false\n    applicationCall.response.run {\n        header(\"realTimeInfo.dlna.org\", \"DLNA.ORG_TLAG=*\")\n        header(\"contentFeatures.dlna.org\", \"\")\n        header(\"transferMode.dlna.org\", \"Streaming\")\n        header(\"Connection\", \"keep-alive\")\n        header(\"Server\", \"DLNADOC\u002F1.50 UPnP\u002F1.0 Plain\u002F1.0 Android\u002F${android.os.Build.VERSION.RELEASE}\")\n        status(HttpStatusCode.PartialContent) \u002F\u002F some TV OS only accept 206\n    }\n    applicationCall.respond(LocalFileContent(file))\n    return true\n}\n",[344,2280,2281,2305,2323,2347,2357,2374,2390,2406,2422,2438,2449,2453,2470,2478],{"__ignoreMap":248},[1079,2282,2283,2286,2288,2290,2293,2296,2298,2300,2303],{"class":1081,"line":1082},[1079,2284,2285],{"class":1085},"override",[1079,2287,1480],{"class":1085},[1079,2289,1291],{"class":1085},[1079,2291,2292],{"class":1089}," respondDlnaFile",[1079,2294,2295],{"class":1093},"(path: ",[1079,2297,1506],{"class":1089},[1079,2299,1529],{"class":1093},[1079,2301,2302],{"class":1089},"Boolean",[1079,2304,1534],{"class":1093},[1079,2306,2307,2309,2312,2314,2317,2320],{"class":1081,"line":252},[1079,2308,1307],{"class":1085},[1079,2310,2311],{"class":1093}," file ",[1079,2313,1108],{"class":1085},[1079,2315,2316],{"class":1093}," java.io.",[1079,2318,2319],{"class":1089},"File",[1079,2321,2322],{"class":1093},"(path)\n",[1079,2324,2325,2327,2330,2333,2336,2339,2342,2344],{"class":1081,"line":249},[1079,2326,1128],{"class":1085},[1079,2328,2329],{"class":1093}," (",[1079,2331,2332],{"class":1085},"!",[1079,2334,2335],{"class":1093},"file.",[1079,2337,2338],{"class":1089},"exists",[1079,2340,2341],{"class":1093},"()) ",[1079,2343,1410],{"class":1085},[1079,2345,2346],{"class":1273}," false\n",[1079,2348,2349,2352,2355],{"class":1081,"line":1162},[1079,2350,2351],{"class":1093},"    applicationCall.response.",[1079,2353,2354],{"class":1089},"run",[1079,2356,1534],{"class":1093},[1079,2358,2359,2362,2364,2367,2369,2372],{"class":1081,"line":1174},[1079,2360,2361],{"class":1089},"        header",[1079,2363,1270],{"class":1093},[1079,2365,2366],{"class":1521},"\"realTimeInfo.dlna.org\"",[1079,2368,953],{"class":1093},[1079,2370,2371],{"class":1521},"\"DLNA.ORG_TLAG=*\"",[1079,2373,1284],{"class":1093},[1079,2375,2376,2378,2380,2383,2385,2388],{"class":1081,"line":1182},[1079,2377,2361],{"class":1089},[1079,2379,1270],{"class":1093},[1079,2381,2382],{"class":1521},"\"contentFeatures.dlna.org\"",[1079,2384,953],{"class":1093},[1079,2386,2387],{"class":1521},"\"\"",[1079,2389,1284],{"class":1093},[1079,2391,2392,2394,2396,2399,2401,2404],{"class":1081,"line":1188},[1079,2393,2361],{"class":1089},[1079,2395,1270],{"class":1093},[1079,2397,2398],{"class":1521},"\"transferMode.dlna.org\"",[1079,2400,953],{"class":1093},[1079,2402,2403],{"class":1521},"\"Streaming\"",[1079,2405,1284],{"class":1093},[1079,2407,2408,2410,2412,2415,2417,2420],{"class":1081,"line":1393},[1079,2409,2361],{"class":1089},[1079,2411,1270],{"class":1093},[1079,2413,2414],{"class":1521},"\"Connection\"",[1079,2416,953],{"class":1093},[1079,2418,2419],{"class":1521},"\"keep-alive\"",[1079,2421,1284],{"class":1093},[1079,2423,2424,2426,2428,2431,2433,2436],{"class":1081,"line":1416},[1079,2425,2361],{"class":1089},[1079,2427,1270],{"class":1093},[1079,2429,2430],{"class":1521},"\"Server\"",[1079,2432,953],{"class":1093},[1079,2434,2435],{"class":1521},"\"DLNADOC\u002F1.50 UPnP\u002F1.0 Plain\u002F1.0 Android\u002F${android.os.Build.VERSION.RELEASE}\"",[1079,2437,1284],{"class":1093},[1079,2439,2440,2443,2446],{"class":1081,"line":1421},[1079,2441,2442],{"class":1089},"        status",[1079,2444,2445],{"class":1093},"(HttpStatusCode.PartialContent) ",[1079,2447,2448],{"class":2199},"\u002F\u002F some TV OS only accept 206\n",[1079,2450,2451],{"class":1081,"line":1430},[1079,2452,1185],{"class":1093},[1079,2454,2456,2459,2462,2464,2467],{"class":1081,"line":2455},12,[1079,2457,2458],{"class":1093},"    applicationCall.",[1079,2460,2461],{"class":1089},"respond",[1079,2463,1270],{"class":1093},[1079,2465,2466],{"class":1089},"LocalFileContent",[1079,2468,2469],{"class":1093},"(file))\n",[1079,2471,2473,2475],{"class":1081,"line":2472},13,[1079,2474,1424],{"class":1085},[1079,2476,2477],{"class":1273}," true\n",[1079,2479,2481],{"class":1081,"line":2480},14,[1079,2482,1191],{"class":1093},[30,2484,2485,2486,2489,2490,2492,2493,2496],{},"Note the status is always ",[344,2487,2488],{},"206 Partial Content",", not ",[344,2491,1041],{}," — some TV\nfirmware treats a plain ",[344,2494,2495],{},"200"," response as \"not seekable\" and refuses to\nplay it, even for a full-file GET. This one status-code choice is the\ndifference between \"plays fine\" and \"TV shows a spinner forever\" on\nseveral real devices.",[30,2498,2499,2500,2503,2504,2507,2508,2511,2512,2514,2515,2517],{},"The same route serves ",[85,2501,2502],{},"album art"," for cast audio items too:\n",[344,2505,2506],{},"UrlHelper.getAlbumArtHttpUrl()"," maps a ",[344,2509,2510],{},"content:\u002F\u002Fmedia\u002F...\u002Falbumart\u002F\u003Cid>","\nURI into the identical ",[344,2513,2142],{}," path, so ",[344,2516,2157],{}," streaming and\nDLNA file serving share one code path regardless of whether the \"media\"\nin question is the song or its cover image.",[34,2519,862],{"id":2520},"gena-events-playback-state-callbacks",[30,2522,2523,2524,2527],{},"After starting playback, the sender subscribes to the renderer's\n",[85,2525,2526],{},"AVTransport eventing"," service (GENA — General Event Notification\nArchitecture) so it learns about state changes without polling:",[1071,2529,2531],{"className":1073,"code":2530,"language":1075,"meta":248,"style":248},"suspend fun subscribeEvent(device: DlnaDevice, callbackUrl: String): String {\n    val service = device.getAVTransportService() ?: return \"\"\n    val response = createHttpClient().subscribe(baseUrl + eventSubURL) {\n        headers { set(\"NT\", \"upnp:event\"); set(\"TIMEOUT\", \"Second-3600\"); set(\"CALLBACK\", \"\u003C$callbackUrl>\") }\n    }\n    return response.headers[\"SID\"].orEmpty()\n}\n",[344,2532,2533,2559,2579,2604,2661,2665,2683],{"__ignoreMap":248},[1079,2534,2535,2538,2540,2543,2546,2548,2551,2553,2555,2557],{"class":1081,"line":1082},[1079,2536,2537],{"class":1085},"suspend",[1079,2539,1291],{"class":1085},[1079,2541,2542],{"class":1089}," subscribeEvent",[1079,2544,2545],{"class":1093},"(device: ",[1079,2547,997],{"class":1089},[1079,2549,2550],{"class":1093},", callbackUrl: ",[1079,2552,1506],{"class":1089},[1079,2554,1529],{"class":1093},[1079,2556,1506],{"class":1089},[1079,2558,1534],{"class":1093},[1079,2560,2561,2563,2566,2568,2570,2572,2575,2577],{"class":1081,"line":252},[1079,2562,1307],{"class":1085},[1079,2564,2565],{"class":1093}," service ",[1079,2567,1108],{"class":1085},[1079,2569,1546],{"class":1093},[1079,2571,1549],{"class":1089},[1079,2573,2574],{"class":1093},"() ?: ",[1079,2576,1410],{"class":1085},[1079,2578,1557],{"class":1521},[1079,2580,2581,2583,2586,2588,2591,2593,2596,2599,2601],{"class":1081,"line":249},[1079,2582,1307],{"class":1085},[1079,2584,2585],{"class":1093}," response ",[1079,2587,1108],{"class":1085},[1079,2589,2590],{"class":1089}," createHttpClient",[1079,2592,1114],{"class":1093},[1079,2594,2595],{"class":1089},"subscribe",[1079,2597,2598],{"class":1093},"(baseUrl ",[1079,2600,1335],{"class":1085},[1079,2602,2603],{"class":1093}," eventSubURL) {\n",[1079,2605,2606,2609,2611,2614,2616,2619,2621,2624,2627,2629,2631,2634,2636,2639,2641,2643,2645,2648,2650,2653,2656,2658],{"class":1081,"line":1162},[1079,2607,2608],{"class":1089},"        headers",[1079,2610,1326],{"class":1093},[1079,2612,2613],{"class":1085},"set",[1079,2615,1270],{"class":1093},[1079,2617,2618],{"class":1521},"\"NT\"",[1079,2620,953],{"class":1093},[1079,2622,2623],{"class":1521},"\"upnp:event\"",[1079,2625,2626],{"class":1093},"); ",[1079,2628,2613],{"class":1085},[1079,2630,1270],{"class":1093},[1079,2632,2633],{"class":1521},"\"TIMEOUT\"",[1079,2635,953],{"class":1093},[1079,2637,2638],{"class":1521},"\"Second-3600\"",[1079,2640,2626],{"class":1093},[1079,2642,2613],{"class":1085},[1079,2644,1270],{"class":1093},[1079,2646,2647],{"class":1521},"\"CALLBACK\"",[1079,2649,953],{"class":1093},[1079,2651,2652],{"class":1521},"\"\u003C",[1079,2654,2655],{"class":1273},"$callbackUrl",[1079,2657,1593],{"class":1521},[1079,2659,2660],{"class":1093},") }\n",[1079,2662,2663],{"class":1081,"line":1174},[1079,2664,1185],{"class":1093},[1079,2666,2667,2669,2672,2675,2678,2681],{"class":1081,"line":1182},[1079,2668,1424],{"class":1085},[1079,2670,2671],{"class":1093}," response.headers[",[1079,2673,2674],{"class":1521},"\"SID\"",[1079,2676,2677],{"class":1093},"].",[1079,2679,2680],{"class":1089},"orEmpty",[1079,2682,2024],{"class":1093},[1079,2684,2685],{"class":1081,"line":1188},[1079,2686,1191],{"class":1093},[30,2688,2689,1927,2692,1927,2695,2698,2699,2702,2703,2706,2707,2710,2711,2714],{},[344,2690,2691],{},"SUBSCRIBE",[344,2693,2694],{},"RENEW",[344,2696,2697],{},"UNSUBSCRIBE"," are custom HTTP methods (not in the\nstandard verb set), handled via Ktor's generic ",[344,2700,2701],{},"HttpMethod(\"SUBSCRIBE\")","\nrequest builder. The renderer then ",[344,2704,2705],{},"NOTIFY","s\n",[344,2708,2709],{},"callbackUrl"," — PlainApp's own ",[344,2712,2713],{},"\u002Fcallback\u002Fcast"," route — whenever\ntransport state, position, or duration changes:",[1071,2716,2718],{"className":1073,"code":2717,"language":1075,"meta":248,"style":248},"if (xml.contains(\"TransportState val=\\\"STOPPED\\\"\") && !xml.contains(\"AVTransportURIMetaData\")) {\n    \u002F\u002F advance to next playlist item\n} else if (xml.contains(\"TransportState val=\\\"PLAYING\\\"\")) {\n    CastPlayer.isPlaying.value = true\n}\n",[344,2719,2720,2768,2773,2803,2815],{"__ignoreMap":248},[1079,2721,2722,2725,2728,2731,2733,2736,2739,2742,2744,2747,2749,2752,2755,2758,2760,2762,2765],{"class":1081,"line":1082},[1079,2723,2724],{"class":1085},"if",[1079,2726,2727],{"class":1093}," (xml.",[1079,2729,2730],{"class":1089},"contains",[1079,2732,1270],{"class":1093},[1079,2734,2735],{"class":1521},"\"TransportState val=",[1079,2737,2738],{"class":1273},"\\\"",[1079,2740,2741],{"class":1521},"STOPPED",[1079,2743,2738],{"class":1273},[1079,2745,2746],{"class":1521},"\"",[1079,2748,1407],{"class":1093},[1079,2750,2751],{"class":1085},"&&",[1079,2753,2754],{"class":1085}," !",[1079,2756,2757],{"class":1093},"xml.",[1079,2759,2730],{"class":1089},[1079,2761,1270],{"class":1093},[1079,2763,2764],{"class":1521},"\"AVTransportURIMetaData\"",[1079,2766,2767],{"class":1093},")) {\n",[1079,2769,2770],{"class":1081,"line":252},[1079,2771,2772],{"class":2199},"    \u002F\u002F advance to next playlist item\n",[1079,2774,2775,2778,2781,2784,2786,2788,2790,2792,2794,2797,2799,2801],{"class":1081,"line":249},[1079,2776,2777],{"class":1093},"} ",[1079,2779,2780],{"class":1085},"else",[1079,2782,2783],{"class":1085}," if",[1079,2785,2727],{"class":1093},[1079,2787,2730],{"class":1089},[1079,2789,1270],{"class":1093},[1079,2791,2735],{"class":1521},[1079,2793,2738],{"class":1273},[1079,2795,2796],{"class":1521},"PLAYING",[1079,2798,2738],{"class":1273},[1079,2800,2746],{"class":1521},[1079,2802,2767],{"class":1093},[1079,2804,2805,2808,2811,2813],{"class":1081,"line":1162},[1079,2806,2807],{"class":1093},"    CastPlayer.isPlaying.",[1079,2809,2810],{"class":1085},"value",[1079,2812,1518],{"class":1085},[1079,2814,2477],{"class":1273},[1079,2816,2817],{"class":1081,"line":1174},[1079,2818,1191],{"class":1093},[1229,2820,870],{"id":2821},"the-duplicate-callback-guard",[30,2823,2824,2825,2828,2829,2831,2832,2834,2835,2838,2839,2842,2843,2846,2847,2850,2851,2853,2854,2856,2857,2860],{},"Some renderers send ",[85,2826,2827],{},"two"," ",[344,2830,2705],{}," callbacks in quick succession for the\nsame ",[344,2833,2741],{}," transition — the second one happens to carry\n",[344,2836,2837],{},"AVTransportURIMetaData"," while the first doesn't. Advancing the playlist\non both would skip a track every time playback naturally stops. The\n",[344,2840,2841],{},"!xml.contains(\"AVTransportURIMetaData\")"," check is a deliberate,\nnarrow filter: only the ",[133,2844,2845],{},"first"," STOPPED notification (without metadata)\ntriggers auto-advance. A ",[344,2848,2849],{},"startPositionUpdater()"," job also polls\n",[344,2852,1652],{}," every second as a fallback, since PlainApp's own\n",[344,2855,2691],{}," acknowledgment doesn't push events back to ",[133,2858,2859],{},"other","\ncontrollers — only the sender side consumes GENA callbacks from TVs.",[34,2862,876],{"id":2863},"receiver-mode-becoming-a-upnp-mediarenderer",[30,2865,2866,2867,2869,2870,2872],{},"Flip the direction: any DLNA controller (a TV remote app, VLC, another\nPlainApp) can push media to the phone itself. ",[344,2868,978],{}," opens\nthe same kind of HTTP + SSDP server described above, but as the\n",[344,2871,801],{}," being controlled rather than the controller.",[30,2874,2875,2878,2879,2881,2882,2885,2886,2889,2890,2892,2893,2895,2896,2898,2899,2902],{},[344,2876,2877],{},"DlnaHttpRouter.route()"," serves ",[344,2880,1049],{}," (built by\n",[344,2883,2884],{},"DlnaXmlTemplates.deviceDescription()",", listing an ",[344,2887,2888],{},"AVTransport"," and a\nstub ",[344,2891,1662],{}," service) and dispatches SOAP actions to\n",[344,2894,984],{},". A ",[344,2897,1634],{}," call doesn't play anything\nimmediately — it stores a ",[344,2900,2901],{},"PendingCastRequest"," and waits:",[1071,2904,2906],{"className":1073,"code":2905,"language":1075,"meta":248,"style":248},"if (uri.isNotEmpty()) {\n    DlnaRendererState.rawPendingCastRequest.value =\n        PendingCastRequest(senderIp, senderName, uri, title, mediaType, albumArtUri)\n    DlnaRendererState.pendingPlayQueued.value = false\n}\n",[344,2907,2908,2921,2931,2939,2950],{"__ignoreMap":248},[1079,2909,2910,2912,2915,2918],{"class":1081,"line":1082},[1079,2911,2724],{"class":1085},[1079,2913,2914],{"class":1093}," (uri.",[1079,2916,2917],{"class":1089},"isNotEmpty",[1079,2919,2920],{"class":1093},"()) {\n",[1079,2922,2923,2926,2928],{"class":1081,"line":252},[1079,2924,2925],{"class":1093},"    DlnaRendererState.rawPendingCastRequest.",[1079,2927,2810],{"class":1085},[1079,2929,2930],{"class":1085}," =\n",[1079,2932,2933,2936],{"class":1081,"line":249},[1079,2934,2935],{"class":1089},"        PendingCastRequest",[1079,2937,2938],{"class":1093},"(senderIp, senderName, uri, title, mediaType, albumArtUri)\n",[1079,2940,2941,2944,2946,2948],{"class":1081,"line":1162},[1079,2942,2943],{"class":1093},"    DlnaRendererState.pendingPlayQueued.",[1079,2945,2810],{"class":1085},[1079,2947,1518],{"class":1085},[1079,2949,2346],{"class":1273},[1079,2951,2952],{"class":1081,"line":1174},[1079,2953,1191],{"class":1093},[30,2955,2132,2956,2958,2959,2962],{},[344,2957,1637],{}," that arrives before the pending request is resolved doesn't\nstart playback either — it sets ",[344,2960,2961],{},"pendingPlayQueued = true"," so the command\nreplays automatically once the cast request is accepted, instead of being\nsilently lost:",[1071,2964,2966],{"className":1073,"code":2965,"language":1075,"meta":248,"style":248},"val hasPending = DlnaRendererState.rawPendingCastRequest.value != null ||\n    DlnaRendererState.pendingCastRequest.value != null\nif (hasPending) {\n    DlnaRendererState.pendingPlayQueued.value = true\n} else {\n    DlnaRendererState.commandChannel.trySend(DlnaCommand.Play)\n}\n",[344,2967,2968,2991,3002,3009,3019,3027,3038],{"__ignoreMap":248},[1079,2969,2970,2973,2976,2978,2981,2983,2986,2988],{"class":1081,"line":1082},[1079,2971,2972],{"class":1085},"val",[1079,2974,2975],{"class":1093}," hasPending ",[1079,2977,1108],{"class":1085},[1079,2979,2980],{"class":1093}," DlnaRendererState.rawPendingCastRequest.",[1079,2982,2810],{"class":1085},[1079,2984,2985],{"class":1085}," !=",[1079,2987,1404],{"class":1273},[1079,2989,2990],{"class":1085}," ||\n",[1079,2992,2993,2996,2998,3000],{"class":1081,"line":252},[1079,2994,2995],{"class":1093},"    DlnaRendererState.pendingCastRequest.",[1079,2997,2810],{"class":1085},[1079,2999,2985],{"class":1085},[1079,3001,1427],{"class":1273},[1079,3003,3004,3006],{"class":1081,"line":249},[1079,3005,2724],{"class":1085},[1079,3007,3008],{"class":1093}," (hasPending) {\n",[1079,3010,3011,3013,3015,3017],{"class":1081,"line":1162},[1079,3012,2943],{"class":1093},[1079,3014,2810],{"class":1085},[1079,3016,1518],{"class":1085},[1079,3018,2477],{"class":1273},[1079,3020,3021,3023,3025],{"class":1081,"line":1174},[1079,3022,2777],{"class":1093},[1079,3024,2780],{"class":1085},[1079,3026,1534],{"class":1093},[1079,3028,3029,3032,3035],{"class":1081,"line":1182},[1079,3030,3031],{"class":1093},"    DlnaRendererState.commandChannel.",[1079,3033,3034],{"class":1089},"trySend",[1079,3036,3037],{"class":1093},"(DlnaCommand.Play)\n",[1079,3039,3040],{"class":1081,"line":1188},[1079,3041,1191],{"class":1093},[30,3043,3044,3045,3048,3049,3051,3052,3055,3056,3058,3059,3062,3063,3066],{},"Accepted commands flow through a single ",[344,3046,3047],{},"Channel\u003CDlnaCommand>"," that\n",[344,3050,987],{}," drains, decoupling the raw socket-handling\ncoroutine from UI\u002Fplayer state — the HTTP handler never touches\n",[344,3053,3054],{},"ExoPlayer"," directly. Playback then routes to one of three full-screen\ncomposables by ",[344,3057,1990],{},": ",[344,3060,3061],{},"DlnaReceiverAudioPlayerContent"," (gradient\nbackground, album art, seek bar), an image viewer, or\n",[344,3064,3065],{},"DlnaReceiverVideoPlayerContent"," (ExoPlayer).",[34,3068,882],{"id":3069},"security-sender-trust-via-allowdeny-lists",[30,3071,3072,3073,3076,3077,3079],{},"DLNA has ",[85,3074,3075],{},"no authentication"," by design — any device on the LAN can send\na renderer a ",[344,3078,1634],{},". Turning a personal phone into an\nunauthenticated MediaRenderer would let anyone on the same Wi-Fi (a\nshared office network, a friend's house, a hostile guest network) push\narbitrary media URLs to it. PlainApp closes this gap with a\nper-sender-IP trust list, gating every incoming cast request:",[30,3081,3082],{},[913,3083],{"alt":3084,"src":3085},"Diagram 5","\u002Fblog\u002Fdlna-cast\u002Fdiagram-05.svg",[1071,3087,3089],{"className":1073,"code":3088,"language":1075,"meta":248,"style":248},"DlnaRendererState.rawPendingCastRequest.filterNotNull().collect { pending ->\n    val allowed = DlnaAllowedSendersPreference.getAsync()\n    val denied = DlnaDeniedSendersPreference.getAsync()\n    when {\n        DlnaAllowedSendersPreference.containsIp(allowed, pending.senderIp) -> {\n            \u002F\u002F Auto-accept: send commands directly without showing a dialog\n        }\n        DlnaDeniedSendersPreference.containsIp(denied, pending.senderIp) -> {\n            \u002F\u002F Auto-reject: silently discard\n        }\n        else -> {\n            \u002F\u002F Unknown sender: promote to UI-visible state for the user to decide\n            DlnaRendererState.pendingCastRequest.value = pending\n        }\n    }\n}\n",[344,3090,3091,3109,3126,3142,3149,3164,3169,3174,3188,3193,3197,3205,3210,3222,3226,3231],{"__ignoreMap":248},[1079,3092,3093,3096,3099,3101,3104,3107],{"class":1081,"line":1082},[1079,3094,3095],{"class":1093},"DlnaRendererState.rawPendingCastRequest.",[1079,3097,3098],{"class":1089},"filterNotNull",[1079,3100,1114],{"class":1093},[1079,3102,3103],{"class":1089},"collect",[1079,3105,3106],{"class":1093}," { pending ",[1079,3108,1123],{"class":1085},[1079,3110,3111,3113,3116,3118,3121,3124],{"class":1081,"line":252},[1079,3112,1307],{"class":1085},[1079,3114,3115],{"class":1093}," allowed ",[1079,3117,1108],{"class":1085},[1079,3119,3120],{"class":1093}," DlnaAllowedSendersPreference.",[1079,3122,3123],{"class":1089},"getAsync",[1079,3125,2024],{"class":1093},[1079,3127,3128,3130,3133,3135,3138,3140],{"class":1081,"line":249},[1079,3129,1307],{"class":1085},[1079,3131,3132],{"class":1093}," denied ",[1079,3134,1108],{"class":1085},[1079,3136,3137],{"class":1093}," DlnaDeniedSendersPreference.",[1079,3139,3123],{"class":1089},[1079,3141,2024],{"class":1093},[1079,3143,3144,3147],{"class":1081,"line":1162},[1079,3145,3146],{"class":1085},"    when",[1079,3148,1534],{"class":1093},[1079,3150,3151,3154,3157,3160,3162],{"class":1081,"line":1174},[1079,3152,3153],{"class":1093},"        DlnaAllowedSendersPreference.",[1079,3155,3156],{"class":1089},"containsIp",[1079,3158,3159],{"class":1093},"(allowed, pending.senderIp) ",[1079,3161,1347],{"class":1085},[1079,3163,1534],{"class":1093},[1079,3165,3166],{"class":1081,"line":1182},[1079,3167,3168],{"class":2199},"            \u002F\u002F Auto-accept: send commands directly without showing a dialog\n",[1079,3170,3171],{"class":1081,"line":1188},[1079,3172,3173],{"class":1093},"        }\n",[1079,3175,3176,3179,3181,3184,3186],{"class":1081,"line":1393},[1079,3177,3178],{"class":1093},"        DlnaDeniedSendersPreference.",[1079,3180,3156],{"class":1089},[1079,3182,3183],{"class":1093},"(denied, pending.senderIp) ",[1079,3185,1347],{"class":1085},[1079,3187,1534],{"class":1093},[1079,3189,3190],{"class":1081,"line":1416},[1079,3191,3192],{"class":2199},"            \u002F\u002F Auto-reject: silently discard\n",[1079,3194,3195],{"class":1081,"line":1421},[1079,3196,3173],{"class":1093},[1079,3198,3199,3201,3203],{"class":1081,"line":1430},[1079,3200,1813],{"class":1085},[1079,3202,1816],{"class":1085},[1079,3204,1534],{"class":1093},[1079,3206,3207],{"class":1081,"line":2455},[1079,3208,3209],{"class":2199},"            \u002F\u002F Unknown sender: promote to UI-visible state for the user to decide\n",[1079,3211,3212,3215,3217,3219],{"class":1081,"line":2472},[1079,3213,3214],{"class":1093},"            DlnaRendererState.pendingCastRequest.",[1079,3216,2810],{"class":1085},[1079,3218,1518],{"class":1085},[1079,3220,3221],{"class":1093}," pending\n",[1079,3223,3224],{"class":1081,"line":2480},[1079,3225,3173],{"class":1093},[1079,3227,3229],{"class":1081,"line":3228},15,[1079,3230,1185],{"class":1093},[1079,3232,3234],{"class":1081,"line":3233},16,[1079,3235,1191],{"class":1093},[30,3237,3238,3239,3241,3242,3244,3245,3248],{},"An unknown sender's cast request surfaces a confirmation dialog with an\noptional \"remember this choice\" flag; choosing to remember writes the\nsender's IP to the allow or deny preference so future requests from the\nsame address skip the dialog. This is enforced entirely in\n",[344,3240,987],{},", above the wire protocol — the SOAP handler\nitself always returns ",[344,3243,1041],{}," regardless of the trust decision (per the\nUPnP spec, the transport call succeeded; whether the ",[133,3246,3247],{},"media"," actually\nplays is a separate, local decision).",[34,3250,888],{"id":3251},"platform-split-commonmain-orchestration-androidmain-sockets",[30,3253,3254],{},[913,3255],{"alt":3256,"src":3257},"Diagram 6","\u002Fblog\u002Fdlna-cast\u002Fdiagram-06.svg",[30,3259,3260,3261,3263,3264,3267,3268,3271,3272,3275,3276,3279,3280,3283,3284,3287,3288,3290],{},"Following the same pattern as PlainApp's other network features, only the\nraw byte-level socket I\u002FO is platform-specific. ",[344,3262,1299],{}," and\n",[344,3265,3266],{},"DlnaSsdpSocket"," are ",[344,3269,3270],{},"expect"," interfaces; Android's ",[344,3273,3274],{},"actual","\nimplementations wrap ",[344,3277,3278],{},"java.net.ServerSocket"," and ",[344,3281,3282],{},"java.net.MulticastSocket","\ndirectly. Everything else — port selection, the SSDP alive\u002Fbyebye\ncadence, HTTP routing, SOAP parsing, DIDL-Lite metadata, and all four\n",[344,3285,3286],{},"DlnaCommand"," state transitions — lives in ",[344,3289,1616],{}," and is unit\ntestable on the JVM without an Android device.",[30,3292,3293],{},"iOS gets sender-side SOAP control (it's a plain HTTP client, no sockets to\nimplement), but the receiver is a deliberate no-op:",[1071,3295,3297],{"className":1073,"code":3296,"language":1075,"meta":248,"style":248},"actual fun startDlnaRenderer() {}\n",[344,3298,3299],{"__ignoreMap":248},[1079,3300,3301,3304,3306,3309],{"class":1081,"line":1082},[1079,3302,3303],{"class":1093},"actual ",[1079,3305,1086],{"class":1085},[1079,3307,3308],{"class":1089}," startDlnaRenderer",[1079,3310,3311],{"class":1093},"() {}\n",[30,3313,3314,3315,3317],{},"iOS doesn't expose a way to run a background UDP multicast listener\nreliably enough to be a good ",[344,3316,801],{}," citizen, so \"Wireless Cast\"\n(receiving) is Android-only; \"Cast to TV\" (sending) works on both.",[34,3319,894],{"id":3320},"pure-kotlin-engineering-notes",[30,3322,3323],{},"A few implementation details exist specifically to avoid platform\ndependencies that would break Kotlin Multiplatform sharing:",[39,3325,3326,3343,3359,3394],{},[42,3327,3328,2828,3331,3334,3335,3338,3339,3342],{},[85,3329,3330],{},"UUID v4 generation.",[344,3332,3333],{},"DlnaReceiverEngine.randomUuid()"," hand-rolls an\nRFC 4122 v4 UUID from ",[344,3336,3337],{},"Random.nextBytes(16)"," instead of\n",[344,3340,3341],{},"java.util.UUID.randomUUID()",", so the device identity generator is\nidentical on every platform.",[42,3344,3345,2828,3348,3350,3351,3354,3355,3358],{},[85,3346,3347],{},"Percent-decoding.",[344,3349,984],{},"'s private ",[344,3352,3353],{},"percentDecode()","\nreplaces ",[344,3356,3357],{},"java.net.URLDecoder.decode()"," for titles that arrive\nURL-encoded in a media URI.",[42,3360,3361,2828,3364,3367,3368,3371,3372,3375,3376,3379,3380,3383,3384,1927,3387,3390,3391,3393],{},[85,3362,3363],{},"Byte-level HTTP body reads.",[344,3365,3366],{},"Content-Length"," is a ",[133,3369,3370],{},"byte"," count, not\na character count. ",[344,3373,3374],{},"AndroidDlnaClientConnection.readHttpRequest()"," reads\nthe body via ",[344,3377,3378],{},"readBodyBytes(bis, contentLength)"," against the raw\n",[344,3381,3382],{},"BufferedInputStream",", never a ",[344,3385,3386],{},"BufferedReader",[344,3388,3389],{},"CharArray"," — a title\ncontaining multi-byte UTF-8 (e.g. Chinese characters, 3 bytes each)\nwould otherwise under-read the body and stall waiting for bytes that\nalready arrived, silently breaking ",[344,3392,1634],{}," for\nnon-ASCII titles.",[42,3395,3396,2828,3402,3405,3406,3408,3409,953,3412,3415,3416,125],{},[85,3397,3398,3399,125],{},"Base URL parsing without ",[344,3400,3401],{},"java.net.URL",[344,3403,3404],{},"DlnaDevice.getBaseUrl()","\nextracts the scheme\u002Fhost\u002Fport from a ",[344,3407,1045],{}," header with plain\nstring slicing (",[344,3410,3411],{},"substringAfter(\":\u002F\u002F\")",[344,3413,3414],{},"substringBefore('\u002F')",")\ninstead of constructing a ",[344,3417,3401],{},[34,3419,900],{"id":3420},"design-patterns-recap",[155,3422,3423,3435],{},[158,3424,3425],{},[161,3426,3427,3430,3433],{},[164,3428,3429],{},"Pattern",[164,3431,3432],{},"Where",[164,3434,172],{},[174,3436,3437,3455,3474,3488,3508,3523,3537,3555],{},[161,3438,3439,3444,3452],{},[179,3440,3441],{},[85,3442,3443],{},"Shared Protocol, Split I\u002FO",[179,3445,3446,1927,3448,3451],{},[344,3447,993],{},[344,3449,3450],{},"DlnaXmlTemplates"," in commonMain, sockets in androidMain",[179,3453,3454],{},"Wire format defined once, platform only supplies bytes in\u002Fout",[161,3456,3457,3462,3471],{},[179,3458,3459],{},[85,3460,3461],{},"Pending → Rule Check → Promote",[179,3463,3464,3467,3468],{},[344,3465,3466],{},"rawPendingCastRequest"," → ",[344,3469,3470],{},"pendingCastRequest",[179,3472,3473],{},"Separates \"a request arrived\" from \"a request needs a human decision\"",[161,3475,3476,3481,3485],{},[179,3477,3478],{},[85,3479,3480],{},"Command Queue Decoupling",[179,3482,3483],{},[344,3484,3047],{},[179,3486,3487],{},"HTTP handler coroutine never touches ExoPlayer\u002FUI state directly",[161,3489,3490,3495,3500],{},[179,3491,3492],{},[85,3493,3494],{},"Queued-Command Replay",[179,3496,3497],{},[344,3498,3499],{},"pendingPlayQueued",[179,3501,2132,3502,3504,3505,3507],{},[344,3503,1637],{}," that races ahead of ",[344,3506,1634],{},"'s approval isn't dropped",[161,3509,3510,3515,3520],{},[179,3511,3512],{},[85,3513,3514],{},"Deliberate Status Code",[179,3516,3517,3519],{},[344,3518,2260],{}," → always 206",[179,3521,3522],{},"Matches what real TV firmware expects, not just spec-minimum 200",[161,3524,3525,3530,3534],{},[179,3526,3527],{},[85,3528,3529],{},"Narrow Duplicate Filter",[179,3531,3532],{},[344,3533,2841],{},[179,3535,3536],{},"Distinguishes the two STOPPED callbacks some renderers send, without a generic dedup mechanism",[161,3538,3539,3544,3552],{},[179,3540,3541],{},[85,3542,3543],{},"Immediate Byebye",[179,3545,3546,3548,3549,3551],{},[344,3547,1222],{}," sends ",[344,3550,1226],{}," before cancel",[179,3553,3554],{},"Avoids a stale 30-minute SSDP cache entry after the user turns the feature off",[161,3556,3557,3562,3565],{},[179,3558,3559],{},[85,3560,3561],{},"Fail-Open on Unknown, Fail-Closed by Default",[179,3563,3564],{},"allow\u002Fdeny preference + dialog",[179,3566,3567],{},"Neither auto-trusts nor auto-blocks a first-time sender — a human decides once",[34,3569,906],{"id":3570},"further-reading",[39,3572,3573,3581],{},[42,3574,3575,3580],{},[45,3576,3579],{"href":3577,"rel":3578},"https:\u002F\u002Fopenconnectivity.org\u002Fdeveloper\u002Fspecifications\u002Fupnp-resources\u002Fupnp\u002F",[210],"UPnP Device Architecture"," — the underlying SSDP\u002FGENA\u002FSOAP specification.",[42,3582,3583,3584,125],{},"For the WebSocket-based low-latency screen mirroring feature (a different, non-DLNA casting path), see ",[45,3585,3587],{"href":3586},"\u002Fblog\u002Fscreen-mirror","Screen Mirror",[3589,3590,3591],"style",{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":248,"searchDepth":249,"depth":249,"links":3593},[3594,3595,3596,3599,3600,3601,3602,3605,3606,3607,3608,3609,3610],{"id":36,"depth":252,"text":37},{"id":909,"depth":252,"text":824},{"id":1005,"depth":252,"text":830,"children":3597},[3598],{"id":1231,"depth":249,"text":838},{"id":1442,"depth":252,"text":844},{"id":1670,"depth":252,"text":850},{"id":2129,"depth":252,"text":856},{"id":2520,"depth":252,"text":862,"children":3603},[3604],{"id":2821,"depth":249,"text":870},{"id":2863,"depth":252,"text":876},{"id":3069,"depth":252,"text":882},{"id":3251,"depth":252,"text":888},{"id":3320,"depth":252,"text":894},{"id":3420,"depth":252,"text":900},{"id":3570,"depth":252,"text":906},"Transport","2025-07-30","How PlainApp implements DLNA\u002FUPnP AV casting on both sides: scanning and controlling TVs over SOAP as a sender, and turning the phone itself into a UPnP MediaRenderer as a receiver — with SSDP discovery, DIDL-Lite metadata, Range-request media serving, GENA event callbacks, and a sender-IP allow\u002Fdeny trust model, all in pure Kotlin Multiplatform.",{},"\u002Fblog\u002Fdlna-cast","16 min read",{"title":789,"description":3613},"Want to cast to any DLNA smart TV from your own app? See how PlainApp implements UPnP discovery, SOAP control, and a secure sender-trust model — open source.","DLNA Cast: Build a UPnP Sender & Receiver in Kotlin","blog\u002Fdlna-cast","R560NC4kxWz0IiWYJCa9EMYCVGse_gIUEqMBVnO7vMk",{"id":3623,"title":3624,"body":3625,"category":6732,"date":6733,"description":6734,"extension":261,"meta":6735,"navigation":263,"path":3586,"readingTime":6736,"seo":6737,"seoDescription":6738,"seoTitle":6739,"stem":6740,"__hash__":6741},"blog_en\u002Fblog\u002Fscreen-mirror.md","Screen Mirror: Low-Latency Casting Architecture",{"type":27,"value":3626,"toc":6673},[3627,3629,3699,3701,3708,3711,3719,3723,3726,3752,3755,3759,3764,3885,3907,3910,3914,3917,3999,4002,4051,4055,4061,4163,4187,4191,4205,4209,4219,4277,4281,4318,4336,4339,4346,4350,4356,4455,4469,4474,4478,4536,4539,4542,4565,4734,4737,4764,4768,4774,4798,4829,4844,4847,4851,4857,4863,4888,4892,5005,5011,5024,5027,5031,5046,5049,5053,5067,5179,5183,5189,5194,5231,5236,5262,5266,5281,5337,5341,5351,5385,5388,5392,5415,5420,5425,5448,5459,5463,5473,5479,5498,5504,5507,5511,5531,5535,5554,5620,5625,5629,5634,5711,5742,5746,5753,5829,5832,5838,5843,5847,5870,5997,6016,6020,6066,6181,6220,6223,6227,6266,6270,6289,6292,6296,6339,6343,6350,6369,6445,6449,6452,6456,6461,6463,6653,6655,6670],[34,3628,37],{"id":36},[39,3630,3631,3635,3641,3647,3653,3659,3665,3671,3677,3683,3689,3695],{},[42,3632,3633],{},[45,3634,824],{"href":823},[42,3636,3637],{},[45,3638,3640],{"href":3639},"#video-encoding-pipeline-android","Video Encoding Pipeline (Android)",[42,3642,3643],{},[45,3644,3646],{"href":3645},"#videopacket-protocol-design","VideoPacket Protocol Design",[42,3648,3649],{},[45,3650,3652],{"href":3651},"#video-decoding-pipeline-web","Video Decoding Pipeline (Web)",[42,3654,3655],{},[45,3656,3658],{"href":3657},"#webgl2-rendering","WebGL2 Rendering",[42,3660,3661],{},[45,3662,3664],{"href":3663},"#loss-detection--error-recovery","Loss Detection & Error Recovery",[42,3666,3667],{},[45,3668,3670],{"href":3669},"#orientation-change-handling","Orientation Change Handling",[42,3672,3673],{},[45,3674,3676],{"href":3675},"#system-mediaprojection-lifecycle","System MediaProjection Lifecycle",[42,3678,3679],{},[45,3680,3682],{"href":3681},"#remote-control-touch-injection","Remote Control: Touch Injection",[42,3684,3685],{},[45,3686,3688],{"href":3687},"#audio-pipeline","Audio Pipeline",[42,3690,3691],{},[45,3692,3694],{"href":3693},"#performance-optimizations","Performance Optimizations",[42,3696,3697],{},[45,3698,900],{"href":899},[34,3700,824],{"id":909},[30,3702,3703,3704,3707],{},"PlainApp screen mirror is an ",[85,3705,3706],{},"end-to-end low-latency casting system",": the Android device captures screen content, hardware-encodes it to H.264 video and Opus audio, and pushes it over WebSocket using a custom binary protocol to the web client; the web client decodes via the WebCodecs API and renders directly to a Canvas via WebGL2, with zero CPU copies throughout. A transparent touch overlay closes the loop, turning pointer input back into gestures on the phone.",[30,3709,3710],{},"No WebRTC, no RTMP, no intermediate server. The entire pipeline is:",[1071,3712,3717],{"className":3713,"code":3715,"language":3716},[3714],"language-text","Android VirtualDisplay → MediaCodec H.264 Encoder → WebSocket →\nWebCodecs VideoDecoder → WebGL2 Texture → Canvas\n","text",[344,3718,3715],{"__ignoreMap":248},[1229,3720,3722],{"id":3721},"why-not-webrtc","Why not WebRTC?",[30,3724,3725],{},"WebRTC is designed for real-time communication. Its ICE\u002FSTUN\u002FTURN negotiation, congestion control, and jitter buffering are overkill for LAN screen casting. PlainApp's use case is:",[39,3727,3728,3734,3740,3746],{},[42,3729,3730,3733],{},[85,3731,3732],{},"Same LAN",", latency \u003C 5ms, no NAT traversal needed",[42,3735,3736,3739],{},[85,3737,3738],{},"Pursuing extreme low latency",", no jitter buffering",[42,3741,3742,3745],{},[85,3743,3744],{},"High quality",", bitrate can be high (8Mbps)",[42,3747,3748,3751],{},[85,3749,3750],{},"Screen control"," (touch injection), where WebRTC's DataChannel adds unnecessary complexity",[30,3753,3754],{},"A custom binary protocol over WebSocket is lighter and more controllable for the LAN scenario.",[1229,3756,3758],{"id":3757},"component-map","Component map",[30,3760,3761],{},[913,3762],{"alt":915,"src":3763},"\u002Fblog\u002Fscreen-mirror\u002Fdiagram-01.svg",[155,3765,3766,3778],{},[158,3767,3768],{},[161,3769,3770,3773,3775],{},[164,3771,3772],{},"Layer",[164,3774,325],{},[164,3776,3777],{},"Web",[174,3779,3780,3796,3809,3821,3831,3843,3858,3871],{},[161,3781,3782,3785,3794],{},[179,3783,3784],{},"Screen capture",[179,3786,3787,3790,3791],{},[344,3788,3789],{},"MediaProjection"," + ",[344,3792,3793],{},"VirtualDisplay",[179,3795,753],{},[161,3797,3798,3801,3807],{},[179,3799,3800],{},"Video encoding",[179,3802,3803,3806],{},[344,3804,3805],{},"MediaCodec"," H.264 hardware encoder",[179,3808,753],{},[161,3810,3811,3814,3819],{},[179,3812,3813],{},"Audio encoding",[179,3815,3816,3818],{},[344,3817,3805],{}," Opus hardware encoder",[179,3820,753],{},[161,3822,3823,3825,3828],{},[179,3824,3611],{},[179,3826,3827],{},"WebSocket binary events",[179,3829,3830],{},"WebSocket receiver",[161,3832,3833,3836,3838],{},[179,3834,3835],{},"Video decoding",[179,3837,753],{},[179,3839,3840],{},[344,3841,3842],{},"WebCodecs VideoDecoder",[161,3844,3845,3848,3850],{},[179,3846,3847],{},"Audio decoding",[179,3849,753],{},[179,3851,3852,3467,3855],{},[344,3853,3854],{},"WebCodecs AudioDecoder",[344,3856,3857],{},"\u003Caudio>",[161,3859,3860,3863,3865],{},[179,3861,3862],{},"Rendering",[179,3864,753],{},[179,3866,3867,3870],{},[344,3868,3869],{},"WebGL2"," texture direct-render",[161,3872,3873,3876,3882],{},[179,3874,3875],{},"Control",[179,3877,3878,3881],{},[344,3879,3880],{},"AccessibilityService"," gesture injection",[179,3883,3884],{},"Touch overlay → GraphQL mutation",[30,3886,3887,3888,3891,3892,2329,3895,3898,3899,3902,3903,3906],{},"Video and audio always flow ",[85,3889,3890],{},"device → browser"," over the same WebSocket connection; control flows the opposite direction over ",[85,3893,3894],{},"GraphQL",[344,3896,3897],{},"sendScreenMirrorControl","), which also doubles as the side-channel for the codec config (",[344,3900,3901],{},"screenMirrorVideoCodec"," query) and keyframe requests (",[344,3904,3905],{},"requestScreenMirrorKeyFrame"," mutation).",[34,3908,3640],{"id":3909},"video-encoding-pipeline-android",[1229,3911,3913],{"id":3912},"encoding-parameter-tuning","Encoding Parameter Tuning",[30,3915,3916],{},"Encoding parameters were tuned specifically for low-latency LAN screen casting:",[155,3918,3919,3932],{},[158,3920,3921],{},[161,3922,3923,3926,3929],{},[164,3924,3925],{},"Parameter",[164,3927,3928],{},"Value",[164,3930,3931],{},"Notes",[174,3933,3934,3947,3960,3973,3986],{},[161,3935,3936,3941,3944],{},[179,3937,3938],{},[344,3939,3940],{},"KEY_FRAME_RATE",[179,3942,3943],{},"60",[179,3945,3946],{},"60fps for smoothness",[161,3948,3949,3954,3957],{},[179,3950,3951],{},[344,3952,3953],{},"KEY_I_FRAME_INTERVAL",[179,3955,3956],{},"10",[179,3958,3959],{},"IDR interval 10s, reduces keyframe overhead",[161,3961,3962,3967,3970],{},[179,3963,3964],{},[344,3965,3966],{},"KEY_BIT_RATE_MODE",[179,3968,3969],{},"VBR (implicit, no explicit mode set)",[179,3971,3972],{},"Variable bitrate, scene-adaptive",[161,3974,3975,3980,3983],{},[179,3976,3977],{},[344,3978,3979],{},"KEY_PRIORITY",[179,3981,3982],{},"0",[179,3984,3985],{},"Realtime priority",[161,3987,3988,3993,3996],{},[179,3989,3990],{},[344,3991,3992],{},"KEY_LATENCY",[179,3994,3995],{},"1",[179,3997,3998],{},"Low-latency mode",[30,4000,4001],{},"Bitrate is tiered by quality mode — higher bitrates (e.g. 24 Mbps) were tested and caused encoder\u002Fdecoder frame drops and increased end-to-end latency without visible quality gain for screen content:",[155,4003,4004,4017],{},[158,4005,4006],{},[161,4007,4008,4011,4014],{},[164,4009,4010],{},"Mode",[164,4012,4013],{},"Bitrate",[164,4015,4016],{},"Capture resolution",[174,4018,4019,4030,4040],{},[161,4020,4021,4024,4027],{},[179,4022,4023],{},"HD",[179,4025,4026],{},"8 Mbps",[179,4028,4029],{},"1080p short side",[161,4031,4032,4035,4038],{},[179,4033,4034],{},"Smooth",[179,4036,4037],{},"4 Mbps",[179,4039,4029],{},[161,4041,4042,4045,4048],{},[179,4043,4044],{},"Low",[179,4046,4047],{},"2 Mbps",[179,4049,4050],{},"720p short side",[1229,4052,4054],{"id":4053},"encoder-low-latency-configuration","Encoder Low-Latency Configuration",[30,4056,4057,4060],{},[344,4058,4059],{},"MediaCodecVideoEncoder"," configures the encoder once at creation time:",[1071,4062,4064],{"className":1073,"code":4063,"language":1075,"meta":248,"style":248},"MediaFormat.createVideoFormat(MIME, width, height).apply {\n    setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)\n    setInteger(MediaFormat.KEY_BIT_RATE, bitrateBps)\n    setInteger(MediaFormat.KEY_FRAME_RATE, frameRate)          \u002F\u002F 60\n    setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, iFrameIntervalSec) \u002F\u002F 10\n    setLong(MediaFormat.KEY_REPEAT_PREVIOUS_FRAME_AFTER, 100_000L)\n    setInteger(MediaFormat.KEY_COLOR_RANGE, MediaFormat.COLOR_RANGE_LIMITED)\n    setInteger(MediaFormat.KEY_PRIORITY, 0)\n    setInteger(MediaFormat.KEY_LATENCY, 1)\n}\n",[344,4065,4066,4082,4090,4097,4107,4117,4130,4137,4148,4159],{"__ignoreMap":248},[1079,4067,4068,4071,4074,4077,4080],{"class":1081,"line":1082},[1079,4069,4070],{"class":1093},"MediaFormat.",[1079,4072,4073],{"class":1089},"createVideoFormat",[1079,4075,4076],{"class":1093},"(MIME, width, height).",[1079,4078,4079],{"class":1089},"apply",[1079,4081,1534],{"class":1093},[1079,4083,4084,4087],{"class":1081,"line":252},[1079,4085,4086],{"class":1089},"    setInteger",[1079,4088,4089],{"class":1093},"(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)\n",[1079,4091,4092,4094],{"class":1081,"line":249},[1079,4093,4086],{"class":1089},[1079,4095,4096],{"class":1093},"(MediaFormat.KEY_BIT_RATE, bitrateBps)\n",[1079,4098,4099,4101,4104],{"class":1081,"line":1162},[1079,4100,4086],{"class":1089},[1079,4102,4103],{"class":1093},"(MediaFormat.KEY_FRAME_RATE, frameRate)          ",[1079,4105,4106],{"class":2199},"\u002F\u002F 60\n",[1079,4108,4109,4111,4114],{"class":1081,"line":1174},[1079,4110,4086],{"class":1089},[1079,4112,4113],{"class":1093},"(MediaFormat.KEY_I_FRAME_INTERVAL, iFrameIntervalSec) ",[1079,4115,4116],{"class":2199},"\u002F\u002F 10\n",[1079,4118,4119,4122,4125,4128],{"class":1081,"line":1182},[1079,4120,4121],{"class":1089},"    setLong",[1079,4123,4124],{"class":1093},"(MediaFormat.KEY_REPEAT_PREVIOUS_FRAME_AFTER, ",[1079,4126,4127],{"class":1273},"100_000L",[1079,4129,1284],{"class":1093},[1079,4131,4132,4134],{"class":1081,"line":1188},[1079,4133,4086],{"class":1089},[1079,4135,4136],{"class":1093},"(MediaFormat.KEY_COLOR_RANGE, MediaFormat.COLOR_RANGE_LIMITED)\n",[1079,4138,4139,4141,4144,4146],{"class":1081,"line":1393},[1079,4140,4086],{"class":1089},[1079,4142,4143],{"class":1093},"(MediaFormat.KEY_PRIORITY, ",[1079,4145,3982],{"class":1273},[1079,4147,1284],{"class":1093},[1079,4149,4150,4152,4155,4157],{"class":1081,"line":1416},[1079,4151,4086],{"class":1089},[1079,4153,4154],{"class":1093},"(MediaFormat.KEY_LATENCY, ",[1079,4156,3995],{"class":1273},[1079,4158,1284],{"class":1093},[1079,4160,4161],{"class":1081,"line":1421},[1079,4162,1191],{"class":1093},[30,4164,4165,3279,4168,4171,4172,4175,4176,4179,4180,4182,4183,4186],{},[344,4166,4167],{},"KEY_PRIORITY=0",[344,4169,4170],{},"KEY_LATENCY=1"," are the keys to low latency — they tell the encoder to prioritize real-time encoding over compression ratio. The input is a ",[344,4173,4174],{},"Surface"," created by ",[344,4177,4178],{},"MediaCodec.createInputSurface()"," and fed directly to ",[344,4181,3793],{}," — no ",[344,4184,4185],{},"SurfaceTexture"," readback, no I420 conversion, no CPU touches the pixels.",[1229,4188,4190],{"id":4189},"capture-resolution","Capture Resolution",[30,4192,4193,4196,4197,4200,4201,4204],{},[344,4194,4195],{},"ScreenMirrorCaptureSize.compute()"," derives the actual capture size from the physical screen size, the quality mode's short-side target (720\u002F1080), and the encoder's reported ",[344,4198,4199],{},"maxWidth\u002FmaxHeight"," and width\u002Fheight alignment (queried once via ",[344,4202,4203],{},"MediaCodecVideoEncoder.queryEncoderCaps()","), so the encoder never receives dimensions it can't accept.",[1229,4206,4208],{"id":4207},"keyframe-requests","Keyframe Requests",[30,4210,4211,4212,4214,4215,4218],{},"The web client can request an IDR frame via the GraphQL ",[344,4213,3905],{}," mutation to recover from packet loss. Android responds via ",[344,4216,4217],{},"MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME",":",[1071,4220,4222],{"className":1073,"code":4221,"language":1075,"meta":248,"style":248},"fun requestKeyFrame() {\n    val b = Bundle().apply { putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 1) }\n    codec?.setParameters(b)\n}\n",[344,4223,4224,4234,4262,4273],{"__ignoreMap":248},[1079,4225,4226,4228,4231],{"class":1081,"line":1082},[1079,4227,1086],{"class":1085},[1079,4229,4230],{"class":1089}," requestKeyFrame",[1079,4232,4233],{"class":1093},"() {\n",[1079,4235,4236,4238,4241,4243,4246,4248,4250,4252,4255,4258,4260],{"class":1081,"line":252},[1079,4237,1307],{"class":1085},[1079,4239,4240],{"class":1093}," b ",[1079,4242,1108],{"class":1085},[1079,4244,4245],{"class":1089}," Bundle",[1079,4247,1114],{"class":1093},[1079,4249,4079],{"class":1089},[1079,4251,1326],{"class":1093},[1079,4253,4254],{"class":1089},"putInt",[1079,4256,4257],{"class":1093},"(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, ",[1079,4259,3995],{"class":1273},[1079,4261,2660],{"class":1093},[1079,4263,4264,4267,4270],{"class":1081,"line":249},[1079,4265,4266],{"class":1093},"    codec?.",[1079,4268,4269],{"class":1089},"setParameters",[1079,4271,4272],{"class":1093},"(b)\n",[1079,4274,4275],{"class":1081,"line":1162},[1079,4276,1191],{"class":1093},[1229,4278,4280],{"id":4279},"spspps-and-keyframe-broadcast","SPS\u002FPPS and Keyframe Broadcast",[30,4282,4283,4284,4287,4288,1927,4291,4294,4295,4298,4299,4302,4303,4306,4307,4309,4310,4313,4314,4317],{},"After the encoder starts, ",[344,4285,4286],{},"INFO_OUTPUT_FORMAT_CHANGED"," delivers ",[344,4289,4290],{},"csd-0",[344,4292,4293],{},"csd-1"," (SPS\u002FPPS), which ",[344,4296,4297],{},"ScreenMirrorPipeline"," joins into a single Annex-B config blob and caches (",[344,4300,4301],{},"cachedConfig","). The first IDR that follows is cached too (",[344,4304,4305],{},"cachedKeyFrame",") so a freshly-connected web client can pull both via the ",[344,4308,3901],{}," GraphQL query without waiting for the next keyframe interval. When the config just changed (orientation or quality switch), Android does ",[85,4311,4312],{},"not"," send the new IDR as a normal video packet — it bundles SPS\u002FPPS + IDR into one ",[344,4315,4316],{},"screen_mirror_video_codec"," WebSocket event, so the web client completes decoder reconfiguration and first-frame decoding in one shot instead of racing a stale decoder against a new bitstream.",[30,4319,4320,4321,3279,4324,4327,4328,4331,4332,4335],{},"Some OEM encoders (Qualcomm\u002FXiaomi) bundle SPS+PPS+IDR into a single output buffer carrying both ",[344,4322,4323],{},"BUFFER_FLAG_CODEC_CONFIG",[344,4325,4326],{},"BUFFER_FLAG_SYNC_FRAME",". The drain loop only skips buffers that are ",[133,4329,4330],{},"pure"," config (",[344,4333,4334],{},"isConfig && !isKey",") — skipping a config-flagged buffer that also carries the sync frame would silently drop the IDR and leave the decoder with only P-frames, producing mosaic output.",[34,4337,3646],{"id":4338},"videopacket-protocol-design",[30,4340,4341,4342,4345],{},"Both video and audio frames are wrapped in the unified ",[344,4343,4344],{},"VideoPacket"," binary protocol for WebSocket transport.",[1229,4347,4349],{"id":4348},"protocol-format","Protocol Format",[1071,4351,4354],{"className":4352,"code":4353,"language":3716},[3714],"+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+\n| MAGIC  | FLAGS  |           FRAME_ID (4 bytes, big-endian)            |              TIMESTAMP (8 bytes, BE)              |  DATA  |\n| 0x56   |        |   byte2   |   byte3   |   byte4   |   byte5   |  byte6  |  byte7  | ...  |  byte13 |        payload...        |\n+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+\n \\- 1B -\u002F \\- 1B -\u002F \\-------------------- 4B ----------------------\u002F \\----------------------- 8B ------------------------\u002F \\- var -\u002F\n",[344,4355,4353],{"__ignoreMap":248},[155,4357,4358,4371],{},[158,4359,4360],{},[161,4361,4362,4365,4368],{},[164,4363,4364],{},"Field",[164,4366,4367],{},"Size",[164,4369,4370],{},"Description",[174,4372,4373,4393,4416,4429,4442],{},[161,4374,4375,4380,4383],{},[179,4376,4377],{},[344,4378,4379],{},"MAGIC",[179,4381,4382],{},"1 byte",[179,4384,4385,4386,2329,4389,4392],{},"Fixed ",[344,4387,4388],{},"0x56",[344,4390,4391],{},"'V'","), for validation",[161,4394,4395,4400,4402],{},[179,4396,4397],{},[344,4398,4399],{},"FLAGS",[179,4401,4382],{},[179,4403,4404,4407,4408,4411,4412,4415],{},[344,4405,4406],{},"0x01","=keyframe, ",[344,4409,4410],{},"0x02","=config, ",[344,4413,4414],{},"0x04","=audio",[161,4417,4418,4423,4426],{},[179,4419,4420],{},[344,4421,4422],{},"FRAME_ID",[179,4424,4425],{},"4 bytes",[179,4427,4428],{},"Monotonically increasing frame number, uint32 big-endian",[161,4430,4431,4436,4439],{},[179,4432,4433],{},[344,4434,4435],{},"TIMESTAMP",[179,4437,4438],{},"8 bytes",[179,4440,4441],{},"Encoder PTS in microseconds, big-endian",[161,4443,4444,4449,4452],{},[179,4445,4446],{},[344,4447,4448],{},"DATA",[179,4450,4451],{},"variable",[179,4453,4454],{},"H.264 NAL unit or Opus data",[30,4456,4457,4458,4461,4462,4464,4465,4468],{},"Both Android's ",[344,4459,4460],{},"VideoPacket.encode()"," (in ",[344,4463,1616],{},", so its wire format is covered by JVM unit tests without any Android dependency) and the web's ",[344,4466,4467],{},"parseVideoPacket()"," implement this format independently — there's no shared serialization library, just a spec both sides honor.",[30,4470,4471],{},[913,4472],{"alt":1021,"src":4473},"\u002Fblog\u002Fscreen-mirror\u002Fdiagram-02.svg",[1229,4475,4477],{"id":4476},"design-notes","Design Notes",[39,4479,4480,4499,4514,4522],{},[42,4481,4482,3058,4487,4490,4491,4494,4495,4498],{},[85,4483,4484,4486],{},[344,4485,4422],{}," unsigned parsing",[344,4488,4489],{},"((buf[2] \u003C\u003C 24) | (buf[3] \u003C\u003C 16) | (buf[4] \u003C\u003C 8) | buf[5]) >>> 0"," — must use ",[344,4492,4493],{},">>> 0"," to ensure unsigned, otherwise ",[344,4496,4497],{},"frameId > 2^31"," is parsed as negative, causing false loss detection.",[42,4500,4501,4506,4507,4510,4511,4513],{},[85,4502,4503,4505],{},[344,4504,4422],{}," never resets",": When the encoder is rebuilt for orientation change, ",[344,4508,4509],{},"frameId"," continues incrementing (it lives in ",[344,4512,4297],{},", not in the encoder). This lets the web side detect frame loss during rotation via frameId gaps.",[42,4515,4516,4521],{},[85,4517,4518,4520],{},[344,4519,4435],{}," uses encoder PTS",": No dependence on the web client's clock, avoiding clock drift causing A\u002FV desync.",[42,4523,4524,4527,4528,4531,4532,4535],{},[85,4525,4526],{},"Zero-copy parsing",": the web parser slices the payload with ",[344,4529,4530],{},"Uint8Array.subarray()"," — a view into the original WebSocket ",[344,4533,4534],{},"ArrayBuffer",", not a copy.",[34,4537,3652],{"id":4538},"video-decoding-pipeline-web",[1229,4540,3842],{"id":4541},"webcodecs-videodecoder",[30,4543,4544,4545,4548,4549,4552,4553,4556,4557,4560,4561,4564],{},"The web client uses ",[344,4546,4547],{},"WebCodecs API","'s ",[344,4550,4551],{},"VideoDecoder"," for hardware decoding. Compared to ",[344,4554,4555],{},"MediaSource Extensions"," or ",[344,4558,4559],{},"WebRTC",", WebCodecs provides fine-grained control over the decoding process — no jitter buffer, no container layer, and decoded ",[344,4562,4563],{},"VideoFrame"," objects can be directly uploaded as WebGL textures.",[1071,4566,4570],{"className":4567,"code":4568,"language":4569,"meta":248,"style":248},"language-typescript shiki shiki-themes github-light github-dark","const decoder = new VideoDecoder({\n    output: (frame) => this.renderFrame(frame),\n    error: (e) => {\n        this.waitingForIdr = true\n        this.onRequestKeyFrame?.()\n        this.onError?.(e)\n    },\n})\ndecoder.configure({\n    codec,                              \u002F\u002F e.g. 'avc1.42c01e', read from the SPS NAL\n    avc: { format: 'annexb' },\n    optimizeForLatency: true,\n    hardwareAcceleration: 'prefer-hardware',\n})\n","typescript",[344,4571,4572,4591,4619,4635,4647,4659,4671,4676,4681,4691,4699,4710,4720,4730],{"__ignoreMap":248},[1079,4573,4574,4577,4580,4582,4585,4588],{"class":1081,"line":1082},[1079,4575,4576],{"class":1085},"const",[1079,4578,4579],{"class":1273}," decoder",[1079,4581,1518],{"class":1085},[1079,4583,4584],{"class":1085}," new",[1079,4586,4587],{"class":1089}," VideoDecoder",[1079,4589,4590],{"class":1093},"({\n",[1079,4592,4593,4596,4599,4603,4605,4608,4611,4613,4616],{"class":1081,"line":252},[1079,4594,4595],{"class":1089},"    output",[1079,4597,4598],{"class":1093},": (",[1079,4600,4602],{"class":4601},"s4XuR","frame",[1079,4604,1407],{"class":1093},[1079,4606,4607],{"class":1085},"=>",[1079,4609,4610],{"class":1273}," this",[1079,4612,125],{"class":1093},[1079,4614,4615],{"class":1089},"renderFrame",[1079,4617,4618],{"class":1093},"(frame),\n",[1079,4620,4621,4624,4626,4629,4631,4633],{"class":1081,"line":249},[1079,4622,4623],{"class":1089},"    error",[1079,4625,4598],{"class":1093},[1079,4627,4628],{"class":4601},"e",[1079,4630,1407],{"class":1093},[1079,4632,4607],{"class":1085},[1079,4634,1534],{"class":1093},[1079,4636,4637,4640,4643,4645],{"class":1081,"line":1162},[1079,4638,4639],{"class":1273},"        this",[1079,4641,4642],{"class":1093},".waitingForIdr ",[1079,4644,1108],{"class":1085},[1079,4646,2477],{"class":1273},[1079,4648,4649,4651,4653,4656],{"class":1081,"line":1174},[1079,4650,4639],{"class":1273},[1079,4652,125],{"class":1093},[1079,4654,4655],{"class":1089},"onRequestKeyFrame",[1079,4657,4658],{"class":1093},"?.()\n",[1079,4660,4661,4663,4665,4668],{"class":1081,"line":1182},[1079,4662,4639],{"class":1273},[1079,4664,125],{"class":1093},[1079,4666,4667],{"class":1089},"onError",[1079,4669,4670],{"class":1093},"?.(e)\n",[1079,4672,4673],{"class":1081,"line":1188},[1079,4674,4675],{"class":1093},"    },\n",[1079,4677,4678],{"class":1081,"line":1393},[1079,4679,4680],{"class":1093},"})\n",[1079,4682,4683,4686,4689],{"class":1081,"line":1416},[1079,4684,4685],{"class":1093},"decoder.",[1079,4687,4688],{"class":1089},"configure",[1079,4690,4590],{"class":1093},[1079,4692,4693,4696],{"class":1081,"line":1421},[1079,4694,4695],{"class":1093},"    codec,                              ",[1079,4697,4698],{"class":2199},"\u002F\u002F e.g. 'avc1.42c01e', read from the SPS NAL\n",[1079,4700,4701,4704,4707],{"class":1081,"line":1430},[1079,4702,4703],{"class":1093},"    avc: { format: ",[1079,4705,4706],{"class":1521},"'annexb'",[1079,4708,4709],{"class":1093}," },\n",[1079,4711,4712,4715,4718],{"class":1081,"line":2455},[1079,4713,4714],{"class":1093},"    optimizeForLatency: ",[1079,4716,4717],{"class":1273},"true",[1079,4719,1498],{"class":1093},[1079,4721,4722,4725,4728],{"class":1081,"line":2472},[1079,4723,4724],{"class":1093},"    hardwareAcceleration: ",[1079,4726,4727],{"class":1521},"'prefer-hardware'",[1079,4729,1498],{"class":1093},[1079,4731,4732],{"class":1081,"line":2480},[1079,4733,4680],{"class":1093},[30,4735,4736],{},"Key configurations:",[39,4738,4739,4745,4751,4757],{},[42,4740,4741,4744],{},[344,4742,4743],{},"optimizeForLatency: true"," — tells the decoder to prioritize low latency, no frame buffering",[42,4746,4747,4750],{},[344,4748,4749],{},"hardwareAcceleration: 'prefer-hardware'"," — prefer GPU decoding",[42,4752,4753,4756],{},[344,4754,4755],{},"avc: { format: 'annexb' }"," — use Annex-B format with inline SPS\u002FPPS before each IDR",[42,4758,4759,4760,4763],{},"the codec string itself isn't hardcoded — ",[344,4761,4762],{},"extractAvc1CodecString()"," reads profile\u002Fcompat\u002Flevel bytes directly out of the first SPS NAL in the config blob",[1229,4765,4767],{"id":4766},"green-screen-problem-and-startup-sequence","Green Screen Problem and Startup Sequence",[30,4769,4770,4771,4773],{},"The encoder produces its first IDR frame before ",[344,4772,3793],{}," has rendered real screen content — it's a blank (green) frame. If the web side decodes this frame, the user sees a green flash until screen content changes and triggers a new frame.",[30,4775,4776,4779,4780,4782,4783,4786,4787,4790,4791,4794,4795,4797],{},[85,4777,4778],{},"Solution",": On startup, the web side pulls the cached config via the ",[344,4781,3901],{}," GraphQL query but does not decode the bundled keyframe. Instead, it calls ",[344,4784,4785],{},"video.requestIdr()"," to set ",[344,4788,4789],{},"waitingForIdr = true"," (dropping all P-frames until an IDR arrives), then calls ",[344,4792,4793],{},"requestKeyFrame()"," to request a fresh IDR over the same mutation used for loss recovery. By the time the new IDR arrives, ",[344,4796,3793],{}," has real screen content.",[1071,4799,4801],{"className":4567,"code":4800,"language":4569,"meta":248,"style":248},"video.requestIdr()      \u002F\u002F drop P-frames, wait for IDR\nawait requestKeyFrame() \u002F\u002F request fresh IDR via GraphQL mutation\n",[344,4802,4803,4817],{"__ignoreMap":248},[1079,4804,4805,4808,4811,4814],{"class":1081,"line":1082},[1079,4806,4807],{"class":1093},"video.",[1079,4809,4810],{"class":1089},"requestIdr",[1079,4812,4813],{"class":1093},"()      ",[1079,4815,4816],{"class":2199},"\u002F\u002F drop P-frames, wait for IDR\n",[1079,4818,4819,4822,4824,4826],{"class":1081,"line":252},[1079,4820,4821],{"class":1085},"await",[1079,4823,4230],{"class":1089},[1079,4825,2185],{"class":1093},[1079,4827,4828],{"class":2199},"\u002F\u002F request fresh IDR via GraphQL mutation\n",[30,4830,4831,4832,4835,4836,4839,4840,4843],{},"The ",[344,4833,4834],{},"onFirstFrameRendered"," callback is bound to ",[344,4837,4838],{},"renderFrame()"," rather than ",[344,4841,4842],{},"handleVideo()",", ensuring the UI only updates after a real frame is rendered — not merely received.",[34,4845,3658],{"id":4846},"webgl2-rendering",[1229,4848,4850],{"id":4849},"zero-copy-gpu-direct-render","Zero-Copy GPU Direct Render",[30,4852,4853,4854,4856],{},"Decoded ",[344,4855,4563],{}," objects are directly uploaded as WebGL2 textures, never passing through the CPU:",[1071,4858,4861],{"className":4859,"code":4860,"language":3716},[3714],"VideoDecoder → VideoFrame → gl.texImage2D(VideoFrame) → Canvas\n",[344,4862,4860],{"__ignoreMap":248},[30,4864,4865,4868,4869,4871,4872,4875,4876,4879,4880,4883,4884,4887],{},[344,4866,4867],{},"gl.texImage2D"," accepts ",[344,4870,4563],{}," as a pixel source. The browser handles YUV→RGB conversion and GPU upload internally — no ",[344,4873,4874],{},"ImageData"," CPU copy. ",[344,4877,4878],{},"MirrorGLRenderer"," falls back to Canvas 2D ",[344,4881,4882],{},"drawImage()"," if ",[344,4885,4886],{},"getContext('webgl2', ...)"," fails, so older browsers still get a (slightly higher-latency) picture.",[1229,4889,4891],{"id":4890},"desynchronized-context","desynchronized Context",[1071,4893,4895],{"className":4567,"code":4894,"language":4569,"meta":248,"style":248},"const gl = canvas.getContext('webgl2', {\n    alpha: false,\n    desynchronized: true,        \u002F\u002F bypass compositor, write directly to screen\n    preserveDrawingBuffer: true, \u002F\u002F preserve buffer for screenshots\n    powerPreference: 'high-performance',\n    antialias: false,\n    depth: false,\n    stencil: false,\n    premultipliedAlpha: false,\n})\n",[344,4896,4897,4920,4930,4943,4955,4965,4974,4983,4992,5001],{"__ignoreMap":248},[1079,4898,4899,4901,4904,4906,4909,4912,4914,4917],{"class":1081,"line":1082},[1079,4900,4576],{"class":1085},[1079,4902,4903],{"class":1273}," gl",[1079,4905,1518],{"class":1085},[1079,4907,4908],{"class":1093}," canvas.",[1079,4910,4911],{"class":1089},"getContext",[1079,4913,1270],{"class":1093},[1079,4915,4916],{"class":1521},"'webgl2'",[1079,4918,4919],{"class":1093},", {\n",[1079,4921,4922,4925,4928],{"class":1081,"line":252},[1079,4923,4924],{"class":1093},"    alpha: ",[1079,4926,4927],{"class":1273},"false",[1079,4929,1498],{"class":1093},[1079,4931,4932,4935,4937,4940],{"class":1081,"line":249},[1079,4933,4934],{"class":1093},"    desynchronized: ",[1079,4936,4717],{"class":1273},[1079,4938,4939],{"class":1093},",        ",[1079,4941,4942],{"class":2199},"\u002F\u002F bypass compositor, write directly to screen\n",[1079,4944,4945,4948,4950,4952],{"class":1081,"line":1162},[1079,4946,4947],{"class":1093},"    preserveDrawingBuffer: ",[1079,4949,4717],{"class":1273},[1079,4951,953],{"class":1093},[1079,4953,4954],{"class":2199},"\u002F\u002F preserve buffer for screenshots\n",[1079,4956,4957,4960,4963],{"class":1081,"line":1174},[1079,4958,4959],{"class":1093},"    powerPreference: ",[1079,4961,4962],{"class":1521},"'high-performance'",[1079,4964,1498],{"class":1093},[1079,4966,4967,4970,4972],{"class":1081,"line":1182},[1079,4968,4969],{"class":1093},"    antialias: ",[1079,4971,4927],{"class":1273},[1079,4973,1498],{"class":1093},[1079,4975,4976,4979,4981],{"class":1081,"line":1188},[1079,4977,4978],{"class":1093},"    depth: ",[1079,4980,4927],{"class":1273},[1079,4982,1498],{"class":1093},[1079,4984,4985,4988,4990],{"class":1081,"line":1393},[1079,4986,4987],{"class":1093},"    stencil: ",[1079,4989,4927],{"class":1273},[1079,4991,1498],{"class":1093},[1079,4993,4994,4997,4999],{"class":1081,"line":1416},[1079,4995,4996],{"class":1093},"    premultipliedAlpha: ",[1079,4998,4927],{"class":1273},[1079,5000,1498],{"class":1093},[1079,5002,5003],{"class":1081,"line":1421},[1079,5004,4680],{"class":1093},[30,5006,5007,5010],{},[344,5008,5009],{},"desynchronized: true"," bypasses the browser compositor, writing directly to the screen, saving ~1 frame of display latency (~16ms @ 60fps).",[30,5012,5013,5016,5017,5020,5021,5023],{},[344,5014,5015],{},"preserveDrawingBuffer: true"," preserves the drawing buffer so ",[344,5018,5019],{},"canvas.toDataURL()"," screenshots can read the content. With the default ",[344,5022,4927],{},", the buffer is cleared after compositing, producing black screenshots.",[30,5025,5026],{},"The shader itself is deliberately minimal — a fullscreen-triangle vertex shader and a one-line fragment shader that samples the texture — because the only work needed per frame is \"put this texture on the screen.\"",[1229,5028,5030],{"id":5029},"canvas-auto-fit","Canvas Auto-Fit",[30,5032,5033,5034,5037,5038,5041,5042,5045],{},"The canvas backing store size is set from ",[344,5035,5036],{},"VideoFrame.displayWidth\u002FHeight"," whenever it changes. The CSS size is then fit to the wrapper container by ",[344,5039,5040],{},"fitCanvasToWrapper()"," while preserving aspect ratio (letterboxing or pillarboxing as needed). A ",[344,5043,5044],{},"ResizeObserver"," on the canvas's parent element re-runs this fit whenever the container resizes, so the video never stretches.",[34,5047,3664],{"id":5048},"loss-detection-error-recovery",[1229,5050,5052],{"id":5051},"frameid-gap-detection","FrameId Gap Detection",[30,5054,5055,5056,5058,5059,5062,5063,5066],{},"Each video frame carries a monotonically increasing ",[344,5057,4509],{},". The decoder tracks ",[344,5060,5061],{},"lastFrameId","; if a new frame's ",[344,5064,5065],{},"frameId > lastFrameId + 1",", frames were lost:",[1071,5068,5070],{"className":4567,"code":5069,"language":4569,"meta":248,"style":248},"if (!this.waitingForIdr && this.lastFrameId > 0\n    && packet.frameId > this.lastFrameId + 1) {\n    if (!packet.isKeyFrame) {\n        \u002F\u002F Loss: drop subsequent P-frames, request new IDR\n        this.waitingForIdr = true\n        this.onRequestKeyFrame?.()\n        this.lastFrameId = packet.frameId\n        return\n    }\n}\n",[344,5071,5072,5097,5119,5130,5135,5145,5155,5166,5171,5175],{"__ignoreMap":248},[1079,5073,5074,5076,5078,5080,5083,5085,5087,5089,5092,5094],{"class":1081,"line":1082},[1079,5075,2724],{"class":1085},[1079,5077,2329],{"class":1093},[1079,5079,2332],{"class":1085},[1079,5081,5082],{"class":1273},"this",[1079,5084,4642],{"class":1093},[1079,5086,2751],{"class":1085},[1079,5088,4610],{"class":1273},[1079,5090,5091],{"class":1093},".lastFrameId ",[1079,5093,1582],{"class":1085},[1079,5095,5096],{"class":1273}," 0\n",[1079,5098,5099,5102,5105,5107,5109,5111,5113,5116],{"class":1081,"line":252},[1079,5100,5101],{"class":1085},"    &&",[1079,5103,5104],{"class":1093}," packet.frameId ",[1079,5106,1582],{"class":1085},[1079,5108,4610],{"class":1273},[1079,5110,5091],{"class":1093},[1079,5112,1335],{"class":1085},[1079,5114,5115],{"class":1273}," 1",[1079,5117,5118],{"class":1093},") {\n",[1079,5120,5121,5123,5125,5127],{"class":1081,"line":249},[1079,5122,1128],{"class":1085},[1079,5124,2329],{"class":1093},[1079,5126,2332],{"class":1085},[1079,5128,5129],{"class":1093},"packet.isKeyFrame) {\n",[1079,5131,5132],{"class":1081,"line":1162},[1079,5133,5134],{"class":2199},"        \u002F\u002F Loss: drop subsequent P-frames, request new IDR\n",[1079,5136,5137,5139,5141,5143],{"class":1081,"line":1174},[1079,5138,4639],{"class":1273},[1079,5140,4642],{"class":1093},[1079,5142,1108],{"class":1085},[1079,5144,2477],{"class":1273},[1079,5146,5147,5149,5151,5153],{"class":1081,"line":1182},[1079,5148,4639],{"class":1273},[1079,5150,125],{"class":1093},[1079,5152,4655],{"class":1089},[1079,5154,4658],{"class":1093},[1079,5156,5157,5159,5161,5163],{"class":1081,"line":1188},[1079,5158,4639],{"class":1273},[1079,5160,5091],{"class":1093},[1079,5162,1108],{"class":1085},[1079,5164,5165],{"class":1093}," packet.frameId\n",[1079,5167,5168],{"class":1081,"line":1393},[1079,5169,5170],{"class":1085},"        return\n",[1079,5172,5173],{"class":1081,"line":1416},[1079,5174,1185],{"class":1093},[1079,5176,5177],{"class":1081,"line":1421},[1079,5178,1191],{"class":1093},[1229,5180,5182],{"id":5181},"waitingforidr-state-machine","waitingForIdr State Machine",[30,5184,5185,5188],{},[344,5186,5187],{},"waitingForIdr"," is a simple two-state machine:",[30,5190,5191],{},[913,5192],{"alt":1462,"src":5193},"\u002Fblog\u002Fscreen-mirror\u002Fdiagram-03.svg",[155,5195,5196,5206],{},[158,5197,5198],{},[161,5199,5200,5203],{},[164,5201,5202],{},"State",[164,5204,5205],{},"Behavior",[174,5207,5208,5218],{},[161,5209,5210,5215],{},[179,5211,5212],{},[344,5213,5214],{},"NORMAL",[179,5216,5217],{},"Decode all frames normally",[161,5219,5220,5225],{},[179,5221,5222],{},[344,5223,5224],{},"WAITING_FOR_IDR",[179,5226,5227,5228,5230],{},"Drop all P-frames, only decode IDR frames; reset to ",[344,5229,5214],{}," when IDR arrives",[30,5232,5233,5234,4218],{},"Scenarios that trigger the transition into ",[344,5235,5224],{},[80,5237,5238,5244,5250,5256],{},[42,5239,5240,5243],{},[85,5241,5242],{},"At startup",": skip stale GraphQL keyframe, wait for real IDR",[42,5245,5246,5249],{},[85,5247,5248],{},"On packet loss",": drop undecodable P-frames, wait for IDR recovery",[42,5251,5252,5255],{},[85,5253,5254],{},"On decoder error",": reset decoder, wait for IDR",[42,5257,5258,5261],{},[85,5259,5260],{},"On config change",": drop residual P-frames after orientation\u002Fquality change",[1229,5263,5265],{"id":5264},"decoder-error-recovery","Decoder Error Recovery",[30,5267,5268,5269,5272,5273,5276,5277,5280],{},"When ",[344,5270,5271],{},"VideoDecoder.onerror"," fires, ",[344,5274,5275],{},"decoderNeedsReset = true"," is set in the pipeline layer (",[344,5278,5279],{},"screen-mirror-pipeline.ts","). On the next IDR frame, the decoder is reconfigured with the cached SPS\u002FPPS instead of round-tripping to GraphQL again:",[1071,5282,5284],{"className":4567,"code":5283,"language":4569,"meta":248,"style":248},"if (decoderNeedsReset) {\n    if (!packet.isKeyFrame || !cachedConfig) return\n    video.configure(cachedConfig)\n    decoderNeedsReset = false\n}\n",[344,5285,5286,5293,5314,5324,5333],{"__ignoreMap":248},[1079,5287,5288,5290],{"class":1081,"line":1082},[1079,5289,2724],{"class":1085},[1079,5291,5292],{"class":1093}," (decoderNeedsReset) {\n",[1079,5294,5295,5297,5299,5301,5304,5306,5308,5311],{"class":1081,"line":252},[1079,5296,1128],{"class":1085},[1079,5298,2329],{"class":1093},[1079,5300,2332],{"class":1085},[1079,5302,5303],{"class":1093},"packet.isKeyFrame ",[1079,5305,2045],{"class":1085},[1079,5307,2754],{"class":1085},[1079,5309,5310],{"class":1093},"cachedConfig) ",[1079,5312,5313],{"class":1085},"return\n",[1079,5315,5316,5319,5321],{"class":1081,"line":249},[1079,5317,5318],{"class":1093},"    video.",[1079,5320,4688],{"class":1089},[1079,5322,5323],{"class":1093},"(cachedConfig)\n",[1079,5325,5326,5329,5331],{"class":1081,"line":1162},[1079,5327,5328],{"class":1093},"    decoderNeedsReset ",[1079,5330,1108],{"class":1085},[1079,5332,2346],{"class":1273},[1079,5334,5335],{"class":1081,"line":1174},[1079,5336,1191],{"class":1093},[1229,5338,5340],{"id":5339},"backpressure-and-timestamp-deduplication","Backpressure and Timestamp Deduplication",[30,5342,2114,5343,5346,5347,5350],{},[344,5344,5345],{},"decoder.decodeQueueSize > 5",", incoming P-frames are dropped rather than queued — the threshold of 5 (rather than 2) tolerates hardware decoder startup latency without causing unnecessary stutter. Separately, after rendering, ",[344,5348,5349],{},"lastRenderedPts"," is recorded; a frame whose timestamp is older (out-of-order arrival) is dropped unless it's a keyframe:",[1071,5352,5354],{"className":4567,"code":5353,"language":4569,"meta":248,"style":248},"if (packet.timestamp \u003C this.lastRenderedPts && !packet.isKeyFrame) {\n    return\n}\n",[344,5355,5356,5376,5381],{"__ignoreMap":248},[1079,5357,5358,5360,5363,5365,5367,5370,5372,5374],{"class":1081,"line":1082},[1079,5359,2724],{"class":1085},[1079,5361,5362],{"class":1093}," (packet.timestamp ",[1079,5364,1100],{"class":1085},[1079,5366,4610],{"class":1273},[1079,5368,5369],{"class":1093},".lastRenderedPts ",[1079,5371,2751],{"class":1085},[1079,5373,2754],{"class":1085},[1079,5375,5129],{"class":1093},[1079,5377,5378],{"class":1081,"line":252},[1079,5379,5380],{"class":1085},"    return\n",[1079,5382,5383],{"class":1081,"line":249},[1079,5384,1191],{"class":1093},[34,5386,3670],{"id":5387},"orientation-change-handling",[1229,5389,5391],{"id":5390},"encoder-rebuild","Encoder Rebuild",[30,5393,5394,5395,5398,5399,5402,5403,5406,5407,5410,5411,5414],{},"An ",[344,5396,5397],{},"OrientationEventListener"," in ",[344,5400,5401],{},"ScreenMirrorService"," compares the display's ",[344,5404,5405],{},"rotation"," against the cached ",[344,5408,5409],{},"isPortrait"," flag on every sensor callback; only a genuine portrait\u002Flandscape flip calls ",[344,5412,5413],{},"pipeline.onOrientationChanged()"," and invalidates the accessibility screen-size cache used for touch coordinate scaling.",[30,5416,5417],{},[913,5418],{"alt":2147,"src":5419},"\u002Fblog\u002Fscreen-mirror\u002Fdiagram-04.svg",[30,5421,5422,4218],{},[344,5423,5424],{},"rebuildEncoderAndResize()",[80,5426,5427,5430,5439,5442],{},[42,5428,5429],{},"Create a new encoder at the new dimensions (e.g. landscape 1920×1080)",[42,5431,5432,5433,5436,5437],{},"Switch ",[344,5434,5435],{},"VirtualDisplay.surface"," to the new encoder's input ",[344,5438,4174],{},[42,5440,5441],{},"Stop the old encoder",[42,5443,5444,5447],{},[344,5445,5446],{},"VirtualDisplay.resize()"," to the new dimensions",[30,5449,5450,5451,5454,5455,5458],{},"Surface switch happens ",[85,5452,5453],{},"before"," resize — ensuring the new encoder receives frames first, and the old encoder is stopped before it can receive wrong-dimension frames. If ",[344,5456,5457],{},"virtualDisplay?.surface = ..."," throws, the rebuild aborts and keeps the old encoder running rather than leaving the pipeline with no encoder at all.",[1229,5460,5462],{"id":5461},"config-change-notification","Config Change Notification",[30,5464,5465,5466,5469,5470,5472],{},"When the new encoder first outputs SPS\u002FPPS, ",[344,5467,5468],{},"pendingConfigBroadcast"," is set on the pipeline. When the first IDR from the new encoder arrives, it's bundled with that config into a single ",[344,5471,4316],{}," event instead of being sent as an ordinary video packet.",[30,5474,5475,5476,4218],{},"The web client then, in ",[344,5477,5478],{},"handleConfig()",[80,5480,5481,5484,5487,5493],{},[42,5482,5483],{},"Reconfigures the decoder with the new SPS\u002FPPS",[42,5485,5486],{},"Decodes the bundled IDR frame immediately",[42,5488,5489,5490,5492],{},"Calls ",[344,5491,4785],{}," to drop any residual P-frames from the old encoder still in flight",[42,5494,5489,5495,5497],{},[344,5496,4793],{}," to request a clean, fresh IDR",[30,5499,5500,5501,5503],{},"Steps 3-4 are a safety net — even if the new encoder's first IDR has incorrect dimensions (during the async resize window), the web client quickly recovers to the correct dimensions. ",[344,5502,5478],{}," also short-circuits if the incoming config is byte-identical to the cached one, since reconfiguring the decoder with unchanged bytes is a no-op that still costs an IDR to recover from.",[34,5505,3676],{"id":5506},"system-mediaprojection-lifecycle",[1229,5508,5510],{"id":5509},"the-problem","The Problem",[30,5512,5513,5514,5516,5517,5520,5521,5523,5524,5527,5528,5530],{},"Users may close the system-level screen cast (MediaProjection) via the Android system notification bar, rather than through the app's UI. In this case, ",[344,5515,5401],{}," doesn't know casting has stopped — ",[344,5518,5519],{},"running"," remains ",[344,5522,4717],{},", the web client queries ",[344,5525,5526],{},"screenMirrorState"," and gets ",[344,5529,4717],{},", but no video frames arrive, and the page is stuck on loading.",[1229,5532,5534],{"id":5533},"mediaprojectioncallback","MediaProjection.Callback",[30,5536,5537,5539,5540,5543,5544,5547,5548,5398,5551,4218],{},[344,5538,3789],{}," provides a ",[344,5541,5542],{},"Callback.onStop()"," callback that fires when the system stops casting. ",[344,5545,5546],{},"ScreenMirrorPipeline.startEncoders()"," registers this callback and calls ",[344,5549,5550],{},"ScreenMirrorService.instance?.stop()",[344,5552,5553],{},"onStop()",[1071,5555,5557],{"className":1073,"code":5556,"language":1075,"meta":248,"style":248},"projection.registerCallback(object : MediaProjection.Callback() {\n    override fun onStop() {\n        ScreenMirrorService.instance?.stop()\n    }\n}, null)\n",[344,5558,5559,5584,5596,5606,5610],{"__ignoreMap":248},[1079,5560,5561,5564,5567,5569,5572,5575,5577,5579,5582],{"class":1081,"line":1082},[1079,5562,5563],{"class":1093},"projection.",[1079,5565,5566],{"class":1089},"registerCallback",[1079,5568,1270],{"class":1093},[1079,5570,5571],{"class":1085},"object",[1079,5573,5574],{"class":1093}," : ",[1079,5576,3789],{"class":1089},[1079,5578,125],{"class":1093},[1079,5580,5581],{"class":1089},"Callback",[1079,5583,4233],{"class":1093},[1079,5585,5586,5589,5591,5594],{"class":1081,"line":252},[1079,5587,5588],{"class":1085},"    override",[1079,5590,1291],{"class":1085},[1079,5592,5593],{"class":1089}," onStop",[1079,5595,4233],{"class":1093},[1079,5597,5598,5601,5604],{"class":1081,"line":249},[1079,5599,5600],{"class":1093},"        ScreenMirrorService.instance?.",[1079,5602,5603],{"class":1089},"stop",[1079,5605,2024],{"class":1093},[1079,5607,5608],{"class":1081,"line":1162},[1079,5609,1185],{"class":1093},[1079,5611,5612,5615,5618],{"class":1081,"line":1174},[1079,5613,5614],{"class":1093},"}, ",[1079,5616,5617],{"class":1273},"null",[1079,5619,1284],{"class":1093},[30,5621,5622],{},[913,5623],{"alt":3084,"src":5624},"\u002Fblog\u002Fscreen-mirror\u002Fdiagram-05.svg",[1229,5626,5628],{"id":5627},"servicestop-responsibilities","Service.stop() Responsibilities",[30,5630,5631,5633],{},[344,5632,1222],{}," is the explicit stop point, responsible for notifying the web client and stopping the service:",[1071,5635,5637],{"className":1073,"code":5636,"language":1075,"meta":248,"style":248},"fun stop() {\n    if (!running) return  \u002F\u002F prevent recursion\n    running = false\n    sendEvent(WebSocketEvent(EventType.SCREEN_MIRRORING, \"\"\"{\"running\":false}\"\"\"))\n    stopForeground(STOP_FOREGROUND_REMOVE)\n    stopSelf()\n}\n",[344,5638,5639,5648,5664,5673,5692,5700,5707],{"__ignoreMap":248},[1079,5640,5641,5643,5646],{"class":1081,"line":1082},[1079,5642,1086],{"class":1085},[1079,5644,5645],{"class":1089}," stop",[1079,5647,4233],{"class":1093},[1079,5649,5650,5652,5654,5656,5659,5661],{"class":1081,"line":252},[1079,5651,1128],{"class":1085},[1079,5653,2329],{"class":1093},[1079,5655,2332],{"class":1085},[1079,5657,5658],{"class":1093},"running) ",[1079,5660,1410],{"class":1085},[1079,5662,5663],{"class":2199},"  \u002F\u002F prevent recursion\n",[1079,5665,5666,5669,5671],{"class":1081,"line":249},[1079,5667,5668],{"class":1093},"    running ",[1079,5670,1108],{"class":1085},[1079,5672,2346],{"class":1273},[1079,5674,5675,5678,5680,5683,5686,5689],{"class":1081,"line":1162},[1079,5676,5677],{"class":1089},"    sendEvent",[1079,5679,1270],{"class":1093},[1079,5681,5682],{"class":1089},"WebSocketEvent",[1079,5684,5685],{"class":1093},"(EventType.SCREEN_MIRRORING, ",[1079,5687,5688],{"class":1521},"\"\"\"{\"running\":false}\"\"\"",[1079,5690,5691],{"class":1093},"))\n",[1079,5693,5694,5697],{"class":1081,"line":1174},[1079,5695,5696],{"class":1089},"    stopForeground",[1079,5698,5699],{"class":1093},"(STOP_FOREGROUND_REMOVE)\n",[1079,5701,5702,5705],{"class":1081,"line":1182},[1079,5703,5704],{"class":1089},"    stopSelf",[1079,5706,2024],{"class":1093},[1079,5708,5709],{"class":1081,"line":1188},[1079,5710,1191],{"class":1093},[30,5712,4831,5713,5716,5717,3467,5719,3467,5721,3467,5724,3467,5727,3467,5730,3467,5733,3467,5735,5737,5738,5741],{},[344,5714,5715],{},"if (!running) return"," guard prevents recursion: ",[344,5718,5553],{},[344,5720,1222],{},[344,5722,5723],{},"stopSelf()",[344,5725,5726],{},"onDestroy()",[344,5728,5729],{},"pipeline.stop()",[344,5731,5732],{},"projection.stop()",[344,5734,5553],{},[344,5736,1222],{}," (at this point ",[344,5739,5740],{},"running=false",", returns immediately).",[1229,5743,5745],{"id":5744},"web-side-handling","Web-Side Handling",[30,5747,5748,5749,5752],{},"When the web client receives the ",[344,5750,5751],{},"{\"running\":false}"," event, it resets to idle state and shows the start button:",[1071,5754,5756],{"className":4567,"code":5755,"language":4569,"meta":248,"style":248},"const onScreenMirroring = (data: any) => {\n    if (data?.running === false) {\n        cleanupFn()\n        fullReset()\n        return\n    }\n    \u002F\u002F running=true → connect to stream\n}\n",[344,5757,5758,5783,5798,5805,5812,5816,5820,5825],{"__ignoreMap":248},[1079,5759,5760,5762,5765,5767,5769,5772,5774,5777,5779,5781],{"class":1081,"line":1082},[1079,5761,4576],{"class":1085},[1079,5763,5764],{"class":1089}," onScreenMirroring",[1079,5766,1518],{"class":1085},[1079,5768,2329],{"class":1093},[1079,5770,5771],{"class":4601},"data",[1079,5773,4218],{"class":1085},[1079,5775,5776],{"class":1273}," any",[1079,5778,1407],{"class":1093},[1079,5780,4607],{"class":1085},[1079,5782,1534],{"class":1093},[1079,5784,5785,5787,5790,5793,5796],{"class":1081,"line":252},[1079,5786,1128],{"class":1085},[1079,5788,5789],{"class":1093}," (data?.running ",[1079,5791,5792],{"class":1085},"===",[1079,5794,5795],{"class":1273}," false",[1079,5797,5118],{"class":1093},[1079,5799,5800,5803],{"class":1081,"line":249},[1079,5801,5802],{"class":1089},"        cleanupFn",[1079,5804,2024],{"class":1093},[1079,5806,5807,5810],{"class":1081,"line":1162},[1079,5808,5809],{"class":1089},"        fullReset",[1079,5811,2024],{"class":1093},[1079,5813,5814],{"class":1081,"line":1174},[1079,5815,5170],{"class":1085},[1079,5817,5818],{"class":1081,"line":1182},[1079,5819,1185],{"class":1093},[1079,5821,5822],{"class":1081,"line":1188},[1079,5823,5824],{"class":2199},"    \u002F\u002F running=true → connect to stream\n",[1079,5826,5827],{"class":1081,"line":1393},[1079,5828,1191],{"class":1093},[34,5830,3682],{"id":5831},"remote-control-touch-injection",[30,5833,5834,5835,125],{},"Screen mirroring is one-way by default (video\u002Faudio only); remote control is opt-in and requires the user to enable PlainApp's Accessibility Service once, since Android has no public API for injecting arbitrary touch events outside of ",[344,5836,5837],{},"AccessibilityService.dispatchGesture()",[30,5839,5840],{},[913,5841],{"alt":3256,"src":5842},"\u002Fblog\u002Fscreen-mirror\u002Fdiagram-06.svg",[1229,5844,5846],{"id":5845},"coordinate-normalization-web","Coordinate Normalization (Web)",[30,5848,5849,5850,5853,5854,5857,5858,5861,5862,5865,5866,5869],{},"A transparent overlay sits above the ",[344,5851,5852],{},"\u003Ccanvas>"," and captures pointer events. ",[344,5855,5856],{},"normalizeCoords()"," converts a raw ",[344,5859,5860],{},"clientX\u002FclientY"," into ",[344,5863,5864],{},"[0,1]"," coordinates relative to the ",[133,5867,5868],{},"actual video content area"," — not the overlay's bounding box — by computing the letterbox\u002Fpillarbox offset from the canvas's backing-store aspect ratio vs. its rendered container aspect ratio:",[1071,5871,5873],{"className":4567,"code":5872,"language":4569,"meta":248,"style":248},"if (videoAspect > containerAspect) {\n    \u002F\u002F Letterboxed top\u002Fbottom\n    renderW = containerW\n    renderH = containerW \u002F videoAspect\n    offsetY = (containerH - renderH) \u002F 2\n} else {\n    \u002F\u002F Pillarboxed left\u002Fright\n    renderH = containerH\n    renderW = containerH * videoAspect\n    offsetX = (containerW - renderW) \u002F 2\n}\n",[344,5874,5875,5887,5892,5902,5917,5938,5946,5951,5960,5974,5993],{"__ignoreMap":248},[1079,5876,5877,5879,5882,5884],{"class":1081,"line":1082},[1079,5878,2724],{"class":1085},[1079,5880,5881],{"class":1093}," (videoAspect ",[1079,5883,1582],{"class":1085},[1079,5885,5886],{"class":1093}," containerAspect) {\n",[1079,5888,5889],{"class":1081,"line":252},[1079,5890,5891],{"class":2199},"    \u002F\u002F Letterboxed top\u002Fbottom\n",[1079,5893,5894,5897,5899],{"class":1081,"line":249},[1079,5895,5896],{"class":1093},"    renderW ",[1079,5898,1108],{"class":1085},[1079,5900,5901],{"class":1093}," containerW\n",[1079,5903,5904,5907,5909,5912,5914],{"class":1081,"line":1162},[1079,5905,5906],{"class":1093},"    renderH ",[1079,5908,1108],{"class":1085},[1079,5910,5911],{"class":1093}," containerW ",[1079,5913,1927],{"class":1085},[1079,5915,5916],{"class":1093}," videoAspect\n",[1079,5918,5919,5922,5924,5927,5930,5933,5935],{"class":1081,"line":1174},[1079,5920,5921],{"class":1093},"    offsetY ",[1079,5923,1108],{"class":1085},[1079,5925,5926],{"class":1093}," (containerH ",[1079,5928,5929],{"class":1085},"-",[1079,5931,5932],{"class":1093}," renderH) ",[1079,5934,1927],{"class":1085},[1079,5936,5937],{"class":1273}," 2\n",[1079,5939,5940,5942,5944],{"class":1081,"line":1182},[1079,5941,2777],{"class":1093},[1079,5943,2780],{"class":1085},[1079,5945,1534],{"class":1093},[1079,5947,5948],{"class":1081,"line":1188},[1079,5949,5950],{"class":2199},"    \u002F\u002F Pillarboxed left\u002Fright\n",[1079,5952,5953,5955,5957],{"class":1081,"line":1393},[1079,5954,5906],{"class":1093},[1079,5956,1108],{"class":1085},[1079,5958,5959],{"class":1093}," containerH\n",[1079,5961,5962,5964,5966,5969,5972],{"class":1081,"line":1416},[1079,5963,5896],{"class":1093},[1079,5965,1108],{"class":1085},[1079,5967,5968],{"class":1093}," containerH ",[1079,5970,5971],{"class":1085},"*",[1079,5973,5916],{"class":1093},[1079,5975,5976,5979,5981,5984,5986,5989,5991],{"class":1081,"line":1421},[1079,5977,5978],{"class":1093},"    offsetX ",[1079,5980,1108],{"class":1085},[1079,5982,5983],{"class":1093}," (containerW ",[1079,5985,5929],{"class":1085},[1079,5987,5988],{"class":1093}," renderW) ",[1079,5990,1927],{"class":1085},[1079,5992,5937],{"class":1273},[1079,5994,5995],{"class":1081,"line":1430},[1079,5996,1191],{"class":1093},[30,5998,5999,6000,6003,6004,6007,6008,6011,6012,6015],{},"A pointer press starts a ",[344,6001,6002],{},"GestureState"," that tracks start position\u002Ftime; a 500ms hold with \u003C 10px movement escalates to ",[344,6005,6006],{},"LONG_PRESS",", movement past that threshold becomes a ",[344,6009,6010],{},"SWIPE",", and a quick release is a ",[344,6013,6014],{},"TAP",". A visual touch indicator (a growing\u002Ffading dot) gives the operator feedback on what gesture was recognized, before the phone even responds.",[1229,6017,6019],{"id":6018},"graphql-accessibilityservice","GraphQL → AccessibilityService",[30,6021,6022,6023,6026,6027,2329,6030,1927,6032,1927,6034,1927,6036,1927,6039,1927,6042,1927,6045,1927,6048,1927,6051,6054,6055,6058,6059,6062,6063,4218],{},"Every recognized gesture is sent as one ",[344,6024,6025],{},"sendScreenMirrorControl(input)"," mutation carrying an ",[344,6028,6029],{},"action",[344,6031,6014],{},[344,6033,6006],{},[344,6035,6010],{},[344,6037,6038],{},"SCROLL",[344,6040,6041],{},"BACK",[344,6043,6044],{},"HOME",[344,6046,6047],{},"RECENTS",[344,6049,6050],{},"LOCK_SCREEN",[344,6052,6053],{},"KEY",") plus normalized coordinates. The resolver calls ",[344,6056,6057],{},"dispatchScreenMirrorControl()",", which multiplies the normalized coordinates by the real screen size (from ",[344,6060,6061],{},"PlainAccessibilityService.getScreenSize()",", invalidated on every orientation change) and delegates to ",[344,6064,6065],{},"PlainAccessibilityService.dispatchControl()",[1071,6067,6069],{"className":1073,"code":6068,"language":1075,"meta":248,"style":248},"private fun dispatchTap(x: Float, y: Float) {\n    val path = Path().apply { moveTo(x, y) }\n    val stroke = GestureDescription.StrokeDescription(path, 0, 50)\n    dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null)\n}\n",[344,6070,6071,6093,6117,6144,6177],{"__ignoreMap":248},[1079,6072,6073,6075,6077,6080,6083,6086,6089,6091],{"class":1081,"line":1082},[1079,6074,1256],{"class":1085},[1079,6076,1291],{"class":1085},[1079,6078,6079],{"class":1089}," dispatchTap",[1079,6081,6082],{"class":1093},"(x: ",[1079,6084,6085],{"class":1089},"Float",[1079,6087,6088],{"class":1093},", y: ",[1079,6090,6085],{"class":1089},[1079,6092,5118],{"class":1093},[1079,6094,6095,6097,6100,6102,6105,6107,6109,6111,6114],{"class":1081,"line":252},[1079,6096,1307],{"class":1085},[1079,6098,6099],{"class":1093}," path ",[1079,6101,1108],{"class":1085},[1079,6103,6104],{"class":1089}," Path",[1079,6106,1114],{"class":1093},[1079,6108,4079],{"class":1089},[1079,6110,1326],{"class":1093},[1079,6112,6113],{"class":1089},"moveTo",[1079,6115,6116],{"class":1093},"(x, y) }\n",[1079,6118,6119,6121,6124,6126,6129,6132,6135,6137,6139,6142],{"class":1081,"line":249},[1079,6120,1307],{"class":1085},[1079,6122,6123],{"class":1093}," stroke ",[1079,6125,1108],{"class":1085},[1079,6127,6128],{"class":1093}," GestureDescription.",[1079,6130,6131],{"class":1089},"StrokeDescription",[1079,6133,6134],{"class":1093},"(path, ",[1079,6136,3982],{"class":1273},[1079,6138,953],{"class":1093},[1079,6140,6141],{"class":1273},"50",[1079,6143,1284],{"class":1093},[1079,6145,6146,6149,6152,6155,6157,6160,6163,6166,6169,6171,6173,6175],{"class":1081,"line":1162},[1079,6147,6148],{"class":1089},"    dispatchGesture",[1079,6150,6151],{"class":1093},"(GestureDescription.",[1079,6153,6154],{"class":1089},"Builder",[1079,6156,1114],{"class":1093},[1079,6158,6159],{"class":1089},"addStroke",[1079,6161,6162],{"class":1093},"(stroke).",[1079,6164,6165],{"class":1089},"build",[1079,6167,6168],{"class":1093},"(), ",[1079,6170,5617],{"class":1273},[1079,6172,953],{"class":1093},[1079,6174,5617],{"class":1273},[1079,6176,1284],{"class":1093},[1079,6178,6179],{"class":1081,"line":1174},[1079,6180,1191],{"class":1093},[30,6182,6183,3279,6185,6187,6188,6191,6192,6194,6195,6198,6199,6202,6203,1927,6205,1927,6207,1927,6209,6211,6212,6215,6216,6219],{},[344,6184,6010],{},[344,6186,6006],{}," build the same ",[344,6189,6190],{},"GestureDescription"," with a longer stroke duration or a line path instead of a single point; ",[344,6193,6038],{}," is implemented as a synthetic swipe from ",[344,6196,6197],{},"(x, y)"," to ",[344,6200,6201],{},"(x, y + deltaY)"," clamped to ±500px. The four global actions (",[344,6204,6041],{},[344,6206,6044],{},[344,6208,6047],{},[344,6210,6050],{},") skip gesture dispatch entirely and call ",[344,6213,6214],{},"performGlobalAction()"," directly. If the Accessibility Service isn't enabled, the resolver throws a ",[344,6217,6218],{},"GraphQLError"," rather than silently dropping the input, so the web UI can prompt the user to enable it.",[34,6221,3688],{"id":6222},"audio-pipeline",[1229,6224,6226],{"id":6225},"android-opus-encoding","Android Opus Encoding",[30,6228,6229,6232,6233,6236,6237,6239,6240,6243,6244,6246,6247,6250,6251,6254,6255,6257,6258,6261,6262,6265],{},[344,6230,6231],{},"MediaCodecAudioEncoder"," uses ",[344,6234,6235],{},"AudioPlaybackCaptureConfiguration"," (built from the same ",[344,6238,3789],{},") to capture system audio via ",[344,6241,6242],{},"AudioRecord",", feeding raw PCM into a ",[344,6245,3805],{}," Opus encoder. This requires Android 10+ and the ",[344,6248,6249],{},"RECORD_AUDIO"," permission — on older devices or without the permission, ",[344,6252,6253],{},"start()"," logs a warning and skips audio entirely (video keeps working). Encoded Opus packets are wrapped in the same ",[344,6256,4344],{}," protocol (with ",[344,6259,6260],{},"FLAG_AUDIO"," set) and share the video packet's ",[344,6263,6264],{},"SCREEN_MIRROR_AUDIO"," WebSocket channel.",[1229,6267,6269],{"id":6268},"web-opus-decoding","Web Opus Decoding",[30,6271,6272,6232,6275,6277,6278,6281,6282,6284,6285,6288],{},[344,6273,6274],{},"ScreenMirrorAudioPipeline",[344,6276,3854],{}," to decode Opus data, outputting ",[344,6279,6280],{},"AudioData"," routed to an ",[344,6283,3857],{}," element. Audio frame ",[344,6286,6287],{},"timestamp"," is used for A\u002FV sync — sharing the same time base (encoder PTS, in microseconds) as video frames, so no separate clock negotiation is needed between the two streams.",[34,6290,3694],{"id":6291},"performance-optimizations",[1229,6293,6295],{"id":6294},"zero-copy-paths","Zero-Copy Paths",[155,6297,6298,6308],{},[158,6299,6300],{},[161,6301,6302,6305],{},[164,6303,6304],{},"Path",[164,6306,6307],{},"Method",[174,6309,6310,6318,6329],{},[161,6311,6312,6315],{},[179,6313,6314],{},"VirtualDisplay → encoder Surface",[179,6316,6317],{},"GPU direct, Surface passthrough",[161,6319,6320,6323],{},[179,6321,6322],{},"VideoDecoder → VideoFrame → WebGL texture",[179,6324,6325,6328],{},[344,6326,6327],{},"gl.texImage2D(VideoFrame)",", GPU direct",[161,6330,6331,6334],{},[179,6332,6333],{},"WebSocket receive → VideoPacket parse",[179,6335,6336,6338],{},[344,6337,4530],{}," is a view, no copy",[1229,6340,6342],{"id":6341},"avcctoannexb-optimization","avccToAnnexB Optimization",[30,6344,6345,6346,6349],{},"Some Android encoders output AVCC format (4-byte length prefix), which needs conversion to Annex-B format (",[344,6347,6348],{},"00 00 00 01"," start code) for WebCodecs decoding.",[30,6351,6352,6353,6356,6357,6360,6361,6364,6365,6368],{},"The early implementation used ",[344,6354,6355],{},"ArrayList\u003CByte>"," with per-byte boxing — a 50KB IDR frame produced 50,000 ",[344,6358,6359],{},"java.lang.Byte"," boxing operations, creating massive GC pressure. The optimization uses two-pass scan + ",[344,6362,6363],{},"copyInto"," (which maps to the ",[344,6366,6367],{},"System.arraycopy"," intrinsic on JVM):",[1071,6370,6372],{"className":1073,"code":6371,"language":1075,"meta":248,"style":248},"\u002F\u002F First pass: compute output size\nvar outSize = 0\n\u002F\u002F Second pass: bulk copy\nval out = ByteArray(outSize)\navcc.copyInto(out, writeOff + 4, off + 4, off + 4 + len)\n",[344,6373,6374,6379,6391,6396,6411],{"__ignoreMap":248},[1079,6375,6376],{"class":1081,"line":1082},[1079,6377,6378],{"class":2199},"\u002F\u002F First pass: compute output size\n",[1079,6380,6381,6384,6387,6389],{"class":1081,"line":252},[1079,6382,6383],{"class":1085},"var",[1079,6385,6386],{"class":1093}," outSize ",[1079,6388,1108],{"class":1085},[1079,6390,5096],{"class":1273},[1079,6392,6393],{"class":1081,"line":249},[1079,6394,6395],{"class":2199},"\u002F\u002F Second pass: bulk copy\n",[1079,6397,6398,6400,6403,6405,6408],{"class":1081,"line":1162},[1079,6399,2972],{"class":1085},[1079,6401,6402],{"class":1093}," out ",[1079,6404,1108],{"class":1085},[1079,6406,6407],{"class":1089}," ByteArray",[1079,6409,6410],{"class":1093},"(outSize)\n",[1079,6412,6413,6416,6418,6421,6423,6426,6429,6431,6433,6435,6437,6439,6442],{"class":1081,"line":1174},[1079,6414,6415],{"class":1093},"avcc.",[1079,6417,6363],{"class":1089},[1079,6419,6420],{"class":1093},"(out, writeOff ",[1079,6422,1335],{"class":1085},[1079,6424,6425],{"class":1273}," 4",[1079,6427,6428],{"class":1093},", off ",[1079,6430,1335],{"class":1085},[1079,6432,6425],{"class":1273},[1079,6434,6428],{"class":1093},[1079,6436,1335],{"class":1085},[1079,6438,6425],{"class":1273},[1079,6440,6441],{"class":1085}," +",[1079,6443,6444],{"class":1093}," len)\n",[1229,6446,6448],{"id":6447},"p-frame-drop-strategy","P-Frame Drop Strategy",[30,6450,6451],{},"The decoder may be slow during initialization. If the P-frame queue is too long, latency accumulates. The decode queue size threshold is set to > 5 (rather than > 2) to avoid excessive frame loss during hardware decoder initialization.",[1229,6453,6455],{"id":6454},"idr-request-deduplication","IDR Request Deduplication",[30,6457,4831,6458,6460],{},[344,6459,5187],{}," guard ensures only one IDR request per loss event, preventing duplicate requests while waiting for an IDR to arrive.",[34,6462,900],{"id":3420},[155,6464,6465,6475],{},[158,6466,6467],{},[161,6468,6469,6471,6473],{},[164,6470,3429],{},[164,6472,3432],{},[164,6474,172],{},[174,6476,6477,6492,6512,6527,6542,6555,6576,6593,6608,6622,6636],{},[161,6478,6479,6484,6489],{},[179,6480,6481],{},[85,6482,6483],{},"State Machine",[179,6485,6486,6488],{},[344,6487,5187],{}," flag",[179,6490,6491],{},"Explicit P-frame drop\u002Frecovery state transitions",[161,6493,6494,6499,6505],{},[179,6495,6496],{},[85,6497,6498],{},"Recursion Guard",[179,6500,6501,5398,6503],{},[344,6502,5715],{},[344,6504,1222],{},[179,6506,6507,6508,6511],{},"Prevents ",[344,6509,6510],{},"onStop → stop → onDestroy → pipeline.stop → projection.stop → onStop"," recursion",[161,6513,6514,6519,6524],{},[179,6515,6516],{},[85,6517,6518],{},"Zero-Copy Pipeline",[179,6520,6521],{},[344,6522,6523],{},"VideoFrame → gl.texImage2D",[179,6525,6526],{},"GPU direct texture upload, no CPU copy",[161,6528,6529,6534,6539],{},[179,6530,6531],{},[85,6532,6533],{},"Two-Pass Scan",[179,6535,6536],{},[344,6537,6538],{},"avccToAnnexB",[179,6540,6541],{},"Pre-compute size, single allocation + bulk copy, eliminates boxing",[161,6543,6544,6549,6552],{},[179,6545,6546],{},[85,6547,6548],{},"Bundled Event",[179,6550,6551],{},"SPS\u002FPPS + IDR in one event",[179,6553,6554],{},"Config change completes reconfiguration + first-frame decode in one event",[161,6556,6557,6562,6573],{},[179,6558,6559],{},[85,6560,6561],{},"Callback Separation",[179,6563,6564,6566,6567,6566,6570],{},[344,6565,4834],{}," vs ",[344,6568,6569],{},"onDisconnected",[344,6571,6572],{},"onScreenMirrorOff",[179,6574,6575],{},"Clear distinction between first-frame render, transport failure, and phone-side stop",[161,6577,6578,6583,6590],{},[179,6579,6580],{},[85,6581,6582],{},"Safety Net",[179,6584,6585,3790,6588],{},[344,6586,6587],{},"requestIdr()",[344,6589,4793],{},[179,6591,6592],{},"Drop residual frames + request clean IDR after config change",[161,6594,6595,6600,6605],{},[179,6596,6597],{},[85,6598,6599],{},"PTS Deduplication",[179,6601,6602],{},[344,6603,6604],{},"timestamp \u003C lastRenderedPts",[179,6606,6607],{},"Drop out-of-order frames",[161,6609,6610,6615,6619],{},[179,6611,6612],{},[85,6613,6614],{},"FrameId Gap",[179,6616,6617],{},[344,6618,5065],{},[179,6620,6621],{},"ACK-free packet loss detection",[161,6623,6624,6628,6633],{},[179,6625,6626],{},[85,6627,4891],{},[179,6629,6630,6631],{},"WebGL2 ",[344,6632,5009],{},[179,6634,6635],{},"Bypass compositor, save 1 frame of latency",[161,6637,6638,6643,6650],{},[179,6639,6640],{},[85,6641,6642],{},"Explicit Fail Fast",[179,6644,6645,6647,6648],{},[344,6646,3897],{}," throws ",[344,6649,6218],{},[179,6651,6652],{},"Surfaces \"accessibility disabled\" instead of silently dropping input",[34,6654,906],{"id":3570},[39,6656,6657],{},[42,6658,6659,6663,6664,1927,6666,6669],{},[45,6660,4547],{"href":6661,"rel":6662},"https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FWebCodecs_API",[210]," — MDN documentation covering the ",[344,6665,4551],{},[344,6667,6668],{},"AudioDecoder"," interfaces.",[3589,6671,6672],{},"html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":248,"searchDepth":249,"depth":249,"links":6674},[6675,6676,6680,6687,6691,6695,6700,6706,6710,6716,6720,6724,6730,6731],{"id":36,"depth":252,"text":37},{"id":909,"depth":252,"text":824,"children":6677},[6678,6679],{"id":3721,"depth":249,"text":3722},{"id":3757,"depth":249,"text":3758},{"id":3909,"depth":252,"text":3640,"children":6681},[6682,6683,6684,6685,6686],{"id":3912,"depth":249,"text":3913},{"id":4053,"depth":249,"text":4054},{"id":4189,"depth":249,"text":4190},{"id":4207,"depth":249,"text":4208},{"id":4279,"depth":249,"text":4280},{"id":4338,"depth":252,"text":3646,"children":6688},[6689,6690],{"id":4348,"depth":249,"text":4349},{"id":4476,"depth":249,"text":4477},{"id":4538,"depth":252,"text":3652,"children":6692},[6693,6694],{"id":4541,"depth":249,"text":3842},{"id":4766,"depth":249,"text":4767},{"id":4846,"depth":252,"text":3658,"children":6696},[6697,6698,6699],{"id":4849,"depth":249,"text":4850},{"id":4890,"depth":249,"text":4891},{"id":5029,"depth":249,"text":5030},{"id":5048,"depth":252,"text":3664,"children":6701},[6702,6703,6704,6705],{"id":5051,"depth":249,"text":5052},{"id":5181,"depth":249,"text":5182},{"id":5264,"depth":249,"text":5265},{"id":5339,"depth":249,"text":5340},{"id":5387,"depth":252,"text":3670,"children":6707},[6708,6709],{"id":5390,"depth":249,"text":5391},{"id":5461,"depth":249,"text":5462},{"id":5506,"depth":252,"text":3676,"children":6711},[6712,6713,6714,6715],{"id":5509,"depth":249,"text":5510},{"id":5533,"depth":249,"text":5534},{"id":5627,"depth":249,"text":5628},{"id":5744,"depth":249,"text":5745},{"id":5831,"depth":252,"text":3682,"children":6717},[6718,6719],{"id":5845,"depth":249,"text":5846},{"id":6018,"depth":249,"text":6019},{"id":6222,"depth":252,"text":3688,"children":6721},[6722,6723],{"id":6225,"depth":249,"text":6226},{"id":6268,"depth":249,"text":6269},{"id":6291,"depth":252,"text":3694,"children":6725},[6726,6727,6728,6729],{"id":6294,"depth":249,"text":6295},{"id":6341,"depth":249,"text":6342},{"id":6447,"depth":249,"text":6448},{"id":6454,"depth":249,"text":6455},{"id":3420,"depth":252,"text":900},{"id":3570,"depth":252,"text":906},"Architecture","2025-07-29","This article covers the end-to-end design of PlainApp's screen mirror system: how Android captures and hardware-encodes H.264\u002FOpus via MediaCodec, how frames travel over WebSocket using a custom binary protocol, how the web side decodes via WebCodecs and renders via WebGL2 with zero CPU copies, how loss detection, orientation change, and remote touch control are handled, and how the system MediaProjection lifecycle is kept in sync.",{},"15 min read",{"title":3624,"description":6734},"How to build ultra-low-latency Android screen mirroring? Detailed guide on MediaCodec hardware encoding, WebCodecs decoding, WebGL2 GPU rendering, packet loss recovery, orientation change handling, and AccessibilityService remote control.","Screen Mirror Architecture: WebCodecs + WebGL2 Low-Latency Casting","blog\u002Fscreen-mirror","oMeBPH88ycaJM9pPH1aGTeiTTdnRa2SpxyUJg4l-UmE",{"id":6743,"title":6744,"body":6745,"category":6749,"date":6750,"description":6751,"extension":261,"meta":6752,"navigation":263,"path":123,"readingTime":6753,"seo":6754,"seoDescription":6755,"seoTitle":6756,"stem":6757,"__hash__":6758},"blog_en\u002Fblog\u002Fencrypted-android-phone-manager.md","PlainApp Security Deep Dive",{"type":27,"value":6746,"toc":6747},[],{"title":248,"searchDepth":249,"depth":249,"links":6748},[],"Security","2025-02-01","How PlainApp protects your data on local Wi-Fi — with evidence from the open-source code.",{},"12 min read",{"title":6744,"description":6751},"Looking for a truly encrypted Android phone manager? PlainApp uses XChaCha20-Poly1305, Ed25519, and ECDH key exchange — all verifiable in open-source code.","Encrypted Android Phone Manager: How PlainApp Secures Your Data","blog\u002Fencrypted-android-phone-manager","qujx9MnPgwrbnU6_lO6w8kHJk1DPKopiItTd48eRcs8",{"id":6760,"title":6761,"body":6762,"category":3611,"date":8495,"description":8496,"extension":261,"meta":8497,"navigation":263,"path":8498,"readingTime":8499,"seo":8500,"seoDescription":8501,"seoTitle":8502,"stem":8503,"__hash__":8504},"blog_en\u002Fblog\u002Fwifi-aware-transport.md","Wi-Fi Aware Transport Design — Neighbor Discovery & Data Paths",{"type":27,"value":6763,"toc":8437},[6764,6775,6792,6794,6890,6893,6900,6914,6919,6923,6930,6960,6974,6977,6992,6997,7001,7008,7015,7018,7045,7050,7054,7071,7078,7082,7108,7111,7122,7129,7134,7138,7149,7152,7177,7180,7195,7209,7214,7218,7235,7252,7283,7287,7297,7323,7329,7332,7337,7342,7349,7371,7382,7385,7396,7402,7406,7419,7426,7463,7466,7470,7486,7491,7509,7524,7530,7534,7552,7560,7580,7583,7586,7599,7605,7609,7616,7620,7639,7642,7645,7651,7655,7704,7707,7714,7720,7724,7739,7765,7776,7780,7805,7808,7815,7832,7846,7852,7856,7859,7892,7899,7906,7923,7949,7953,7959,7962,7975,7981,7987,7993,8004,8007,8035,8049,8052,8395,8398,8404,8406],[30,6765,6766,6767,6770,6771,6774],{},"The article covers the Android-only Aware session lifecycle, the publish \u002F\nsubscribe discovery model, the ",[85,6768,6769],{},"two-phase role-split handshake"," that\nsynchronizes ",[344,6772,6773],{},"requestNetwork"," on both sides within the framework's ~500 ms\nwindow, the per-peer link pool with idle sweeping, the IPv6 + custom-DNS\ntrick that lets a single OkHttp client serve both LAN and Aware, and the\nprewarmer that triggers peer Aware startup via BLE.",[30,6776,6777,6778,6782,6783,6787,6788,125],{},"For the broader fallback chain, see ",[45,6779,6781],{"href":6780},"\u002Fblog\u002Fchat-architecture","Chat Architecture",".\nFor the BLE transport that takes over when Aware is unavailable, see\n",[45,6784,6786],{"href":6785},"\u002Fblog\u002Fble-transport","BLE Transport",". For how the shared ChaCha20 key reused\nas the Aware PMK is established, see ",[45,6789,6791],{"href":6790},"\u002Fblog\u002Fpairing-flow","Pairing Flow",[34,6793,37],{"id":36},[39,6795,6796,6802,6808,6814,6820,6826,6832,6838,6848,6854,6860,6866,6872,6878,6884],{},[42,6797,6798],{},[45,6799,6801],{"href":6800},"#why-wi-fi-aware","Why Wi-Fi Aware?",[42,6803,6804],{},[45,6805,6807],{"href":6806},"#where-aware-sits-in-the-fallback-chain","Where Aware Sits in the Fallback Chain",[42,6809,6810],{},[45,6811,6813],{"href":6812},"#session-lifecycle-attach--publish--subscribe","Session Lifecycle: Attach → Publish + Subscribe",[42,6815,6816],{},[45,6817,6819],{"href":6818},"#discovery--role-assignment","Discovery & Role Assignment",[42,6821,6822],{},[45,6823,6825],{"href":6824},"#the-two-phase-handshake-hello--ready","The Two-Phase Handshake (hello + ready)",[42,6827,6828],{},[45,6829,6831],{"href":6830},"#ndp-requestnetwork--the-500-ms-window","NDP requestNetwork — The 500 ms Window",[42,6833,6834],{},[45,6835,6837],{"href":6836},"#per-peer-link-pool--idle-sweeping","Per-Peer Link Pool & Idle Sweeping",[42,6839,6840],{},[45,6841,6843,6844,6847],{"href":6842},"#ipv6-addressing--the-plain-aware-peer-dns-trick","IPv6 Addressing & the ",[344,6845,6846],{},"plain-aware-peer"," DNS Trick",[42,6849,6850],{},[45,6851,6853],{"href":6852},"#cryptography-pmk-derivation--chacha20-reuse","Cryptography: PMK Derivation & ChaCha20 Reuse",[42,6855,6856],{},[45,6857,6859],{"href":6858},"#message-send-path-end-to-end","Message Send Path (End-to-End)",[42,6861,6862],{},[45,6863,6865],{"href":6864},"#file-download-path-end-to-end","File Download Path (End-to-End)",[42,6867,6868],{},[45,6869,6871],{"href":6870},"#prewarming-ble-triggered-aware-startup","Prewarming: BLE-Triggered Aware Startup",[42,6873,6874],{},[45,6875,6877],{"href":6876},"#failure-modes--the-fast-skip-flag","Failure Modes & the Fast-Skip Flag",[42,6879,6880],{},[45,6881,6883],{"href":6882},"#key-constants-reference","Key Constants Reference",[42,6885,6886],{},[45,6887,6889],{"href":6888},"#design-trade-offs-recap","Design Trade-offs Recap",[34,6891,6801],{"id":6892},"why-wi-fi-aware",[30,6894,6895,6896,6899],{},"Wi-Fi Aware (IEEE 802.11bc, formerly NAN — Neighbor Awareness Networking)\nis a Wi-Fi Alliance certification that lets two devices ",[85,6897,6898],{},"discover each\nother and exchange data without any Wi-Fi infrastructure"," — no AP, no\nrouter, no DHCP. PlainApp uses it for two scenarios that LAN cannot cover:",[39,6901,6902,6908],{},[42,6903,6904,6907],{},[85,6905,6906],{},"Different SSIDs \u002F VLANs."," A phone on the guest network and a laptop on\nthe IoT VLAN are both \"online\" via Wi-Fi but cannot reach each other's\nIP. Aware creates a direct device-to-device data path that bypasses the\ninfrastructure entirely.",[42,6909,6910,6913],{},[85,6911,6912],{},"No infrastructure at all."," Two devices in the wilderness with Wi-Fi\non but no AP can still chat. (BLE also covers this, but Aware is much\nfaster — ~10 ms round trips vs seconds, and MB\u002Fs vs tens of KB\u002Fs.)",[30,6915,6916],{},[913,6917],{"alt":915,"src":6918},"\u002Fblog\u002Fwifi-aware-transport\u002Fdiagram-01.svg",[1229,6920,6922],{"id":6921},"platform-constraints","Platform constraints",[30,6924,6925,6926,6929],{},"Wi-Fi Aware is ",[85,6927,6928],{},"Android-only"," in PlainApp:",[39,6931,6932,6949],{},[42,6933,6934,6935,6938,6939,3279,6942,6945,6946,125],{},"Android 13 (API 33) is the minimum — the ",[344,6936,6937],{},"WifiAwareNetworkSpecifier.Builder","\nwith ",[344,6940,6941],{},"setPort()",[344,6943,6944],{},"setPmk()"," overloads that PlainApp depends on require\n",[344,6947,6948],{},"isTPlus()",[42,6950,6951,6952,6955,6956,6959],{},"iOS does not expose Wi-Fi Aware to third-party apps. iOS PlainApp falls\ndirectly from LAN to BLE; the ",[344,6953,6954],{},"WifiAwareTransport"," object is not even\ncompiled into the iOS target (",[344,6957,6958],{},"@RequiresApi(Build.VERSION_CODES.S)"," +\nandroidMain source set).",[30,6961,6962,6963,6966,6967,6970,6971,6973],{},"This is why the ",[344,6964,6965],{},"PeerTransportRouter.buildList"," calls\n",[344,6968,6969],{},"createWifiAwareTransport()"," — a factory that returns ",[344,6972,5617],{}," on iOS.",[34,6975,6807],{"id":6976},"where-aware-sits-in-the-fallback-chain",[30,6978,6979,6980,6983,6984,6987,6988,6991],{},"PlainApp's ",[344,6981,6982],{},"PeerTransportRouter"," is an ordered list. For each ",[344,6985,6986],{},"send"," or\n",[344,6989,6990],{},"downloadFile"," call, it walks the list and tries each transport until one\nsucceeds; failures cascade down.",[30,6993,6994],{},[913,6995],{"alt":1021,"src":6996},"\u002Fblog\u002Fwifi-aware-transport\u002Fdiagram-02.svg",[1229,6998,7000],{"id":6999},"why-is-aware-the-middle-and-not-the-first","Why is Aware \"the middle\" and not \"the first\"?",[30,7002,7003,7004,7007],{},"Because ",[85,7005,7006],{},"LAN is almost always faster when available",". A same-subnet Wi-Fi\nhop through an AP is a single 802.11 frame exchange; an Aware data path\nadds an NDP setup (~5 s on first use) plus a second Wi-Fi radio context\nfor the device-to-device link. If both are reachable, LAN wins on latency\nand throughput.",[30,7009,7010,7011,7014],{},"Conversely, BLE is ",[85,7012,7013],{},"always slower"," — but it works whenever both devices\nare paired. Aware is in the middle: faster than BLE, slower than LAN, and\nonly available on Android 13+ devices with Wi-Fi on.",[34,7016,6813],{"id":7017},"session-lifecycle-attach-publish-subscribe",[30,7019,7020,7021,7024,7025,7028,7029,7032,7033,7036,7037,7040,7041,7044],{},"A Wi-Fi Aware session is ",[85,7022,7023],{},"process-wide",". There is exactly one\n",[344,7026,7027],{},"WifiAwareSession"," per device; within it, PlainApp runs ",[85,7030,7031],{},"one publish\nsession"," (so peers can discover us) and ",[85,7034,7035],{},"one subscribe session"," (so we\ncan discover peers). Both are started the moment ",[344,7038,7039],{},"AwareSession.start()","\ncompletes the ",[344,7042,7043],{},"attach"," callback.",[30,7046,7047],{},[913,7048],{"alt":1462,"src":7049},"\u002Fblog\u002Fwifi-aware-transport\u002Fdiagram-03.svg",[1229,7051,7053],{"id":7052},"why-publish-and-subscribe-on-the-same-device","Why publish AND subscribe on the same device?",[30,7055,7056,7057,7060,7061,7064,7065,7068,7069,1881],{},"The Wi-Fi Aware discovery model is ",[85,7058,7059],{},"asymmetric",": a publisher advertises\na service, a subscriber scans for it. To make discovery symmetric (both\ndevices discover each other), PlainApp does ",[85,7062,7063],{},"both at once",". Without this,\ndevice A would have to know in advance whether it's the publisher or the\nsubscriber for a given peer — but peer roles are determined later by\n",[344,7066,7067],{},"clientId"," comparison (see ",[45,7070,6819],{"href":6818},[30,7072,7073,7074,7077],{},"Publishing and subscribing simultaneously means each device sees the\nother's ",[344,7075,7076],{},"onServiceDiscovered"," (as subscriber) AND receives the other's\nhello messages (as publisher) — both directions of the handshake are\nalways available.",[1229,7079,7081],{"id":7080},"auto-restart-on-termination","Auto-restart on termination",[30,7083,7084,7085,7088,7089,7092,7093,7096,7097,7099,7100,7103,7104,7107],{},"Some Android variants (MIUI in particular) kill long-running Aware\nsessions to save battery. PlainApp handles this in the ",[344,7086,7087],{},"onSessionTerminated","\ncallbacks: it nulls out the terminated session and immediately calls\n",[344,7090,7091],{},"publishOwnService"," \u002F ",[344,7094,7095],{},"subscribeOwnService"," again on the still-attached\n",[344,7098,7027],{},". The attach session itself is not lost — only the\npublish\u002Fsubscribe discovery session. Peer handles from before the\ntermination become stale, which is why ",[344,7101,7102],{},"awaitPeerHandle"," checks the\n",[344,7105,7106],{},"discoveredAt"," timestamp and discards handles older than 30 s.",[34,7109,6819],{"id":7110},"discovery-role-assignment",[30,7112,7113,7114,7117,7118,7121],{},"The Wi-Fi Aware data-path protocol requires ",[85,7115,7116],{},"one side to act as the\npublisher (server)"," and the other as the ",[85,7119,7120],{},"subscriber (client)",". Both\nsides cannot simultaneously be the initiator — the framework rejects\nrequests without a matching counterpart.",[30,7123,7124,7125,7128],{},"PlainApp assigns roles ",[85,7126,7127],{},"deterministically per peer"," using a simple\nlexicographic comparison of clientIds:",[30,7130,7131],{},[913,7132],{"alt":2147,"src":7133},"\u002Fblog\u002Fwifi-aware-transport\u002Fdiagram-04.svg",[1229,7135,7137],{"id":7136},"why-deterministic-and-not-negotiated","Why deterministic and not negotiated?",[30,7139,7140,7141,7144,7145,7148],{},"A negotiated approach (e.g. \"lower MAC is the server\") would require an\nextra message exchange. The lexicographic comparison is ",[85,7142,7143],{},"idempotent,\nsymmetric, and stateless",": both devices compute the same role for the\nsame pair without any communication. The clientId is a 13-character short\nUUID, so ties (",[344,7146,7147],{},"clientId == peer.id",") only happen when comparing a peer to\nitself — which never reaches the transport.",[30,7150,7151],{},"The role determines two things downstream:",[80,7153,7154,7163],{},[42,7155,7156,7159,7160,7162],{},[85,7157,7158],{},"Who drives the retry loop."," Only the client retries\n",[344,7161,6773],{},"; the server makes exactly one attempt per hello\nreceived. This is critical for the 500 ms window (next section).",[42,7164,7165,7168,7169,7172,7173,7176],{},[85,7166,7167],{},"Who sets the port."," The publisher calls ",[344,7170,7171],{},"setPort(httpsPort)"," because\nit's the one accepting incoming connections on its HTTPS server port.\nThe subscriber doesn't set a port — it learns the peer's port from the\n",[344,7174,7175],{},"WifiAwareNetworkInfo"," after the data path is established.",[34,7178,6825],{"id":7179},"the-two-phase-handshake-hello-ready",[30,7181,7182,7183,7186,7187,7190,7191,7194],{},"The hardest part of Wi-Fi Aware data-path setup is ",[85,7184,7185],{},"timing",". The\nframework requires both sides to call ",[344,7188,7189],{},"connectivityManager.requestNetwork","\nwithin roughly 500 ms of each other — if one side calls it before the\nother has registered its matching request, the framework rejects it\nimmediately with ",[344,7192,7193],{},"onUnavailable"," (\"releaseRequestAsUnfulfillableByAnyFactory\").",[30,7196,7197,7198,7201,7202,7205,7206,7208],{},"PlainApp solves this with a ",[85,7199,7200],{},"two-message application-layer handshake","\nthat runs on top of the Aware L2 message channel (the same ",[344,7203,7204],{},"sendMessage","\nAPI used by ",[344,7207,7076],{},"):",[30,7210,7211],{},[913,7212],{"alt":3084,"src":7213},"\u002Fblog\u002Fwifi-aware-transport\u002Fdiagram-05.svg",[1229,7215,7217],{"id":7216},"why-two-messages-hello-ready-instead-of-just-one","Why two messages (hello + ready) instead of just one?",[30,7219,7220,7221,7224,7225,7227,7228,7230,7231,7234],{},"The hello alone is not enough because of ",[85,7222,7223],{},"direction asymmetry",". The\nsubscriber can send hello the instant it discovers the publisher (in\n",[344,7226,7076],{},"), but the publisher cannot start\n",[344,7229,6773],{}," until it has the subscriber's ",[344,7232,7233],{},"PeerHandle",", which it\nonly learns by receiving the hello. So the hello serves two purposes:",[80,7236,7237,7246],{},[42,7238,7239,7242,7243,125],{},[85,7240,7241],{},"Deliver the subscriber's PeerHandle to the publisher."," The publisher\nneeds it to build the ",[344,7244,7245],{},"WifiAwareNetworkSpecifier",[42,7247,7248,7251],{},[85,7249,7250],{},"Signal intent to connect."," Receiving the hello tells the publisher\n\"the subscriber is about to requestNetwork, so I should too.\"",[30,7253,4831,7254,7257,7258,7261,7262,7264,7265,7267,7268,7271,7272,7274,7275,7278,7279,7282],{},[344,7255,7256],{},"ready"," receipt exists for the ",[85,7259,7260],{},"opposite direction"," — to tell the\nsubscriber \"the publisher has registered its requestNetwork.\" Without it,\nthe subscriber's ",[344,7263,6773],{}," might race ahead of the publisher's\nand get rejected by the framework. The ",[344,7266,7256],{}," receipt is a ",[85,7269,7270],{},"non-blocking\nsignal",": the subscriber doesn't wait for it before calling\n",[344,7273,6773],{}," (that would add a round trip), but if it arrives while\nthe subscriber is in ",[344,7276,7277],{},"IDLE"," state (between retry attempts), the subscriber\ncan immediately retry without waiting for the ",[344,7280,7281],{},"RETRY_DELAY_MS"," gap.",[1229,7284,7286],{"id":7285},"the-retry-loop-asymmetry","The retry-loop asymmetry",[30,7288,7289,7290,7293,7294,7296],{},"This is the most subtle part of the design. ",[85,7291,7292],{},"Only the subscriber\nretries."," The publisher makes exactly one ",[344,7295,6773],{}," attempt per\nhello received. This is because:",[39,7298,7299,7309],{},[42,7300,7301,7302,7305,7306,7308],{},"If both sides retried independently, their retry cycles would drift\nout of phase (different ",[344,7303,7304],{},"delay()"," durations, different GC pauses), and\nthe two ",[344,7307,6773],{}," calls would rarely overlap inside the 500 ms\nwindow.",[42,7310,7311,7312,7315,7316,7319,7320,7322],{},"The subscriber's retry loop sends a fresh hello on each attempt, which\nre-triggers the publisher's ",[344,7313,7314],{},"buildLink"," via ",[344,7317,7318],{},"publishHelloListeners",".\nThis guarantees the publisher's ",[344,7321,6773],{}," always follows the\nhello by ~50 ms, well inside the 500 ms window.",[30,7324,7325,7326,125],{},"This is documented in detail in\n",[344,7327,7328],{},"AwarePeerLink.build",[34,7330,6831],{"id":7331},"ndp-requestnetwork-the-500-ms-window",[30,7333,4831,7334,7336],{},[344,7335,6773],{}," call is the most timing-sensitive operation in the\nAware transport. Here's what happens on each side:",[30,7338,7339],{},[913,7340],{"alt":3256,"src":7341},"\u002Fblog\u002Fwifi-aware-transport\u002Fdiagram-06.svg",[1229,7343,7345,7346,7348],{"id":7344},"what-the-onunavailable-callback-means","What the ",[344,7347,7193],{}," callback means",[30,7350,7351,7353,7354,7356,7357,7360,7361,7363,7364,7367,7368,7370],{},[344,7352,7193],{}," fires when the framework rejects the ",[344,7355,6773],{},"\nbefore finding a matching peer request. ",[85,7358,7359],{},"The PeerHandle itself is still\nvalid"," — only the NDP (Neighbor Discovery Protocol) pairing failed\nbecause the other side hadn't registered yet. PlainApp deliberately does\n",[85,7362,4312],{}," call ",[344,7365,7366],{},"session.invalidatePeerHandle"," in this case, because\ninvalidating the handle would discard the only signal that\n",[344,7369,7076],{}," was ever called (it fires once per peer per\nsubscribe session lifetime). With the handle preserved, the retry can\nreuse it instead of waiting for a fresh discovery.",[30,7372,7373,7374,7377,7378,7381],{},"The same applies to the publisher-side handle from ",[344,7375,7376],{},"onMessageReceived"," —\nthe publisher keeps the ",[344,7379,7380],{},"publishPeerHandles[fromCid]"," entry across failed\nattempts, so the subscriber's next hello re-uses the cached handle instead\nof being dropped.",[34,7383,6837],{"id":7384},"per-peer-link-pool-idle-sweeping",[30,7386,7387,7388,7391,7392,7395],{},"Each paired peer gets its own ",[344,7389,7390],{},"AwarePeerLink"," object, owned by the\nprocess-wide ",[344,7393,7394],{},"AwareLinkPool",". The pool handles discovery events, link\nreuse, and idle eviction.",[30,7397,7398],{},[913,7399],{"alt":7400,"src":7401},"Diagram 7","\u002Fblog\u002Fwifi-aware-transport\u002Fdiagram-07.svg",[1229,7403,7405],{"id":7404},"why-no-auto-build-on-discovery","Why no auto-build on discovery?",[30,7407,7408,7409,7412,7413,7415,7416,7418],{},"The pool explicitly ",[85,7410,7411],{},"does not"," build a link when\n",[344,7414,7076],{}," fires. This is a critical decision: a busy coffee\nshop might have 100 PlainApp devices all publishing the \"plain-peer\"\nservice. If each discovery triggered a ",[344,7417,6773],{},", the framework\nwould be flooded with NDP setup attempts and the Wi-Fi radio would be\nsaturated.",[30,7420,7421,7422,7425],{},"Instead, the pool ",[85,7423,7424],{},"only records the PeerHandle"," and waits for one of:",[80,7427,7428,7441,7453],{},[42,7429,7430,3467,7433,7436,7437,7440],{},[85,7431,7432],{},"The local user sends a message",[344,7434,7435],{},"WifiAwareTransport.send"," →\n",[344,7438,7439],{},"pool.buildLink(peer)"," (sender-side trigger).",[42,7442,7443,3467,7446,7436,7449,7452],{},[85,7444,7445],{},"The remote peer sends a hello",[344,7447,7448],{},"onPublishHelloReceived",[344,7450,7451],{},"buildLink(peer)"," (receiver-side trigger).",[42,7454,7455,3467,7458,7436,7461,7452],{},[85,7456,7457],{},"The remote peer sends a ready",[344,7459,7460],{},"onSubscribeReadyReceived",[344,7462,7451],{},[30,7464,7465],{},"This way, links are only built for peers that the user is actually\nexchanging messages with — not every PlainApp device in radio range.",[1229,7467,7469],{"id":7468},"idle-sweep","Idle sweep",[30,7471,7472,7473,7476,7477,3279,7479,7481,7482,7485],{},"Every 10 seconds, the pool walks all links and closes any whose\n",[344,7474,7475],{},"lastActiveAt"," is older than 60 seconds. Each ",[344,7478,6986],{},[344,7480,6990],{},"\ncalls ",[344,7483,7484],{},"link.touch()"," to refresh the timestamp. This reclaims the Wi-Fi\nradio context and OkHttp connection pool for peers the user has stopped\nchatting with — important because Android limits the number of\nsimultaneous Aware data paths to roughly 4–10 (device-dependent).",[34,7487,6843,7489,6847],{"id":7488},"ipv6-addressing-the-plain-aware-peer-dns-trick",[344,7490,6846],{},[30,7492,7493,7494,7497,7498,7501,7502,1683,7505,7508],{},"Wi-Fi Aware data paths use ",[85,7495,7496],{},"link-local IPv6 only",". There is no IPv4, no\nDNS server, no DHCP. The peer's IPv6 address is delivered via the\n",[344,7499,7500],{},"WifiAwareNetworkInfo.peerIpv6Addr"," field in\n",[344,7503,7504],{},"onCapabilitiesChanged",[344,7506,7507],{},"fe80::..."," address that's only meaningful\non the Aware network interface.",[30,7510,7511,7512,7515,7516,7519,7520,7523],{},"PlainApp needs to send HTTPS requests to this address, but OkHttp's\n",[344,7513,7514],{},"https:\u002F\u002F"," URL parsing refuses raw IPv6 literals in a hostname\n(",[344,7517,7518],{},"https:\u002F\u002F[fe80::abcd]:8443\u002F"," works, but routing it through a custom\n",[344,7521,7522],{},"Dns"," resolver is cleaner). The trick:",[30,7525,7526],{},[913,7527],{"alt":7528,"src":7529},"Diagram 8","\u002Fblog\u002Fwifi-aware-transport\u002Fdiagram-08.svg",[1229,7531,7533],{"id":7532},"why-a-sentinel-hostname","Why a sentinel hostname?",[30,7535,7536,7537,7540,7541,7543,7544,7547,7548,7551],{},"The alternative — passing the IPv6 literal directly in the URL — would\nrequire every call site to know about the link-local address. By using a\nsentinel hostname, the URL construction is identical for LAN and Aware:\nboth produce a valid ",[344,7538,7539],{},"https:\u002F\u002F\u003Chost>:\u003Cport>\u002Fpeer_graphql"," URL that OkHttp\ncan parse. The only difference is the ",[344,7542,7522],{}," implementation bound to the\nclient — LAN uses the system DNS, Aware uses ",[344,7545,7546],{},"awareDns(peerIpv6)"," which\nreturns the cached link-local address for the sentinel hostname and\nfalls through to ",[344,7549,7550],{},"Dns.SYSTEM"," for anything else.",[1229,7553,7555,7556,7559],{"id":7554},"why-networksocketfactory","Why ",[344,7557,7558],{},"network.socketFactory","?",[30,7561,7562,7563,7566,7567,7569,7570,7573,7574,7576,7577,125],{},"Android's ",[344,7564,7565],{},"Network"," object represents a specific network interface (in\nthis case, the Aware data path). By calling ",[344,7568,7558],{}," and\npassing it to OkHttp's ",[344,7571,7572],{},"socketFactory"," config, we force all TCP sockets\nto be created on the Aware interface — ",[85,7575,4312],{}," the default Wi-Fi or\ncellular interface. Without this, the OS would route the request via the\ndefault network, where the link-local IPv6 is unreachable, and the\nrequest would fail with ",[344,7578,7579],{},"ENETUNREACH",[34,7581,6853],{"id":7582},"cryptography-pmk-derivation-chacha20-reuse",[30,7584,7585],{},"Wi-Fi Aware supports an optional PMK (Pairwise Master Key) for the data\npath. When set, the L2 link itself is encrypted with that PMK — the\nWi-Fi radio handles encryption, no application-layer crypto needed.",[30,7587,7588,7589,3048,7592,3279,7595,7598],{},"PlainApp derives the PMK from the ",[85,7590,7591],{},"same ChaCha20 shared key",[344,7593,7594],{},"LanTransport",[344,7596,7597],{},"BleTransport"," use for application-layer encryption:",[30,7600,7601],{},[913,7602],{"alt":7603,"src":7604},"Diagram 9","\u002Fblog\u002Fwifi-aware-transport\u002Fdiagram-09.svg",[1229,7606,7608],{"id":7607},"why-truncate-to-32-bytes","Why truncate to 32 bytes?",[30,7610,7611,7612,7615],{},"The Wi-Fi Aware PMK must be exactly 32 bytes (256 bits). The ChaCha20\nshared key from pairing is also 32 bytes in the normal case, so the\n",[344,7613,7614],{},"raw.size == 32"," branch is the common path. The truncation\u002Fpadding\nfallback handles the (theoretical) case where the key was stored shorter\n— padding with zeros to 32 bytes is a defensive measure, not something\nthat happens in practice with properly paired peers.",[1229,7617,7619],{"id":7618},"the-signed-envelope-is-identical-to-lan","The signed envelope is identical to LAN",[30,7621,7003,7622,7625,7626,7628,7629,7632,7633,7636,7637,125],{},[344,7623,7624],{},"createCryptoHttpClient"," is the same factory used by\n",[344,7627,7594],{},", the L7 crypto on Aware is ",[85,7630,7631],{},"byte-for-byte identical"," to\nLAN. The server-side ",[344,7634,7635],{},"PeerGraphQLService"," doesn't know (or care) which\ntransport delivered the request — it just sees a signed, encrypted\nGraphQL payload and decrypts it with the peer's shared key. This is the\n\"one codebase, many transports\" principle documented in\n",[45,7638,6781],{"href":6780},[34,7640,6859],{"id":7641},"message-send-path-end-to-end",[30,7643,7644],{},"Putting it all together — what happens when a chat message is sent over\nWi-Fi Aware:",[30,7646,7647],{},[913,7648],{"alt":7649,"src":7650},"Diagram 10","\u002Fblog\u002Fwifi-aware-transport\u002Fdiagram-10.svg",[1229,7652,7654],{"id":7653},"notable-design-choices","Notable design choices",[39,7656,7657,7672,7681],{},[42,7658,7659,7662,7663,7665,7666,2828,7668,7671],{},[85,7660,7661],{},"Connection reuse."," Unlike ",[344,7664,7597],{},", which tears down the GATT\nconnection after every request, ",[344,7667,6954],{},[85,7669,7670],{},"reuses the Aware\ndata path"," for as many requests as the user makes within the 60 s idle\nwindow. The first request pays the ~400 ms handshake; subsequent\nrequests are ~10 ms round trips.",[42,7673,7674,7677,7678,7680],{},[85,7675,7676],{},"Same crypto as LAN."," The ChaCha20 interceptor and signed envelope are\nbyte-identical to LAN. The peer's ",[344,7679,7635],{}," doesn't know\nwhich transport delivered the request.",[42,7682,7683,7686,7687,7689,7690,7693,7694,7696,7697,7699,7700,7703],{},[85,7684,7685],{},"No preemption on link failure."," If ",[344,7688,7314],{}," fails, the transport\nthrows ",[344,7691,7692],{},"TransportUnavailable"," and the router falls through to BLE. There\nis no retry within ",[344,7695,6986],{}," — ",[344,7698,7328],{}," already does its own\ninternal retry loop (with ",[344,7701,7702],{},"MAX_BUILD_ATTEMPTS = 1"," on the client, more\nif the prewarmer has primed both sides).",[34,7705,6865],{"id":7706},"file-download-path-end-to-end",[30,7708,7709,7710,7713],{},"File downloads over Aware reuse the same data path as chat messages, but\nuse a ",[85,7711,7712],{},"separate OkHttp client"," configured for streaming large files:",[30,7715,7716],{},[913,7717],{"alt":7718,"src":7719},"Diagram 11","\u002Fblog\u002Fwifi-aware-transport\u002Fdiagram-11.svg",[1229,7721,7723],{"id":7722},"why-a-separate-client-for-downloads","Why a separate client for downloads?",[30,7725,7726,7727,7730,7731,7734,7735,7738],{},"The chat client (",[344,7728,7729],{},"AwareHttpClientFactory.build",") has a 30 s\n",[344,7732,7733],{},"requestTimeoutMillis"," — appropriate for GraphQL mutations but\ncatastrophic for a 100 MB file download. The download client\n(",[344,7736,7737],{},"buildFileDownload",") sets:",[39,7740,7741,7747,7753,7759],{},[42,7742,7743,7746],{},[344,7744,7745],{},"connectTimeoutMillis = 10_000"," (longer than chat's 5 s, more tolerant\nof slow first-packet on a fresh data path)",[42,7748,7749,7752],{},[344,7750,7751],{},"readTimeout = 120 s"," per read (vs the implicit default of 10 s)",[42,7754,7755,7758],{},[344,7756,7757],{},"requestTimeoutMillis = 120_000"," (2 minutes — enough for most files)",[42,7760,7761,7764],{},[344,7762,7763],{},"retryOnConnectionFailure(true)"," — a dropped mid-download read is\nretried instead of failing the whole transfer",[30,7766,7767,7768,7771,7772,7775],{},"It also ",[85,7769,7770],{},"omits the ChaCha20 interceptor",". The ",[344,7773,7774],{},"\u002Ffs"," endpoint serves raw\nfile bytes (not a signed GraphQL envelope), and the L2 PMK (when present)\nalready encrypts the radio link. Double-encrypting a 50 MB video with\nChaCha20 in software would waste CPU and slow the transfer.",[1229,7777,7779],{"id":7778},"streaming-not-buffering","Streaming, not buffering",[30,7781,7782,7783,7786,7787,7790,7791,7794,7795,7092,7797,7800,7801,7804],{},"Like the BLE path, Aware downloads stream the file through a\n",[344,7784,7785],{},"ByteReadChannel"," — the file is written to a temp file as bytes arrive,\nnot buffered in memory. ",[344,7788,7789],{},"PeerFileDownloader"," reads 8 KB chunks and emits\nprogress events every second. The same ",[344,7792,7793],{},"DownloadedResponse"," \u002F\n",[344,7796,7789],{},[344,7798,7799],{},"DownloadQueue"," pipeline is reused across all\ntransports — transport-specific only the ",[344,7802,7803],{},"channel"," source.",[34,7806,6871],{"id":7807},"prewarming-ble-triggered-aware-startup",[30,7809,7810,7811,7814],{},"The biggest user-visible latency in the Aware path is the ",[85,7812,7813],{},"first\nhandshake"," — if both sides haven't started Aware yet, the user's first\nmessage has to wait for:",[80,7816,7817,7820,7823,7826,7829],{},[42,7818,7819],{},"Local Aware session attach (~1 s)",[42,7821,7822],{},"Local publish + subscribe start (~1 s)",[42,7824,7825],{},"Remote peer's Aware startup (~2 s over BLE)",[42,7827,7828],{},"Mutual discovery (~1 s)",[42,7830,7831],{},"NDP handshake (~400 ms)",[30,7833,7834,7835,7838,7839,7842,7843,4218],{},"That's ~5 seconds before the first byte is sent. To hide this latency,\n",[344,7836,7837],{},"PeerTransportPrewarmer"," runs on ",[344,7840,7841],{},"ChatPage"," entry and ",[85,7844,7845],{},"triggers the\nremote peer's Aware startup via BLE",[30,7847,7848],{},[913,7849],{"alt":7850,"src":7851},"Diagram 12","\u002Fblog\u002Fwifi-aware-transport\u002Fdiagram-12.svg",[1229,7853,7855],{"id":7854},"the-dual-role-of-ble","The dual role of BLE",[30,7857,7858],{},"BLE serves two purposes here:",[80,7860,7861,7874],{},[42,7862,7863,7866,7867,7870,7871,7873],{},[85,7864,7865],{},"Read the peer's current Aware state"," (cheap, no GATT connect — the\nscan response's ",[344,7868,7869],{},"serviceData"," byte",[1079,7872,3982],{}," carries the Aware flags).",[42,7875,7876,7879,7880,7883,7884,7887,7888,7891],{},[85,7877,7878],{},"Trigger the peer to start Aware"," if it supports but isn't currently\nrunning it. This goes through the regular ",[344,7881,7882],{},"BleTransport.send"," path —\na ",[344,7885,7886],{},"startAware"," GraphQL mutation encrypted with the shared ChaCha20\nkey, delivered via GATT RPC to the peer's ",[344,7889,7890],{},"\u002Fpeer_graphql"," endpoint.",[30,7893,7894,7895,7898],{},"This is one of the few places where the transports cooperate rather\nthan just fall back: BLE is used to ",[85,7896,7897],{},"pre-emptively upgrade"," the\nsession to the faster Aware transport, before the user even notices.",[1229,7900,7902,7903,7559],{"id":7901},"why-optimistic-setawarerunningtrue","Why optimistic ",[344,7904,7905],{},"setAwareRunning(true)",[30,7907,4831,7908,7910,7911,7914,7915,7918,7919,7922],{},[344,7909,7886],{}," mutation returns success as soon as the remote peer's\nresolver invokes ",[344,7912,7913],{},"WifiAwareTransport.start()"," — but the Aware session\nisn't actually attached yet (",[344,7916,7917],{},"onAttached"," fires asynchronously). PlainApp\nmarks the peer as ",[344,7920,7921],{},"awareRunning = true"," optimistically, because:",[39,7924,7925,7931,7942],{},[42,7926,7927,7928,7930],{},"If it actually started, the next ",[344,7929,6986],{}," will use Aware (fast).",[42,7932,7933,7934,4548,7936,7938,7939,7941],{},"If it didn't (e.g. peer's Wi-Fi is off), the next ",[344,7935,6986],{},[344,7937,7314],{},"\nwill fail with ",[344,7940,7692],{}," and fall back to BLE naturally.",[42,7943,7944,7945,7948],{},"The cost of a false positive is one ~5 s timeout, not a permanent\nblock — ",[344,7946,7947],{},"PeerCircuitBreaker"," records the failure but doesn't open the\nBLE leg (BLE only opens on its own failures).",[1229,7950,7952],{"id":7951},"why-throttle-to-30-s","Why throttle to 30 s?",[30,7954,7955,7958],{},[344,7956,7957],{},"PeerTransportPrewarmer.prewarm(peerId)"," records a timestamp per peer\nand refuses to re-run within 30 s. This is because the user navigates\nback and forth between chat list and chat page frequently — without\nthrottling, every navigation would trigger a BLE scan + startAware\nmutation, draining battery and spamming the BLE radio. The 30 s window\nis short enough to catch a peer that just came online (e.g. user opened\nthe app on the remote device) but long enough to avoid spurious re-runs.",[34,7960,6877],{"id":7961},"failure-modes-the-fast-skip-flag",[30,7963,7964,7965,7968,7969,7971,7972,7974],{},"Aware has more failure modes than any other transport. The\n",[344,7966,7967],{},"isAwareRunning"," fast-skip flag is the single most important\noptimization in the whole module — without it, every ",[344,7970,6986],{}," would\nwaste 10 s on ",[344,7973,7314],{}," timing out before falling back to BLE.",[30,7976,7977],{},[913,7978],{"alt":7979,"src":7980},"Diagram 13","\u002Fblog\u002Fwifi-aware-transport\u002Fdiagram-13.svg",[1229,7982,4831,7984,7986],{"id":7983},"the-isawarerunning-flag-is-the-linchpin",[344,7985,7967],{}," flag is the linchpin",[30,7988,7989,7990,7992],{},"Without this single boolean, every Aware ",[344,7991,6986],{}," would either:",[39,7994,7995,8001],{},[42,7996,7997,7998,8000],{},"Always attempt ",[344,7999,7314],{}," → 10 s timeout on every send to a peer whose\nAware isn't running.",[42,8002,8003],{},"Always skip Aware → never use it even when both sides have it running.",[30,8005,8006],{},"The flag is refreshed from two sources, in order of authority:",[80,8008,8009,8025],{},[42,8010,8011,8014,8015,8018,8019,8021,8022,8024],{},[85,8012,8013],{},"BLE scan response"," (cheap, no GATT connect) — set by\n",[344,8016,8017],{},"PeerTransportPrewarmer.refreshAwareFlagFromScan",". The peer advertises\nits Aware state in the 9-byte ",[344,8020,7869],{}," payload (byte",[1079,8023,3982],{}," bitfield).",[42,8026,8027,8030,8031,8034],{},[85,8028,8029],{},"GATT DISCOVER reply"," (authoritative) — set by\n",[344,8032,8033],{},"PairingTransport.scanAndDiscover"," when a full discovery happens. This\noverwrites the scan hint.",[30,8036,8037,8038,3279,8040,8042,8043,2828,8045,8048],{},"When false, ",[344,8039,7435],{},[344,8041,6990],{}," throw\n",[344,8044,7692],{},[85,8046,8047],{},"immediately"," — no scan, no handshake, no\ntimeout. The router falls through to BLE in microseconds.",[34,8050,6883],{"id":8051},"key-constants-reference",[155,8053,8054,8068],{},[158,8055,8056],{},[161,8057,8058,8061,8063,8065],{},[164,8059,8060],{},"Constant",[164,8062,3928],{},[164,8064,3432],{},[164,8066,8067],{},"Purpose",[174,8069,8070,8088,8104,8123,8137,8151,8166,8181,8196,8213,8229,8244,8262,8276,8289,8304,8318,8333,8348,8367,8381],{},[161,8071,8072,8077,8082,8085],{},[179,8073,8074],{},[344,8075,8076],{},"AwareSession.SERVICE_NAME",[179,8078,8079],{},[344,8080,8081],{},"\"plain-peer\"",[179,8083,8084],{},"Discovery",[179,8086,8087],{},"Service name published & subscribed by every PlainApp device",[161,8089,8090,8095,8098,8101],{},[179,8091,8092],{},[344,8093,8094],{},"AwareSession.PEER_HANDLE_MAX_AGE_MS",[179,8096,8097],{},"30 000",[179,8099,8100],{},"PeerHandle cache",[179,8102,8103],{},"Discard stale handles (peer's publish session may have been restarted)",[161,8105,8106,8111,8114,8117],{},[179,8107,8108],{},[344,8109,8110],{},"AwareSession.READY_TIMEOUT_MS",[179,8112,8113],{},"15 000",[179,8115,8116],{},"Handshake",[179,8118,8119,8120,8122],{},"Subscriber wait for publisher's ",[344,8121,7256],{}," receipt",[161,8124,8125,8130,8132,8134],{},[179,8126,8127],{},[344,8128,8129],{},"AwareSession.MSG_HELLO",[179,8131,3982],{},[179,8133,8116],{},[179,8135,8136],{},"Subscriber → Publisher message ID",[161,8138,8139,8144,8146,8148],{},[179,8140,8141],{},[344,8142,8143],{},"AwareSession.MSG_READY",[179,8145,3995],{},[179,8147,8116],{},[179,8149,8150],{},"Publisher → Subscriber message ID",[161,8152,8153,8158,8160,8163],{},[179,8154,8155],{},[344,8156,8157],{},"AwarePeerLink.MAX_BUILD_ATTEMPTS",[179,8159,3995],{},[179,8161,8162],{},"Handshake (client only)",[179,8164,8165],{},"Single attempt — was 3, now 1 because prewarmer primes both sides",[161,8167,8168,8173,8176,8178],{},[179,8169,8170],{},[344,8171,8172],{},"AwarePeerLink.ATTEMPT_TIMEOUT_MS",[179,8174,8175],{},"5 000",[179,8177,8116],{},[179,8179,8180],{},"Per-attempt timeout — was 10 s, halved to speed fallback",[161,8182,8183,8188,8191,8193],{},[179,8184,8185],{},[344,8186,8187],{},"AwarePeerLink.RETRY_DELAY_MS",[179,8189,8190],{},"500",[179,8192,8116],{},[179,8194,8195],{},"Delay between retry attempts (client only)",[161,8197,8198,8203,8205,8208],{},[179,8199,8200],{},[344,8201,8202],{},"AwarePeerLink.REQUEST_TIMEOUT_MS",[179,8204,8097],{},[179,8206,8207],{},"NDP",[179,8209,8210,8212],{},[344,8211,7189],{}," timeout",[161,8214,8215,8220,8223,8226],{},[179,8216,8217],{},[344,8218,8219],{},"AwareLinkPool.IDLE_TIMEOUT_MS",[179,8221,8222],{},"60 000",[179,8224,8225],{},"Pool sweep",[179,8227,8228],{},"Close idle links after 60 s of inactivity",[161,8230,8231,8236,8239,8241],{},[179,8232,8233],{},[344,8234,8235],{},"AwareLinkPool.IDLE_SWEEP_INTERVAL_MS",[179,8237,8238],{},"10 000",[179,8240,8225],{},[179,8242,8243],{},"Sweep interval",[161,8245,8246,8251,8256,8259],{},[179,8247,8248],{},[344,8249,8250],{},"AwareHttpClientFactory.AWARE_HOST",[179,8252,8253],{},[344,8254,8255],{},"\"plain-aware-peer\"",[179,8257,8258],{},"DNS",[179,8260,8261],{},"Sentinel hostname resolved to peer IPv6 by custom Dns",[161,8263,8264,8269,8271,8273],{},[179,8265,8266,8268],{},[344,8267,6165],{}," chat client",[179,8270],{},[179,8272],{},[179,8274,8275],{},"connectTimeout 5 s, requestTimeout 30 s, ChaCha20 interceptor",[161,8277,8278,8282,8284,8286],{},[179,8279,8280],{},[344,8281,7737],{},[179,8283],{},[179,8285],{},[179,8287,8288],{},"connectTimeout 10 s, readTimeout 120 s, requestTimeout 120 s, no crypto",[161,8290,8291,8296,8298,8301],{},[179,8292,8293],{},[344,8294,8295],{},"PeerTransportPrewarmer.PREWARM_TTL_MS",[179,8297,8097],{},[179,8299,8300],{},"Prewarm",[179,8302,8303],{},"Throttle per peer",[161,8305,8306,8311,8313,8315],{},[179,8307,8308],{},[344,8309,8310],{},"PeerTransportPrewarmer.BLE_SCAN_TIMEOUT_MS",[179,8312,8113],{},[179,8314,8300],{},[179,8316,8317],{},"BLE scan timeout for refreshAwareFlagFromScan",[161,8319,8320,8325,8327,8330],{},[179,8321,8322],{},[344,8323,8324],{},"PeerCircuitBreaker.WINDOW_MS",[179,8326,8097],{},[179,8328,8329],{},"Circuit breaker",[179,8331,8332],{},"Open duration after threshold",[161,8334,8335,8340,8343,8345],{},[179,8336,8337],{},[344,8338,8339],{},"PeerCircuitBreaker.MAX_FAILURES",[179,8341,8342],{},"2",[179,8344,8329],{},[179,8346,8347],{},"Failures within window to open",[161,8349,8350,8355,8358,8361],{},[179,8351,8352],{},[344,8353,8354],{},"TempData.httpsPort",[179,8356,8357],{},"8443 (default)",[179,8359,8360],{},"Server",[179,8362,8363,8364],{},"Publisher's port advertised via ",[344,8365,8366],{},"WifiAwareNetworkSpecifier.setPort",[161,8368,8369,8374,8376,8378],{},[179,8370,8371],{},[344,8372,8373],{},"BleServiceData.AWARE_SUPPORTED",[179,8375,4406],{},[179,8377,8013],{},[179,8379,8380],{},"Bit indicating peer supports Wi-Fi Aware",[161,8382,8383,8388,8390,8392],{},[179,8384,8385],{},[344,8386,8387],{},"BleServiceData.AWARE_RUNNING",[179,8389,4410],{},[179,8391,8013],{},[179,8393,8394],{},"Bit indicating peer's Aware service is currently running",[34,8396,6889],{"id":8397},"design-trade-offs-recap",[30,8399,8400],{},[913,8401],{"alt":8402,"src":8403},"Diagram 14","\u002Fblog\u002Fwifi-aware-transport\u002Fdiagram-14.svg",[34,8405,906],{"id":3570},[39,8407,8408,8420,8425],{},[42,8409,8410,8412,8413,8415,8416,8419],{},[45,8411,6781],{"href":6780}," — how ",[344,8414,6954],{},"\nfits into the ",[344,8417,8418],{},"LAN → Aware → BLE"," fallback chain and the broader chat\nsend\u002Freceive pipeline.",[42,8421,8422,8424],{},[45,8423,6786],{"href":6785}," — the last-resort transport that\ntakes over when Aware is unavailable; also the channel used by the\nprewarmer to trigger Aware startup on the remote peer.",[42,8426,8427,8429,8430,7092,8433,8436],{},[45,8428,6791],{"href":6790}," — how the shared ChaCha20 key reused\nas the Aware PMK is established, and how the BLE scan response flags\n(",[344,8431,8432],{},"AWARE_SUPPORTED",[344,8434,8435],{},"AWARE_RUNNING",") are populated.\n\n",{"title":248,"searchDepth":249,"depth":249,"links":8438},[8439,8440,8443,8446,8450,8453,8457,8461,8465,8471,8475,8478,8482,8488,8492,8493,8494],{"id":36,"depth":252,"text":37},{"id":6892,"depth":252,"text":6801,"children":8441},[8442],{"id":6921,"depth":249,"text":6922},{"id":6976,"depth":252,"text":6807,"children":8444},[8445],{"id":6999,"depth":249,"text":7000},{"id":7017,"depth":252,"text":6813,"children":8447},[8448,8449],{"id":7052,"depth":249,"text":7053},{"id":7080,"depth":249,"text":7081},{"id":7110,"depth":252,"text":6819,"children":8451},[8452],{"id":7136,"depth":249,"text":7137},{"id":7179,"depth":252,"text":6825,"children":8454},[8455,8456],{"id":7216,"depth":249,"text":7217},{"id":7285,"depth":249,"text":7286},{"id":7331,"depth":252,"text":6831,"children":8458},[8459],{"id":7344,"depth":249,"text":8460},"What the onUnavailable callback means",{"id":7384,"depth":252,"text":6837,"children":8462},[8463,8464],{"id":7404,"depth":249,"text":7405},{"id":7468,"depth":249,"text":7469},{"id":7488,"depth":252,"text":8466,"children":8467},"IPv6 Addressing & the plain-aware-peer DNS Trick",[8468,8469],{"id":7532,"depth":249,"text":7533},{"id":7554,"depth":249,"text":8470},"Why network.socketFactory?",{"id":7582,"depth":252,"text":6853,"children":8472},[8473,8474],{"id":7607,"depth":249,"text":7608},{"id":7618,"depth":249,"text":7619},{"id":7641,"depth":252,"text":6859,"children":8476},[8477],{"id":7653,"depth":249,"text":7654},{"id":7706,"depth":252,"text":6865,"children":8479},[8480,8481],{"id":7722,"depth":249,"text":7723},{"id":7778,"depth":249,"text":7779},{"id":7807,"depth":252,"text":6871,"children":8483},[8484,8485,8487],{"id":7854,"depth":249,"text":7855},{"id":7901,"depth":249,"text":8486},"Why optimistic setAwareRunning(true)?",{"id":7951,"depth":249,"text":7952},{"id":7961,"depth":252,"text":6877,"children":8489},[8490],{"id":7983,"depth":249,"text":8491},"The isAwareRunning flag is the linchpin",{"id":8051,"depth":252,"text":6883},{"id":8397,"depth":252,"text":6889},{"id":3570,"depth":252,"text":906},"2025-01-30","This article explains how PlainApp uses Wi-Fi Aware (NAN — Neighbor Awareness Networking) as the middle tier of its peer transport fallback chain, between LAN (same-subnet HTTPS) and BLE (last-resort GATT RPC). Wi-Fi Aware is what makes two PlainApp devices talk when they are on different SSIDs, guest vs IoT VLANs, or no Wi-Fi infrastructure at all — without ever needing an IP address from a DHCP server.",{},"\u002Fblog\u002Fwifi-aware-transport","19 min read",{"title":6761,"description":8496},"How can two phones talk on different Wi-Fi networks without an IP? PlainApp uses Wi-Fi Aware (NAN) for discovery, handshake, and data transfer — no DHCP needed.","Wi-Fi Aware (NAN): Device-to-Device Data Transfer Without IP","blog\u002Fwifi-aware-transport","4IN4yBV-dbXKL1zaeiiVIGcq8aA68cTQInGmtnzLkcs",{"id":8506,"title":8507,"body":8508,"category":3611,"date":9870,"description":9871,"extension":261,"meta":9872,"navigation":263,"path":6785,"readingTime":6736,"seo":9873,"seoDescription":9874,"seoTitle":9875,"stem":9876,"__hash__":9877},"blog_en\u002Fblog\u002Fble-transport.md","BLE Transport Design — Messages & File Downloads",{"type":27,"value":8509,"toc":9820},[8510,8518,8520,8607,8610,8625,8636,8646,8651,8654,8664,8669,8673,8679,8708,8712,8748,8751,8761,8766,8770,8777,8795,8805,8808,8811,8816,8820,8841,8845,8852,8857,8868,8873,8877,8913,8916,8919,8924,8928,8958,8960,8963,8968,8970,9024,9026,9043,9048,9052,9055,9058,9084,9091,9122,9125,9131,9135,9150,9157,9162,9166,9169,9205,9209,9212,9243,9256,9259,9262,9267,9271,9298,9301,9305,9324,9327,9332,9337,9348,9351,9354,9361,9366,9373,9376,9388,9393,9397,9420,9433,9435,9794,9796,9801,9803],[30,8511,8512,8513,8515,8516,125],{},"For the broader chat architecture that consumes this transport, see\n",[45,8514,6781],{"href":6780},". For how two devices obtain the\nshared ChaCha20 key used to encrypt every BLE payload, see\n",[45,8517,6791],{"href":6790},[34,8519,37],{"id":36},[39,8521,8522,8528,8534,8540,8546,8555,8561,8565,8569,8575,8581,8587,8593,8599,8603],{},[42,8523,8524],{},[45,8525,8527],{"href":8526},"#why-a-ble-transport-at-all","Why a BLE Transport at All?",[42,8529,8530],{},[45,8531,8533],{"href":8532},"#gatt-service-layout","GATT Service Layout",[42,8535,8536],{},[45,8537,8539],{"href":8538},"#peer-identification-shortid-not-mac","Peer Identification: shortId, not MAC",[42,8541,8542],{},[45,8543,8545],{"href":8544},"#two-layer-chunking-design","Two-Layer Chunking Design",[42,8547,8548],{},[45,8549,8551,8552],{"href":8550},"#the-rpc-primitive-bledeviceapirequestasync","The RPC Primitive: ",[344,8553,8554],{},"BleDeviceApi.requestAsync",[42,8556,8557],{},[45,8558,8560],{"href":8559},"#wire-envelope-format","Wire Envelope Format",[42,8562,8563],{},[45,8564,6859],{"href":6858},[42,8566,8567],{},[45,8568,6865],{"href":6864},[42,8570,8571],{},[45,8572,8574],{"href":8573},"#prioritization-how-chat-beats-files-in-practice","Prioritization: How Chat Beats Files in Practice",[42,8576,8577],{},[45,8578,8580],{"href":8579},"#concurrency-control--the-static-gatt-queue","Concurrency Control & The Static GATT Queue",[42,8582,8583],{},[45,8584,8586],{"href":8585},"#connection-lifecycle--mtu-negotiation","Connection Lifecycle & MTU Negotiation",[42,8588,8589],{},[45,8590,8592],{"href":8591},"#flow-control-for-notifications","Flow Control for Notifications",[42,8594,8595],{},[45,8596,8598],{"href":8597},"#error-handling-transportunavailable-vs-real-failure","Error Handling: TransportUnavailable vs Real Failure",[42,8600,8601],{},[45,8602,6883],{"href":6882},[42,8604,8605],{},[45,8606,6889],{"href":6888},[34,8608,8527],{"id":8609},"why-a-ble-transport-at-all",[30,8611,8612,8613,8616,8617,8620,8621,8624],{},"PlainApp is ",[85,8614,8615],{},"serverless and offline-first",". The transport layer is an ordered\nfallback chain: ",[344,8618,8619],{},"LAN → Wi-Fi Aware → BLE",". LAN is the happy path (HTTPS over\nWi-Fi, ~10 ms round trips). Wi-Fi Aware\ncovers cross-subnet peers (different SSIDs, guest vs IoT VLANs). Both require\n",[85,8622,8623],{},"IP connectivity"," of some kind. BLE is the only transport that works:",[39,8626,8627,8630,8633],{},[42,8628,8629],{},"When the devices are not on the same IP network at all.",[42,8631,8632],{},"When Wi-Fi is off or in airplane mode (BLE radio is separate).",[42,8634,8635],{},"When Wi-Fi Aware is unsupported (Android \u003C 13, all iOS variants of PlainApp).",[30,8637,8638,8639,8642,8643,8645],{},"BLE is slow — tens of KB\u002Fs, seconds of latency per request — but it is\n",[85,8640,8641],{},"guaranteed"," for any paired peer, because the only thing it needs is the\npeer's ",[344,8644,7067],{},", which is always broadcast in the BLE scan response.",[30,8647,8648],{},[913,8649],{"alt":915,"src":8650},"\u002Fblog\u002Fble-transport\u002Fdiagram-01.svg",[34,8652,8533],{"id":8653},"gatt-service-layout",[30,8655,8656,8657,8660,8661,4218],{},"PlainApp advertises a ",[85,8658,8659],{},"single custom GATT service"," with two characteristics.\nThere is no registered 16-bit UUID — the service uses a 128-bit UUID whose\ntrailing bytes ASCII-decode to ",[344,8662,8663],{},"plpai\\x01",[30,8665,8666],{},[913,8667],{"alt":1021,"src":8668},"\u002Fblog\u002Fble-transport\u002Fdiagram-02.svg",[1229,8670,8672],{"id":8671},"why-two-characteristics","Why two characteristics?",[30,8674,8675,8676,4218],{},"The two protocols have ",[85,8677,8678],{},"completely different trust models and payload\nshapes",[39,8680,8681,8690],{},[42,8682,8683,8686,8687,8689],{},[85,8684,8685],{},"NEARBY"," carries pairing messages. They arrive ",[85,8688,5453],{}," the peer is\npaired (no shared key yet), so they use their own Ed25519-signed JSON\npayloads with their own prefix routing. The body is a plain string.",[42,8691,8692,8695,8696,8699,8700,953,8702,953,8704,8707],{},[85,8693,8694],{},"HTTP"," carries all post-pairing traffic (chat, files, presence). It is\nalways ChaCha20-encrypted with the shared key and uses the same\n",[344,8697,8698],{},"HttpRouteRegistry"," as the LAN Ktor server, so the route handlers\n(",[344,8701,7890],{},[344,8703,7774],{},[344,8705,8706],{},"\u002Fpeer_status",") are written once and reused for\nboth transports.",[1229,8709,8711],{"id":8710},"why-notifications-instead-of-reads","Why notifications instead of reads?",[30,8713,8714,8715,8718,8719,8726,8727,8730,8731,8734,8735,8738,8739,1498,8741,8744,8745,125],{},"The BLE ATT protocol limits a single attribute read to ",[85,8716,8717],{},"512 bytes",". A\nGraphQL response or a 16 KB file chunk can be far larger. PlainApp works around\nthis by ",[85,8720,8721,8722,8725],{},"never using ",[344,8723,8724],{},"readCharacteristic"," for real data"," — the server's\n",[344,8728,8729],{},"onCharacteristicReadRequest"," returns an empty payload with ",[344,8732,8733],{},"GATT_SUCCESS",".\nInstead, the client writes its request to the characteristic, and the server\nresponds by sending a sequence of ",[85,8736,8737],{},"chunked notifications"," that the client\nreassembles. This is documented in ",[344,8740,8554],{},[344,8742,8743],{},"BleServerProtocol.handleWrite",", and ",[344,8746,8747],{},"AndroidBleGattServer.sendChunkedResponse",[34,8749,8539],{"id":8750},"peer-identification-shortid-not-mac",[30,8752,8753,8754,8757,8758,8760],{},"BLE advertising packets are tiny (31 bytes) and the BLE MAC address is\n",[85,8755,8756],{},"randomized by Android every ~15 minutes"," — so it cannot be used as a\nstable identifier. PlainApp instead broadcasts a 9-byte ",[344,8759,7869],{}," payload\nin the scan response:",[30,8762,8763],{},[913,8764],{"alt":1462,"src":8765},"\u002Fblog\u002Fble-transport\u002Fdiagram-03.svg",[1229,8767,8769],{"id":8768},"why-a-truncated-hash-instead-of-the-full-clientid","Why a truncated hash instead of the full clientId?",[30,8771,8772,8773,8776],{},"A 13-character clientId would fit in 13 bytes, but PlainApp opts for an\n8-byte ",[85,8774,8775],{},"truncated SHA-256"," for two reasons:",[80,8778,8779,8785],{},[42,8780,8781,8784],{},[85,8782,8783],{},"Stable byte budget."," 9 bytes total fits comfortably in the 31-byte\nadvertising payload alongside the service UUID (16 bytes), length, and\ntype fields (~27 bytes used, 4 bytes headroom).",[42,8786,8787,8790,8791,8794],{},[85,8788,8789],{},"Privacy."," A passive observer scanning BLE cannot recover the clientId\nfrom the shortId (the 8-byte prefix of a SHA-256 hash is irreversible\nin practice). They can only ",[133,8792,8793],{},"recognize"," a peer they've already seen\nadvertise the same shortId — they cannot enumerate PlainApp users.",[30,8796,8797,8798,8800,8801,8804],{},"The full ",[344,8799,7067],{}," is only revealed to a peer that has actually connected\nover GATT and exchanged a ",[344,8802,8803],{},"DDiscoverReply"," — i.e. a peer that the user has\nalready chosen to interact with.",[34,8806,8545],{"id":8807},"two-layer-chunking-design",[30,8809,8810],{},"This is the most subtle part of the BLE transport, and it is essential to\nunderstand both layers because they have completely different sizes and\npurposes:",[30,8812,8813],{},[913,8814],{"alt":2147,"src":8815},"\u002Fblog\u002Fble-transport\u002Fdiagram-04.svg",[1229,8817,8819],{"id":8818},"why-380-characters","Why 380 characters?",[30,8821,8822,8823,2329,8826,8829,8830,2329,8833,8836,8837,8840],{},"The negotiated ATT MTU is ",[85,8824,8825],{},"517 bytes on Android",[344,8827,8828],{},"requestMtu(517)"," — the\nmaximum allowed by the BLE specification) and ~185+ on iOS (auto-negotiated by\nCoreBluetooth). Subtracting the ATT header (~3 bytes) and the JSON wrapper\noverhead of ",[344,8831,8832],{},"BleSegmentData",[344,8834,8835],{},"{\"d\":\"...\",\"s\":N}"," adds ~12 bytes), 380 chars\nof payload fits comfortably within a single ATT MTU on both platforms. The\nvalue is ",[85,8838,8839],{},"symmetric"," (both client request fragments and server notification\nfragments use 380), which keeps the code simple.",[1229,8842,8844],{"id":8843},"why-16-kib-for-file-chunks","Why 16 KiB for file chunks?",[30,8846,8847,8848,8851],{},"A 16 KiB file chunk base64-encodes to ~22 KiB of JSON, which fragments into\n~58 GATT notification segments. Each ",[344,8849,8850],{},"requestAsync"," round-trip takes seconds\nover BLE, so fewer-but-larger chunks reduce per-chunk overhead. Going much\nlarger would risk hitting BLE RPC timeouts and produce poor progress feedback\n(the user sees progress update only once per chunk). 16 KiB is the empirically\ntuned sweet spot — large enough for throughput, small enough for responsive\nprogress UI.",[34,8853,8551,8855],{"id":8854},"the-rpc-primitive-bledeviceapirequestasync",[344,8856,8554],{},[30,8858,8859,8860,8863,8864,8867],{},"Every BLE chat message and every file chunk is one call to\n",[344,8861,8862],{},"BleDeviceApi.requestAsync(service, requestData)"," — a suspend function that\nreturns a ",[344,8865,8866],{},"BleResult",". It is synchronous from the caller's perspective:\none request → one fully reassembled response, no pipelining.",[30,8869,8870],{},[913,8871],{"alt":3084,"src":8872},"\u002Fblog\u002Fble-transport\u002Fdiagram-05.svg",[1229,8874,8876],{"id":8875},"key-invariants","Key invariants",[80,8878,8879,8887,8896],{},[42,8880,8881,2828,8884,8886],{},[85,8882,8883],{},"One request → one response.",[344,8885,8850],{}," is synchronous from the\ncaller's perspective — it returns only after the full response has been\nreassembled. There is no pipelining.",[42,8888,8889,8892,8893,8895],{},[85,8890,8891],{},"Notifications enabled per-call."," The client writes the CCCD at the\nstart of every ",[344,8894,8850],{}," and disables it at the end. This is\nwasteful (two extra GATT writes per call) but keeps the protocol\nstateless — the server doesn't have to track which clients are\n\"listening\".",[42,8897,8898,8901,8902,8905,8906,8909,8910,8912],{},[85,8899,8900],{},"No retry within an RPC."," If any single ",[344,8903,8904],{},"writeCharacteristic"," times out\n(5 s), the entire RPC aborts. Only ",[344,8907,8908],{},"ensureConnected"," retries (3 attempts\non connect failure). Coarse transport-level backoff is provided by\n",[344,8911,7947],{},", not by the RPC layer.",[34,8914,8560],{"id":8915},"wire-envelope-format",[30,8917,8918],{},"The payload inside Layer A segments is a nested JSON envelope. Stripping the\nfragmentation, the logical structure is:",[30,8920,8921],{},[913,8922],{"alt":3256,"src":8923},"\u002Fblog\u002Fble-transport\u002Fdiagram-06.svg",[1229,8925,8927],{"id":8926},"response-shape","Response shape",[30,8929,8930,8931,8934,8935,8938,8939,8942,8943,8946,8947,8950,8951,8954,8955,8957],{},"The response flows in the opposite direction through the same Layer A\nfragmentation, but the inner JSON is a ",[344,8932,8933],{},"BleHttpResponse"," with three fields:\n",[344,8936,8937],{},"s"," (HTTP status code), ",[344,8940,8941],{},"h"," (response headers map), and ",[344,8944,8945],{},"b"," (body). The body\nis ",[85,8948,8949],{},"always base64-encoded"," by ",[344,8952,8953],{},"BleHttpCall.encodeResponse()",", even when\nempty — the response might be binary (encrypted GraphQL bytes, raw ",[344,8956,7774],{},"\nfile bytes) and the BLE transport is string-only, so the same JSON envelope\ncarries both text and binary payloads.",[34,8959,6859],{"id":7641},[30,8961,8962],{},"Putting it all together — what happens when a chat message is sent over BLE:",[30,8964,8965],{},[913,8966],{"alt":7400,"src":8967},"\u002Fblog\u002Fble-transport\u002Fdiagram-07.svg",[1229,8969,7654],{"id":7653},[39,8971,8972,8991,9011],{},[42,8973,8974,8977,8978,8980,8981,1927,8984,8987,8988,8990],{},[85,8975,8976],{},"Same key as LAN."," The ChaCha20 shared key from pairing is reused for\nBLE — there's no separate BLE key. The OkHttp crypto interceptor used by\n",[344,8979,7594],{}," and the manual ",[344,8982,8983],{},"chaCha20Encrypt",[344,8985,8986],{},"chaCha20Decrypt"," in\n",[344,8989,7597],{}," are the same primitive, just invoked differently.",[42,8992,8993,2828,8996,8999,9000,9003,9004,953,9006,953,9008,9010],{},[85,8994,8995],{},"Same route handlers as LAN.",[344,8997,8998],{},"BleHttpRequest"," is dispatched through\n",[344,9001,9002],{},"HttpRouteRegistry.matchRoute(path)",", which is the same registry the Ktor\nLAN server uses. So ",[344,9005,7890],{},[344,9007,7774],{},[344,9009,8706],{}," etc. are\nimplemented exactly once and work identically over both transports.",[42,9012,9013,9016,9017,9020,9021,125],{},[85,9014,9015],{},"No connection reuse."," The ",[344,9018,9019],{},"finally { scanner.teardownConnection(client) }","\nblock always runs. Each message pays the full connect→discoverServices→MTU\ncost (~seconds). This is a deliberate trade-off — see\n",[45,9022,9023],{"href":6888},"Design Trade-offs",[34,9025,6865],{"id":7706},[30,9027,9028,9029,9032,9033,2828,9036,9038,9039,9042],{},"Downloads over BLE are ",[85,9030,9031],{},"streaming"," — the file is read in 16 KiB chunks and\nwritten to a temp file as it arrives, so a 10 MB file doesn't need 10 MB of\nRAM. The trick is that each chunk's RPC is a ",[133,9034,9035],{},"separate",[344,9037,8850],{}," call,\nand the chunks are pushed into a ",[344,9040,9041],{},"ByteChannel"," that the consumer reads\nconcurrently.",[30,9044,9045],{},[913,9046],{"alt":7528,"src":9047},"\u002Fblog\u002Fble-transport\u002Fdiagram-08.svg",[1229,9049,9051],{"id":9050},"why-streaming-instead-of-one-big-rpc","Why streaming instead of one big RPC?",[30,9053,9054],{},"A 10 MB file sent as a single RPC would mean ~280 000 notification segments,\nall held in memory on both sides before the response could even start — and\nthe entire transfer would have to succeed before any progress is reported.\nWorse, a single dropped notification in the middle would corrupt the whole\nthing.",[30,9056,9057],{},"The chunked design has three wins:",[80,9059,9060,9066,9075],{},[42,9061,9062,9065],{},[85,9063,9064],{},"Constant memory."," Only one 16 KiB chunk is in flight at a time.",[42,9067,9068,2828,9071,9074],{},[85,9069,9070],{},"Live progress.",[344,9072,9073],{},"DownloadQueue.notifyProgressUpdate()"," fires every\nsecond, and the UI shows a download bar.",[42,9076,9077,9080,9081,9083],{},[85,9078,9079],{},"Resilience."," A failed chunk can be retried independently (the\n",[344,9082,7799],{}," supports pause\u002Fresume\u002Fretry at the task level; a\nmid-stream failure leaves the partial temp file, though currently the\ndownloader deletes it on failure — see trade-offs).",[1229,9085,7555,9087,9090],{"id":9086},"why-onclose-cancels-the-download-job",[344,9088,9089],{},"onClose"," cancels the download job",[30,9092,4831,9093,9096,9097,9100,9101,9103,9104,9107,9108,9110,9111,9114,9115,9117,9118,9121],{},[344,9094,9095],{},"DownloadedResponse.onClose"," callback calls ",[344,9098,9099],{},"downloadJob.cancel()",". This\nis essential because the download loop runs in a child coroutine that would\notherwise keep running forever if the consumer abandoned the channel early\n(e.g. user tapped ",[85,9102,1640],{},"). The ",[344,9105,9106],{},"AutoCloseable"," contract on\n",[344,9109,7793],{}," means the consumer's ",[344,9112,9113],{},"use { ... }"," block automatically\ninvokes ",[344,9116,9089],{}," on exit, cancelling the BLE download coroutine and tearing\ndown the GATT connection in the coroutine's ",[344,9119,9120],{},"finally"," block.",[34,9123,8574],{"id":9124},"prioritization-how-chat-beats-files-in-practice",[30,9126,9127,9128],{},"This is the most important question for any chat application: ",[85,9129,9130],{},"when a slow\nBLE file download is in progress, can a new chat message jump ahead of it?",[1229,9132,9134],{"id":9133},"the-honest-answer-there-is-no-explicit-priority-scheme","The honest answer: there is no explicit priority scheme",[30,9136,9137,9138,9141,9142,9145,9146,9149],{},"There is ",[85,9139,9140],{},"no priority field, no priority queue, no preemption"," anywhere in\nthe BLE code or the download queue. I verified this by exhaustive grep — the\nonly ",[344,9143,9144],{},"priority"," matches in ",[344,9147,9148],{},"shared\u002Fsrc"," are log-priority levels and EXIF\nmetadata, nothing related to message-vs-download ordering.",[30,9151,9152,9153,9156],{},"What exists instead is a set of ",[85,9154,9155],{},"architectural separations"," that produce\nthe desired behavior as an emergent property:",[30,9158,9159],{},[913,9160],{"alt":7603,"src":9161},"\u002Fblog\u002Fble-transport\u002Fdiagram-09.svg",[1229,9163,9165],{"id":9164},"why-it-works-in-practice","Why it works in practice",[30,9167,9168],{},"The separation that makes chat \"feel prioritized\" is structural:",[80,9170,9171,9187,9196],{},[42,9172,9173,9178,9179,3467,9182,3467,9184,9186],{},[85,9174,9175,9176,125],{},"Chat sends don't go through ",[344,9177,7799],{}," They're issued directly\nby ",[344,9180,9181],{},"PeerGraphQLClient",[344,9183,6982],{},[344,9185,7882],{},". So\na chat message never sits behind a queue of file downloads.",[42,9188,9189,9195],{},[85,9190,9191,9192,9194],{},"Each ",[344,9193,7597],{}," call opens its own GATT connection."," A\nlong-running download holding one connection does not prevent a chat\nsend from opening a second connection to the same peer. Android\nsupports multiple simultaneous GATT connections.",[42,9197,9198,9201,9202,9204],{},[85,9199,9200],{},"Chat RPCs are short."," A single chat message is one ",[344,9203,8850],{},"\nround trip (~1 s after connect). Even if the radio is busy with a\ndownload, the chat send completes within a few seconds.",[1229,9206,9208],{"id":9207},"where-the-design-falls-short","Where the design falls short",[30,9210,9211],{},"The trade-offs of \"no explicit priority\":",[39,9213,9214,9220,9237],{},[42,9215,9216,9219],{},[85,9217,9218],{},"Connect latency."," Both chat and download pay the connect→discover→MTU\ncost (~seconds) every time, because connections aren't reused. A chat\nmessage arriving during a download can't piggyback on the download's\nexisting connection — it opens a new one.",[42,9221,9222,9225,9226,8987,9229,9232,9233,9236],{},[85,9223,9224],{},"Static queue on Android."," The process-wide ",[344,9227,9228],{},"operationQueue",[344,9230,9231],{},"AndroidBleGattClient"," serializes GATT ops across all peers and all\nconnections. So while two GATT connections can coexist, their\nwrite\u002Fread\u002Fnotify ",[133,9234,9235],{},"operations"," are interleaved at the queue level. In\npractice this is fine (each op is ~ms) but it's a subtle global\nbottleneck under high concurrency.",[42,9238,9239,9242],{},[85,9240,9241],{},"No preemption."," A download in progress cannot be paused to let a\nchat message through. The chat send simply runs concurrently and\ncompetes for radio time.",[30,9244,9245,9246,9249,9250,9252,9253,9255],{},"A future improvement could be a per-peer ",[344,9247,9248],{},"Mutex"," around ",[344,9251,7882],{},"\nand ",[344,9254,6990],{},", plus a priority field on the queue — but the current\ndesign relies on the fact that chat RPCs are short enough that contention\nis rarely user-visible.",[34,9257,8580],{"id":9258},"concurrency-control-the-static-gatt-queue",[30,9260,9261],{},"This deserves its own section because it's the most subtle aspect of the\nAndroid BLE implementation.",[30,9263,9264],{},[913,9265],{"alt":7649,"src":9266},"\u002Fblog\u002Fble-transport\u002Fdiagram-10.svg",[1229,9268,9270],{"id":9269},"why-static-process-wide","Why static (process-wide)?",[30,9272,9273,9274,9281,9282,9284,9285,9287,9288,9290,9291,9293,9294,9297],{},"The Android BLE stack ",[85,9275,9276,9277,9280],{},"does not allow concurrent GATT operations on a single\n",[344,9278,9279],{},"BluetoothGatt"," instance"," — calling ",[344,9283,8904],{}," while another\nwrite is in flight returns ",[344,9286,4927],{}," and silently drops the second write. The\nstandard workaround is a per-",[344,9289,9279],{}," queue. PlainApp goes one step\nfurther and uses a ",[85,9292,7023],{}," queue (in the ",[344,9295,9296],{},"companion object","), which\nis overly conservative but correct: it guarantees no two GATT operations\nanywhere in the app run simultaneously.",[30,9299,9300],{},"The cost is that a long BLE file download's write\u002Fread\u002Fnotify operations\nqueue behind (and are queued behind) any other peer's GATT operations.\nSince each individual op is ~ms, this is rarely a user-visible bottleneck —\nbut under heavy concurrent BLE traffic to multiple peers, it could become\none.",[1229,9302,9304],{"id":9303},"no-per-peer-lock-at-the-transport-layer","No per-peer lock at the transport layer",[30,9306,9307,9309,9310,9313,9314,9317,9318,9320,9321,9323],{},[344,9308,8554],{}," is a plain ",[344,9311,9312],{},"suspend fun"," with ",[85,9315,9316],{},"no mutex, no\nqueue, no per-peer serialization",". Two concurrent calls to\n",[344,9319,7882],{}," for the same peer will each open their own GATT\nconnection and proceed independently. The serialization happens implicitly\nat the GATT operation level (via the static queue on Android, or via\nsequential ",[344,9322,4821],{}," on iOS).",[34,9325,8586],{"id":9326},"connection-lifecycle-mtu-negotiation",[30,9328,9329],{},[913,9330],{"alt":7718,"src":9331},"\u002Fblog\u002Fble-transport\u002Fdiagram-11.svg",[1229,9333,7555,9335,7559],{"id":9334},"why-requestmtu517",[344,9336,8828],{},[30,9338,9339,9340,9343,9344,9347],{},"The default ATT MTU is ",[85,9341,9342],{},"23 bytes"," (only ",[85,9345,9346],{},"20 bytes"," of payload after the\n3-byte ATT header). With the default MTU, every 380-char segment would require\n~19 GATT writes instead of 1 — a 19× slowdown. Requesting the maximum MTU\nallowed by the BLE spec (517 bytes) lets the 380-char segments fit in a\nsingle ATT operation, dramatically improving throughput.",[30,9349,9350],{},"iOS doesn't expose an explicit MTU request API — CoreBluetooth negotiates it\nautomatically with the peripheral during connection. Modern iOS devices\ntypically negotiate ~185 bytes, which still comfortably fits the 380-char\nsegments (after subtracting ATT header + JSON wrapper overhead).",[34,9352,8592],{"id":9353},"flow-control-for-notifications",[30,9355,9356,9357,9360],{},"The server sends response fragments as notifications, but BLE notifications\nhave ",[85,9358,9359],{},"no built-in flow control"," — if the server sends notifications faster\nthan the controller can transmit them, they are silently dropped. PlainApp\nimplements explicit ack-based flow control:",[30,9362,9363],{},[913,9364],{"alt":7850,"src":9365},"\u002Fblog\u002Fble-transport\u002Fdiagram-12.svg",[30,9367,9368,9369,9372],{},"Without this flow control, back-to-back notifications would be silently\ndropped by the BLE controller when its internal send queue fills up — a\nwell-known Android BLE issue documented in the ",[344,9370,9371],{},"BleGattServer"," interface\ncomments. The per-device single-in-flight rule guarantees that every\nnotification is either transmitted or triggers a timeout (which is then\ntreated as a transport failure).",[34,9374,8598],{"id":9375},"error-handling-transportunavailable-vs-real-failure",[30,9377,9378,9380,9381,9383,9384,9387],{},[344,9379,7692],{}," is the signal that tells ",[344,9382,6982],{}," to\n",[85,9385,9386],{},"fall through to the next transport",". Anything else is a real failure\nreturned to the caller.",[30,9389,9390],{},[913,9391],{"alt":7979,"src":9392},"\u002Fblog\u002Fble-transport\u002Fdiagram-13.svg",[1229,9394,9396],{"id":9395},"the-download-failure-subtlety","The download failure subtlety",[30,9398,9399,9402,9403,9406,9408,9409,9412,9413,9416,9417,125],{},[344,9400,9401],{},"BleTransport.downloadFile"," returns ",[344,9404,9405],{},"DownloadedResponse(200, channel, onClose)",[85,9407,8047],{}," — the chunked download loop runs in a background coroutine\nthat writes to the channel. If a chunk RPC fails mid-stream, the loop calls\n",[344,9410,9411],{},"channel.close(TransportUnavailable(...))",", which means the consumer\n(",[344,9414,9415],{},"PeerFileDownloader.downloadAsync",") sees the error as a thrown exception\nfrom ",[344,9418,9419],{},"channel.readAvailable(buf)",[30,9421,9422,9423,9426,9427,9429,9430,9432],{},"This means the ",[344,9424,9425],{},"PeerTransportRouter.downloadFile"," call itself succeeded\n(returned a ",[344,9428,7793],{},"), so the circuit breaker ",[85,9431,7411],{}," record\na failure for mid-stream download errors. Only connect-time and scan-time\nfailures are caught by the router. This is a deliberate design choice — a\nmid-stream failure shouldn't permanently disable BLE for that peer (the\npeer might just have gone out of range temporarily).",[34,9434,6883],{"id":8051},[155,9436,9437,9449],{},[158,9438,9439],{},[161,9440,9441,9443,9445,9447],{},[164,9442,8060],{},[164,9444,3928],{},[164,9446,3432],{},[164,9448,8067],{},[174,9450,9451,9471,9489,9507,9524,9542,9559,9575,9592,9610,9625,9640,9657,9675,9694,9707,9719,9734,9750,9765,9780],{},[161,9452,9453,9458,9461,9464],{},[179,9454,9455],{},[344,9456,9457],{},"BleDeviceApi.CHUNK_SIZE",[179,9459,9460],{},"380",[179,9462,9463],{},"GATT segment fragmentation",[179,9465,9466,9467,9470],{},"Size of each ",[344,9468,9469],{},"BleSegmentData.data"," (fits within ATT MTU after JSON overhead)",[161,9472,9473,9478,9481,9484],{},[179,9474,9475],{},[344,9476,9477],{},"BleTransport.CHUNK_SIZE",[179,9479,9480],{},"16 384 (16 KiB)",[179,9482,9483],{},"File-download byte-range",[179,9485,9466,9486,9488],{},[344,9487,7774],{}," chunk request",[161,9490,9491,9496,9498,9501],{},[179,9492,9493],{},[344,9494,9495],{},"BleTransport.SCAN_TIMEOUT_MS",[179,9497,8238],{},[179,9499,9500],{},"BLE scan",[179,9502,9503,9504],{},"Timeout for ",[344,9505,9506],{},"scanner.findOne",[161,9508,9509,9514,9516,9519],{},[179,9510,9511],{},[344,9512,9513],{},"BleDeviceApi.NOTIFY_TIMEOUT_MS",[179,9515,8113],{},[179,9517,9518],{},"RPC response",[179,9520,9521,9522],{},"Per-notification wait in ",[344,9523,8850],{},[161,9525,9526,9531,9534,9537],{},[179,9527,9528,9530],{},[344,9529,9231],{}," MTU",[179,9532,9533],{},"517",[179,9535,9536],{},"Connection setup",[179,9538,9539,9541],{},[344,9540,8828],{}," — max allowed by BLE spec",[161,9543,9544,9549,9551,9553],{},[179,9545,9546,9548],{},[344,9547,9231],{}," connect timeout",[179,9550,8238],{},[179,9552,9536],{},[179,9554,9555,9556],{},"Wait for ",[344,9557,9558],{},"STATE_CONNECTED",[161,9560,9561,9566,9568,9570],{},[179,9562,9563,9565],{},[344,9564,9231],{}," MTU timeout",[179,9567,8175],{},[179,9569,9536],{},[179,9571,9555,9572],{},[344,9573,9574],{},"onMtuChanged",[161,9576,9577,9582,9584,9587],{},[179,9578,9579,9581],{},[344,9580,9231],{}," write timeout",[179,9583,8175],{},[179,9585,9586],{},"GATT write",[179,9588,9555,9589],{},[344,9590,9591],{},"onCharacteristicWrite",[161,9593,9594,9599,9601,9604],{},[179,9595,9596,9598],{},[344,9597,9231],{}," read timeout",[179,9600,8238],{},[179,9602,9603],{},"GATT read",[179,9605,9555,9606,9609],{},[344,9607,9608],{},"onCharacteristicRead"," (unused for real data)",[161,9611,9612,9617,9619,9622],{},[179,9613,9614,9616],{},[344,9615,9231],{}," notify-state timeout",[179,9618,8175],{},[179,9620,9621],{},"CCCD write",[179,9623,9624],{},"Wait for CCCD descriptor write",[161,9626,9627,9632,9635,9637],{},[179,9628,9629,9631],{},[344,9630,8908],{}," retries",[179,9633,9634],{},"3",[179,9636,9536],{},[179,9638,9639],{},"Up to 4 total attempts (0..3)",[161,9641,9642,9647,9649,9652],{},[179,9643,9644],{},[344,9645,9646],{},"AndroidBleGattServer.NOTIFY_ACK_TIMEOUT_MS",[179,9648,8238],{},[179,9650,9651],{},"Notification flow control",[179,9653,9555,9654],{},[344,9655,9656],{},"onNotificationSent",[161,9658,9659,9665,9667,9670],{},[179,9660,9661,9664],{},[344,9662,9663],{},"AndroidBleGattServer"," notifyChunkSize",[179,9666,9460],{},[179,9668,9669],{},"Response fragmentation",[179,9671,9672,9673],{},"Same as ",[344,9674,9457],{},[161,9676,9677,9683,9685,9687],{},[179,9678,9679,9682],{},[344,9680,9681],{},"IosBleGattServer"," retry cap",[179,9684,3956],{},[179,9686,9651],{},[179,9688,9689,9690,9693],{},"Max ",[344,9691,9692],{},"updateValue"," retries before giving up",[161,9695,9696,9700,9702,9705],{},[179,9697,9698],{},[344,9699,8324],{},[179,9701,8097],{},[179,9703,9704],{},"Transport circuit breaker",[179,9706,8332],{},[161,9708,9709,9713,9715,9717],{},[179,9710,9711],{},[344,9712,8339],{},[179,9714,8342],{},[179,9716,9704],{},[179,9718,8347],{},[161,9720,9721,9726,9728,9731],{},[179,9722,9723],{},[344,9724,9725],{},"DownloadQueue.MAX_CONCURRENT",[179,9727,9634],{},[179,9729,9730],{},"Download worker pool",[179,9732,9733],{},"Concurrent download coroutines",[161,9735,9736,9741,9744,9747],{},[179,9737,9738],{},[344,9739,9740],{},"BleServiceData.SHORT_ID_BYTES",[179,9742,9743],{},"8",[179,9745,9746],{},"Peer identification",[179,9748,9749],{},"Truncated SHA256 prefix bytes",[161,9751,9752,9757,9760,9762],{},[179,9753,9754],{},[344,9755,9756],{},"BleServiceData.PAYLOAD_BYTES",[179,9758,9759],{},"9",[179,9761,9746],{},[179,9763,9764],{},"1 flags byte + 8 shortId bytes",[161,9766,9767,9772,9774,9777],{},[179,9768,9769],{},[344,9770,9771],{},"BleSegmentData.STATE_START_BIT",[179,9773,3995],{},[179,9775,9776],{},"Layer A EOF signaling",[179,9778,9779],{},"First segment of a multi-segment message",[161,9781,9782,9787,9789,9791],{},[179,9783,9784],{},[344,9785,9786],{},"BleSegmentData.STATE_END_BIT",[179,9788,8342],{},[179,9790,9776],{},[179,9792,9793],{},"Last segment (or single segment)",[34,9795,6889],{"id":8397},[30,9797,9798],{},[913,9799],{"alt":8402,"src":9800},"\u002Fblog\u002Fble-transport\u002Fdiagram-14.svg",[34,9802,906],{"id":3570},[39,9804,9805,9815],{},[42,9806,9807,8412,9809,9811,9812,9814],{},[45,9808,6781],{"href":6780},[344,9810,7597],{}," fits into\nthe ",[344,9813,8418],{}," fallback chain and the broader chat send\u002Freceive\npipeline.",[42,9816,9817,9819],{},[45,9818,6791],{"href":6790}," — how the shared ChaCha20 key used by\nevery BLE payload is established, and how the NEARBY characteristic is\nused for the pairing handshake.",{"title":248,"searchDepth":249,"depth":249,"links":9821},[9822,9823,9824,9828,9831,9835,9839,9842,9845,9850,9855,9859,9863,9864,9867,9868,9869],{"id":36,"depth":252,"text":37},{"id":8609,"depth":252,"text":8527},{"id":8653,"depth":252,"text":8533,"children":9825},[9826,9827],{"id":8671,"depth":249,"text":8672},{"id":8710,"depth":249,"text":8711},{"id":8750,"depth":252,"text":8539,"children":9829},[9830],{"id":8768,"depth":249,"text":8769},{"id":8807,"depth":252,"text":8545,"children":9832},[9833,9834],{"id":8818,"depth":249,"text":8819},{"id":8843,"depth":249,"text":8844},{"id":8854,"depth":252,"text":9836,"children":9837},"The RPC Primitive: BleDeviceApi.requestAsync",[9838],{"id":8875,"depth":249,"text":8876},{"id":8915,"depth":252,"text":8560,"children":9840},[9841],{"id":8926,"depth":249,"text":8927},{"id":7641,"depth":252,"text":6859,"children":9843},[9844],{"id":7653,"depth":249,"text":7654},{"id":7706,"depth":252,"text":6865,"children":9846},[9847,9848],{"id":9050,"depth":249,"text":9051},{"id":9086,"depth":249,"text":9849},"Why onClose cancels the download job",{"id":9124,"depth":252,"text":8574,"children":9851},[9852,9853,9854],{"id":9133,"depth":249,"text":9134},{"id":9164,"depth":249,"text":9165},{"id":9207,"depth":249,"text":9208},{"id":9258,"depth":252,"text":8580,"children":9856},[9857,9858],{"id":9269,"depth":249,"text":9270},{"id":9303,"depth":249,"text":9304},{"id":9326,"depth":252,"text":8586,"children":9860},[9861],{"id":9334,"depth":249,"text":9862},"Why requestMtu(517)?",{"id":9353,"depth":252,"text":8592},{"id":9375,"depth":252,"text":8598,"children":9865},[9866],{"id":9395,"depth":249,"text":9396},{"id":8051,"depth":252,"text":6883},{"id":8397,"depth":252,"text":6889},{"id":3570,"depth":252,"text":906},"2025-01-25","This article explains how PlainApp pushes chat messages and downloads files over Bluetooth Low Energy when neither LAN nor Wi-Fi Aware is available. BLE is the guaranteed fallback: slow, but it works without any IP connectivity at all. The article covers the wire format, the two-layer chunking design, how concurrent traffic is (and isn't) prioritized, and why every connection is torn down after each request.",{},{"title":8507,"description":9871},"Need to send files and chat over Bluetooth Low Energy? See how PlainApp's BLE transport handles wire format, chunking, and connection lifecycle.","How to Send Files & Messages Over Bluetooth Low Energy (BLE)","blog\u002Fble-transport","c4x-_S6J3UbNNVXfXpejn_YF4Ki9Hdb6HpYTWkVbCXM",{"id":9879,"title":9880,"body":9881,"category":6732,"date":11472,"description":11473,"extension":261,"meta":11474,"navigation":263,"path":6780,"readingTime":6753,"seo":11475,"seoDescription":11476,"seoTitle":11477,"stem":11478,"__hash__":11479},"blog_en\u002Fblog\u002Fchat-architecture.md","Peer & Channel Chat Architecture",{"type":27,"value":9882,"toc":11428},[9883,9885,9961,9963,9976,9979,10024,10031,10033,10038,10044,10078,10081,10085,10126,10130,10133,10194,10199,10202,10255,10258,10264,10297,10311,10315,10336,10351,10379,10383,10400,10416,10419,10426,10431,10434,10512,10530,10533,10541,10546,10550,10564,10567,10577,10585,10612,10616,10621,10625,10628,10635,10639,10662,10666,10700,10703,10716,10825,10829,10861,10882,10887,10891,10913,10916,10921,10924,10932,10966,10971,10975,10998,11001,11005,11011,11032,11046,11049,11062,11067,11080,11083,11090,11095,11099,11134,11137,11147,11152,11156,11171,11173,11410,11412],[34,9884,37],{"id":36},[39,9886,9887,9891,9897,9903,9909,9915,9921,9927,9933,9939,9945,9951,9957],{},[42,9888,9889],{},[45,9890,824],{"href":823},[42,9892,9893],{},[45,9894,9896],{"href":9895},"#data-model","Data Model",[42,9898,9899],{},[45,9900,9902],{"href":9901},"#graphql-api-surface","GraphQL API Surface",[42,9904,9905],{},[45,9906,9908],{"href":9907},"#peer-chat-sending-a-message","Peer Chat: Sending a Message",[42,9910,9911],{},[45,9912,9914],{"href":9913},"#peer-chat-receiving-a-message","Peer Chat: Receiving a Message",[42,9916,9917],{},[45,9918,9920],{"href":9919},"#channel-chat-leader-election--fan-out","Channel Chat: Leader Election & Fan-Out",[42,9922,9923],{},[45,9924,9926],{"href":9925},"#channel-system-messages","Channel System Messages",[42,9928,9929],{},[45,9930,9932],{"href":9931},"#channel-lifecycle","Channel Lifecycle",[42,9934,9935],{},[45,9936,9938],{"href":9937},"#peer-transport-layer-lan--wi-fi-aware--ble","Peer Transport Layer (LAN → Wi-Fi Aware → BLE)",[42,9940,9941],{},[45,9942,9944],{"href":9943},"#peer-status--presence","Peer Status & Presence",[42,9946,9947],{},[45,9948,9950],{"href":9949},"#caching-layer","Caching Layer",[42,9952,9953],{},[45,9954,9956],{"href":9955},"#file-downloads","File Downloads",[42,9958,9959],{},[45,9960,900],{"href":899},[34,9962,824],{"id":909},[30,9964,9965,9966,9969,9970,9972,9973,125],{},"PlainApp chat is ",[85,9967,9968],{},"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",[85,9971,7067],{}," and authenticated through an Ed25519 + ECDH handshake performed\nduring ",[45,9974,9975],{"href":6790},"pairing",[30,9977,9978],{},"Two kinds of conversations exist:",[155,9980,9981,9992],{},[158,9982,9983],{},[161,9984,9985,9988,9990],{},[164,9986,9987],{},"Type",[164,9989,8060],{},[164,9991,4370],{},[174,9993,9994,10009],{},[161,9995,9996,10001,10006],{},[179,9997,9998],{},[344,9999,10000],{},"PEER",[179,10002,10003],{},[344,10004,10005],{},"ChatTargetType.PEER",[179,10007,10008],{},"1-to-1 direct chat between two paired devices.",[161,10010,10011,10016,10021],{},[179,10012,10013],{},[344,10014,10015],{},"CHANNEL",[179,10017,10018],{},[344,10019,10020],{},"ChatTargetType.CHANNEL",[179,10022,10023],{},"Multi-party group chat owned by one device; members fan out messages to each other.",[30,10025,10026,10027,10030],{},"A special ",[344,10028,10029],{},"\"local\""," target is the device's own scratchpad (notes to self) —\nsending to it is a no-op over the wire.",[1229,10032,3758],{"id":3757},[30,10034,10035],{},[913,10036],{"alt":915,"src":10037},"\u002Fblog\u002Fchat-architecture\u002Fdiagram-01.svg",[30,10039,10040,10041,4218],{},"The architecture is intentionally ",[85,10042,10043],{},"layered",[80,10045,10046,10052,10060,10072],{},[42,10047,10048,10051],{},[85,10049,10050],{},"UI \u002F GraphQL entry points"," never touch transports or DB directly.",[42,10053,10054,10059],{},[85,10055,10056],{},[344,10057,10058],{},"ChatManager"," is a façade — every caller (UI, GraphQL resolver, peer\nreceiver) goes through it.",[42,10061,10062,10067,10068,10071],{},[85,10063,10064],{},[344,10065,10066],{},"ChatSender"," is a dispatcher that branches on ",[344,10069,10070],{},"ChatTargetType"," and\ndelegates to the peer or channel senders.",[42,10073,10074,10077],{},[85,10075,10076],{},"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.",[34,10079,9896],{"id":10080},"data-model",[1229,10082,10084],{"id":10083},"chattarget","ChatTarget",[30,10086,10087,10088,1683,10090,10093,10094,10097,10098,4556,10100,10102,10103,2329,10106,10109,10110,10113,10114,10117,10118,10121,10122,10125],{},"The smallest unit of routing is a ",[344,10089,10084],{},[344,10091,10092],{},"(toId, type)"," pair where\n",[344,10095,10096],{},"type"," is either ",[344,10099,10000],{},[344,10101,10015],{},". It exposes an ",[344,10104,10105],{},"encodedToId",[344,10107,10108],{},"peer:\u003Cid>","\nor ",[344,10111,10112],{},"channel:\u003Cid>",") that the UI uses as a stable routing key (e.g.\n",[344,10115,10116],{},"TempData.activeToId"," so the receiver knows whether to emit a notification),\nan ",[344,10119,10120],{},"isLocal()"," check (toId == \"local\"), and a ",[344,10123,10124],{},"parseId"," companion that\nreconstructs the target from a stored string.",[1229,10127,10129],{"id":10128},"database-tables","Database tables",[30,10131,10132],{},"All persistence uses Room. Three tables matter for chat:",[155,10134,10135,10147],{},[158,10136,10137],{},[161,10138,10139,10142,10145],{},[164,10140,10141],{},"Table",[164,10143,10144],{},"Entity",[164,10146,8067],{},[174,10148,10149,10164,10179],{},[161,10150,10151,10156,10161],{},[179,10152,10153],{},[344,10154,10155],{},"chats",[179,10157,10158],{},[344,10159,10160],{},"DChat",[179,10162,10163],{},"One row per message (text \u002F image \u002F file).",[161,10165,10166,10171,10176],{},[179,10167,10168],{},[344,10169,10170],{},"chat_channels",[179,10172,10173],{},[344,10174,10175],{},"DChatChannel",[179,10177,10178],{},"One row per group channel.",[161,10180,10181,10186,10191],{},[179,10182,10183],{},[344,10184,10185],{},"peers",[179,10187,10188],{},[344,10189,10190],{},"DPeer",[179,10192,10193],{},"One row per known device (paired or channel-only).",[30,10195,10196],{},[913,10197],{"alt":1021,"src":10198},"\u002Fblog\u002Fchat-architecture\u002Fdiagram-02.svg",[30,10200,10201],{},"A few things worth noting:",[39,10203,10204,10217,10234],{},[42,10205,10206,10212,10213,10216],{},[85,10207,10208,10209,10211],{},"Identity is ",[344,10210,7067],{},", 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 (",[344,10214,10215],{},"shortId",") is broadcast over BLE to allow\ndiscovery.",[42,10218,10219,10225,10226,10229,10230,10233],{},[85,10220,10221,10224],{},[344,10222,10223],{},"status=\"channel\""," peers"," are members of a channel that this device has\nnever directly paired with. Their ",[344,10227,10228],{},"key"," is empty — they authenticate using\nthe ",[85,10231,10232],{},"channel key"," instead of a pairwise shared key.",[42,10235,10236,10241,10242,10244,10245,10248,10249,9252,10252,125],{},[85,10237,10238],{},[344,10239,10240],{},"owner=\"me\""," is a sentinel that lets a freshly-installed device act as\nowner before its ",[344,10243,7067],{}," is stable; ",[344,10246,10247],{},"isOwnedByMe()"," accepts both ",[344,10250,10251],{},"\"me\"",[344,10253,10254],{},"TempData.clientId",[34,10256,9902],{"id":10257},"graphql-api-surface",[30,10259,10260,10261,10263],{},"PlainApp exposes ",[85,10262,2827],{}," GraphQL schemas:",[80,10265,10266,10285],{},[42,10267,10268,2329,10271,3790,10274,8987,10277,10280,10281,10284],{},[85,10269,10270],{},"Web GraphQL",[344,10272,10273],{},"addChatChannelSchema",[344,10275,10276],{},"addChatMessageSchema",[344,10278,10279],{},"shared\u002Fsrc\u002FcommonMain\u002Fkotlin\u002Fcom\u002Fismartcoding\u002Fplain\u002Fhttpserver\u002F",")\n— served by the local Ktor server to the browser UI and to the\n",[344,10282,10283],{},"apitest\u002F"," harness. Authenticated by a ChaCha20-encrypted token.",[42,10286,10287,2329,10290,10293,10294,10296],{},[85,10288,10289],{},"Peer GraphQL",[344,10291,10292],{},"PeerGraphQLService.applyPeerSchema",") — exposed at\n",[344,10295,7890],{}," for other devices over the encrypted peer transport.\nAuthenticated by Ed25519 signature + ChaCha20 body encryption.",[30,10298,10299,10300,1498,10303,10306,10307,10310],{},"The two schemas share the same business logic singletons (",[344,10301,10302],{},"ChannelManager",[344,10304,10305],{},"ChatMessageReceiver",", …) but expose different surfaces because the ",[85,10308,10309],{},"trust\nmodel differs",": the web GraphQL trusts the local UI, while the peer GraphQL\nonly trusts cryptographically authenticated peers.",[1229,10312,10314],{"id":10313},"web-graphql-surface-chat","Web GraphQL surface (chat)",[30,10316,10317,10318,10321,10322,10325,10326,10328,10329,10331,10332,10335],{},"Queries: ",[344,10319,10320],{},"chatChannels"," (list all channels), ",[344,10323,10324],{},"chatItems(id)"," (messages for a\ntarget — id is \"local\", ",[344,10327,10108],{},", or ",[344,10330,10112],{},"), and\n",[344,10333,10334],{},"latestChatItems"," (preview across all chats).",[30,10337,10338,10339,953,10342,1498,10345,8744,10348,125],{},"Chat mutations: ",[344,10340,10341],{},"sendChatItem(toId, content)",[344,10343,10344],{},"deleteChatItem(id)",[344,10346,10347],{},"deleteChatItems(query)",[344,10349,10350],{},"retryChatItem(id)",[30,10352,10353,10354,953,10357,1498,10360,953,10363,953,10366,953,10369,953,10372,10375,10376,125],{},"Channel mutations: ",[344,10355,10356],{},"createChatChannel(name)",[344,10358,10359],{},"updateChatChannel(id, name)",[344,10361,10362],{},"deleteChatChannel(id)",[344,10364,10365],{},"leaveChatChannel(id)",[344,10367,10368],{},"addChatChannelMember(id, peerId)",[344,10370,10371],{},"removeChatChannelMember(id, peerId)",[344,10373,10374],{},"acceptChatChannelInvite(id)",",\nand ",[344,10377,10378],{},"declineChatChannelInvite(id)",[1229,10380,10382],{"id":10381},"peer-graphql-surface-transport","Peer GraphQL surface (transport)",[30,10384,10385,10386,10388,10389,10392,10393,10396,10397,10399],{},"Exposed at ",[344,10387,7890],{}," and authenticated by Ed25519 signature + ChaCha20\nbody encryption. Only three mutations cross the transport boundary:\n",[344,10390,10391],{},"createChatItem(content)"," (an incoming peer message), ",[344,10394,10395],{},"channelSystemMessage(type, payload)"," (channel lifecycle events like invite\u002Fleave), and ",[344,10398,7886],{}," (a\nnudge asking the peer to start its Wi-Fi Aware service so a faster transport\ncan take over).",[30,10401,4831,10402,10405,10406,10408,10409,10412,10413,10415],{},[344,10403,10404],{},"c-id"," HTTP header carries the sender's ",[344,10407,7067],{},"; the ",[344,10410,10411],{},"c-cid"," header\ncarries a channel id when the request is channel-scoped (so the receiver picks\nthe ",[85,10414,10232],{}," rather than the pairwise peer key for decryption).",[34,10417,9908],{"id":10418},"peer-chat-sending-a-message",[30,10420,10421,10422,10425],{},"When the user taps ",[85,10423,10424],{},"Send"," in a peer conversation, the call chain is:",[30,10427,10428],{},[913,10429],{"alt":1462,"src":10430},"\u002Fblog\u002Fchat-architecture\u002Fdiagram-03.svg",[30,10432,10433],{},"The key invariants enforced at each hop:",[80,10435,10436,10448,10464,10479,10492],{},[42,10437,10438,10443,10444,10447],{},[85,10439,10440],{},[344,10441,10442],{},"ChatManager.createChatItem"," always inserts a row first, ",[85,10445,10446],{},"then"," sends.\nThis means the UI sees a \"pending\" bubble immediately and the message\nsurvives app crashes even if delivery hasn't happened yet.",[42,10449,10450,10455,10456,10459,10460,10463],{},[85,10451,10452],{},[344,10453,10454],{},"PeerGraphQLClient.buildSignedRequest"," builds an envelope of the form\n",[344,10457,10458],{},"signature|timestamp|requestJson",". The signature is Ed25519 over\n",[344,10461,10462],{},"\"$timestamp$requestJson\"",", binding the timestamp to the body so it cannot\nbe replayed with a fresh timestamp.",[42,10465,10466,10471,10472,10475,10476,10478],{},[85,10467,10468],{},[344,10469,10470],{},"PeerTransportRouter.send"," iterates transports in order\n",[344,10473,10474],{},"Lan → WifiAware → Ble",". Each transport can throw ",[344,10477,7692],{},"\nto let the router try the next one.",[42,10480,10481,10482,10487,10488,10491],{},"On the receiving side, ",[85,10483,10484],{},[344,10485,10486],{},"PeerChatParser.decrypt"," checks the timestamp\nis within ",[344,10489,10490],{},"±5 min"," and verifies the Ed25519 signature before the GraphQL\nmutation is even executed.",[42,10493,10494,10499,10500,10503,10504,10507,10508,10511],{},[85,10495,10496],{},[344,10497,10498],{},"ChatMessageReceiver.receive"," keeps a ",[344,10501,10502],{},"seenSignatures"," set keyed by\n",[344,10505,10506],{},"\"$fromPeerId|$signature|$timestamp\""," and throws\n",[344,10509,10510],{},"ReplayedMessageException"," on duplicates — essential because the\ntransport may deliver the same payload twice (LAN + BLE).",[30,10513,2114,10514,10517,10518,6966,10520,10525,10526,10529],{},[344,10515,10516],{},"PeerChatSender.send"," returns a non-null error string, ",[344,10519,10066],{},[85,10521,10522],{},[344,10523,10524],{},"triggerPeerRediscovery(peerId)",", which fires a directed, encrypted\n",[344,10527,10528],{},"DISCOVER"," broadcast so the peer can re-announce its current IP\u002Fport.",[34,10531,9914],{"id":10532},"peer-chat-receiving-a-message",[30,10534,10535,10536,10538,10539,4218],{},"Inbound requests land at the local Ktor server's ",[344,10537,7890],{}," route,\nhandled by ",[344,10540,7635],{},[30,10542,10543],{},[913,10544],{"alt":2147,"src":10545},"\u002Fblog\u002Fchat-architecture\u002Fdiagram-04.svg",[1229,10547,10549],{"id":10548},"notifications","Notifications",[30,10551,10552,10555,10556,10559,10560,10563],{},[344,10553,10554],{},"emitNotificationIfNeeded"," is the final step. It suppresses the notification\nwhen ",[344,10557,10558],{},"TempData.activeToId == targetId"," (i.e. the user is currently looking at\nthat conversation) or when ",[344,10561,10562],{},"canShowNotifications()"," is false. Channel\nnotifications are prefixed with the sender's name.",[34,10565,9920],{"id":10566},"channel-chat-leader-election-fan-out",[30,10568,10569,10570,10572,10573,10576],{},"Channels are multi-party but ",[85,10571,9968],{},". To avoid every member fanning out\nthe same message N times, the sender side elects a single ",[85,10574,10575],{},"leader"," whose job\nis to broadcast to all joined members.",[1229,10578,10580,10581,10584],{"id":10579},"leader-election-algorithm-dchatchannelelectleader","Leader election algorithm (",[344,10582,10583],{},"DChatChannel.electLeader",")",[80,10586,10587,10590,10597,10606],{},[42,10588,10589],{},"Filter to joined members that are currently online (the local device is\nalways considered online).",[42,10591,10592,10593,10596],{},"If the ",[85,10594,10595],{},"owner"," is among the online joined members → the owner is the\nleader.",[42,10598,10599,10600,10605],{},"Otherwise, the leader is the online joined member with the ",[85,10601,10602,10603],{},"smallest\n",[344,10604,7067],{}," (deterministic tiebreak, no coordination required).",[42,10607,10608,10609,10611],{},"Returns ",[344,10610,5617],{}," if no online joined members exist.",[1229,10613,10615],{"id":10614},"send-flow","Send flow",[30,10617,10618],{},[913,10619],{"alt":3084,"src":10620},"\u002Fblog\u002Fchat-architecture\u002Fdiagram-05.svg",[1229,10622,10624],{"id":10623},"why-a-leader-at-all","Why a leader at all?",[30,10626,10627],{},"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.",[30,10629,10630,10631,10634],{},"If the leader is offline, the sender falls back to ",[344,10632,10633],{},"Result.NoLeader",",\ntriggers peer rediscovery (so the leader's IP can be found), and clears the\nstatus to let the user retry.",[1229,10636,10638],{"id":10637},"channel-key-routing","Channel key routing",[30,10640,10641,10642,10645,10646,10648,10649,3279,10651,10654,10655,10657,10658,10661],{},"Channel messages are encrypted with the ",[85,10643,10644],{},"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 ",[344,10647,10185],{}," row has ",[344,10650,10223],{},[344,10652,10653],{},"key=\"\"",". The sender sets the\n",[344,10656,10411],{}," HTTP header to the channel id; the receiver looks up\n",[344,10659,10660],{},"ChannelCacher.getKeyBytes(channelId)"," instead of the pairwise key.",[1229,10663,10665],{"id":10664},"per-recipient-retry","Per-recipient retry",[30,10667,9191,10668,10671,10672,10675,10676,10679,10680,10683,10684,10687,10688,10691,10692,10695,10696,10699],{},[344,10669,10670],{},"sendToMember"," returns a ",[344,10673,10674],{},"DMessageDeliveryResult",". The aggregated\n",[344,10677,10678],{},"DMessageStatusData"," is persisted as the chat item's ",[344,10681,10682],{},"status_data"," JSON.\nThe UI shows \"Delivered to Alice, Bob; Failed for Carol\" and lets the user\ntap ",[85,10685,10686],{},"Retry"," for Carol specifically — ",[344,10689,10690],{},"ChatManager.sendToChannelMembers","\nre-runs ",[344,10693,10694],{},"sendToRecipients"," for the retry subset and ",[85,10697,10698],{},"merges"," the new results\nwith existing ones, replacing only the retried peers.",[34,10701,9926],{"id":10702},"channel-system-messages",[30,10704,10705,10706,2828,10709,10712,10713,10715],{},"Channel control-plane messages (invite, accept, decline, update, kick, leave)\nare exchanged over the ",[85,10707,10708],{},"peer GraphQL",[344,10710,10711],{},"channelSystemMessage"," mutation. They\nare JSON payloads typed by a ",[344,10714,10096],{}," string:",[155,10717,10718,10732],{},[158,10719,10720],{},[161,10721,10722,10724,10727,10730],{},[164,10723,9987],{},[164,10725,10726],{},"Direction",[164,10728,10729],{},"Signed?",[164,10731,8067],{},[174,10733,10734,10750,10766,10780,10795,10810],{},[161,10735,10736,10741,10744,10747],{},[179,10737,10738],{},[344,10739,10740],{},"channel_invite",[179,10742,10743],{},"Owner → invitee",[179,10745,10746],{},"Yes",[179,10748,10749],{},"Invite a peer; carries channel key + members.",[161,10751,10752,10757,10760,10763],{},[179,10753,10754],{},[344,10755,10756],{},"channel_invite_accept",[179,10758,10759],{},"Invitee → owner",[179,10761,10762],{},"No",[179,10764,10765],{},"Acceptance; carries accepter's public key.",[161,10767,10768,10773,10775,10777],{},[179,10769,10770],{},[344,10771,10772],{},"channel_invite_decline",[179,10774,10759],{},[179,10776,10762],{},[179,10778,10779],{},"Decline; owner removes member.",[161,10781,10782,10787,10790,10792],{},[179,10783,10784],{},[344,10785,10786],{},"channel_update",[179,10788,10789],{},"Owner → all members",[179,10791,10746],{},[179,10793,10794],{},"Membership\u002Fname change broadcast.",[161,10796,10797,10802,10805,10807],{},[179,10798,10799],{},[344,10800,10801],{},"channel_kick",[179,10803,10804],{},"Owner → kicked peer",[179,10806,10746],{},[179,10808,10809],{},"Targeted kick; also broadcast on channel delete.",[161,10811,10812,10817,10820,10822],{},[179,10813,10814],{},[344,10815,10816],{},"channel_leave",[179,10818,10819],{},"Member → owner",[179,10821,10762],{},[179,10823,10824],{},"Member-initiated leave notice.",[1229,10826,10828],{"id":10827},"signed-payload-format","Signed payload format",[30,10830,10831,10832,953,10835,953,10838,10841,10842,10845,10846,10848,10849,953,10851,953,10853,8744,10855,10858,10859,1881],{},"The three signed types (",[344,10833,10834],{},"invite",[344,10836,10837],{},"update",[344,10839,10840],{},"kick",") use a canonical pipe-\ndelimited string: ",[344,10843,10844],{},"\"$channelId|$version|$action|$target\"",", where ",[344,10847,6029],{}," is\none of ",[344,10850,10834],{},[344,10852,10837],{},[344,10854,10840],{},[344,10856,10857],{},"target"," is the invitee\u002Fkicked peer id\n(empty for broadcast ",[344,10860,10840],{},[30,10862,10863,10864,2828,10867,10869,10870,10873,10874,10877,10878,10881],{},"The owner signs this string with its Ed25519 key. Receivers reject any\nmessage where ",[344,10865,10866],{},"channel.owner != fromId",[85,10868,5453],{}," even checking the\nsignature, and reject ",[344,10871,10872],{},"ChannelUpdate"," payloads whose ",[344,10875,10876],{},"version"," is\n",[344,10879,10880],{},"≤"," the local version (stale-version guard against out-of-order delivery).",[30,10883,10884],{},[913,10885],{"alt":3256,"src":10886},"\u002Fblog\u002Fchat-architecture\u002Fdiagram-06.svg",[1229,10888,10890],{"id":10889},"lazy-peer-hydration","Lazy peer hydration",[30,10892,10893,3279,10896,10898,10899,10902,10903,10906,10907,10909,10910,10912],{},[344,10894,10895],{},"ChannelInvite",[344,10897,10872],{}," carry a ",[344,10900,10901],{},"memberPeers: List\u003CMemberPeerInfo>","\nlist — lightweight peer info (id, name, publicKey, deviceType, ip, port) for\nevery member. The receiver's ",[344,10904,10905],{},"ensureChannelPeer"," creates a ",[344,10908,10190],{}," row with\n",[344,10911,10223],{}," for any member it has never seen before. This is critical\nbecause fan-out routing needs every member's peer record to send messages.",[34,10914,9932],{"id":10915},"channel-lifecycle",[30,10917,10918],{},[913,10919],{"alt":7400,"src":10920},"\u002Fblog\u002Fchat-architecture\u002Fdiagram-07.svg",[34,10922,9938],{"id":10923},"peer-transport-layer-lan-wi-fi-aware-ble",[30,10925,10926,3367,10928,10931],{},[344,10927,6982],{},[85,10929,10930],{},"strategy chain with circuit breaking",". The\nordered list of transports is:",[80,10933,10934,10945,10959],{},[42,10935,10936,10940,10941,10944],{},[85,10937,10938],{},[344,10939,7594],{}," — first choice. Uses OkHttp with a ChaCha20 crypto\ninterceptor over HTTPS. Skipped entirely when ",[344,10942,10943],{},"peer.ip"," is empty (cross-\nsubnet peer we haven't discovered yet).",[42,10946,10947,10951,10952,10955,10956,10958],{},[85,10948,10949],{},[344,10950,6954],{}," (Android 13+ only) — uses Wi-Fi Aware (NAN) data\npaths. Fast-skip when the peer's ",[344,10953,10954],{},"awareRunning"," flag is false (refreshed by\nthe BLE prewarmer scan). The peer's IPv6 is resolved via a custom DNS that\nmaps the hostname ",[344,10957,6846],{}," to the link-local address.",[42,10960,10961,10965],{},[85,10962,10963],{},[344,10964,7597],{}," — guaranteed fallback for any paired peer. Streams\nchunked RPC over GATT. Slower but works without any IP connectivity.",[30,10967,10968],{},[913,10969],{"alt":7528,"src":10970},"\u002Fblog\u002Fchat-architecture\u002Fdiagram-08.svg",[1229,10972,10974],{"id":10973},"why-this-order","Why this order?",[39,10976,10977,10983,10992],{},[42,10978,10979,10982],{},[85,10980,10981],{},"LAN is the fastest"," (single HTTPS round trip, ~10 ms timeout).",[42,10984,10985,10988,10989,10991],{},[85,10986,10987],{},"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 ",[344,10990,7314],{}," timeout.",[42,10993,10994,10997],{},[85,10995,10996],{},"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.",[30,10999,11000],{},"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.",[1229,11002,11004],{"id":11003},"wi-fi-aware-handshake","Wi-Fi Aware handshake",[30,11006,4831,11007,11010],{},[344,11008,11009],{},"AwareSession"," does a two-message handshake before opening a data path:",[39,11012,11013,11021],{},[42,11014,11015,11020],{},[85,11016,11017],{},[344,11018,11019],{},"MSG_HELLO"," (subscriber → publisher): \"I see you, here is my peer\nhandle.\"",[42,11022,11023,11028,11029,11031],{},[85,11024,11025],{},[344,11026,11027],{},"MSG_READY"," (publisher → subscriber): \"I've registered my network\nspecifier, you can ",[344,11030,6773],{}," now.\"",[30,11033,11034,11035,11038,11039,11042,11043,11045],{},"This synchronizes both sides' ",[344,11036,11037],{},"connectivityManager.requestNetwork(...)"," calls\nwithin the Android framework's ~500 ms window. The ",[85,11040,11041],{},"subscriber"," is the side\nwith the smaller ",[344,11044,7067],{}," (deterministic role split — both sides agree\nwithout coordination), and it owns the retry loop.",[34,11047,9944],{"id":11048},"peer-status-presence",[30,11050,11051,11052,11055,11056,11059,11060,125],{},"Presence is tracked via ",[85,11053,11054],{},"long-lived WebSocket connections",". Only one side\nof each pair opens the socket — decided by the deterministic rule\n",[344,11057,11058],{},"TempData.clientId \u003C peer.id",". The other side accepts the inbound connection\nat ",[344,11061,8706],{},[30,11063,11064],{},[913,11065],{"alt":7603,"src":11066},"\u002Fblog\u002Fchat-architecture\u002Fdiagram-09.svg",[30,11068,11069,11072,11073,11076,11077,1881],{},[344,11070,11071],{},"PeerCacher.onlineMap"," is the source of truth for presence. It is exposed as\n",[344,11074,11075],{},"onlinePeerIds: StateFlow\u003CSet\u003CString>>",", which is consumed by the channel\nleader election (",[344,11078,11079],{},"electLeader(onlinePeerIds, myId)",[34,11081,9950],{"id":11082},"caching-layer",[30,11084,11085,11086,11089],{},"Two caches mirror the database tables in memory and expose ",[344,11087,11088],{},"StateFlow","s that\nCompose collects directly:",[30,11091,11092],{},[913,11093],{"alt":7649,"src":11094},"\u002Fblog\u002Fchat-architecture\u002Fdiagram-10.svg",[1229,11096,11098],{"id":11097},"why-copy-on-write","Why copy-on-write?",[30,11100,11101,11102,11105,11106,11108,11109,11112,11113,2828,11116,11118,11119,11122,11123,11126,11127,1927,11130,11133],{},"Kotlin's ",[344,11103,11104],{},"MutableStateFlow.distinctUntilChanged"," uses structural equality. If\nwe mutated the ",[344,11107,10190],{}," in place, the derived ",[344,11110,11111],{},"pairedPeers"," list would contain\nthe ",[85,11114,11115],{},"same",[344,11117,10190],{}," reference before and after, and ",[344,11120,11121],{},"distinctUntilChanged","\nwould see no difference and suppress emission. By copying the entity first,\nmutating the copy, and ",[85,11124,11125],{},"replacing"," the map entry with a new\n",[344,11128,11129],{},"PeerRuntime",[344,11131,11132],{},"ChannelRuntime",", the derived list gets a new list-of-new-\nreferences and the flow fires.",[34,11135,9956],{"id":11136},"file-downloads",[30,11138,11139,11140,11142,11143,11146],{},"Inbound file\u002Fimage messages are downloaded automatically by a bounded\nworker pool. Each download streams through whatever transport is available\n(",[344,11141,9425],{},") and writes to a temp file, then imports\ninto the app's media store and patches the chat item's ",[344,11144,11145],{},"uri"," field.",[30,11148,11149],{},[913,11150],{"alt":7718,"src":11151},"\u002Fblog\u002Fchat-architecture\u002Fdiagram-11.svg",[1229,11153,11155],{"id":11154},"transport-agnostic-streaming","Transport-agnostic streaming",[30,11157,4831,11158,11161,11162,11165,11166,7771,11168,11170],{},[344,11159,11160],{},"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 ",[344,11163,11164],{},"GET \u002Ffs?id=…&offset=…&length=…",")\nthrough the same ",[344,11167,7785],{},[344,11169,9089],{}," callback lets BLE cancel\nits background download coroutine when the consumer closes the response\nearly (e.g. on pause).",[34,11172,900],{"id":3420},[155,11174,11175,11185],{},[158,11176,11177],{},[161,11178,11179,11181,11183],{},[164,11180,3429],{},[164,11182,3432],{},[164,11184,172],{},[174,11186,11187,11201,11224,11238,11255,11273,11288,11306,11324,11346,11363,11377,11395],{},[161,11188,11189,11194,11198],{},[179,11190,11191],{},[85,11192,11193],{},"Façade",[179,11195,11196],{},[344,11197,10058],{},[179,11199,11200],{},"Single entry point; callers never touch DB\u002Ftransport directly.",[161,11202,11203,11208,11218],{},[179,11204,11205],{},[85,11206,11207],{},"Strategy + Chain of Resp.",[179,11209,11210,3790,11212,1927,11214,1927,11216],{},[344,11211,6982],{},[344,11213,7594],{},[344,11215,6954],{},[344,11217,7597],{},[179,11219,11220,11221,11223],{},"Pluggable transports with ",[344,11222,7692],{}," as the fall-through signal.",[161,11225,11226,11231,11235],{},[179,11227,11228],{},[85,11229,11230],{},"Circuit Breaker",[179,11232,11233],{},[344,11234,7947],{},[179,11236,11237],{},"2 fails \u002F 30 s opens a (peer, transport) leg so Wi-Fi Aware doesn't block fallback.",[161,11239,11240,11244,11252],{},[179,11241,11242],{},[85,11243,6483],{},[179,11245,11246,953,11249],{},[344,11247,11248],{},"PeerStatusManager.PeerState",[344,11250,11251],{},"AwarePeerLink.LinkState",[179,11253,11254],{},"Explicit transitions for socket lifecycle and NDP link lifecycle.",[161,11256,11257,11262,11270],{},[179,11258,11259],{},[85,11260,11261],{},"Producer\u002FConsumer + Pool",[179,11263,11264,11266,11267,10584],{},[344,11265,7799],{}," (3 workers, ",[344,11268,11269],{},"Channel.BUFFERED",[179,11271,11272],{},"Bounded concurrency for file downloads.",[161,11274,11275,11280,11285],{},[179,11276,11277],{},[85,11278,11279],{},"Observer \u002F Reactive",[179,11281,11282,11284],{},[344,11283,11088],{}," everywhere",[179,11286,11287],{},"Compose collects directly; no manual refresh.",[161,11289,11290,11295,11303],{},[179,11291,11292],{},[85,11293,11294],{},"Replay Protection",[179,11296,11297,953,11300],{},[344,11298,11299],{},"ChatMessageReceiver.seenSignatures",[344,11301,11302],{},"PeerChatParser.MAX_TIMESTAMP_DIFF_MS",[179,11304,11305],{},"Drop duplicates from LAN+BLE dual delivery; reject out-of-window timestamps.",[161,11307,11308,11313,11318],{},[179,11309,11310],{},[85,11311,11312],{},"Exponential Backoff",[179,11314,11315],{},[344,11316,11317],{},"PeerStatusManager.scheduleReconnect",[179,11319,11320,11323],{},[344,11321,11322],{},"min(60 s, 1 s × 2^min(n-1, 6))"," — caps at 64 s.",[161,11325,11326,11331,11339],{},[179,11327,11328],{},[85,11329,11330],{},"Copy-on-Write",[179,11332,11333,953,11336],{},[344,11334,11335],{},"PeerCacher.mutatePeer",[344,11337,11338],{},"ChannelCacher.mutateChannel",[179,11340,11341,11342,11345],{},"Forces ",[344,11343,11344],{},"StateFlow.distinctUntilChanged"," to fire on every mutation.",[161,11347,11348,11353,11357],{},[179,11349,11350],{},[85,11351,11352],{},"Signed Envelope",[179,11354,11355],{},[344,11356,10454],{},[179,11358,11359,11362],{},[344,11360,11361],{},"signature|timestamp|body"," — binds timestamp to body to prevent replay.",[161,11364,11365,11370,11374],{},[179,11366,11367],{},[85,11368,11369],{},"Deterministic Role Split",[179,11371,11372],{},[344,11373,11058],{},[179,11375,11376],{},"Decides WebSocket client vs server, and Wi-Fi Aware subscriber vs publisher.",[161,11378,11379,11384,11389],{},[179,11380,11381],{},[85,11382,11383],{},"Lazy Hydration",[179,11385,11386,11388],{},[344,11387,10905],{}," on invite\u002Fupdate",[179,11390,11391,11392,11394],{},"Creates ",[344,11393,10185],{}," rows for unseen channel members so fan-out routing works.",[161,11396,11397,11402,11407],{},[179,11398,11399],{},[85,11400,11401],{},"Encrypted Identity",[179,11403,11404],{},[344,11405,11406],{},"LANDiscoverManager.discoverSpecificDevice",[179,11408,11409],{},"Directed DISCOVER encrypts target id with peer key — only the target recognizes it.",[34,11411,906],{"id":3570},[39,11413,11414,11419],{},[42,11415,11416,11418],{},[45,11417,6791],{"href":6790}," — how two devices establish trust and\nexchange the shared ChaCha20 key used by every transport in this article.",[42,11420,11421,3279,11424,11427],{},[344,11422,11423],{},"apitest\u002Fgroups\u002Fchat-messages.sh",[344,11425,11426],{},"apitest\u002Fgroups\u002Fchat-channels.sh"," —\nexecutable test plan exercising every GraphQL mutation end-to-end.",{"title":248,"searchDepth":249,"depth":249,"links":11429},[11430,11431,11434,11438,11442,11443,11446,11454,11458,11459,11463,11464,11467,11470,11471],{"id":36,"depth":252,"text":37},{"id":909,"depth":252,"text":824,"children":11432},[11433],{"id":3757,"depth":249,"text":3758},{"id":10080,"depth":252,"text":9896,"children":11435},[11436,11437],{"id":10083,"depth":249,"text":10084},{"id":10128,"depth":249,"text":10129},{"id":10257,"depth":252,"text":9902,"children":11439},[11440,11441],{"id":10313,"depth":249,"text":10314},{"id":10381,"depth":249,"text":10382},{"id":10418,"depth":252,"text":9908},{"id":10532,"depth":252,"text":9914,"children":11444},[11445],{"id":10548,"depth":249,"text":10549},{"id":10566,"depth":252,"text":9920,"children":11447},[11448,11450,11451,11452,11453],{"id":10579,"depth":249,"text":11449},"Leader election algorithm (DChatChannel.electLeader)",{"id":10614,"depth":249,"text":10615},{"id":10623,"depth":249,"text":10624},{"id":10637,"depth":249,"text":10638},{"id":10664,"depth":249,"text":10665},{"id":10702,"depth":252,"text":9926,"children":11455},[11456,11457],{"id":10827,"depth":249,"text":10828},{"id":10889,"depth":249,"text":10890},{"id":10915,"depth":252,"text":9932},{"id":10923,"depth":252,"text":9938,"children":11460},[11461,11462],{"id":10973,"depth":249,"text":10974},{"id":11003,"depth":249,"text":11004},{"id":11048,"depth":252,"text":9944},{"id":11082,"depth":252,"text":9950,"children":11465},[11466],{"id":11097,"depth":249,"text":11098},{"id":11136,"depth":252,"text":9956,"children":11468},[11469],{"id":11154,"depth":249,"text":11155},{"id":3420,"depth":252,"text":900},{"id":3570,"depth":252,"text":906},"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.",{},{"title":9880,"description":11473},"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",{"id":11481,"title":6791,"body":11482,"category":6749,"date":12403,"description":12404,"extension":261,"meta":12405,"navigation":263,"path":6790,"readingTime":12406,"seo":12407,"seoDescription":12408,"seoTitle":12409,"stem":12410,"__hash__":12411},"blog_en\u002Fblog\u002Fpairing-flow.md",{"type":27,"value":11483,"toc":12366},[11484,11486,11560,11563,11570,11587,11604,11609,11612,11615,11674,11680,11695,11698,11700,11710,11715,11719,11832,11835,11841,11846,11850,11863,11878,11881,11885,11902,11905,11908,11913,11917,11937,11941,11951,11954,11957,11962,11966,12005,12008,12011,12018,12023,12031,12047,12050,12053,12058,12073,12076,12087,12092,12099,12112,12115,12118,12123,12129,12135,12164,12167,12316,12320,12343,12346,12351,12353],[34,11485,37],{"id":36},[39,11487,11488,11494,11500,11506,11512,11518,11524,11530,11536,11542,11548,11554],{},[42,11489,11490],{},[45,11491,11493],{"href":11492},"#why-pairing-exists","Why Pairing Exists",[42,11495,11496],{},[45,11497,11499],{"href":11498},"#trust-model--cryptography","Trust Model & Cryptography",[42,11501,11502],{},[45,11503,11505],{"href":11504},"#component-map","Component Map",[42,11507,11508],{},[45,11509,11511],{"href":11510},"#discovery-phase","Discovery Phase",[42,11513,11514],{},[45,11515,11517],{"href":11516},"#pairing-sequence-happy-path","Pairing Sequence (Happy Path)",[42,11519,11520],{},[45,11521,11523],{"href":11522},"#key-exchange-details","Key Exchange Details",[42,11525,11526],{},[45,11527,11529],{"href":11528},"#responder-acceptdecline-flow","Responder Accept\u002FDecline Flow",[42,11531,11532],{},[45,11533,11535],{"href":11534},"#cancel-flow","Cancel Flow",[42,11537,11538],{},[45,11539,11541],{"href":11540},"#dual-channel-delivery-lan--ble","Dual-Channel Delivery (LAN + BLE)",[42,11543,11544],{},[45,11545,11547],{"href":11546},"#session--peer-storage","Session & Peer Storage",[42,11549,11550],{},[45,11551,11553],{"href":11552},"#security-properties","Security Properties",[42,11555,11556],{},[45,11557,11559],{"href":11558},"#state-machine-recap","State Machine Recap",[34,11561,11493],{"id":11562},"why-pairing-exists",[30,11564,11565,11566,11569],{},"PlainApp has ",[85,11567,11568],{},"no central account server",". Devices must therefore answer two\nquestions before they can talk:",[80,11571,11572,11581],{},[42,11573,11574,11577,11578,11580],{},[85,11575,11576],{},"\"Who are you?\""," — every device generates a stable ",[344,11579,7067],{}," on first\nlaunch (a 13-character id derived from its Ed25519 key material). This is\nthe only identifier used for routing, presence, and channel membership.",[42,11582,11583,11586],{},[85,11584,11585],{},"\"Can I trust you?\""," — without a server to vouch for identity, the only\nway to be sure a peer is who they claim to be is for a human to confirm\nthe pairing on both devices and for the protocol to verify cryptographic\nsignatures.",[30,11588,11589,11590,11592,11593,11596,11597,11599,11600,11603],{},"Pairing produces a single artifact: a ",[344,11591,10190],{}," row in the database with\n",[344,11594,11595],{},"status=\"paired\"",", a ChaCha20 ",[344,11598,10228],{}," (the shared transport secret), and the\npeer's Ed25519 ",[344,11601,11602],{},"public_key"," (for verifying future message signatures). Every\nlater protocol in the chat subsystem assumes these two fields exist.",[30,11605,11606],{},[913,11607],{"alt":915,"src":11608},"\u002Fblog\u002Fpairing-flow\u002Fdiagram-01.svg",[34,11610,11499],{"id":11611},"trust-model-cryptography",[30,11613,11614],{},"Pairing uses two independent cryptographic primitives:",[155,11616,11617,11629],{},[158,11618,11619],{},[161,11620,11621,11624,11626],{},[164,11622,11623],{},"Primitive",[164,11625,8067],{},[164,11627,11628],{},"Lifecycle",[174,11630,11631,11656],{},[161,11632,11633,11639,11642],{},[179,11634,11635,11638],{},[85,11636,11637],{},"Ed25519"," (signature)",[179,11640,11641],{},"Authenticate the pairing request and response. Verifies \"this really came from the device claiming to send it\" and binds the timestamp to prevent replay.",[179,11643,11644,11645,11648,11649,11652,11653,11655],{},"The signing key is the device's ",[85,11646,11647],{},"long-term identity key",". Its public half is stored as ",[344,11650,11651],{},"DPeer.public_key"," and later used by ",[344,11654,10486],{}," to verify every chat message signature.",[161,11657,11658,11664,11667],{},[179,11659,11660,11663],{},[85,11661,11662],{},"X25519-style ECDH"," (key agreement)",[179,11665,11666],{},"Produce a shared secret that becomes the ChaCha20 transport key. The two devices compute the same secret without ever transmitting it.",[179,11668,11669,11670,11673],{},"Ephemeral key pair generated per pairing session, discarded immediately after the shared key is computed. The resulting 32-byte secret is stored as ",[344,11671,11672],{},"DPeer.key"," and reused for the lifetime of the pairing.",[30,11675,9137,11676,11679],{},[85,11677,11678],{},"no PIN, no QR code, no out-of-band code",". Trust is established by:",[80,11681,11682,11689,11692],{},[42,11683,11684,11685,11688],{},"A human tapping ",[85,11686,11687],{},"Accept"," on the responder device (the user is asserting\n\"yes, this is the device I want to pair with\").",[42,11690,11691],{},"Both sides verifying the other's Ed25519 signature on the request\u002Fresponse\n(proving the responder is talking to the same device that started the\nsession and vice versa).",[42,11693,11694],{},"A ±5 min timestamp window on both messages (preventing replay of an old\ncaptured handshake).",[30,11696,11697],{},"The asymmetry matters: a single human confirmation would be vulnerable to a\nman-in-the-middle (the attacker could pair with both sides separately). The\nEd25519 signature on the ECDH public key prevents this — the responder\nverifies the request was signed by the same Ed25519 key that initiated the\nsession, and vice versa, so an MITM cannot transparently substitute its own\nECDH key without also controlling the long-term signing key.",[34,11699,11505],{"id":3757},[30,11701,11702,11703,11706,11707,7208],{},"All pairing code lives in the ",[344,11704,11705],{},"discover\u002F"," package (not ",[344,11708,11709],{},"chat\u002Fpeer\u002Fpair\u002F",[30,11711,11712],{},[913,11713],{"alt":1021,"src":11714},"\u002Fblog\u002Fpairing-flow\u002Fdiagram-02.svg",[1229,11716,11718],{"id":11717},"file-locations","File locations",[155,11720,11721,11734],{},[158,11722,11723],{},[161,11724,11725,11728],{},[164,11726,11727],{},"Component",[164,11729,11730,11731,10584],{},"Path (under ",[344,11732,11733],{},"shared\u002Fsrc\u002FcommonMain\u002Fkotlin\u002Fcom\u002Fismartcoding\u002Fplain\u002F",[174,11735,11736,11748,11760,11772,11784,11796,11808,11820],{},[161,11737,11738,11743],{},[179,11739,11740],{},[344,11741,11742],{},"LANDiscoverManager",[179,11744,11745],{},[344,11746,11747],{},"discover\u002FLANDiscoverManager.kt",[161,11749,11750,11755],{},[179,11751,11752],{},[344,11753,11754],{},"PairingCore",[179,11756,11757],{},[344,11758,11759],{},"discover\u002FPairingCore.kt",[161,11761,11762,11767],{},[179,11763,11764],{},[344,11765,11766],{},"PairingInitiator",[179,11768,11769],{},[344,11770,11771],{},"discover\u002FPairingInitiator.kt",[161,11773,11774,11779],{},[179,11775,11776],{},[344,11777,11778],{},"PairingResponder",[179,11780,11781],{},[344,11782,11783],{},"discover\u002FPairingResponder.kt",[161,11785,11786,11791],{},[179,11787,11788],{},[344,11789,11790],{},"PairingSecurity",[179,11792,11793],{},[344,11794,11795],{},"discover\u002FPairingSecurity.kt",[161,11797,11798,11803],{},[179,11799,11800],{},[344,11801,11802],{},"PairingSessionStore",[179,11804,11805],{},[344,11806,11807],{},"discover\u002FPairingSessionStore.kt",[161,11809,11810,11815],{},[179,11811,11812],{},[344,11813,11814],{},"PairingPeerStore",[179,11816,11817],{},[344,11818,11819],{},"discover\u002FPairingPeerStore.kt",[161,11821,11822,11827],{},[179,11823,11824],{},[344,11825,11826],{},"PairingMessenger",[179,11828,11829],{},[344,11830,11831],{},"discover\u002FPairingMessenger.kt",[34,11833,11511],{"id":11834},"discovery-phase",[30,11836,11837,11838,11840],{},"Before pairing can happen, devices must find each other.\n",[344,11839,11742],{}," runs continuously once the app starts:",[30,11842,11843],{},[913,11844],{"alt":1462,"src":11845},"\u002Fblog\u002Fpairing-flow\u002Fdiagram-03.svg",[1229,11847,11849],{"id":11848},"why-directed-discovery-is-encrypted","Why directed discovery is encrypted",[30,11851,11852,11853,11856,11857,11859,11860,11862],{},"The broadcast DISCOVER reveals nothing sensitive (just ",[344,11854,11855],{},"fromId=clientId","), so\nit's fine for any device on the LAN to see it. The directed variant, however,\nis used when one device already knows another's ",[344,11858,7067],{}," (e.g. it's paired\nbut the peer's IP has changed) and wants to wake it up. Encrypting the\ntarget ",[344,11861,7067],{}," with the peer's shared key means:",[39,11864,11865,11872],{},[42,11866,11867,11868,11871],{},"The right peer can decrypt the ",[344,11869,11870],{},"toId",", recognize itself, and reply.",[42,11873,11874,11875,11877],{},"Every other device on the LAN sees only ciphertext — they cannot enumerate\nwhich ",[344,11876,7067],{},"s the sender is trying to reach.",[30,11879,11880],{},"This is a small but real privacy property: passive LAN observers cannot\nbuild a graph of who is paired with whom.",[1229,11882,11884],{"id":11883},"aware-flags-in-the-reply","Aware flags in the reply",[30,11886,11887,11888,3279,11891,11893,11894,11897,11898,11901],{},"The DISCOVER_REPLY carries ",[344,11889,11890],{},"awareSupported",[344,11892,10954],{},". These are\n",[85,11895,11896],{},"not persisted"," to the database — they're stored in-memory in ",[344,11899,11900],{},"PeerCacher","\nand refreshed on every reply (and also from BLE scan-response serviceData).\nThe transport layer consults them to decide whether to attempt a Wi-Fi Aware\nlink or skip straight to BLE.",[34,11903,11517],{"id":11904},"pairing-sequence-happy-path",[30,11906,11907],{},"The end-to-end flow when both devices are on the same LAN and the user\naccepts the pairing:",[30,11909,11910],{},[913,11911],{"alt":2147,"src":11912},"\u002Fblog\u002Fpairing-flow\u002Fdiagram-04.svg",[1229,11914,11916],{"id":11915},"why-both-sides-store-the-peer-independently","Why both sides store the peer independently",[30,11918,11919,11920,11923,11924,11927,11928,11930,11931,11933,11934,11936],{},"Notice that ",[85,11921,11922],{},"both"," the initiator (step 9) and the responder (step 7) call\n",[344,11925,11926],{},"PairingPeerStore.save(...)"," for the ",[133,11929,2859],{}," device. This is intentional: each\ndevice ends up with a ",[344,11932,10190],{}," row keyed by the other's ",[344,11935,7067],{},", containing\nits own copy of the shared ChaCha20 key and the other's Ed25519 public key.\nThere is no central registry — the pairing is symmetric and self-contained.",[1229,11938,11940],{"id":11939},"why-the-responder-computes-the-key-first","Why the responder computes the key first",[30,11942,11943,11944,11947,11948,11950],{},"The responder's ",[344,11945,11946],{},"acceptPairingRequest"," computes the shared key immediately\nupon acceptance and persists it. This means the responder can start\nreceiving encrypted traffic ",[85,11949,5453],{}," the response arrives back at the\ninitiator. If the response is lost in transit, the responder is still\npaired — only the initiator needs to retry.",[34,11952,11523],{"id":11953},"key-exchange-details",[30,11955,11956],{},"The cryptographic core of pairing is a standard X25519-style ECDH\nkey agreement, but with an Ed25519 signature layered on top to authenticate\nit.",[30,11958,11959],{},[913,11960],{"alt":3084,"src":11961},"\u002Fblog\u002Fpairing-flow\u002Fdiagram-05.svg",[1229,11963,11965],{"id":11964},"what-the-signature-actually-protects","What the signature actually protects",[30,11967,11968,11969,11972,11973,953,11976,953,11979,953,11982,1498,11985,953,11988,953,11991,8744,11993,11996,11997,12001,12002,12004],{},"The signed payload (",[344,11970,11971],{},"toSignatureData()",") is a canonical concatenation of the\nstable request fields: ",[344,11974,11975],{},"fromId",[344,11977,11978],{},"fromName",[344,11980,11981],{},"port",[344,11983,11984],{},"deviceType",[344,11986,11987],{},"ecdhPublicKey",[344,11989,11990],{},"signaturePublicKey",[344,11992,6287],{},[344,11994,11995],{},"ips",". By signing\nthe ",[85,11998,11999],{},[344,12000,11987],{}," together with the long-term ",[344,12003,11990],{},",\nthe protocol binds the ephemeral key to the device's identity. An attacker\ncannot substitute their own ECDH public key in transit without invalidating\nthe signature — and they cannot forge the signature without controlling the\nlong-term Ed25519 key.",[30,12006,12007],{},"This is what defeats a man-in-the-middle: even if the attacker relays every\npacket between the two devices, they cannot read the encrypted traffic\n(because they don't have either side's ECDH private key) and they cannot\nsubstitute their own ECDH keys (because the signatures would break).",[34,12009,11529],{"id":12010},"responder-acceptdecline-flow",[30,12012,12013,12014,12017],{},"The responder side exposes a UI dialog when a ",[344,12015,12016],{},"PAIR_REQUEST"," arrives. The\nuser can either accept or decline.",[30,12019,12020],{},[913,12021],{"alt":3256,"src":12022},"\u002Fblog\u002Fpairing-flow\u002Fdiagram-06.svg",[1229,12024,12026,12027,12030],{"id":12025},"why-the-responder-fires-pairingsuccessevent-immediately-on-accept","Why the responder fires ",[344,12028,12029],{},"PairingSuccessEvent"," immediately on accept",[30,12032,11943,12033,12035,12036,12038,12039,2828,12041,12043,12044,12046],{},[344,12034,11946],{}," calls ",[344,12037,11926],{},"\nand fires ",[344,12040,12029],{},[85,12042,5453],{}," sending the response. This is\ndeliberate: if the response never reaches the initiator (network glitch),\nthe responder is still paired — the next time the initiator tries to pair,\nthe responder's already-existing ",[344,12045,10190],{}," row will be picked up by the\npresence system. The initiator simply retries; the responder does not need\nto re-confirm.",[34,12048,11535],{"id":12049},"cancel-flow",[30,12051,12052],{},"Either side can cancel an in-flight pairing.",[30,12054,12055],{},[913,12056],{"alt":7400,"src":12057},"\u002Fblog\u002Fpairing-flow\u002Fdiagram-07.svg",[30,12059,12060,12061,12064,12065,12068,12069,12072],{},"Note that ",[344,12062,12063],{},"DPairingCancel"," is sent over ",[85,12066,12067],{},"LAN unicast only"," (the initiator\nalready has the responder's IP from the discovery phase), whereas the\ndecline response is sent over ",[85,12070,12071],{},"both LAN and BLE"," because the responder\ncannot be sure which transport the initiator is reachable on.",[34,12074,11541],{"id":12075},"dual-channel-delivery-lan-ble",[30,12077,12078,12079,12082,12083,12086],{},"When the responder sends the ",[344,12080,12081],{},"DPairingResponse",", it does so over ",[85,12084,12085],{},"both LAN\nand BLE simultaneously",". The initiator accepts the first copy and silently\ndiscards the duplicate.",[30,12088,12089],{},[913,12090],{"alt":7528,"src":12091},"\u002Fblog\u002Fpairing-flow\u002Fdiagram-08.svg",[1229,12093,7555,12095,12098],{"id":12094},"why-blepairingsessionstore-exists",[344,12096,12097],{},"BlePairingSessionStore"," exists",[30,12100,12101,12102,12104,12105,12107,12108,12111],{},"When a ",[344,12103,12016],{}," arrives over BLE, the responder doesn't have a LAN IP\nfor the initiator — only its BLE MAC address. ",[344,12106,12097],{}," maps\n",[344,12109,12110],{},"peerId → MAC"," so the response can be routed back over BLE if needed. This\nis a small, in-memory, ephemeral map that is only populated for BLE-routed\nrequests and cleared once the response is sent.",[34,12113,11547],{"id":12114},"session-peer-storage",[30,12116,12117],{},"Two stores participate in pairing, with very different lifetimes:",[30,12119,12120],{},[913,12121],{"alt":7603,"src":12122},"\u002Fblog\u002Fpairing-flow\u002Fdiagram-09.svg",[1229,12124,7555,12126,12128],{"id":12125},"why-clientid-is-the-only-persisted-identifier",[344,12127,7067],{}," is the only persisted identifier",[30,12130,12131,12132,12134],{},"Android randomizes the BLE MAC address on every connection, so storing it\nwould be useless. The ",[344,12133,7067],{}," is derived from the device's long-term\nEd25519 key material, so it is:",[39,12136,12137,12143,12152],{},[42,12138,12139,12142],{},[85,12140,12141],{},"Stable"," across app reinstalls (the key is in the platform keystore).",[42,12144,12145,12148,12149,12151],{},[85,12146,12147],{},"Self-authenticating"," — anyone claiming a ",[344,12150,7067],{}," must prove they hold\nthe corresponding Ed25519 private key (verified on every signed message).",[42,12153,12154,12157,12158,12160,12161,12163],{},[85,12155,12156],{},"Privacy-preserving"," — only an 8-byte SHA-256 prefix (",[344,12159,10215],{},") is ever\nbroadcast over BLE for discovery; the full ",[344,12162,7067],{}," is only revealed to\ndevices you actually pair with.",[34,12165,11553],{"id":12166},"security-properties",[155,12168,12169,12179],{},[158,12170,12171],{},[161,12172,12173,12176],{},[164,12174,12175],{},"Property",[164,12177,12178],{},"How it's achieved",[174,12180,12181,12191,12214,12224,12246,12260,12273,12289,12304],{},[161,12182,12183,12188],{},[179,12184,12185],{},[85,12186,12187],{},"Confidentiality",[179,12189,12190],{},"All transport is ChaCha20 encrypted with the ECDH-derived shared key. The key never leaves the two devices after pairing.",[161,12192,12193,12198],{},[179,12194,12195],{},[85,12196,12197],{},"Authentication",[179,12199,12200,12201,12204,12205,1927,12207,1927,12209,12211,12212,125],{},"Every signed message (pairing request\u002Fresponse, chat ",[344,12202,12203],{},"createChatItem",", channel ",[344,12206,10834],{},[344,12208,10837],{},[344,12210,10840],{},") is Ed25519-verified against the sender's stored ",[344,12213,11602],{},[161,12215,12216,12221],{},[179,12217,12218],{},[85,12219,12220],{},"Integrity",[179,12222,12223],{},"Ed25519 signatures cover the full request body; any tampering invalidates the signature.",[161,12225,12226,12231],{},[179,12227,12228],{},[85,12229,12230],{},"Replay resistance",[179,12232,12233,12236,12237,3279,12240,12242,12243,12245],{},[344,12234,12235],{},"±5 min timestamp window"," (enforced by ",[344,12238,12239],{},"PeerChatParser",[344,12241,11790],{},"). ",[344,12244,11299],{}," dedups within the window.",[161,12247,12248,12253],{},[179,12249,12250],{},[85,12251,12252],{},"Man-in-the-middle resistance",[179,12254,12255,12256,12259],{},"The ephemeral ECDH public key is ",[85,12257,12258],{},"signed together with"," the long-term Ed25519 public key. An MITM cannot substitute its own ECDH key without breaking the signature.",[161,12261,12262,12267],{},[179,12263,12264],{},[85,12265,12266],{},"Forward secrecy (limited)",[179,12268,12269,12270,12272],{},"ECDH key pairs are ephemeral per pairing session. Compromising the long-term Ed25519 key later does not decrypt past traffic (the shared key is also still needed — but if both the ECDH private keys and the stored ",[344,12271,11672],{}," are wiped, past captures cannot be decrypted).",[161,12274,12275,12280],{},[179,12276,12277],{},[85,12278,12279],{},"Denial-of-service resistance",[179,12281,12282,12285,12286,12288],{},[344,12283,12284],{},"onDatagram"," wraps every message in try\u002Fcatch so a malformed packet cannot kill the discovery receiver. ",[344,12287,7947],{}," skips a flaky transport for 30 s after 2 failures.",[161,12290,12291,12296],{},[179,12292,12293],{},[85,12294,12295],{},"Privacy (directed discovery)",[179,12297,12298,12300,12301,12303],{},[344,12299,11406],{}," encrypts the target ",[344,12302,7067],{}," with the peer's key — passive LAN observers cannot enumerate who is paired with whom.",[161,12305,12306,12311],{},[179,12307,12308],{},[85,12309,12310],{},"Identity stability",[179,12312,12313,12315],{},[344,12314,7067],{}," is derived from long-term Ed25519 key material in the platform keystore — stable across reinstalls, self-authenticating, and not tied to a phone number or email.",[1229,12317,12319],{"id":12318},"what-pairing-does-not-defend-against","What pairing does NOT defend against",[39,12321,12322,12331,12337],{},[42,12323,12324,12327,12328,1881],{},[85,12325,12326],{},"Physical device compromise."," If an attacker gains root on a paired\ndevice, they can read the shared key from the database and impersonate\nthat peer. There is no hardware-backed key store enforcement for the\nshared transport key (only for the Ed25519 signing key, via\n",[344,12329,12330],{},"SignatureHelper",[42,12332,12333,12336],{},[85,12334,12335],{},"Active relay attacks."," An attacker who can simultaneously relay BLE\nand LAN traffic between two devices that think they're pairing with each\nother could theoretically position themselves in the middle — but the\nEd25519 signature on the ECDH public key means they cannot read the\ntraffic, only relay it. This is the same trade-off as Bluetooth pairing\nwithout numeric comparison.",[42,12338,12339,12342],{},[85,12340,12341],{},"Network-level blocking."," A firewall can block UDP multicast, BLE can\nbe jammed, and Wi-Fi Aware can be unavailable. The system degrades\ngracefully (BLE is the guaranteed fallback for paired peers) but cannot\nbypass an actively hostile network.",[34,12344,11559],{"id":12345},"state-machine-recap",[30,12347,12348],{},[913,12349],{"alt":7649,"src":12350},"\u002Fblog\u002Fpairing-flow\u002Fdiagram-10.svg",[34,12352,906],{"id":3570},[39,12354,12355,12360],{},[42,12356,12357,12359],{},[45,12358,6781],{"href":6780}," — what the shared key is used\nfor: peer chat send\u002Freceive, channel fan-out, presence, file downloads.",[42,12361,12362,12365],{},[344,12363,12364],{},"apitest\u002Fgroups\u002Fdiscovery.sh"," — executable test plan exercising the\ndiscovery and pairing API surface end-to-end.",{"title":248,"searchDepth":249,"depth":249,"links":12367},[12368,12369,12370,12371,12374,12378,12382,12385,12389,12390,12394,12398,12401,12402],{"id":36,"depth":252,"text":37},{"id":11562,"depth":252,"text":11493},{"id":11611,"depth":252,"text":11499},{"id":3757,"depth":252,"text":11505,"children":12372},[12373],{"id":11717,"depth":249,"text":11718},{"id":11834,"depth":252,"text":11511,"children":12375},[12376,12377],{"id":11848,"depth":249,"text":11849},{"id":11883,"depth":249,"text":11884},{"id":11904,"depth":252,"text":11517,"children":12379},[12380,12381],{"id":11915,"depth":249,"text":11916},{"id":11939,"depth":249,"text":11940},{"id":11953,"depth":252,"text":11523,"children":12383},[12384],{"id":11964,"depth":249,"text":11965},{"id":12010,"depth":252,"text":11529,"children":12386},[12387],{"id":12025,"depth":249,"text":12388},"Why the responder fires PairingSuccessEvent immediately on accept",{"id":12049,"depth":252,"text":11535},{"id":12075,"depth":252,"text":11541,"children":12391},[12392],{"id":12094,"depth":249,"text":12393},"Why BlePairingSessionStore exists",{"id":12114,"depth":252,"text":11547,"children":12395},[12396],{"id":12125,"depth":249,"text":12397},"Why clientId is the only persisted identifier",{"id":12166,"depth":252,"text":11553,"children":12399},[12400],{"id":12318,"depth":249,"text":12319},{"id":12345,"depth":252,"text":11559},{"id":3570,"depth":252,"text":906},"2025-01-15","This article explains how two PlainApp devices establish trust for the first time — how they discover each other, exchange keys, and arrive at the shared ChaCha20 transport key that every chat message, file transfer, and presence ping is encrypted with afterwards. The chat and channel architecture that consumes this key is covered in the separate Chat Architecture article.",{},"10 min read",{"title":6791,"description":12404},"How do two phones establish trust without a server? PlainApp's pairing flow: discovery, key exchange, and a shared ChaCha20 key that encrypts everything.","How Phones Pair Securely: Device Trust & Key Exchange","blog\u002Fpairing-flow","e8aH92Zo1wqzN_hD7p15BeByTb9DSkWLShP4qm4Meqs",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":12413},"\u003Cpath fill=\"currentColor\" d=\"m221.66 133.66l-72 72a8 8 0 0 1-11.32-11.32L196.69 136H40a8 8 0 0 1 0-16h156.69l-58.35-58.34a8 8 0 0 1 11.32-11.32l72 72a8 8 0 0 1 0 11.32\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":12415},"\u003Cg fill=\"currentColor\">\u003Cpath d=\"M80 40v176H48a8 8 0 0 1-8-8V48a8 8 0 0 1 8-8Z\" opacity=\".2\"\u002F>\u003Cpath d=\"M184 112a8 8 0 0 1-8 8h-64a8 8 0 0 1 0-16h64a8 8 0 0 1 8 8m-8 24h-64a8 8 0 0 0 0 16h64a8 8 0 0 0 0-16m48-88v160a16 16 0 0 1-16 16H48a16 16 0 0 1-16-16V48a16 16 0 0 1 16-16h160a16 16 0 0 1 16 16M48 208h24V48H48Zm160 0V48H88v160z\"\u002F>\u003C\u002Fg>",1788008980769]