#flutter #android #linux #windows #arquitetura #privacidade #segurança

mayinab: como eu construí um app de estudos completo (arquitetura, segurança, privacidade e os bugs que quase me mataram)

Miuna Hamasaki

em 2022, eu tava estudando pra um concurso. precisava de um jeito de guardar notas, criar flashcards e revisar conteúdo, mas sem precisar abrir dez abas diferentes e sem configurar nada por 30 minutos só pra começar.

fui testar o que existia.

notion é poderoso demais: antes de escrever a primeira anotação, você precisa entender páginas, databases, views, relations... o obsidian é a mesma coisa, você passa mais tempo configurando plugins do que estudando. o anki tem visual de 2003 e onboarding obrigatório antes de criar o primeiro card.

discord e whatsapp mandam tudo pro servidor de terceiros sem controle nenhum. eu acabava guardando notas no próprio telegram, mandando mensagem pra mim. funcionava, mas claramente não era o ideal.

todos tinham alguma coisa errada: complexos demais, exigiam conta, onboarding obrigatório, ou falhavam em privacidade. nenhum combinava a simplicidade de um chat com os recursos que um estudante realmente precisa.

então eu decidi fazer o meu. (ꐦ°᷄д°᷅)

hoje, o mayinab tem 2 mil downloads na play store, sem gastar 1 centavo com divulgação. também tem versões pra linux e windows. e nesse artigo vou abrir o capô e mostrar como tudo funciona por dentro, arquitetura, banco de dados, criptografia do backup, algoritmo de revisão espaçada, decisões de privacidade, boas práticas, bugs que me enlouqueceram, e os desafios que ainda enfrentarei.

Mayinab versão desktop: split view com sidebar de matérias, árvore de tópicos e chat de notas
versão desktop: split view com sidebar de matérias, árvore de tópicos e chat de notas

arquitetura geral

o mayinab é um app flutter com suporte a android, linux e windows. a stack principal é:

  • flutter + dart: UI e lógica de negócio
  • drift (sqlite): banco de dados local, gerado via code generation
  • riverpod: gerenciamento de estado reativo
  • go_router: navegação declarativa
  • supabase: backend opcional pra sync e vault (backup na nuvem)
  • revenuecat: gerenciamento de assinaturas no android
  • stripe: pagamentos no linux/windows

a decisão mais importante que tomei no início foi que tudo funciona 100% offline, sem conta, sem servidor. o supabase, revenuecat e stripe são opcionais, só entram em cena se o usuário quiser sincronizar o backup na nuvem ou ativar um plano premium. o app abre, você cria suas notas e estuda. ponto.

isso resolve o problema de onboarding na raiz. não tem tela de "crie sua conta" antes de usar. não tem tutorial obrigatório. não tem permissão de rede obrigatória. você baixa e começa.

organização de código

o projeto segue uma estrutura de features:

lib/
  core/          → utilitários, tema, logger, config
  database/      → drift tables, DAOs, migrations
  features/      → telas e widgets por feature
    chat/        → thread de notas estilo chat
    home/        → lista de matérias
    topic/       → árvore de tópicos
    search/      → command palette
    explore/     → content packs
    subscription/
    settings/
  models/        → DTOs e extensões sobre os tipos do drift
  providers/     → riverpod providers
  services/      → lógica de negócio (backup, SRS, mídia, etc.)
  shared/        → widgets reutilizáveis

cada feature tem seus próprios widgets, e os providers ficam separados por domínio. nada de um arquivo gigante de providers. nada de god class. (pelo menos em teoria; na prática alguns providers ficaram gordos demais e precisam de refactor. honestidade acima de tudo.)

performance e isolates

o dart executa o código em uma thread principal por padrão. se você tentar compactar um backup de 500mb ou comprimir imagens grandes diretamente nela, a interface congela e o usuário não consegue interagir com o app.

pra manter o app rodando sem travar, precisei implementar background isolates. operações que exigem muito processamento, tipo a compressão de imagens no desktop, a criação do arquivo zip de backup e a varredura do storage, rodam em processos paralelos. assim, a interface continua respondendo instantaneamente enquanto o processamento pesado acontece em segundo plano.

também criei um lru cache próprio (lib/core/lru_cache.dart) pra salvar dados que o app acessa o tempo todo, como thumbnails de vídeos e previews de links. isso evita queries repetidas no banco de dados, reduzindo o uso de cpu e poupando bateria.


banco de dados: drift + sqlite

