#flutter #android #linux #windows #architecture #privacy #security

mayinab: how i built a complete study app (architecture, security, privacy and the bugs that almost killed me)

Miuna

in 2022 i was studying for a public exam. i needed a way to keep notes, create flashcards and review content, but without opening ten different tabs and without spending 30 minutes configuring stuff just to start.

i went to test what existed.

notion is too powerful. before writing the first note, u need to understand pages, databases, views, relations... obsidian is the same thing, u spend more time configuring plugins than studying. anki looks like its from 2003 and has mandatory onboarding before creating the first card.

discord and whatsapp send everything to third party servers with zero control. i ended up keeping notes in telegram, sending messages to myself. it worked, but clearly wasnt ideal.

all of them had something wrong. too complex, required account, mandatory onboarding, or failed at privacy. none combined the simplicity of a chat with the features a student actually needs.

so i decided to make my own. (ꐦ°᷄д°᷅)

today, mayinab has 2k downloads on the play store, without spending a single cent on ads. also has versions for linux and windows. and in this article im gonna pop the hood and show how everything works inside. architecture, database, backup encryption, spaced repetition algorithm, privacy decisions, best practices, bugs that drove me crazy, and challenges i still face.

Mayinab desktop version: split view with subject sidebar, topic tree and note chat
desktop version: split view with subject sidebar, topic tree and note chat

general architecture

mayinab is a flutter app with android, linux and windows support. the main stack is:

  • flutter + dart: UI and business logic
  • drift (sqlite): local database, generated via code generation
  • riverpod: reactive state management
  • go_router: declarative navigation
  • supabase: optional backend for sync and vault (cloud backup)
  • revenuecat: subscription management on android
  • stripe: payments on linux/windows

the most important decision i made at the start was that everything works 100% offline, no account, no server. supabase, revenuecat and stripe are optional, they only come in if the user wants to sync backup to the cloud or activate a premium plan. the app opens, u create ur notes and study. period.

this solves the onboarding problem at the root. no "create ur account" screen before using. no mandatory tutorial. no mandatory network permission. u download and start.

code organization

the project follows a feature-based structure:

lib/
  core/          -> utilities, theme, logger, config
  database/      -> drift tables, DAOs, migrations
  features/      -> screens and widgets by feature
    chat/        -> chat-style note thread
    home/        -> subject list
    topic/       -> topic tree
    search/      -> command palette
    explore/     -> content packs
    subscription/
    settings/
  models/        -> DTOs and extensions on drift types
  providers/     -> riverpod providers
  services/      -> business logic (backup, SRS, media, etc.)
  shared/        -> reusable widgets

each feature has its own widgets, and providers are separated by domain. no giant provider file. no god class. (at least in theory. in practice some providers got too fat and need refactoring. honesty above everything.)

performance and isolates

dart runs code on a main thread by default. if u try to compress a 500mb backup or crunch big images directly on it, the UI freezes and the user cant interact with the app.

to keep the app running without freezing, i had to implement background isolates. operations that need heavy processing, like image compression on desktop, creating the zip backup file and storage scanning, run on parallel processes. this way the interface keeps responding instantly while the heavy stuff happens in the background.

i also built a custom lru cache (lib/core/lru_cache.dart) to save data the app accesses all the time, like video thumbnails and link previews. this avoids repeated database queries, reducing cpu usage and saving battery.


database: drift + sqlite

the database is sqlite managed by drift, which generates boilerplate code via build_runner. u define tables as dart classes and it generates types, typesafe queries and DAOs.

the data hierarchy is:

SubjectFolder (folder)
  └── Subject (subject)
        └── Topic (topic, infinitely nestable)
              └── Note (note)
                    └── NoteTag (tag)

topics support infinite nesting via parent_id. u can have Chemistry -> Organic -> Aromatics -> Benzene -> Nitration Mechanism and the app handles it. the topic sidebar renders this as a navigable tree.

notes have 7 types: text, flashcard, image, video, audio, file and checklist. each type uses different columns from the notes table, no separate tables per type. everything goes in the same one, with nullable columns for each case.

Mayinab home screen with list of subjects organized in folders
home: folders and subjects with review badges
Mayinab note thread with LaTeX formulas and pending flashcard review
note chat: rendered LaTeX + pending flashcard

full-text search

for search, i use sqlite FTS5, a virtual table created in onCreate:

CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts
USING fts5(content, answer, content='notes', content_rowid='id')

with triggers to keep the index synced on insert, update and delete. works 100% offline, no elasticsearch, no external API. the speed is surprising even with thousands of notes.

migrations

the schema has been through 16 versions already. each migration uses an idempotent ALTER TABLE ADD COLUMN pattern, because sqlite doesnt have ADD COLUMN IF NOT EXISTS, and if the process dies between adding the column and writing user_version, the app crashes on next open with "duplicate column name".

the solution i adopted: wrap each ALTER TABLE in try/catch, and only silence "duplicate column" errors. any other error (missing table, disk full, lock) is rethrown. looks like a hack but its the standard solution in sqlite.

