<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jin Xu</title>
    <description>The latest articles on DEV Community by Jin Xu (@jin_xu_68829b12fa0db4f31d).</description>
    <link>https://dev.to/jin_xu_68829b12fa0db4f31d</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4224104%2F022f0835-0a73-43c6-83ef-41325c27a377.jpg</url>
      <title>DEV Community: Jin Xu</title>
      <link>https://dev.to/jin_xu_68829b12fa0db4f31d</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jin_xu_68829b12fa0db4f31d"/>
    <language>en</language>
    <item>
      <title>Shipping a Privacy-First "Super App" With Expo SDK 57, SQLite and Zero Servers</title>
      <dc:creator>Jin Xu</dc:creator>
      <pubDate>Tue, 03 Nov 2026 15:35:38 +0000</pubDate>
      <link>https://dev.to/jin_xu_68829b12fa0db4f31d/shipping-a-privacy-first-super-app-with-expo-sdk-57-sqlite-and-zero-servers-1lf2</link>
      <guid>https://dev.to/jin_xu_68829b12fa0db4f31d/shipping-a-privacy-first-super-app-with-expo-sdk-57-sqlite-and-zero-servers-1lf2</guid>
      <description>&lt;p&gt;At one point my phone had a calendar app, a budgeting app, a habit tracker, a notes app, a password manager, a mood journal and a shopping list app. Each wanted its own account. Three wanted a subscription. All of them wanted my data on their servers.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;LifeDesk&lt;/strong&gt;, a single app that covers all of that and more. I gave myself one hard rule: &lt;strong&gt;no backend.&lt;/strong&gt; No LifeDesk account, no LifeDesk server, nothing to breach. This post covers what that rule forced me to figure out.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The rule: your phone is the database
&lt;/h2&gt;

&lt;p&gt;Everything lives in one SQLite file on the device (&lt;code&gt;expo-sqlite&lt;/code&gt;, WAL mode). Onboarding asks for one thing, your name, and even that never leaves the phone.&lt;/p&gt;

&lt;p&gt;This changes a lot. Without a server there's no "fix it in the API": every bug fix that touches data has to ship as a client migration and run correctly on thousands of devices in states you can't see.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Expo SDK 57 / React Native 0.86&lt;/strong&gt;, &lt;code&gt;expo-router&lt;/code&gt; for file-based navigation (~120 screens)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;expo-sqlite&lt;/strong&gt; for storage, &lt;strong&gt;expo-secure-store&lt;/strong&gt; for secrets, &lt;strong&gt;expo-crypto&lt;/strong&gt; for encryption&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;i18next&lt;/strong&gt; for 5 languages (EN, VI, ES, FR, ZH)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firebase&lt;/strong&gt; used only for force-update config (Remote Config), system push topics and the AI gateway. No user data is stored there.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Migrations are your API versioning now
&lt;/h2&gt;

&lt;p&gt;The schema has gone through 20+ versions. What I learned:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Run migrations sequentially and idempotently.&lt;/strong&gt; Each step checks whether its table or column already exists, because dev builds and interrupted upgrades leave devices half-migrated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn on &lt;code&gt;PRAGMA foreign_keys&lt;/code&gt; from the start.&lt;/strong&gt; I didn't, and later had to write explicit clean-up code for orphaned rows (habit logs, order items, budgets of deleted categories).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never lose history when a user deletes a category.&lt;/strong&gt; Records move to a fallback "Other" category instead. People forgive a missing feature. They don't forgive lost data.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. A vault on a phone: don't derive the key from the PIN
&lt;/h2&gt;

&lt;p&gt;The Vault stores passwords, card numbers and CVVs encrypted with &lt;strong&gt;AES-256-GCM&lt;/strong&gt;. The tempting design is "derive the key from the user's 4-digit PIN". I chose not to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A 4-digit PIN has only 10,000 possibilities, which is too weak to be a key on its own.&lt;/li&gt;
&lt;li&gt;A forgotten PIN would make the data unrecoverable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Instead, a random per-install key lives in the iOS Keychain or Android Keystore (&lt;code&gt;expo-secure-store&lt;/code&gt;). The PIN and biometrics gate access to it. The Vault re-locks the moment you leave it, and it never shows ads.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Backup without a server: Google Drive's appDataFolder
&lt;/h2&gt;

&lt;p&gt;The obvious objection to local-first is "what if I lose my phone?". My answer is Google Drive's &lt;strong&gt;appDataFolder&lt;/strong&gt;, a hidden, app-scoped space in the user's &lt;em&gt;own&lt;/em&gt; Drive (&lt;code&gt;drive.appdata&lt;/code&gt; scope). The app can't see your other files, and I never see your backup.&lt;/p&gt;