o banco é sqlite gerenciado pelo drift, que gera o código boilerplate via build_runner. você define as tabelas como classes dart e ele gera os tipos, queries typesafe e DAOs.

a hierarquia de dados é:

SubjectFolder (pasta)
  └── Subject (matéria)
        └── Topic (tópico, aninhável infinitamente)
              └── Note (nota)
                    └── NoteTag (tag)

os tópicos suportam aninhamento infinito via parent_id. você pode ter Química → Orgânica → Aromáticos → Benzeno → Mecanismo de Nitração e o app aguenta. a sidebar de tópicos renderiza isso como uma árvore navegável.

as notas têm 7 tipos: texto, flashcard, imagem, vídeo, áudio, arquivo e checklist. cada tipo usa colunas diferentes da tabela notes, sem tabelas separadas por tipo; tudo vai na mesma, com colunas nullable pra cada caso.

Tela home do Mayinab com lista de matérias organizadas em pastas
home: pastas e matérias com badges de revisão
Thread de notas do Mayinab com fórmulas LaTeX e flashcard pendente de revisão
chat de notas: LaTeX renderizado + flashcard pendente

busca full-text

pra busca, uso FTS5 do sqlite, uma tabela virtual criada no onCreate:

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

com triggers pra manter o índice sincronizado em insert, update e delete. funciona 100% offline, sem elasticsearch, sem API externa. a velocidade é surpreendente mesmo com milhares de notas.

migrations

o esquema já foi por 16 versões. cada migração usa um padrão de ALTER TABLE ADD COLUMN idempotente, porque o sqlite não tem ADD COLUMN IF NOT EXISTS, e se o processo morrer entre a adição da coluna e o write do user_version, o app trava na próxima abertura com "duplicate column name".

a solução que adotei: envolver cada ALTER TABLE em try/catch, e silenciar apenas erros de "duplicate column". qualquer outro erro (tabela inexistente, disco cheio, lock) é relançado. parece gambiarra mas é a solução padrão no sqlite.

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

repetição espaçada: SM-2 de verdade

o algoritmo de flashcards é o SM-2 clássico, baseado na pesquisa de Wozniak dos anos 80. o anki já migrou pro FSRS, que é mais preciso, e o mayinab ainda vai seguir esse caminho.

o usuário avalia a resposta numa escala de 0 a 5:

  • 0-2 → falhou, reinicia o intervalo
  • 3 → difícil, mas passou
  • 4 → bom
  • 5 → fácil

o ease factor (fator de facilidade) começa em 2.5 e é ajustado a cada revisão:

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

o próximo intervalo é calculado assim:

  • 1ª revisão → 1 dia
  • 2ª revisão → 6 dias
  • revisões seguintes → intervalo anterior × ease factor

o SrsService é puro: sem efeitos colaterais, sem estado, sem dependências de UI. ele recebe uma nota e um rating, e retorna um record com os novos parâmetros. isso facilita muito os testes.


segurança e criptografia do vault

SITUAÇÃO ATUAL

os cadastros no supabase e na minha cloud própria estão desativados temporariamente, talvez definitivamente. o backup continua funcionando normalmente, você pode exportar pra um arquivo local ou sincronizar com o google drive, de graça, sem precisar de conta nenhuma.

o backup local exporta um snapshot ZIP de todos os dados e mídias. o vault (backup na nuvem) criptografa esse snapshot antes de enviar pro supabase storage.

a criptografia usa PBKDF2-HMAC-SHA256 pra derivação de chave + AES-256-GCM pra criptografia. o GCM é criptografia autenticada, se alguém adulterar o arquivo ou a senha estiver errada, o decrypt lança exceção em vez de retornar silenciosamente lixo.

parâmetros:

  • salt: 16 bytes aleatórios (por backup)
  • IV: 12 bytes aleatórios (por chunk)
  • PBKDF2 iterations: 100.000
  • chave derivada: 256 bits

formato chunked (v2)

o formato original (v1) carregava o arquivo inteiro na memória antes de criptografar; um backup grande de 500MB causava pico de ~1.5GB de RAM. o v2 processa em chunks de 1MB:

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

cada chunk tem seu próprio IV. o pico de memória cai pra aproximadamente input + 1MB. backups maiores que seu upload de memória param de crashar o app. (°ロ°)

o formato v1 ainda é suportado na leitura pra retrocompatibilidade, o código detecta o magic header "BKC1" pra distinguir os formatos.

legado: por que tinha AES-CBC antes