Future<void> _addColumn(String sql) async {
  try {
    await customStatement(sql);
  } catch (e) {
    if (e.toString().toLowerCase().contains('duplicate column')) return;
    rethrow;
  }
}

spaced repetition: real SM-2

the flashcard algorithm is the classic SM-2, based on Wozniak's research from the 80s. anki already migrated to FSRS, which is more accurate, and mayinab will follow that path too.

the user rates the answer on a scale of 0 to 5:

  • 0-2 -> failed, resets the interval
  • 3 -> hard, but passed
  • 4 -> good
  • 5 -> easy

the ease factor starts at 2.5 and adjusts every review:

easeFactor = easeFactor + (0.1 - (5 - rating) * (0.08 + (5 - rating) * 0.02));
if (easeFactor < 1.3) easeFactor = 1.3; // minimum

the next interval is calculated like this:

  • 1st review -> 1 day
  • 2nd review -> 6 days
  • following reviews -> previous interval * ease factor

the SrsService is pure: no side effects, no state, no UI dependencies. it receives a note and a rating, and returns a record with the new parameters. this makes testing so much easier.


security and vault encryption

CURRENT SITUATION

signups on supabase and my own cloud are temporarily disabled, maybe permanently. backup still works normally, u can export to a local file or sync with google drive, for free, without needing any account.

local backup exports a ZIP snapshot of all data and media. the vault (cloud backup) encrypts this snapshot before sending to supabase storage.

the encryption uses PBKDF2-HMAC-SHA256 for key derivation + AES-256-GCM for encryption. GCM is authenticated encryption, if someone tampers with the file or the password is wrong, decrypt throws an exception instead of silently returning garbage.

parameters:

  • salt: 16 random bytes (per backup)
  • IV: 12 random bytes (per chunk)
  • PBKDF2 iterations: 100,000
  • derived key: 256 bits

chunked format (v2)

the original format (v1) loaded the entire file into memory before encrypting; a big 500MB backup caused a ~1.5GB RAM spike. v2 processes in 1MB chunks:

[MAGIC "BKC1" (4 B)] [salt (16 B)] [num_chunks (4 B BE)]
{ [cipher_len (4 B BE)] [IV (12 B)] [ciphertext + GCM tag] } x N

each chunk has its own IV. the memory spike drops to approximately input + 1MB. backups larger than ur available memory stop crashing the app. (°ロ°)

v1 format is still supported for reading for backwards compatibility. the code detects the magic header "BKC1" to distinguish formats.

legacy: why AES-CBC existed before

the first version used SHA-256(password) as the key directly (no PBKDF2) + AES-CBC. this is insecure for two reasons: without KDF, key strength is limited by human password entropy; CBC doesnt provide authentication, so corrupted or tampered bytes generate invalid plaintext silently. migrated everything to PBKDF2 + GCM.


privacy and ethics

privacy isnt a feature, its a prerequisite. the app was designed around it:

  • no mandatory account: data stays on the device. supabase only comes in if u explicitly activate the vault
  • no telemetry: i dont send usage analytics, dont track what u study, dont send anything to any server without consent
  • no ads: never. backup is free via google drive or local file
  • zero-knowledge backup: the vault encrypts data locally before sending. the server receives encrypted bytes, not even i can read what u saved
  • delete account actually works: theres a dedicated SQL script (supabase_delete_account.sql) that wipes everything. data, files, account. no residual trash

the comparison with competitors is honest. discord is convenient but data goes to their servers without end-to-end encryption. whatsapp has E2E in chat but backups on google drive are unencrypted by default (partially changed in recent versions). notion stores everything on their servers, with no easy self-host option.

im not saying those apps are bad, theyre great for their use cases. but for storing study notes, especially sensitive content like exam materials, military questions or health stuff, privacy matters.


real bugs and challenges

im gonna be transparent. development had bad moments. lots of them. here are the biggest ones:

the navigation freeze (7 tries)

the most tenacious bug ive ever faced. when navigating quickly between subjects and subtopics, the app froze permanently. wasnt lag. was total freeze. had to kill the process.

i tried 6 things that didnt work (replace GptMarkdown, remove IntrinsicWidth, add debounce, add RepaintBoundary...) until finally reading the source code of ChatScrollObserver from the scrollview_observer package. the packages constructor scheduled an addPostFrameCallback that scheduled another one internally, creating an infinite callback loop. the fix was removing ChatScrollObserver entirely. i wrote a whole article about this bug.

flutter 3.38 broke latex

flutter_math_fork v0.7.4 uses a RenderLine class that doesnt implement computeDryBaseline, a method that became mandatory in flutter 3.38. result: 733 errors per frame. the app wouldnt open.

the fix was a workaround that renders latex as italic monospace text instead of formatted equations. ugly, but functional. the packages are unmaintained so theres no waiting for a fix. detailed it here (pt-br).

SSL certificates on windows

dart on windows doesnt automatically trust the system cert store, causing CERTIFICATE_VERIFY_FAILED when connecting to supabase. the temptation is to use badCertificateCallback = (cert, host, port) => true, which accepts any certificate, including from attackers. the microsoft store rejects this too.

