NO SUS: a Flutter web + Android privacy app I built, and three Flutter things that bit me

Hi all, I’m Shubham, an engineering student in Mumbai. For the last few months I’ve been building NO SUS, a Flutter app (web plus Android) for sharing documents and notes you don’t want sticking around. I’m the only developer, it’s Alpha and free right now, and the client is MIT on GitHub. So this is a maker post, and I’m mostly here for feedback.

What’s shipped:

  • Burn Notes / Burn Files: encrypted in the browser with AES-256 (CTR for notes, CBC for files), no account on either side, files up to 25 MB, expiry 1h/24h/7d, and it opens once, then it’s gone. The key lives in the URL fragment.
  • Go: open your Saved items on a borrowed PC (cyber cafe, college lab) by scanning a QR with the phone app, matching a 2-digit code and approving with fingerprint or face. The PC never gets your Google password or token.
  • SecureSend: each viewer’s identity is watermarked on the document, with view limits, expiry, a view log and revocation.
  • On Android, screenshots and screen recording are blocked with OS flags. On web that isn’t possible, so it’s touch-to-reveal blur plus the watermark: deterrence and attribution, not prevention.

Honest limit: SecureSend and group files are not end-to-end encrypted. They rely on Supabase RLS policies.

Stack: Flutter, Riverpod 3, supabase_flutter (Postgres + RLS, Deno edge functions), encrypt/pointycastle for the crypto, local_auth for the biometric step, pdfrx for viewing.

Three Flutter-specific things I learned the hard way:

  1. Android App Links can’t match on the URL fragment. Every link the app mints looks like https://<host>/#/burn/..., so the autoVerify intent filter can’t be path-scoped. Once the app is installed it catches every link to that host. If the native side doesn’t recognise a link shape, the link just lands on the home screen. There’s no browser fallback, because Android already chose the app. I ended up with one routing function that has to know every link shape the app can create, mirroring the Uri.base handling on web, plus tests that pin the old formats so links already out there keep working.

  2. ref.watch(provider.select(...)) silently does nothing if your models don’t override ==/hashCode. Every list emission creates new instances, so the selected value never compares equal and you rebuild anyway. There’s no error, the optimisation just turns into a no-op. I only noticed because a group detail screen rebuilt whenever any group changed.

  3. Multi-file burn shares: each file gets its own key/IV, and the encrypt → upload → confirm pipeline for each file runs in parallel with Future.wait, with the AES work in compute(). On mobile that keeps the UI responsive. On web, compute() doesn’t get a separate isolate, so the heavy part still competes with the UI there.

Feedback I’d like:

  • Has anyone moved heavy crypto off the main thread in Flutter web cleanly (a Web Worker, wasm, anything)? That’s the part I’d most like ideas on.
  • Is there a better answer to the fragment + App Links problem than one big router that knows every link shape?
  • If you try a burn link on web, does the flow make sense the first time?

Site: https://nosus.foo
Code: GitHub - https-shubhamsahu/NON_SUS · GitHub

1 Like

Dealing with files on web was always a tough thing due to the differences in how browser uses files and flutter via dart io which is not supported on the web…I would implement the file functionalities with web APIS and use dart js_interop to communicate with flutter, this way you can even offload the work to a web worker.

We all got burned by the equality part, objects cannot be compared properly if they dont override hashcode and equality, always do that for state objects

Thanks, this is where I was leaning. On web the AES work goes through compute(), which there is just the main isolate, so the heavy part still competes with the UI. Two options I’m weighing:

  • Move the cipher to WebCrypto (crypto.subtle.encrypt through dart:js_interop). It’s promise-based and the browser runs it off the main thread, so encrypt/decrypt might not need a worker at all. Reading the file and copying bytes between Uint8List and JSUint8Array would still happen on the main thread.
  • A dedicated Worker that owns the whole read, encrypt, upload chain. You’d post it the File/Blob so the bytes never pass through Dart on the main thread.

Have you gone the worker route yourself? If so, did the file reading go in the worker too, or only the crypto? And was the worker plain JS, or Dart compiled for it separately?

On equality, agreed. Adding explicit ==/hashCode to the state classes is what fixed the group screen.

Correction from me, 29 Sep 2026 (the edit window on the first post has closed): the source is private now, so the Code link above 404s and the MIT mention no longer applies. A few details have also changed since I wrote it. Every single burn note or file now gets the two-digit code by default, and while that code is valid (20 minutes by default) its key is held on the server too, then swept within about 10 minutes after the code is used or expires. Go approval uses your phone’s screen lock. And the SecureSend watermark is the email each viewer enters, which is an attribution clue, not a verified identity.

You can pass File objects to Web workers but becareful, the files are copied into memory(the workers are multiprocessing implementations so the memory space is not shared similar to isolates from Dart), what you want to do for large files is process them on service worker as a stream and proccess it by chunks. There is also a StreamSaver.js package which tricks the browser into using a file stream download that will be used by you to write the desired file.

Thanks — the stream/chunk point is the one I was missing. Copying a whole File into a Worker for a 25 MB burn would just move the memory spike, so for larger files the Worker (or a service worker) has to own the read as a stream and encrypt/upload by chunks. StreamSaver.js is a good tip for the download/redeem side too, where the browser otherwise wants to buffer the whole decrypted blob.

I’ll try a Worker that receives the File handle, reads it in chunks, and posts progress back to Dart over js_interop, instead of shipping the full Uint8List across. If that works cleanly on web I’ll report back here. Appreciate the detail.