&lt;p&gt;When a user deletes their account, the app also deletes the Drive backup and revokes the OAuth grant. That's important for both the store policies and user trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. An AI assistant that writes into SQLite
&lt;/h2&gt;

&lt;p&gt;The feature I'm proudest of: you type or say &lt;em&gt;"Lunch 12 dollars"&lt;/em&gt; or &lt;em&gt;"Remind me to call mom tomorrow at 7"&lt;/em&gt;, and the right record lands in the right module.&lt;/p&gt;

&lt;p&gt;How it works:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The model returns &lt;strong&gt;exactly one JSON object&lt;/strong&gt; that must match a strict schema.&lt;/li&gt;
&lt;li&gt;The prompt includes the user's real category list and today's date, so "next Tuesday" resolves correctly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If a required field is missing, the AI asks instead of guessing.&lt;/strong&gt; A short reply like "12k" is merged into the pending request rather than starting a new one.&lt;/li&gt;
&lt;li&gt;Every result shows as an action card with "View" and "Edit" buttons, so the user always sees what was written.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Validating everything before it touches the database mattered more than prompt tricks.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Paying for it without wrecking UX
&lt;/h2&gt;

&lt;p&gt;LifeDesk is free and ad-supported. My rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Banners only on the main tab screens. Never in the Vault or on PIN screens.&lt;/li&gt;
&lt;li&gt;Every rewarded ad is opt-in, in exchange for points the user can spend (unlocking PDF/CSV export, freezing a habit streak).&lt;/li&gt;
&lt;li&gt;App-open ads are rate-limited, so you don't get an ad every time you switch apps.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  8. What I'd do differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Turn on foreign keys on day one.&lt;/li&gt;
&lt;li&gt;Treat backup and restore as a core feature from the first sprint, not a late add-on.&lt;/li&gt;
&lt;li&gt;Scope smaller. Nine modules is a lot to polish at once.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;LifeDesk is free on &lt;a href="https://play.google.com/store/apps/details?id=com.bzotech.lifedesk" rel="noopener noreferrer"&gt;Google Play&lt;/a&gt; and the &lt;a href="https://apps.apple.com/app/id6814314145" rel="noopener noreferrer"&gt;App Store&lt;/a&gt;. I'd especially love feedback from people who care about local-first software: what would make you trust an app like this with your data?&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>sqlite</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I built a P2P disappearing-message app with React Native and WebRTC. Here's what was harder than expected</title>
      <dc:creator>Jin Xu</dc:creator>
      <pubDate>Tue, 03 Nov 2026 11:15:09 +0000</pubDate>
      <link>https://dev.to/jin_xu_68829b12fa0db4f31d/i-built-a-p2p-disappearing-message-app-with-react-native-and-webrtc-heres-what-was-harder-than-25o3</link>
      <guid>https://dev.to/jin_xu_68829b12fa0db4f31d/i-built-a-p2p-disappearing-message-app-with-react-native-and-webrtc-heres-what-was-harder-than-25o3</guid>
      <description>&lt;p&gt;Most "private" messengers still ask for your phone number. I wanted something closer to passing a note: share a short code with someone you already know, talk, and when you're done there's nothing left on either phone.&lt;/p&gt;

&lt;p&gt;That became &lt;strong&gt;IncogSnap&lt;/strong&gt;, an Android app (iOS coming) built with Expo / React Native. You create a room, get a 6-digit code (or a QR / link), your friend types it in, and the two phones talk directly over WebRTC. No account, no phone number, no email.&lt;/p&gt;

&lt;p&gt;This post is about the parts that turned out harder than they looked.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Expo SDK 57 / React Native 0.86&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;react-native-webrtc&lt;/strong&gt; for the peer connection and DataChannel&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tweetnacl&lt;/strong&gt; for crypto&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firebase Realtime Database&lt;/strong&gt; for signaling only (exchanging SDP offers/answers and ICE candidates)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;expo-screen-capture&lt;/strong&gt; to block screenshots and screen recording&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. "Peer-to-peer" doesn't connect on its own
&lt;/h2&gt;

&lt;p&gt;My first version used public STUN servers only. On the same Wi-Fi it worked every time. Then I tested one phone on mobile data and one on home Wi-Fi, and a large share of connections simply never came up.&lt;/p&gt;

&lt;p&gt;Carrier-grade NAT and symmetric NAT are the usual culprits: STUN lets each phone discover its public address, but some NATs won't accept traffic from a peer they haven't talked to first. The fix is a &lt;strong&gt;TURN relay&lt;/strong&gt; as a fallback, over both UDP and TCP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;iceServers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stun:…:3478&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;turn:…:3478?transport=udp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;turn:…:3478?transport=tcp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;credential&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trade-off is worth being honest about: when TURN is used, the relay sees both IP addresses. It can't read anything, because the payload is already end-to-end encrypted before it reaches WebRTC, but "nothing ever touches a server" stops being a true sentence. I removed that claim from the store listing.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. End-to-end encryption on top of WebRTC
&lt;/h2&gt;