the fix was an explicit allowlist of known hosts:

static const _trustedHosts = [
  'supabase.co', 'supabase.com',
  'googleapis.com', 'google.com',
  'cloudflare.com', 'stripe.com',
  'revenuecat.com', 'api.revenuecat.com',
];

client.badCertificateCallback = (cert, host, port) {
  return _trustedHosts.any(
    (trusted) => host == trusted || host.endsWith('.$trusted'),
  );
};

accepts verification failures only on known hosts, rejecting everything else. passed microsoft store review.

migration race condition

during a migration, if the process dies between ALTER TABLE being executed and drift writing the new user_version, the user is left with an inconsistent database. on next open, drift tries to run the migration again and explodes with "duplicate column name".

the _addColumn idempotent workaround already covers 99% of cases, but theres a worse edge case: the database could be at version 12 but missing the column that should have been added in v12. this happens if the process died before ALTER TABLE but after the user_version write. for this, i added a beforeOpen that tries to add the column again (silencing "duplicate column").

multi-platform support

flutter promises "write once, run anywhere", but in practice each platform has its quirks:

  • linux: no flutter_inappwebview support, i had to create a local stub (tool/flutter_inappwebview_linux_stub) so the build wouldnt crash
  • windows: SSL issues, window manager problems, and the MSIX for microsoft store has its own signing process
  • android: SCHEDULE_EXACT_ALARM permission was removed bc its not needed and increased scrutiny on the play store
  • google drive on desktop: the OAuth flow is different from mobile, desktop opens external browser, mobile uses native webview. needed separate services (google_drive_service.dart and google_drive_service_desktop.dart)

best practices i adopted

the main services (backup, storage, media, subscription) implement interfaces (ISnapshotService, IStorageService, etc.), which allows swapping implementation by platform without changing providers, and testing with fakes without depending on disk or network.

operations that can fail in expected ways use a custom Result<T> type instead of throwing exceptions up the stack. exceptions are for truly unexpected errors.

the SrsService and VaultCrypto are pure: no mutable state, no UI dependencies, no side effects. theres a custom LruCache (lib/core/lru_cache.dart) to cache link previews, thumbnails and search results. each riverpod provider is granular, no centralized AppState.


main features

  • 7 note types: text (markdown + latex), flashcard (with rich media: image, local video, youtube), image, video, audio, file and checklist
  • infinite topic hierarchy: subject -> topic -> subtopic -> subsubtopic... no depth limit
  • slash commands: /flashcard, /checklist, /image, etc.
  • full-text search via FTS5, 100% offline
  • wiki links: [[topic name]] without needing to configure anything
  • scribble pad: freehand drawing board
  • OCR via google mlkit
  • study statistics and streaks per subject
  • configurable pomodoro timer
  • flashcard review notifications
  • custom themes: light, dark and system
  • resizable split view: sidebar + chat side by side
  • command palette: ctrl+k
  • importable content packs (ex: AWS SAA-C03 with 187 notes and 142 flashcards)
  • trash with 30-day auto-purge
  • i18n: portuguese and english

what tests i have

the project has unit and integration tests covering:

  • SRS service (SM-2 interval calculations)
  • flashcard import
  • content pack service
  • note, search and SRS providers
  • note creation flow (DAO)
  • wiki links
  • utilities

the in-memory database (AppDatabase.forTesting()) ensures DAO tests run without disk and without shared state between tests.


distribution: android, linux and windows

the app was published on the play store in 2026, after a few months of internal testing. theres an AppImage build for linux (single file, no dependencies), MSIX for microsoft store and an inno setup installer for those who prefer no store.

CI/CD is github actions: build, test, and packaging for each platform. the windows pipeline in particular was suffering: flutter clean before rebuild is mandatory to avoid dart-defines cache that made the build use old credentials.

2k downloads in production without spending a single cent on ads. organic growth, word of mouth, and some posts on study forums.


challenges i still have ahead

honesty first:

  • iOS: the xcode structure is already in the repo, but publishing on the app store costs $99/year and requires a physical mac in the CI pipeline. not now.
  • real-time multi-device sync: today the vault is manual backup. real-time sync would require a considerably more complex conflict resolution system
  • unmaintained packages: both flutter_math_fork and others have irregular maintenance. every flutter update is roulette
  • performance with many notes: lazy loading works well up to a few thousand. with tens of thousands, probably would need more aggressive virtual scrolling
  • accessibility: has basic semantics but is far from complete

building mayinab was, and still is, a personal project born from a real need. i started because available tools were either too complex, careless with privacy, or just plain ugly.

i learned a lot in the process. that multi-platform support in flutter is way more work than the marketing promises, that diy cryptography has real pitfalls, that intermitent bugs are the worst in history, and that 2k people downloading something u built from scratch hits different. (⌐■_■)

the app still has flaws. theres code i look at today and feel embarrassed. but its in production, its being used, and its improving every version.

if u want a study app that opens in 0 seconds of configuration, works offline, doesnt track u and has real encryption on backup: download mayinab.

and if ur a dev and wanna discuss architecture, bugs or flutter in general, hit me up.

ler em português →