I wanted to consult federal laws on my phone the way I consult them on the web: open the text, search with plain words, read it. Not a legal database with an account and a sync button, and not a chat that summarizes the law: the literal text, offline, with a search box that understands the question.
The Planalto publishes the full text of every Brazilian federal law as HTML, free, with no API. So the app builds its own library from those pages, on the device: it downloads, parses, stores and indexes. The result is Lista Leis, an Android app with 121 laws across 17 areas, now on Google Play. It has no backend, no account, no analytics; the only network calls go to the Planalto pages and to Google Play for billing.
The home screen: the four free laws ready to read, the rest tagged Premium.
The source is a page built for reading, not for parsing
The whole catalog comes from a single kind of source: one HTML page per law at planalto.gov.br. There is no feed, no JSON, no change log. There is a page written for humans, and the app reads exactly that. Three things about these pages shape the whole parsing problem.
The markup is old and uneven. Most pages are ISO-8859-1; one of them (Maria da Penha) is served as UTF-16LE with the last byte cut off at the server, so the decoder has to look at the BOM before assuming an encoding. The source also contains typos of its own, like a mangled chapter header in the Lei das Contravenções Penais.
The same article can appear twice. When an amendment rewrites an article, the page keeps the old text and appends the new one, marked “Redação dada pela Lei X”. Reading order alone lies, so the parser keeps the last occurrence of each article number.
Citations look exactly like the law. When a law cites articles of another statute, the citation sits in a <blockquote> and contains headers like “Art. 1º”. Left alone, every citation becomes a ghost article of the law being parsed. The parser strips blockquotes recursively, innermost first:
// Citations from other laws look like articles: out.
// Remove the innermost blocks first, until the page stops changing.
val inner = Regex("""(?is)<blockquote\b[^>]*>(?:(?!</?blockquote\b).)*</blockquote>""")
while (true) {
val next = inner.replace(out, "")
if (next == out) return out
out = next
}
From there the parser walks p, h1, h2 and h3 elements, splits each one into lines at <br>, and recognizes three things: article headers, divisions (PARTE, LIVRO, TÍTULO, CAPÍTULO, SEÇÃO) and epigraphs, the short bold rubric that names an article. The number has to survive what the source does to it: Art. 42, Art. 1.595 with a dotted thousand, Art. 9º-A from an amendment, and Art. 1o with the ordinal in a <sup> tag. The base keeps a conservative pattern, and a law opts into an override when its page does something else: the Código Eleitoral writes Ar. and At., the Lei das Eleições writes Art without the dot, and the Código de Processo Penal Militar (CPPM) has a bold line (“Parte de deserção”) that looks like a division but is a note inside an article. No override changes how any other law is parsed.
There is also an end-of-document marker: the notice “Este texto não substitui o publicado no DOU”. Except it is not always at the end. Lei 8.112 contains two of them, one inside a re-publication note; only the last occurrence closes the document.
All of this is why the app carries one parser per law when needed. It is the same problem repeated across 121 pages that disagree with each other.
The law index: divisions collapse into titles and chapters, in the same structure as the source.
A hash decides whether anything changed
Checking 121 pages is only practical if the check itself is cheap. Every fetch computes a SHA-256 over the page after removing its <script> elements. The Planalto injects an anti-bot script whose payload changes on every load; hashing the raw HTML would mark every law as changed every time.
The stored hash also carries the parser version, sha256(stableHtml(html)) + ":p" + parseVersion. When the parsing logic improves, the hash from the previous version no longer matches, and the next pass rebuilds the law without a database migration.
A match means the content is the same, and the app updates only the “checked at” date. A different hash means re-parse: sections and articles are replaced inside a single transaction, the hash is written last (a crash in the middle never leaves a law marked as ingested with zero articles), and the search cache is invalidated. An empty parse is treated as a transient error page from the source, not as a law with no articles: the previous content stays and the app tries again later.
Search with the words you have
A search box that needs the right keyword is not useful for a law library. If someone types “cobraram duas vezes o mesmo item”, the article that answers it probably says “cobrança indevida”. So every article is embedded on the device with a Universal Sentence Encoder, through MediaPipe. The model is about 6 MB and ships inside the APK, so there is no first-run download; the vectors live as blobs in the same local database as the text, and the query is embedded at search time and compared to the corpus with cosine similarity.
The semantic branch alone would be a black box, so the ranking merges four signals:
| signal | what it is | weight |
|---|---|---|
| semantic | cosine between the query and each article embedding | the base score |
| keyword | LIKE over the article text | 0.7, plus a boost when it hits the epigraph |
| approximate | Levenshtein similarity between query terms and article tokens | enters at 0.62 similarity, adds 0.3 |
| article number | “art. 42” recognized in the query | 2.0, leads the list |
The number path exists because people search for articles by number, and an exact article should never lose to a semantically close one. The approximate path is the typo tolerance: it is what makes “sussego” find “sossego”.
Results come in two passes. The keyword ranking is emitted first, in tens of milliseconds, and the merged final ranking replaces it when the embedding and the approximate pass finish. A failure in the embedding does not break the search: that branch contributes nothing and the result comes from the rest. Repeated queries hit a local cache with a seven-day validity, kept per scope, so a search inside one law is cached separately from the global search.
Search for “defeito” inside the Consumer Defense Code: the section path sits above each article, and the main group is separated from “Outros artigos possíveis”.
Read first, index later
Parsing a law is fast. Generating its embeddings is not. The app keeps two serial queues: download plus parse, and index. A law becomes readable as soon as it is parsed, still without vectors; the embeddings arrive in the background. Opening a law moves it to the front of both queues, and the indexing task is interrupted between articles, writing each vector as it goes, so it resumes from the first article still missing one.
On a fresh install the four free laws download right away; premium laws join the same queue after the purchase is restored.
The reader, with the literal text and the link back to the source. The footnote repeats what the text is: literal, from the official source, and not a substitute for the Diário Oficial.
Public text, paid tool
Brazilian federal law text is public: it is free to copy, and the app takes it from the official source and shows it literally. What the app sells is the tool around it: the on-device search, the offline library, the catalog beyond the first four laws. Those four are free for everyone: the Código de Defesa do Consumidor (CDC), the Lei das Contravenções Penais (LCP), the Consolidação das Leis do Trabalho (CLT) and the Maria da Penha Law. The other 117 are unlocked with a monthly or annual subscription, or a one-time founder purchase. Billing runs through Google Play, so the purchase follows the Google account and restores on a new device without an account inside the app.
Honest limits
Three things the app is not:
- It is a reader, not advice. It shows the literal text with a link to the source and does not summarize or interpret anything; the publication in the Diário Oficial is what prevails.
- The source can change underneath it. The parsers are tested against slices of the real pages, and a page that stops parsing leaves the previous content in place, but a redesign at the Planalto breaks the parser, and that is not something the phone fixes on its own.
- Semantic search ranks, it does not answer. It can miss a relevant article or order results differently than a person would; the exact number search and the keyword floor are the guarantees, everything else is a ranking.
The app is an independent repository of legal texts, with no link to any government body.
If you want to check anything the app shows, the path is the one in the code above: planalto.gov.br plus the page of the law. Any browser and a view-source handle the rest. The same text is in the app, byte for byte, because that is the only kind of fidelity worth promising.