a primeira versão usava SHA-256(senha) como chave diretamente (sem PBKDF2) + AES-CBC. isso é inseguro por dois motivos: sem KDF, a força da chave é limitada pela entropia da senha humana; CBC não fornece autenticação, então bytes corrompidos ou adulterados geram plaintext inválido silenciosamente. migrei tudo pra PBKDF2 + GCM.


privacidade e ética

privacidade não é feature, é pré-requisito. o app foi desenhado em torno dela:

  • sem conta obrigatória: os dados ficam no dispositivo. o supabase só entra se você explicitamente ativar o vault
  • sem telemetria: não envio analytics de uso, não rastreio o que você estuda, não mando nada pra nenhum servidor sem consentimento
  • sem ads: nunca. o backup é gratuito via google drive ou arquivo local
  • zero-knowledge backup: o vault criptografa os dados localmente antes de enviar. o servidor recebe bytes criptografados, nem eu consigo ler o que você salvou
  • delete account funciona de verdade: tem um script SQL dedicado (supabase_delete_account.sql) que apaga tudo: dados, arquivos, conta. sem lixo residual

a comparação com concorrentes é honesta. o discord é conveniente mas os dados vão pros servidores deles sem criptografia de ponta a ponta. o whatsapp tem E2E no chat mas os backups no google drive ficam sem criptografia por padrão (mudou parcialmente em versões recentes). o notion guarda tudo no servidor deles, sem opção de self-host fácil.

não estou dizendo que esses apps são ruins, são ótimos pra seus casos de uso. mas pra guardar anotações de estudo, especialmente conteúdo sensível como materiais de concurso, questões militares ou de saúde, privacidade importa.


bugs e desafios reais

vou ser transparente. o desenvolvimento teve momentos ruins. muitos. aqui vão os maiores:

o freeze de navegação (7 tentativas)

o bug mais tenaz que já enfrentei. ao navegar rapidamente entre subjects e subtopics, o app congelava permanentemente. não era lag, era freeze total, tinha que matar o processo.

tentei 6 coisas que não funcionaram (substituir o GptMarkdown, remover IntrinsicWidth, adicionar debounce, adicionar RepaintBoundary...) até finalmente ler o código fonte do ChatScrollObserver do pacote scrollview_observer. o construtor do pacote agendava um addPostFrameCallback que agendava outro internamente, criando um loop infinito de callbacks. a solução foi remover o ChatScrollObserver inteiramente. escrevi um artigo inteiro sobre esse bug.

flutter 3.38 quebrou o latex

o flutter_math_fork v0.7.4 usa uma classe RenderLine que não implementa computeDryBaseline, método que virou obrigatório no flutter 3.38. resultado: 733 erros por frame. o app não abria.

a solução foi um workaround que renderiza latex como texto monospace itálico em vez de equação formatada. feio, mas funcional. os pacotes são unmaintained então não tem como esperar um fix. detalhei isso aqui.

certificados SSL no windows

o dart no windows não confia automaticamente no cert store do sistema, causando CERTIFICATE_VERIFY_FAILED ao conectar no supabase. a tentação é usar badCertificateCallback = (cert, host, port) => true, que aceita qualquer certificado, inclusive de atacantes. a microsoft store rejeita isso também.

a solução foi um allowlist explícito de hosts conhecidos:

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'),
  );
};

aceita falhas de verificação apenas nos hosts conhecidos, rejeitando tudo mais. passou na revisão da microsoft store.

migration race condition

durante uma migration, se o processo morrer no intervalo entre o ALTER TABLE ser executado e o drift escrever o novo user_version, o usuário fica com o banco num estado inconsistente. na próxima abertura, o drift tenta rodar a migration de novo e explode com "duplicate column name".

o workaround do _addColumn idempotente já cobre 99% dos casos, mas há um edge case pior: o banco pode estar na versão 12 mas sem a coluna que deveria ter sido adicionada na v12; isso acontece se o processo morreu antes do ALTER TABLE mas depois do user_version write. pra isso, adicionei um beforeOpen que tenta adicionar a coluna novamente (silenciando "duplicate column").

suporte a múltiplas plataformas

o flutter promete "write once, run anywhere", mas na prática cada plataforma tem suas peculiaridades:

  • linux: sem suporte a flutter_inappwebview, precisei criar um stub local (tool/flutter_inappwebview_linux_stub) pra não travar o build
  • windows: problemas com SSL, window manager, e o MSIX pra microsoft store tem um processo de assinatura próprio
  • android: permissão SCHEDULE_EXACT_ALARM foi removida porque não é necessária e aumentava o escrutínio na play store
  • google drive no desktop: o fluxo OAuth é diferente do mobile, desktop abre browser externo, mobile usa webview nativo. precisou de serviços separados (google_drive_service.dart e google_drive_service_desktop.dart)