&lt;p&gt;WebRTC already encrypts the transport (DTLS-SRTP), but the keys are negotiated through the signaling channel. If the signaling server were compromised, it could in principle sit in the middle. So each room also does its own key exchange:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each device generates an X25519 key pair (&lt;code&gt;nacl.box.keyPair()&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Public keys are exchanged, and both sides derive the same shared key with &lt;code&gt;nacl.box.before()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Every message, photo and clip is sealed with &lt;code&gt;nacl.secretbox&lt;/code&gt; (XSalsa20-Poly1305) and a fresh random nonce.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To make a man-in-the-middle detectable, both phones show a short &lt;strong&gt;fingerprint&lt;/strong&gt; of the two public keys. My first version used 4 bytes, which turned out to be too short: brute-forcing a colliding key pair at 32 bits is cheap. It's now 8 bytes (64 bits), shown as four groups of four hex characters so two people can read it aloud:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sessionFingerprint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;nacl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;utf8Encode&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;|&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/.&lt;/span&gt;&lt;span class="se"&gt;{1,4}&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. On mobile, "offline" is the normal state
&lt;/h2&gt;

&lt;p&gt;Pure P2P has an awkward property: if the other phone isn't connected right now, your message has nowhere to go. And on mobile the OS suspends background apps aggressively, so "the other phone isn't connected right now" happens constantly.&lt;/p&gt;

&lt;p&gt;A lost message looks exactly like a broken app, so I added a small &lt;strong&gt;encrypted mailbox&lt;/strong&gt; for 2-person rooms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If the live DataChannel send fails, the message (already encrypted with the session key) is written to &lt;code&gt;rooms/{code}/mailbox/{role}/{msgId}&lt;/code&gt; in Firebase.&lt;/li&gt;
&lt;li&gt;When the other phone reconnects, it drains the mailbox and deletes each entry.&lt;/li&gt;
&lt;li&gt;Firebase only ever holds ciphertext. It never has the key.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To make reconnecting possible, each device stores its room key pair in SecureStore instead of generating a new one on every launch. A room where nobody has been connected for an hour is closed for good: the record and its mailbox are deleted, and both phones wipe their keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Disappearing timers when two clocks disagree
&lt;/h2&gt;

&lt;p&gt;"Delete this message 5 minutes after it's seen" sounds simple until you remember that two phones never agree on the time, and either user can change the timer mid-conversation.&lt;/p&gt;

&lt;p&gt;What worked for me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The timer value travels &lt;strong&gt;with each message&lt;/strong&gt;, so a message keeps the setting it was sent with, even if someone changes the timer later.&lt;/li&gt;
&lt;li&gt;Timer changes are their own protocol message (&lt;code&gt;timer-set&lt;/code&gt;) with a timestamp, so both sides apply the same change in the same order.&lt;/li&gt;
&lt;li&gt;The countdown starts on each device when &lt;em&gt;that&lt;/em&gt; device displays the message, so clock skew between the phones doesn't matter.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. App Store rules for content you can't read
&lt;/h2&gt;

&lt;p&gt;Apple's guideline 1.2 requires apps with user-generated content to offer reporting, blocking and a way to act on abusive users, even if you can't see the content. With no accounts and E2E encryption, all of it has to work around that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Text runs through an on-device word filter before display.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Report&lt;/strong&gt; sends the reported user's anonymous ID and a reason to a moderation queue, never message content.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Block&lt;/strong&gt; ends the chat and prevents that ID from ever connecting to this device again.&lt;/li&gt;
&lt;li&gt;A confirmed ban is enforced both by the app (checked at launch and on resume) and by the database rules, so a banned ID can't create or join rooms.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Test across networks on day one.&lt;/strong&gt; Same-Wi-Fi testing hid the NAT problem completely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the privacy copy last.&lt;/strong&gt; Every architecture change (TURN, mailbox, push) made a previously true sentence false. I now treat the privacy policy as part of the code review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design for "offline" first.&lt;/strong&gt; The mailbox should have been in v1.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;IncogSnap is free on Android (ad-supported, no ads inside chats or the camera):&lt;br&gt;
&lt;a href="https://play.google.com/store/apps/details?id=com.bzotech.incogsnap" rel="noopener noreferrer"&gt;https://play.google.com/store/apps/details?id=com.bzotech.incogsnap&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It's closed source for now. I'm considering publishing the crypto and protocol module, so if you'd find that useful, tell me in the comments. I'm also happy to go deeper on any of the WebRTC or crypto parts.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>webrtc</category>
      <category>security</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