boas práticas que adotei

os serviços principais (backup, storage, mídia, subscription) implementam interfaces (ISnapshotService, IStorageService, etc.), o que permite trocar implementação por plataforma sem mudar os providers e testar com fakes sem depender de disco ou rede.

operações que podem falhar de forma esperada usam um tipo Result<T> próprio em vez de jogar exceções pra cima. exceções ficam pra erros realmente inesperados.

o SrsService e o VaultCrypto são puros: sem estado mutável, sem dependências de UI, sem side effects. tem um LruCache próprio (lib/core/lru_cache.dart) pra cachear previews de link, thumbnails e resultados de busca. cada provider riverpod é granular, sem AppState centralizado.


features principais

  • 7 tipos de nota: texto (markdown + latex), flashcard (com mídia rica: imagem, vídeo local, youtube), imagem, vídeo, áudio, arquivo e checklist
  • hierarquia infinita de tópicos: matéria → tópico → subtópico → subsubtópico... sem limite de profundidade
  • slash commands: /flashcard, /checklist, /image, etc.
  • busca full-text via FTS5, 100% offline
  • wiki links: [[nome do tópico]] sem precisar configurar nada
  • scribble pad: quadro de desenho à mão livre
  • OCR via google mlkit
  • estatísticas e streaks de estudo por matéria
  • pomodoro timer configurável
  • notificações de revisão de flashcards
  • temas customizados: light, dark e system
  • split view redimensionável: sidebar + chat lado a lado
  • command palette: ctrl+k
  • content packs importáveis (ex: AWS SAA-C03 com 187 notas e 142 flashcards)
  • lixeira com auto-purge de 30 dias
  • i18n: português e inglês

o que tenho de testes

o projeto tem testes unitários e de integração cobrindo:

  • SRS service (cálculo de intervalos SM-2)
  • importação de flashcards
  • content pack service
  • providers de nota, busca e SRS
  • fluxo de criação de nota (DAO)
  • wiki links
  • utilitários

o banco em memória (AppDatabase.forTesting()) garante que os testes de DAO rodam sem disco e sem estado compartilhado entre testes.


distribuição: android, linux e windows

o app foi publicado na play store em 2026, após alguns meses de testes internos. tem build como AppImage pra linux (arquivo único, sem dependências), MSIX pra microsoft store e instalador via inno setup pra quem prefere sem store.

o CI/CD é github actions: build, test, e empacotamento pra cada plataforma. o pipeline do windows em particular foi um sofrimento: o flutter clean antes do rebuild é obrigatório pra evitar cache de dart-defines que fazia o build usar credenciais antigas.

2 mil downloads em produção sem gastar 1 centavo com ads. crescimento orgânico, boca a boca, e alguns posts em fóruns de estudo.


desafios que ainda tenho pela frente

honestidade primeiro:

  • iOS: a estrutura xcode já está no repositório, mas publicar na app store custa $99/ano e exige um mac físico no pipeline de CI. não é agora.
  • sync multi-dispositivo em tempo real: hoje o vault é backup manual. sync em tempo real exigiria um sistema de conflict resolution consideravelmente mais complexo
  • pacotes unmaintained: tanto o flutter_math_fork quanto outros têm manutenção irregular. cada atualização do flutter é uma roleta
  • performance com muitas notas: o lazy loading funciona bem até uns poucos milhares. com dezenas de milhares, provavelmente precisaria de virtual scrolling mais agressivo
  • acessibilidade: tem semantics básicos mas está longe de ser completo

construir o mayinab foi, e continua sendo, um projeto pessoal que nasceu de uma necessidade real. comecei porque as ferramentas disponíveis eram ou complexas demais, ou descuidadas com privacidade, ou simplesmente feias.

aprendi muito no processo, que suporte multiplataforma em flutter é muito mais trabalho do que o marketing promete, que criptografia feita por conta própria tem armadilhas reais, que bugs intermitentes são os piores da história, e que 2 mil pessoas baixando algo que você construiu do zero bate diferente. 💀

o app ainda tem defeitos. tem código que eu olho hoje e fico com vergonha. mas está em produção, está sendo usado, e está melhorando a cada versão.

se você quer um app de estudos que abre em 0 segundos de configuração, funciona offline, não te rastreia e tem criptografia real no backup: baixa o mayinab.

e se você é dev e quiser discutir arquitetura, bugs ou flutter em geral, me manda mensagem.