From 2e412c899728941c21c35d9198cf045d974ffac1 Mon Sep 17 00:00:00 2001 From: Patrice Haltaya Date: Thu, 29 Oct 2026 16:16:10 +0100 Subject: [PATCH 01/16] feat(import): one Markdown reader for the share sheet and the import First brick of 3.2.0 (docs/13-VERSION-3.2.md, reviewed by GPT 5.6 sol: 16 findings, all checked, the kept ones folded into the backup design). LecteurMarkdown reads back the export's own frontmatter exactly - quoted strings with its escapes, local dates, inline tags - and the common forms of other tools (plain values, block tag lists, dates with an offset). A frontmatter that never closes is not one; a byte-order mark and CRLF are handled; a title is cut at the repository's 200 without splitting a surrogate pair; a tag holding a comma is split, since tags are stored comma-joined. Tested through the real NoteMarkdown.render; two sabotages (an escape, the CRLF conversion) each caught by the test meant for it. Co-Authored-By: Claude Opus 5.5 --- .../domain/importation/LecteurMarkdown.kt | 212 ++++++++++++++++++ .../domain/importation/LecteurMarkdownTest.kt | 137 +++++++++++ docs/13-VERSION-3.2.md | 189 ++++++++++++++++ 3 files changed, 538 insertions(+) create mode 100644 app/src/main/java/com/filestech/notes_tech/domain/importation/LecteurMarkdown.kt create mode 100644 app/src/test/java/com/filestech/notes_tech/domain/importation/LecteurMarkdownTest.kt create mode 100644 docs/13-VERSION-3.2.md diff --git a/app/src/main/java/com/filestech/notes_tech/domain/importation/LecteurMarkdown.kt b/app/src/main/java/com/filestech/notes_tech/domain/importation/LecteurMarkdown.kt new file mode 100644 index 0000000..e1a5797 --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/domain/importation/LecteurMarkdown.kt @@ -0,0 +1,212 @@ +package com.filestech.notes_tech.domain.importation + +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneId +import java.time.format.DateTimeParseException + +/** + * A Markdown file read back into a note (3.2.0): what a share from another app and the Markdown import + * both go through, so that a file means the same thing whichever door it comes in by. + * + * ## What it reads + * + * - **The frontmatter of Notes Tech's own export** (`NoteMarkdown.render`), exactly: double-quoted + * strings with its escapes (`\\`, `\"`, `\n`, `\r`, `\t`, `\xHH`), local dates without an offset, + * inline tag lists. A note exported then imported keeps its title, tags, dates, pin and favourite. + * - **The common forms of other tools** (Obsidian, Logseq, Bear): unquoted values, single quotes, a + * block list of tags (`- tag`), dates with an offset or a date alone. + * - Unknown keys are ignored; a frontmatter that never closes is not one, and the whole file is the body. + * - Windows files: a byte-order mark is dropped and `\r\n` becomes `\n`. + * + * It never fails: whatever the text, the result is a note. Bounds (file size, number of files) are + * the caller's, which knows where the text came from. + */ +object LecteurMarkdown { + + /** The longest title the repository accepts (`NotesRepository.TITLE_MAX_LENGTH`). */ + const val TITRE_MAX = 200 + + /** + * @param nomDuFichier the file's name, if there is one: the title when the frontmatter gives none. + * @param zone the time zone of a date written without an offset — the export's own form. + */ + fun lire(texte: String, nomDuFichier: String? = null, zone: ZoneId = ZoneId.systemDefault()): NoteImportee { + val propre = texte.removePrefix("\uFEFF").replace("\r\n", "\n") + val (champs, corps) = separer(propre) + val titre = champs["title"]?.let(::scalaire)?.takeIf { it.isNotBlank() } + ?: nomDuFichier?.let(::titreDuNom)?.takeIf { it.isNotBlank() } + ?: "" + return NoteImportee( + titre = tronquer(titre.trim()), + contenu = corps, + // ⚠️ Split on commas too: tags are stored comma-joined, unescaped (`TagCodec`), so a tag + // carrying one would come back as two anyway — better now than half-broken in the database. + etiquettes = champs["tags"]?.let(::liste).orEmpty() + .flatMap { it.split(',') }.map(String::trim).filter(String::isNotEmpty).distinct(), + dossier = champs["folder"]?.let(::scalaire)?.takeIf { it.isNotBlank() }, + creeeLe = champs["created"]?.let { date(scalaire(it), zone) }, + modifieeLe = champs["updated"]?.let { date(scalaire(it), zone) }, + epinglee = champs["pinned"]?.let(::scalaire)?.trim() == "true", + favorite = champs["favorite"]?.let(::scalaire)?.trim() == "true", + ) + } + + /** A title for a shared text: its subject if it has one, otherwise its first non-blank line. */ + fun titreDUnTexte(sujet: String?, texte: String): String = + tronquer((sujet?.takeIf { it.isNotBlank() } ?: texte.lineSequence().firstOrNull { it.isNotBlank() }.orEmpty()).trim()) + + // ── Frontmatter ───────────────────────────────────────────────────────────────────────────── + + /** + * The raw values by key, and the body. A value is the rest of its line; a block list (`tags:` then + * ` - a` lines) is kept as its lines, joined by `\n`, for [liste]. + */ + private fun separer(texte: String): Pair, String> { + if (!texte.startsWith("---\n")) return emptyMap() to texte + val fin = texte.indexOf("\n---", startIndex = 3) + if (fin < 0) return emptyMap() to texte + val apresFin = texte.indexOf('\n', fin + 4).let { if (it < 0) texte.length else it + 1 } + if (texte.substring(fin + 4, if (apresFin == texte.length) texte.length else apresFin - 1).isNotBlank()) { + return emptyMap() to texte + } + val champs = linkedMapOf() + var cleCourante: String? = null + for (ligne in texte.substring(4, fin + 1).lineSequence()) { + val enListe = ligne.trimStart() + if (cleCourante != null && enListe.startsWith("- ")) { + champs[cleCourante] = champs[cleCourante].orEmpty() + "\n" + enListe.removePrefix("- ") + continue + } + if (ligne.isBlank() || ligne.trimStart().startsWith("#") || ligne.first().isWhitespace()) continue + val deuxPoints = ligne.indexOf(':') + if (deuxPoints <= 0) { + cleCourante = null + continue + } + cleCourante = ligne.substring(0, deuxPoints).trim() + champs[cleCourante] = ligne.substring(deuxPoints + 1).trim() + } + // The export writes a blank line after the closing fence, and a final line break the note may + // not have had. + val corps = texte.substring(apresFin).removePrefix("\n").removeSuffix("\n") + return champs to corps + } + + /** A YAML scalar: double-quoted with escapes, single-quoted, or plain. */ + private fun scalaire(brut: String): String { + val valeur = brut.trim() + return when { + valeur.length >= 2 && valeur.startsWith('"') && valeur.endsWith('"') -> + desechapper(valeur.substring(1, valeur.length - 1)) + valeur.length >= 2 && valeur.startsWith('\'') && valeur.endsWith('\'') -> + valeur.substring(1, valeur.length - 1).replace("''", "'") + else -> valeur.substringBefore(" #") + } + } + + /** `[a, "b, c"]`, a block list, or a single value. */ + private fun liste(brut: String): List { + val valeur = brut.trim() + if (valeur.contains('\n')) { + return valeur.split('\n').map(String::trim).filter(String::isNotEmpty).map(::scalaire) + } + if (!valeur.startsWith('[') || !valeur.endsWith(']')) return listOfNotNull(valeur.takeIf { it.isNotEmpty() }?.let(::scalaire)) + val elements = mutableListOf() + val courant = StringBuilder() + var guillemet: Char? = null + var echappe = false + for (c in valeur.substring(1, valeur.length - 1)) { + when { + echappe -> { courant.append(c); echappe = false } + guillemet == '"' && c == '\\' -> { courant.append(c); echappe = true } + guillemet != null && c == guillemet -> { courant.append(c); guillemet = null } + guillemet == null && (c == '"' || c == '\'') -> { courant.append(c); guillemet = c } + guillemet == null && c == ',' -> { elements += courant.toString(); courant.clear() } + else -> courant.append(c) + } + } + elements += courant.toString() + return elements.map(String::trim).filter(String::isNotEmpty).map(::scalaire) + } + + /** The escapes `NoteMarkdown.yamlString` writes, and nothing else: an unknown one is kept as written. */ + private fun desechapper(s: String): String { + val sortie = StringBuilder(s.length) + var i = 0 + while (i < s.length) { + val c = s[i] + if (c != '\\' || i + 1 >= s.length) { + sortie.append(c) + i++ + continue + } + when (val suivant = s[i + 1]) { + '\\' -> sortie.append('\\') + '"' -> sortie.append('"') + 'n' -> sortie.append('\n') + 'r' -> sortie.append('\r') + 't' -> sortie.append('\t') + 'x' -> { + val code = s.substring(i + 2, minOf(i + 4, s.length)).toIntOrNull(16) + if (code != null && i + 4 <= s.length) { + sortie.append(code.toChar()) + i += 4 + continue + } + sortie.append('\\').append(suivant) + } + else -> sortie.append('\\').append(suivant) + } + i += 2 + } + return sortie.toString() + } + + /** The export's local form first, then an offset, then a date alone (at midnight). Else none. */ + private fun date(texte: String, zone: ZoneId): Instant? { + val t = texte.trim() + return try { + LocalDateTime.parse(t).atZone(zone).toInstant() + } catch (_: DateTimeParseException) { + try { + OffsetDateTime.parse(t).toInstant() + } catch (_: DateTimeParseException) { + try { + LocalDate.parse(t).atStartOfDay(zone).toInstant() + } catch (_: DateTimeParseException) { + null + } + } + } + } + + private fun titreDuNom(nom: String): String { + val seul = nom.substringAfterLast('/') + val sansExtension = seul.substringBeforeLast('.', seul) + // The suffix the export adds to a note of an unlocked vault (`NoteMarkdown.safeFileName`). + return sansExtension.removeSuffix(" [unlocked]") + } + + /** At most [TITRE_MAX] UTF-16 units, never half of a surrogate pair. */ + private fun tronquer(titre: String): String { + if (titre.length <= TITRE_MAX) return titre + val coupe = titre.take(TITRE_MAX) + return if (coupe.last().isHighSurrogate()) coupe.dropLast(1) else coupe + } +} + +/** A note read from a Markdown file, before it has an id or a folder. */ +data class NoteImportee( + val titre: String, + val contenu: String, + val etiquettes: List, + /** The `folder:` of the frontmatter, if any — the export's folder label. */ + val dossier: String?, + val creeeLe: Instant?, + val modifieeLe: Instant?, + val epinglee: Boolean, + val favorite: Boolean, +) diff --git a/app/src/test/java/com/filestech/notes_tech/domain/importation/LecteurMarkdownTest.kt b/app/src/test/java/com/filestech/notes_tech/domain/importation/LecteurMarkdownTest.kt new file mode 100644 index 0000000..68dcc28 --- /dev/null +++ b/app/src/test/java/com/filestech/notes_tech/domain/importation/LecteurMarkdownTest.kt @@ -0,0 +1,137 @@ +package com.filestech.notes_tech.domain.importation + +import com.filestech.notes_tech.domain.export.NoteMarkdown +import com.filestech.notes_tech.domain.model.EncryptedFormat +import com.filestech.notes_tech.domain.model.Note +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import java.time.Instant +import java.time.ZoneId + +/** + * The Markdown reader of 3.2.0, shared by the share sheet and the import. Its first duty: a note + * exported by Notes Tech comes back as it left — through the export's real writer, not a copy of its + * format. + */ +class LecteurMarkdownTest { + + private val zone = ZoneId.of("Europe/Paris") + + @Test + fun `a note exported by Notes Tech comes back with its title, tags, dates, pin and favourite`() { + val note = note( + titre = "Courses \"samedi\" \\ ok\tet\u0007 fin", + contenu = "# Liste\n\n- pain\n- [[Recettes]]\n", + etiquettes = listOf("maison", "à faire"), + epinglee = true, + favorite = true, + ) + val fichier = NoteMarkdown.render(note, folderLabel = "Perso", vaultMention = "from a vault", zone = zone) + + val lue = LecteurMarkdown.lire(fichier, "ignored.md", zone) + + assertThat(lue.titre).isEqualTo(note.title) + assertThat(lue.contenu).isEqualTo("# Liste\n\n- pain\n- [[Recettes]]") + assertThat(lue.etiquettes).containsExactly("maison", "à faire").inOrder() + assertThat(lue.dossier).isEqualTo("Perso") + assertThat(lue.creeeLe).isEqualTo(note.createdAt) + assertThat(lue.modifieeLe).isEqualTo(note.updatedAt) + assertThat(lue.epinglee).isTrue() + assertThat(lue.favorite).isTrue() + } + + @Test + fun `a note without line break at the end, exported then read, is unchanged`() { + val note = note(contenu = "une ligne") + val lue = LecteurMarkdown.lire(NoteMarkdown.render(note, "Inbox", zone = zone), null, zone) + assertThat(lue.contenu).isEqualTo("une ligne") + assertThat(lue.etiquettes).isEmpty() + assertThat(lue.epinglee).isFalse() + } + + @Test + fun `the common forms of other tools are read`() { + val fichier = """ + --- + title: Réunion de lundi + tags: + - travail + - "projet, X" + created: 2026-03-01 + updated: 2026-03-02T10:15:00+01:00 + aliases: [ignored] + --- + Corps + """.trimIndent() + + val lue = LecteurMarkdown.lire(fichier, null, zone) + + assertThat(lue.titre).isEqualTo("Réunion de lundi") + // "projet, X" would break the comma-joined storage: it becomes two tags. + assertThat(lue.etiquettes).containsExactly("travail", "projet", "X").inOrder() + assertThat(lue.creeeLe).isEqualTo(Instant.parse("2026-02-28T23:00:00Z")) + assertThat(lue.modifieeLe).isEqualTo(Instant.parse("2026-03-02T09:15:00Z")) + assertThat(lue.contenu).isEqualTo("Corps") + } + + @Test + fun `without frontmatter the title is the file name, and the text is the body`() { + val lue = LecteurMarkdown.lire("Juste du texte\nsur deux lignes", "dossier/Idées du jour [unlocked].md", zone) + assertThat(lue.titre).isEqualTo("Idées du jour") + assertThat(lue.contenu).isEqualTo("Juste du texte\nsur deux lignes") + assertThat(lue.creeeLe).isNull() + } + + @Test + fun `a frontmatter that never closes is not one`() { + val texte = "---\ntitle: pas fermé\n\nla suite" + val lue = LecteurMarkdown.lire(texte, "brouillon.md", zone) + assertThat(lue.titre).isEqualTo("brouillon") + assertThat(lue.contenu).isEqualTo(texte) + } + + @Test + fun `a Windows file — byte-order mark and CRLF — is read like any other`() { + val texte = "\uFEFF---\r\ntitle: \"Win\"\r\n---\r\n\r\nligne 1\r\nligne 2\r\n" + val lue = LecteurMarkdown.lire(texte, null, zone) + assertThat(lue.titre).isEqualTo("Win") + assertThat(lue.contenu).isEqualTo("ligne 1\nligne 2") + } + + @Test + fun `a title longer than the repository accepts is cut, never inside a surrogate pair`() { + val titre = "a".repeat(LecteurMarkdown.TITRE_MAX - 1) + "😀" + "fin" + val lue = LecteurMarkdown.lire("---\ntitle: \"$titre\"\n---\n", null, zone) + assertThat(lue.titre).isEqualTo("a".repeat(LecteurMarkdown.TITRE_MAX - 1)) + assertThat(lue.titre.length).isAtMost(LecteurMarkdown.TITRE_MAX) + } + + @Test + fun `a shared text is titled by its subject, else its first non-blank line`() { + assertThat(LecteurMarkdown.titreDUnTexte("Sujet", "corps")).isEqualTo("Sujet") + assertThat(LecteurMarkdown.titreDUnTexte(null, "\n\n Première vraie ligne \nsuite")).isEqualTo("Première vraie ligne") + assertThat(LecteurMarkdown.titreDUnTexte(" ", "")).isEmpty() + } + + private fun note( + titre: String = "Titre", + contenu: String = "", + etiquettes: List = emptyList(), + epinglee: Boolean = false, + favorite: Boolean = false, + ) = Note( + id = "00000000-0000-0000-0000-000000000001", + title = titre, + content = contenu, + folderId = "inbox", + tags = etiquettes, + pinned = epinglee, + favorite = favorite, + archived = false, + trashedAt = null, + createdAt = Instant.parse("2026-08-14T13:07:23.123Z"), + updatedAt = Instant.parse("2026-08-15T09:00:00.000Z"), + encrypted = null, + encVersion = EncryptedFormat.CONTENT_ONLY, + ) +} diff --git a/docs/13-VERSION-3.2.md b/docs/13-VERSION-3.2.md new file mode 100644 index 0000000..7b29b6d --- /dev/null +++ b/docs/13-VERSION-3.2.md @@ -0,0 +1,189 @@ +# Notes Tech 3.2.0 — sauvegarde chiffrée, import Markdown, recherche dans la note, partage entrant + +> Écrit le 2026-10-29, après la 3.1.1. Décision de Patrice du même jour : « ajoute 1, 2 et 3 », puis +> « fais-le, que ça marche, proprement, que tout soit bien fait ». Ce document fixe le périmètre et les +> décisions **avant** le code ; la partie sauvegarde est relue par un modèle externe avant toute ligne. + +## 0. Pourquoi + +- **Une désinstallation fait tout perdre.** La sauvegarde Android est coupée exprès + (`allowBackup=false`, `data_extraction_rules.xml`) : la clé de la base est scellée par un Keystore non + exportable, et la copier ne servirait à rien. L'export ZIP sort en clair et ne revient pas. + Conséquences constatées : un utilisateur inquiet de perdre ses notes si F-Droid retirait l'appli + (e-mail du 2026-10-26) ; passer d'une installation F-Droid à GitHub exige de désinstaller. +- **La recherche trouve la note, pas l'endroit** (idée de hcarrega, issue #10). +- **Faire entrer du texte** depuis une autre appli n'existe pas (aucun filtre `SEND`) — #36 en est la + forme la plus simple. + +## 1. Sauvegarde chiffrée et restauration + +### D-029 — Un fichier `.ntbackup`, chiffré par une phrase choisie à la création + +> Relu par GPT 5.6 « sol » (effort élevé) le 2026-10-29 sur la première version de ce plan : +> 16 constats, tous vérifiés ; ceux retenus sont intégrés ci-dessous et marqués **(R-n)**. + +- **Contenu** : tous les dossiers et toutes les notes, **corbeille comprise** — une note à la corbeille + est encore récupérable, l'omettre la perdrait au premier téléphone perdu (R-1). Elle revient dans la + corbeille, avec sa date. Pas les réglages, pas le modèle de dictée, pas le verrou de l'appli. +- **Restaurer ramène ce que contient le fichier**, y compris ce qui a été supprimé depuis sa création : + un fichier ne peut pas savoir ce qui sera supprimé après lui (R-2). L'écran de restauration le dit. +- **Phrase de passe** saisie deux fois, **normalisée en Unicode NFC** avant usage (un même « é » tapé + sur deux claviers donne deux suites d'octets ; R-15), **12 caractères au moins**, et refusée si elle + est évidente : un seul caractère répété, une suite (`123456789012`, `abcdefghijkl`), ou une phrase de + la courte liste des plus courantes (R-14). L'écran dit pourquoi : le fichier peut être attaqué hors + ligne, sans limite d'essais. +- **Argon2id v1.3, t = 3, m = 64 Mio, p = 1**, sortie 32 octets (les paramètres des coffres à phrase, + `VaultParams`) ; **AES-256-GCM, étiquette de 128 bits**, par Bouncy Castle (déjà dans l'appli pour + Argon2) en flux. +- **Écrit où l'utilisateur veut** par `ACTION_CREATE_DOCUMENT` — toujours un **nouveau** document, jamais + par-dessus un ancien (R-12). Aucun fichier intermédiaire en clair. +- **Le succès n'est annoncé qu'après relecture** : le fichier écrit est rouvert, déchiffré en entier et + son étiquette vérifiée. En cas d'échec, le document créé est supprimé si le fournisseur le permet. +- **Lecture cohérente** : dossiers et notes lus dans **une seule transaction** de lecture ; les clés des + coffres à code ouverts sont **copiées** avant d'écrire (une copie propre à la sauvegarde, que le + verrouillage automatique n'efface pas), puis effacées (R-7). +- **Aucune permission nouvelle**, aucun réseau. + +### Format, version 1 + +``` +octets taille champ +0 8 magique "NTBACKUP" (ASCII) +8 2 version du format = 1 (uint16 gros-boutiste) +10 1 KDF = 1 (Argon2id v1.3) +11 4 t, itérations (uint32 gros-boutiste) +15 4 m, mémoire en Kio (uint32 gros-boutiste) +19 1 p, parallélisme +20 16 sel (aléatoire, à chaque fichier) +36 12 nonce GCM (aléatoire, à chaque fichier) +48 … AES-256-GCM(clé = Argon2id(phrase NFC en UTF-8, sel, t, m, p), nonce, + AAD = les 48 octets d'en-tête) du contenu ; étiquette de 16 octets à la fin +``` + +- **L'en-tête est authentifié** (AAD). +- **Avant de dériver** (rien n'est encore authentifié) : magique, version = 1, KDF = 1, et **exactement** + t = 3, m = 65 536 Kio, p = 1 — les seuls paramètres qu'écrit la version 1. Un fichier forgé ne peut ni + faire allouer plus de 64 Mio ni faire calculer plus longtemps qu'une vraie sauvegarde (R-10). D'autres + paramètres viendront avec une autre version du format. +- **Taille** : 48 + 16 octets au moins ; **64 Mio au plus**, comptés sur ce qui est **réellement lu** (un + flux qui s'arrête au 64 Mio + 1ᵉ octet), la taille annoncée par le fournisseur n'étant qu'un premier + refus (R-9). 64 Mio de notes, c'est des dizaines de milliers de pages ; la création refuse au-delà et le + dit. +- **Contenu** : JSON UTF-8 écrit et lu **en flux** (`android.util.JsonWriter` / `JsonReader`), jamais + d'arbre en mémoire (R-8). Les dossiers d'abord, puis les notes : + ``` + { "format": "notes-tech-backup", "version": 1, "createdAt": , + "folders": [ { "id", "name", "parentId"|null, "color", "icon"|null, "createdAt", "updatedAt", + "vault": null + | { "mode": "passphrase", "salt", "wrappedKey", "iv", "verifier" } (Base64) + | { "mode": "pin", "folderKey" } (Base64) } ], + "notes": [ { "id", "folderId", "title", "content", "sealed"|null (Base64), "envV", + "tags": [..], "pinned", "favorite", "archived", "trashedAt"|null, "colorId"|null, + "createdAt", "updatedAt" } ] } + ``` + `folderId` n'est jamais nul (`notes.folder_id` est `NOT NULL` ; R-13). + +### D-030 — Les coffres voyagent sans être ouverts… sauf ceux à code + +- **Coffre à phrase** : ses colonnes (`vault_salt`, `vault_kek_wrapped`, `vault_iv`, `vault_verifier`) + et les blobs scellés de ses notes sont copiés **tels quels** : ils ne dépendent que de la phrase du + coffre et s'ouvrent avec elle sur un autre téléphone. Pas besoin de l'ouvrir pour le sauvegarder ; la + sauvegarde ne le déchiffre jamais. +- **Coffre à code (PIN)** : sa clé est scellée par le Keystore **de ce téléphone**. Il n'est sauvegardé + **que s'il est ouvert** : la clé de dossier (32 octets) entre dans le contenu chiffré, ses notes y + restent **scellées**. À la restauration, l'utilisateur **choisit un code** pour ce coffre (saisi deux + fois), et la clé est réemballée comme à la création d'un coffre à code : nouveau sel, nouveau nonce, + nouvelle clé Keystore, vérificateur recalculé. ⚠️ **À dire à l'écran** : tant que le fichier existe, + ce coffre n'est protégé que par la phrase de la sauvegarde. Un coffre à code fermé est **omis**, et + l'écran le nomme avant d'écrire. +- **Les identifiants sont conservés** : le scellement d'une note est lié à son id (AAD = `noteId`), + l'emballage de la clé d'un coffre à phrase à celui du dossier (AAD = `folderId`). +- **Un coffre se reconnaît à sa clé, pas à son sel** (R-5) : deux coffres sont « le même » quand leur + **vérificateur** (HMAC-SHA256 de la clé de dossier) est égal. Pour un coffre à code de la sauvegarde, + le vérificateur se calcule depuis la clé transportée. + +### D-031 — Restaurer ajoute, n'écrase jamais + +- **Deux passes sur le fichier**, chacune déchiffrée et **vérifiée jusqu'à l'étiquette** : + 1. **Passe d'essai**, sans rien écrire : tout est validé et compté, et un résumé s'affiche (dossiers, + notes, coffres, conflits, ce qui sera ignoré et pourquoi). Les coffres à code demandent leur code. + 2. **Passe d'écriture**, dans **une seule transaction Room**, **annulée** si l'étiquette ne passe pas à + la fin. Aucune donnée non authentifiée n'est donc jamais validée en base. +- **Dossiers** : graphe vérifié **acyclique** (y compris un dossier parent de lui-même), profondeur + bornée à 32, sur l'union des dossiers de la sauvegarde et du téléphone ; insérés parents d'abord ; un + parent absent devient « sans parent » (R-6). Un dossier dont l'id existe déjà est **réutilisé** tel + qu'il est sur le téléphone. +- **Chaque note est validée contre le dossier où elle va réellement** (R-4) — celui du téléphone si + l'id existe déjà, sinon celui de la sauvegarde : + - dossier ordinaire → la note doit être **en clair** ; + - coffre → la note doit être **scellée**, et le coffre de destination avoir **la même clé** que celui + de la sauvegarde (vérificateurs égaux) ; + - sinon la note est ignorée, et comptée. Une sauvegarde forgée ne peut donc ni glisser une note en + clair dans un coffre existant, ni y glisser un blob qu'il ne saurait pas ouvrir. +- **Note dont l'id existe déjà** (R-3) : identique → rien à faire ; **différente et en clair** → la copie + de la sauvegarde est ajoutée **à côté**, sous un nouvel id (rien n'est écrasé, les deux versions + restent) ; **différente et scellée** → ignorée et comptée (un nouvel id casserait son scellement), et + le résumé le dit. +- **Keystore hors transaction** (R-11) : les clés des coffres à code restaurés sont créées **avant** la + transaction ; si elle échoue, elles sont supprimées. Jamais une clé d'un coffre déjà présent. +- Les liens `[[…]]` des notes en clair sont réindexés dans la transaction (D-009) ; ceux des notes + scellées restent détachés, comme à l'ouverture de la base. +- **Mauvaise phrase et fichier abîmé** se présentent pareil (l'étiquette ne passe pas). + +### Effacement + +- Le mode panique ne peut pas atteindre un fichier enregistré hors de l'appli : **le dire** à l'écran + de création et dans la politique de confidentialité. +- Clé dérivée, phrase et clés de coffre : tableaux effacés (`fill(0)`) dès qu'ils ne servent plus. C'est + **au mieux** (R-16) : les chaînes Kotlin (titres, contenus, champ de saisie) ne s'effacent pas ; on les + garde le moins longtemps possible. + +## 2. Import Markdown + +### D-032 — Des fichiers `.md` / `.txt`, ou un ZIP, vers des dossiers ordinaires + +- `ACTION_OPEN_DOCUMENT` multiple : `.md`, `.markdown`, `.txt`, `.zip` (dont l'export de Notes Tech). +- **Frontmatter** lu s'il est là (celui de l'export : `title`, `tags`, `created`, `updated`, `pinned`, + `favorite`) ; sinon titre = nom du fichier sans extension. +- **Dossier** : dans un ZIP, le premier niveau de chemin désigne un dossier **ordinaire** de ce nom + (réutilisé s'il existe, créé sinon). **Jamais un coffre** : un nom qui correspond à un coffre envoie + la note dans la Boîte de réception (et le résumé le dit). Fichier seul : Boîte de réception. +- Le `README.md` à la racine d'un export Notes Tech est reconnu et ignoré. +- **Bornes** : 5 000 fichiers, 5 Mio par fichier, 100 Mio décompressés au total (archive piégée) ; + UTF-8 strict, sinon fichier ignoré et compté. +- Toujours des **nouvelles** notes (nouveaux ids) : importer deux fois crée deux copies — c'est un + import, pas une restauration. + +## 3. Recherche dans la note + +### D-033 — Une barre « Rechercher dans la note », et l'arrivée depuis un résultat + +- Menu ⋮ de l'éditeur → barre de recherche : occurrences **surlignées**, « 3 / 12 », précédente / + suivante ; en lecture (fiche) comme en écriture. +- **Même règle que la recherche de l'appli** : insensible à la casse et aux accents, chaque mot cherché + séparément. +- **Depuis un résultat de recherche**, la note s'ouvre avec la barre remplie et la première occurrence + sous les yeux. +- Rien n'est indexé ni écrit : la recherche porte sur le texte affiché (une note de coffre ouverte + aussi — elle n'est déjà déchiffrée qu'en mémoire). + +## 4. Partage entrant + +- « Partager → Notes Tech » depuis une autre appli : un **texte** (`text/plain`) crée une note dans la + Boîte de réception (titre = sujet du partage, sinon première ligne) ; un ou plusieurs **fichiers** + texte ou Markdown passent par l'import (§2). +- **Une activité dédiée**, exportée, sans interface : elle lit le partage, le borne (1 Mio de texte, + 5 Mio par fichier) et le confie en mémoire à l'appli, puis ouvre `MainActivity`, qui **continue de + jeter tous les extras** (audit E1) : aucune donnée extérieure n'atteint la navigation. +- **Verrou de l'appli** : la note n'est créée qu'après déverrouillage. +- Jamais dans un coffre. + +## 5. Tests et relectures + +- JVM : format de sauvegarde (aller-retour, en-tête modifié, bornes, mauvaise phrase, troncature), + fusion (conflits, coffre différent, restaurer deux fois), import (frontmatter, bornes, ZIP piégé), + correspondance insensible aux accents. +- Instrumentés : aller-retour réel sur SQLCipher avec un coffre à phrase et un coffre à code + (restauration avec nouveau code), partage entrant, barre de recherche. +- Chaque nouveau test vu échouer une fois, sabotage à l'appui. +- Relecture externe (GPT) du format et du code de la sauvegarde ; audit de sécurité avant la release. From fa440544c8abad1223a69f655c99f160e199a4ed Mon Sep 17 00:00:00 2001 From: Patrice Haltaya Date: Thu, 29 Oct 2026 16:30:04 +0100 Subject: [PATCH 02/16] feat(share): "Share -> Notes Tech" creates notes in the Inbox docs/13-VERSION-3.2.md section 4. A dedicated exported activity with no screen reads what another app sends (SEND / SEND_MULTIPLE, text types), bounds it and hands it over in memory, then opens the app. MainActivity is not touched: it keeps dropping every extra of its intent (audit E1). - LectureDUnPartage (pure, JVM-tested): files win over the text sent with them and are read like imported files (LecteurMarkdown); strict UTF-8, a NUL means not text; 1 MiB of text, 5 MiB per file, 50 files; what is refused is counted, never cut. - The activity: content:// only, never one of this app's providers; each file read up to the bound plus one byte; a malformed extra is counted. - PartagesRecus, composed beside the NavHost INSIDE the app lock's content: a share becomes notes only once the app is unlocked. One note opens; else a summary. NotesRepository.importer: one transaction, new ids, the file's dates and flags, links indexed; refuses a vault (or an unknown folder). - Strings in the five languages, with many forms for fr/es/it. Tested: 6 + 8 JVM tests, a sabotage (the NUL rule) caught; full gate green; on the emulator a text share became the note, opened, no crash. Found on the way: a slash-star in a KDoc opens a NESTED comment in Kotlin and had turned the rest of the activity into a comment (detekt saw every import unused). Co-Authored-By: Claude Opus 5.5 --- app/src/main/AndroidManifest.xml | 26 ++++ .../data/repository/NotesRepository.kt | 41 ++++++ .../domain/importation/LecteurMarkdown.kt | 109 +++++++++------ .../domain/importation/LectureDUnPartage.kt | 83 +++++++++++ .../filestech/notes_tech/ui/MainActivity.kt | 9 +- .../notes_tech/ui/share/PartagesEnAttente.kt | 33 +++++ .../notes_tech/ui/share/PartagesRecus.kt | 128 +++++++++++++++++ .../ui/share/RecevoirUnPartageActivity.kt | 131 ++++++++++++++++++ app/src/main/res/values-de/strings.xml | 11 ++ app/src/main/res/values-es/strings.xml | 13 ++ app/src/main/res/values-fr/strings.xml | 13 ++ app/src/main/res/values-it/strings.xml | 13 ++ app/src/main/res/values/strings.xml | 11 ++ app/src/main/res/values/themes.xml | 10 ++ .../domain/importation/LecteurMarkdownTest.kt | 4 +- .../importation/LectureDUnPartageTest.kt | 67 +++++++++ 16 files changed, 656 insertions(+), 46 deletions(-) create mode 100644 app/src/main/java/com/filestech/notes_tech/domain/importation/LectureDUnPartage.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/ui/share/PartagesEnAttente.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/ui/share/PartagesRecus.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/ui/share/RecevoirUnPartageActivity.kt create mode 100644 app/src/test/java/com/filestech/notes_tech/domain/importation/LectureDUnPartageTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 46663b5..fda4bbb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -72,6 +72,32 @@ + + + + + + + + + + + + + + + Mit Notes Tech geteilt + Das Geteilte konnte nicht gespeichert werden. Es wurde nichts hinzugefügt. + + %1$d Notiz zum Posteingang hinzugefügt. + %1$d Notizen zum Posteingang hinzugefügt. + + + %1$d geteiltes Element konnte nicht gelesen werden: kein Text oder größer als 5 MB. + %1$d geteilte Elemente konnten nicht gelesen werden: kein Text oder größer als 5 MB. + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 1f3bfe5..af5dbaf 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -539,4 +539,17 @@ Se encontró la clave, pero no se pudo usar. Vuelva a intentarlo; si el problema persiste, notifíquelo: sus notas no se modifican. Volver a intentarlo + + Compartido con Notes Tech + No se pudo guardar lo compartido. No se añadió nada. + + %1$d nota añadida a la Bandeja de entrada. + %1$d de notas añadidas a la Bandeja de entrada. + %1$d notas añadidas a la Bandeja de entrada. + + + %1$d elemento compartido no se pudo leer: no es texto, o supera 5 MB. + %1$d de elementos compartidos no se pudieron leer: no son texto, o superan 5 MB. + %1$d elementos compartidos no se pudieron leer: no son texto, o superan 5 MB. + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index e77ccdc..4e87efc 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -572,4 +572,17 @@ Le coffre-fort de clés de l\'appareil est momentanément indisponible. Relancez l\'application ; si le problème persiste, redémarrez l\'appareil. Réessayer + + Partagé avec Notes Tech + Ce qui a été partagé n\'a pas pu être enregistré. Rien n\'a été ajouté. + + %1$d note ajoutée à la Boîte de réception. + %1$d de notes ajoutées à la Boîte de réception. + %1$d notes ajoutées à la Boîte de réception. + + + %1$d élément partagé n\'a pas pu être lu : ce n\'est pas du texte, ou il dépasse 5 Mo. + %1$d d\'éléments partagés n\'ont pas pu être lus : ce n\'est pas du texte, ou ils dépassent 5 Mo. + %1$d éléments partagés n\'ont pas pu être lus : ce n\'est pas du texte, ou ils dépassent 5 Mo. + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 184c4f3..87911fd 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -539,4 +539,17 @@ La chiave è stata trovata, ma non è stato possibile usarla. Riprovi; se il problema persiste, lo segnali: le sue note non vengono modificate. Riprova + + Condiviso con Notes Tech + Ciò che è stato condiviso non è stato salvato. Non è stato aggiunto nulla. + + %1$d nota aggiunta alla Posta in arrivo. + %1$d di note aggiunte alla Posta in arrivo. + %1$d note aggiunte alla Posta in arrivo. + + + %1$d elemento condiviso non è stato letto: non è testo, o supera 5 MB. + %1$d di elementi condivisi non sono stati letti: non sono testo, o superano 5 MB. + %1$d elementi condivisi non sono stati letti: non sono testo, o superano 5 MB. + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 343d826..aa4e626 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -708,4 +708,15 @@ The key was found but could not be used. Try again; if the problem persists, report it — your notes are not modified. Try again + + Shared with Notes Tech + What was shared could not be saved. Nothing was added. + + %1$d note added to the Inbox. + %1$d notes added to the Inbox. + + + %1$d shared item could not be read: not text, or over 5 MB. + %1$d shared items could not be read: not text, or over 5 MB. + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 79e9398..b60848e 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -37,4 +37,14 @@ @android:color/transparent false + + + diff --git a/app/src/test/java/com/filestech/notes_tech/domain/importation/LecteurMarkdownTest.kt b/app/src/test/java/com/filestech/notes_tech/domain/importation/LecteurMarkdownTest.kt index 68dcc28..2f2dacc 100644 --- a/app/src/test/java/com/filestech/notes_tech/domain/importation/LecteurMarkdownTest.kt +++ b/app/src/test/java/com/filestech/notes_tech/domain/importation/LecteurMarkdownTest.kt @@ -109,7 +109,9 @@ class LecteurMarkdownTest { @Test fun `a shared text is titled by its subject, else its first non-blank line`() { assertThat(LecteurMarkdown.titreDUnTexte("Sujet", "corps")).isEqualTo("Sujet") - assertThat(LecteurMarkdown.titreDUnTexte(null, "\n\n Première vraie ligne \nsuite")).isEqualTo("Première vraie ligne") + assertThat( + LecteurMarkdown.titreDUnTexte(null, "\n\n Première vraie ligne \nsuite"), + ).isEqualTo("Première vraie ligne") assertThat(LecteurMarkdown.titreDUnTexte(" ", "")).isEmpty() } diff --git a/app/src/test/java/com/filestech/notes_tech/domain/importation/LectureDUnPartageTest.kt b/app/src/test/java/com/filestech/notes_tech/domain/importation/LectureDUnPartageTest.kt new file mode 100644 index 0000000..4f61246 --- /dev/null +++ b/app/src/test/java/com/filestech/notes_tech/domain/importation/LectureDUnPartageTest.kt @@ -0,0 +1,67 @@ +package com.filestech.notes_tech.domain.importation + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import java.time.ZoneId + +/** "Share → Notes Tech": what becomes a note, what is counted, and the bounds. */ +class LectureDUnPartageTest { + + private val zone = ZoneId.of("Europe/Paris") + + @Test + fun `a shared text becomes one note titled by its subject`() { + val recu = LectureDUnPartage.lire("le corps", "Le sujet", emptyList(), zone) + assertThat(recu.ignores).isEqualTo(0) + assertThat(recu.notes.single().titre).isEqualTo("Le sujet") + assertThat(recu.notes.single().contenu).isEqualTo("le corps") + } + + @Test + fun `a text without subject is titled by its first line`() { + val recu = LectureDUnPartage.lire("Liste de courses\npain\nlait", null, emptyList(), zone) + assertThat(recu.notes.single().titre).isEqualTo("Liste de courses") + } + + @Test + fun `an empty text, or one over the bound, is counted and never cut`() { + assertThat(LectureDUnPartage.lire(" ", null, emptyList(), zone)).isEqualTo(PartageRecu(emptyList(), 1)) + val tropLong = "a".repeat(LectureDUnPartage.TEXTE_MAX + 1) + assertThat(LectureDUnPartage.lire(tropLong, null, emptyList(), zone)).isEqualTo(PartageRecu(emptyList(), 1)) + val pile = "a".repeat(LectureDUnPartage.TEXTE_MAX) + assertThat(LectureDUnPartage.lire(pile, null, emptyList(), zone).notes.single().contenu).hasLength(pile.length) + } + + @Test + fun `files win over the text that comes with them, and are read like imported files`() { + val fichier = FichierPartage("Recette.md", "---\ntitle: \"Tarte\"\ntags: [cuisine]\n---\n\nPâte".toByteArray()) + val recu = LectureDUnPartage.lire("Regarde ce fichier", "Partagé", listOf(fichier), zone) + val note = recu.notes.single() + assertThat(note.titre).isEqualTo("Tarte") + assertThat(note.etiquettes).containsExactly("cuisine") + assertThat(note.contenu).isEqualTo("Pâte") + assertThat(recu.ignores).isEqualTo(0) + } + + @Test + fun `a file that is not text, unreadable, or over the bound is counted, the others kept`() { + val image = FichierPartage("photo.jpg", byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xE0.toByte())) + val binaire = FichierPartage("data.bin", "abc\u0000def".toByteArray()) + val illisible = FichierPartage("ailleurs.txt", null) + val tropGros = FichierPartage("gros.txt", ByteArray(LectureDUnPartage.FICHIER_MAX + 1) { 'a'.code.toByte() }) + val bon = FichierPartage("Idée.txt", "une idée".toByteArray()) + + val recu = LectureDUnPartage.lire(null, null, listOf(image, binaire, illisible, tropGros, bon), zone) + + assertThat(recu.notes.map { it.titre }).containsExactly("Idée") + assertThat(recu.ignores).isEqualTo(4) + } + + @Test + fun `at most FICHIERS_MAX files are read, the rest counted`() { + val fichiers = (1..LectureDUnPartage.FICHIERS_MAX + 3).map { FichierPartage("n$it.txt", "t$it".toByteArray()) } + val recu = LectureDUnPartage.lire(null, null, fichiers, zone) + assertThat(recu.notes).hasSize(LectureDUnPartage.FICHIERS_MAX) + assertThat(recu.ignores).isEqualTo(3) + } +} From 1e4d1d764bc3827f3ed133af5035e7301e5fedf4 Mon Sep 17 00:00:00 2001 From: Patrice Haltaya Date: Thu, 29 Oct 2026 16:46:39 +0100 Subject: [PATCH 03/16] fix(share): the security review of the share feature, all five findings A security deep-dive of "Share -> Notes Tech" (0 critical, 0 high, E1 holds) found two medium and three low issues; each checked against the code, all fixed: - M1: a frontmatter block list was appended to the string read so far - quadratic, minutes of CPU for a 5 MiB file of "- x" lines. Block lists now grow a list; the closing fence must come within 16 KiB; at most 100 tags of 100 characters. Reading a share is bounded to 20 s, the app opens without it. - M2: 50 files of 5 MiB were held at once (250 MiB), and an OutOfMemoryError, which no catch here sees, took the app down. Files are now read one by one under a 16 MiB budget, 20 files at most; past the budget nothing is opened. - L1: content://0@.exports/... passed the startsWith on the raw authority while the resolver strips the 0@. Any user-info or @ is refused, and the host is compared, case-insensitively (estDUneAutreAppli). - L2: file dates are believed only between 1970 and now. - L3: a waiting share expires after 2 minutes (monotonic clock), is not taken while the panic mode runs, and nothing is written before the user confirms - any app can start the share activity by name. Tests: LecteurMarkdown 10, LectureDUnPartage 6 (budget: nothing read once spent), PartagesEnAttente 4 (expiry, taken once), FournisseurDUnPartageTest 3 on real Uri parsing; the L1 fix sabotaged -> 2 of 3 fail. Full gate green. On the emulator: Cancel writes nothing, Add creates and opens the note. Co-Authored-By: Claude Opus 5.5 --- .../ui/share/FournisseurDUnPartageTest.kt | 48 ++++++++ .../data/repository/NotesRepository.kt | 10 +- .../domain/importation/LecteurMarkdown.kt | 48 +++++--- .../domain/importation/LectureDUnPartage.kt | 68 +++++++----- .../filestech/notes_tech/ui/MainActivity.kt | 7 +- .../notes_tech/ui/share/PartagesEnAttente.kt | 41 +++++-- .../notes_tech/ui/share/PartagesRecus.kt | 104 +++++++++++++----- .../ui/share/RecevoirUnPartageActivity.kt | 61 ++++++---- app/src/main/res/values-de/strings.xml | 11 +- app/src/main/res/values-es/strings.xml | 14 ++- app/src/main/res/values-fr/strings.xml | 14 ++- app/src/main/res/values-it/strings.xml | 14 ++- app/src/main/res/values/strings.xml | 11 +- .../domain/importation/LecteurMarkdownTest.kt | 41 +++++++ .../importation/LectureDUnPartageTest.kt | 82 +++++++++----- .../ui/share/PartagesEnAttenteTest.kt | 50 +++++++++ 16 files changed, 483 insertions(+), 141 deletions(-) create mode 100644 app/src/androidTest/java/com/filestech/notes_tech/ui/share/FournisseurDUnPartageTest.kt create mode 100644 app/src/test/java/com/filestech/notes_tech/ui/share/PartagesEnAttenteTest.kt diff --git a/app/src/androidTest/java/com/filestech/notes_tech/ui/share/FournisseurDUnPartageTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/ui/share/FournisseurDUnPartageTest.kt new file mode 100644 index 0000000..12a3041 --- /dev/null +++ b/app/src/androidTest/java/com/filestech/notes_tech/ui/share/FournisseurDUnPartageTest.kt @@ -0,0 +1,48 @@ +package com.filestech.notes_tech.ui.share + +import android.net.Uri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertWithMessage +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Which URIs a share may make Notes Tech read (3.2.0): another app's `content://`, never this app's own + * providers — on real `Uri` parsing, where the `0@` bypass of the 3.2.0 security review (L1) lives. + */ +@RunWith(AndroidJUnit4::class) +class FournisseurDUnPartageTest { + + private val paquet = "com.filestech.notes_tech" + + @Test + fun another_apps_content_uri_is_read() { + lu("content://com.android.providers.downloads.documents/document/42", attendu = true) + lu("content://org.example.notes.fileprovider/shared/idee.md", attendu = true) + } + + @Test + fun this_apps_own_providers_are_never_read_however_written() { + lu("content://$paquet.exports/exports/1/notes-tech-export.zip", attendu = false) + lu("content://$paquet.next.debug.exports/exports/1/a.md", attendu = false) + lu("content://${paquet.uppercase()}.EXPORTS/exports/a.md", attendu = false) + // L1: the user-id syntax the content resolver strips before resolving the provider. + lu("content://0@$paquet.exports/exports/1/a.md", attendu = false) + lu("content://10@$paquet.exports/exports/1/a.md", attendu = false) + } + + @Test + fun anything_but_another_apps_content_uri_is_refused() { + lu("file:///data/data/$paquet/databases/notes_tech.db", attendu = false) + lu("file:///sdcard/Download/idee.md", attendu = false) + lu("http://example.org/idee.md", attendu = false) + // A user-info on another app's authority: the cross-user form, refused too. + lu("content://0@org.example.notes.fileprovider/shared/idee.md", attendu = false) + } + + private fun lu(uri: String, attendu: Boolean) { + assertWithMessage( + uri, + ).that(RecevoirUnPartageActivity.estDUneAutreAppli(Uri.parse(uri), paquet)).isEqualTo(attendu) + } +} diff --git a/app/src/main/java/com/filestech/notes_tech/data/repository/NotesRepository.kt b/app/src/main/java/com/filestech/notes_tech/data/repository/NotesRepository.kt index 62fa726..5fa2dc6 100644 --- a/app/src/main/java/com/filestech/notes_tech/data/repository/NotesRepository.kt +++ b/app/src/main/java/com/filestech/notes_tech/data/repository/NotesRepository.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext import java.time.Clock +import java.time.Instant import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -329,9 +330,14 @@ class NotesRepository @Inject constructor( require(!folders.isVaultFolder(folderId)) { "import refused into folder $folderId: a vault, or unknown" } notes.forEach { requireTitleWithinLimit(it.titre) } val now = clock.instant() + + // A date from a file is believed only between 1970 and now (security review of 3.2.0, L2): a + // year 200 000 000 would keep a note above all others forever, and a larger one overflows the + // epoch milliseconds the database stores. + fun plausible(date: Instant?): Instant? = date?.takeIf { !it.isBefore(Instant.EPOCH) && !it.isAfter(now) } return inTransaction { database -> notes.map { lue -> - val creeeLe = lue.creeeLe ?: now + val creeeLe = plausible(lue.creeeLe) ?: now val note = Note( id = UUID.randomUUID().toString(), title = lue.titre, @@ -343,7 +349,7 @@ class NotesRepository @Inject constructor( archived = false, trashedAt = null, createdAt = creeeLe, - updatedAt = lue.modifieeLe ?: creeeLe, + updatedAt = plausible(lue.modifieeLe) ?: creeeLe, encrypted = null, encVersion = EncryptedFormat.CONTENT_ONLY, ) diff --git a/app/src/main/java/com/filestech/notes_tech/domain/importation/LecteurMarkdown.kt b/app/src/main/java/com/filestech/notes_tech/domain/importation/LecteurMarkdown.kt index e4d8a8b..d753036 100644 --- a/app/src/main/java/com/filestech/notes_tech/domain/importation/LecteurMarkdown.kt +++ b/app/src/main/java/com/filestech/notes_tech/domain/importation/LecteurMarkdown.kt @@ -29,23 +29,34 @@ object LecteurMarkdown { /** The longest title the repository accepts (`NotesRepository.TITLE_MAX_LENGTH`). */ const val TITRE_MAX = 200 + /** The frontmatter must close within this many characters, or the file has none. */ + const val ENTETE_MAX = 16 * 1024 + + /** Tags kept per note, and characters per tag. */ + const val ETIQUETTES_MAX = 100 + const val ETIQUETTE_MAX = 100 + /** * @param nomDuFichier the file's name, if there is one: the title when the frontmatter gives none. * @param zone the time zone of a date written without an offset — the export's own form. */ fun lire(texte: String, nomDuFichier: String? = null, zone: ZoneId = ZoneId.systemDefault()): NoteImportee { val propre = texte.removePrefix("\uFEFF").replace("\r\n", "\n") - val (champs, corps) = separer(propre) + val (entete, corps) = separer(propre) + val champs = entete.champs val titre = champs["title"]?.let(::scalaire)?.takeIf { it.isNotBlank() } ?: nomDuFichier?.let(::titreDuNom)?.takeIf { it.isNotBlank() } ?: "" + val brutes = entete.listes["tags"]?.map(::scalaire) ?: champs["tags"]?.let(::liste).orEmpty() return NoteImportee( titre = tronquer(titre.trim()), contenu = corps, // ⚠️ Split on commas too: tags are stored comma-joined, unescaped (`TagCodec`), so a tag // carrying one would come back as two anyway — better now than half-broken in the database. - etiquettes = champs["tags"]?.let(::liste).orEmpty() - .flatMap { it.split(',') }.map(String::trim).filter(String::isNotEmpty).distinct(), + // Bounded: a file from another app must not hand a note a million tags. + etiquettes = brutes.asSequence() + .flatMap { it.split(',') }.map(String::trim).filter(String::isNotEmpty) + .map { it.take(ETIQUETTE_MAX) }.distinct().take(ETIQUETTES_MAX).toList(), dossier = champs["folder"]?.let(::scalaire)?.takeIf { it.isNotBlank() }, creeeLe = champs["created"]?.let { date(scalaire(it), zone) }, modifieeLe = champs["updated"]?.let { date(scalaire(it), zone) }, @@ -65,26 +76,36 @@ object LecteurMarkdown { // ── Frontmatter ───────────────────────────────────────────────────────────────────────────── + /** The frontmatter's values by key — a value is the rest of its line — and its block lists. */ + private class Entete(val champs: Map, val listes: Map>) + + private val SANS_ENTETE = Entete(emptyMap(), emptyMap()) + /** - * The raw values by key, and the body. A value is the rest of its line; a block list (`tags:` then - * ` - a` lines) is kept as its lines, joined by `\n`, for [liste]. + * The frontmatter and the body. + * + * 🔴 **Bounded, and linear** (security review of 3.2.0, M1): the closing fence is looked for in the + * first [ENTETE_MAX] characters only, and a block list (`tags:` then `- a` lines) grows a list — + * appending each item to the string read so far copied it whole at every line, and a 5 MiB file of + * `- x` lines froze the phone for minutes. */ - private fun separer(texte: String): Pair, String> { - if (!texte.startsWith("---\n")) return emptyMap() to texte + private fun separer(texte: String): Pair { + if (!texte.startsWith("---\n")) return SANS_ENTETE to texte val fin = texte.indexOf("\n---", startIndex = 3) - if (fin < 0) return emptyMap() to texte + if (fin < 0 || fin > ENTETE_MAX) return SANS_ENTETE to texte val apresFin = texte.indexOf('\n', fin + 4).let { if (it < 0) texte.length else it + 1 } if (texte.substring(fin + 4, if (apresFin == texte.length) texte.length else apresFin - 1).isNotBlank()) { - return emptyMap() to texte + return SANS_ENTETE to texte } val champs = linkedMapOf() + val listes = linkedMapOf>() var cleCourante: String? = null for (ligne in texte.substring(4, fin + 1).lineSequence()) { val enListe = ligne.trimStart() val deuxPoints = ligne.indexOf(':') when { cleCourante != null && enListe.startsWith("- ") -> - champs[cleCourante] = champs[cleCourante].orEmpty() + "\n" + enListe.removePrefix("- ") + listes.getOrPut(cleCourante) { mutableListOf() } += enListe.removePrefix("- ") // Blank, a comment, or an indented line that is not a list item. ligne.isBlank() || enListe.startsWith("#") || ligne.first().isWhitespace() -> Unit deuxPoints <= 0 -> cleCourante = null @@ -97,7 +118,7 @@ object LecteurMarkdown { // The export writes a blank line after the closing fence, and a final line break the note may // not have had. val corps = texte.substring(apresFin).removePrefix("\n").removeSuffix("\n") - return champs to corps + return Entete(champs, listes) to corps } /** A YAML scalar: double-quoted with escapes, single-quoted, or plain. */ @@ -112,12 +133,9 @@ object LecteurMarkdown { } } - /** `[a, "b, c"]`, a block list, or a single value. */ + /** `[a, "b, c"]` or a single value; a block list is read by [separer]. */ private fun liste(brut: String): List { val valeur = brut.trim() - if (valeur.contains('\n')) { - return valeur.split('\n').map(String::trim).filter(String::isNotEmpty).map(::scalaire) - } if (!valeur.startsWith('[') || !valeur.endsWith(']') ) { diff --git a/app/src/main/java/com/filestech/notes_tech/domain/importation/LectureDUnPartage.kt b/app/src/main/java/com/filestech/notes_tech/domain/importation/LectureDUnPartage.kt index b9a7893..b8d16be 100644 --- a/app/src/main/java/com/filestech/notes_tech/domain/importation/LectureDUnPartage.kt +++ b/app/src/main/java/com/filestech/notes_tech/domain/importation/LectureDUnPartage.kt @@ -8,8 +8,8 @@ import java.time.ZoneId /** * What "Share → Notes Tech" sent, turned into notes (3.2.0, `docs/13-VERSION-3.2.md` §4). * - * Pure: the Android side reads the bytes (bounded) and hands them here; everything this decides — the - * bounds, the text versus the files, the decoding — is tested on the JVM. + * Pure: the Android side reads the bytes and hands them here; everything this decides — the bounds, + * the text versus the files, the decoding — is tested on the JVM. * * - **Files win over text.** An app that shares a file often adds a text describing it; the file is * what the user chose. @@ -17,39 +17,28 @@ import java.time.ZoneId * shared back keeps its title, tags and dates. * - **Strict UTF-8.** A file that is not text — a picture shared by mistake — is counted, never * imported as a note full of replacement characters. + * - 🔴 **One file at a time, under one budget** (security review of 3.2.0, M2): 50 files of 5 MiB each + * once made 250 MiB held at the same time, and an `OutOfMemoryError` — not an exception any `catch` + * here sees — took the whole app down, a note being edited with it. Each file is read, decoded and + * let go before the next; once [BUDGET] bytes are read, the rest is counted, not read. */ object LectureDUnPartage { /** A shared text longer than this is refused, not cut: cutting would lose its end silently. */ const val TEXTE_MAX = 1_048_576 - /** Per file; the Android side stops reading at this many bytes plus one. */ + /** Per file. */ const val FICHIER_MAX = 5 * 1024 * 1024 + /** For the whole share: what is read, at most, all files together. */ + const val BUDGET = 16 * 1024 * 1024 + /** Files in one share; the rest is counted as ignored. */ - const val FICHIERS_MAX = 50 + const val FICHIERS_MAX = 20 - /** - * @param fichiers the files of the share, each read up to [FICHIER_MAX] + 1 bytes, or `null` bytes - * when it could not be opened. - */ - fun lire( - texte: String?, - sujet: String?, - fichiers: List, - zone: ZoneId = ZoneId.systemDefault(), - ): PartageRecu { - if (fichiers.isNotEmpty()) { - val pris = fichiers.take(FICHIERS_MAX) - val notes = pris.mapNotNull { fichier -> - val octets = fichier.octets?.takeIf { it.size <= FICHIER_MAX } ?: return@mapNotNull null - val contenu = utf8Strict(octets) ?: return@mapNotNull null - LecteurMarkdown.lire(contenu, fichier.nom, zone) - } - return PartageRecu(notes = notes, ignores = fichiers.size - notes.size) - } - if (texte.isNullOrBlank()) return PartageRecu(notes = emptyList(), ignores = 1) - if (texte.length > TEXTE_MAX) return PartageRecu(notes = emptyList(), ignores = 1) + /** A shared text, with no file: one note titled by its subject, else its first line. */ + fun lireUnTexte(texte: String?, sujet: String?): PartageRecu { + if (texte.isNullOrBlank() || texte.length > TEXTE_MAX) return PartageRecu(notes = emptyList(), ignores = 1) val note = NoteImportee( titre = LecteurMarkdown.titreDUnTexte(sujet, texte), contenu = texte, @@ -63,6 +52,35 @@ object LectureDUnPartage { return PartageRecu(notes = listOf(note), ignores = 0) } + /** + * The [nombre] files of a share, read one by one through [lire]. + * + * @param lire reads the file of that index — its name, and its first `maxOctets + 1` bytes at most + * (one more than allowed is how an oversized file is told), or `null` bytes when it cannot be + * opened. Called at most [FICHIERS_MAX] times, never once the budget is spent. + */ + fun lireDesFichiers( + nombre: Int, + zone: ZoneId = ZoneId.systemDefault(), + lire: (index: Int, maxOctets: Int) -> FichierPartage, + ): PartageRecu { + val notes = mutableListOf() + var budget = BUDGET + val lus = minOf(nombre, FICHIERS_MAX) + for (index in 0 until lus) { + if (budget <= 0) break + val maxOctets = minOf(FICHIER_MAX, budget) + val fichier = lire(index, maxOctets) + val octets = fichier.octets + budget -= octets?.size ?: 0 + val note = octets?.takeIf { + it.size <= maxOctets + }?.let(::utf8Strict)?.let { LecteurMarkdown.lire(it, fichier.nom, zone) } + if (note != null) notes += note + } + return PartageRecu(notes = notes, ignores = nombre - notes.size) + } + private fun utf8Strict(octets: ByteArray): String? = try { Charsets.UTF_8.newDecoder() .onMalformedInput(CodingErrorAction.REPORT) diff --git a/app/src/main/java/com/filestech/notes_tech/ui/MainActivity.kt b/app/src/main/java/com/filestech/notes_tech/ui/MainActivity.kt index 9dd208a..869ad56 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/MainActivity.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/MainActivity.kt @@ -270,8 +270,11 @@ private fun ContenuPrincipal(settings: AppSettings, appLock: AppLockManager, ver ) { navController -> NotesTechNavHost(navController = navController) // 3.2.0 — here, inside the lock's content: a share becomes notes only once the - // app is unlocked (`PartagesRecus`). - PartagesRecus(onOuvrirLaNote = { navController.navigate(Destination.Editor(it).route) }) + // app is unlocked and the user has said yes (`PartagesRecus`) — and never while + // the panic mode is destroying the database (security review of 3.2.0, L3). + if (!paniqueDeclenchee) { + PartagesRecus(onOuvrirLaNote = { navController.navigate(Destination.Editor(it).route) }) + } } } diff --git a/app/src/main/java/com/filestech/notes_tech/ui/share/PartagesEnAttente.kt b/app/src/main/java/com/filestech/notes_tech/ui/share/PartagesEnAttente.kt index 927868e..334185c 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/share/PartagesEnAttente.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/share/PartagesEnAttente.kt @@ -1,33 +1,58 @@ package com.filestech.notes_tech.ui.share +import android.os.SystemClock import com.filestech.notes_tech.domain.importation.PartageRecu import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.getAndUpdate import javax.inject.Inject import javax.inject.Singleton /** * A share received and not yet turned into notes (3.2.0). * - * 🔴 **In memory only, and taken once.** [RecevoirUnPartageActivity] puts it here and opens the app; - * [PartagesRecus], composed only once the app lock is passed, takes it. A share is therefore never - * written while the app is locked, and never through `MainActivity`'s intent, whose extras stay - * dropped (audit of 2026-09-26, E1). A newer share replaces one not yet taken; a process that dies in - * between loses it — the other app still has it. + * 🔴 **In memory only, taken once, and short-lived.** [RecevoirUnPartageActivity] puts it here and + * opens the app; [PartagesRecus], composed only once the app lock is passed, asks the user and takes + * it. A share is therefore never written while the app is locked, and never through `MainActivity`'s + * intent, whose extras stay dropped (audit of 2026-09-26, E1). A newer share replaces one not yet + * taken; a process that dies in between loses it — the other app still has it. + * + * Past [DUREE_DE_VIE_MS] it is dropped (security review of 3.2.0, L3): shared while the app was + * locked, it must not surface hours later, at the next unlock, as a note nobody asked for. */ @Singleton class PartagesEnAttente @Inject constructor() { + /** Monotonic: changing the phone's clock neither expires a share nor keeps one alive. */ + internal var horloge: () -> Long = { SystemClock.elapsedRealtime() } + private val enAttente = MutableStateFlow(null) + private var deposeLe = 0L + /** What is waiting, for [PartagesRecus] to show; whether it is still fresh is [prendre]'s call. */ val partage: StateFlow = enAttente.asStateFlow() + @Synchronized fun deposer(recu: PartageRecu) { + deposeLe = horloge() enAttente.value = recu } - /** The share, and nothing left behind: two callers cannot both take it. */ - fun prendre(): PartageRecu? = enAttente.getAndUpdate { null } + /** The share if it is still fresh, and nothing left behind: two callers cannot both take it. */ + @Synchronized + fun prendre(): PartageRecu? { + val recu = enAttente.value ?: return null + enAttente.value = null + return recu.takeIf { horloge() - deposeLe <= DUREE_DE_VIE_MS } + } + + /** The user said no: the share goes, unwritten. */ + @Synchronized + fun abandonner() { + enAttente.value = null + } + + companion object { + const val DUREE_DE_VIE_MS = 2 * 60 * 1000L + } } diff --git a/app/src/main/java/com/filestech/notes_tech/ui/share/PartagesRecus.kt b/app/src/main/java/com/filestech/notes_tech/ui/share/PartagesRecus.kt index b425f07..e7d5168 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/share/PartagesRecus.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/share/PartagesRecus.kt @@ -15,6 +15,7 @@ import androidx.lifecycle.viewModelScope import com.filestech.notes_tech.R import com.filestech.notes_tech.data.local.entity.FolderEntity import com.filestech.notes_tech.data.repository.NotesRepository +import com.filestech.notes_tech.domain.importation.PartageRecu import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow @@ -25,11 +26,14 @@ import timber.log.Timber import javax.inject.Inject /** - * Turns a share waiting in [PartagesEnAttente] into notes of the Inbox, then opens the note — or, when - * the share made several or could not all be read, says what happened. + * A share waiting in [PartagesEnAttente]: the user is asked, then it becomes notes of the Inbox, and + * the note opens — or, when it made several or could not all be read, a summary says what happened. * - * 🔴 **Composed beside the navigation, inside the app lock's content** (`MainActivity`): it exists only - * while the app is unlocked, so a share is never written behind the lock screen. Never in a vault. + * 🔴 **Composed beside the navigation, inside the app lock's content** (`MainActivity`), and not while + * the panic mode runs: it exists only while the app is unlocked, so a share is never written behind + * the lock screen. 🔴 **Nothing is written before the user says "Add"** (security review of 3.2.0, L3): + * any app can start the share activity by name, without the user ever choosing Notes Tech in a share + * sheet. Never in a vault. */ @Composable fun PartagesRecus(onOuvrirLaNote: (String) -> Unit) { @@ -37,42 +41,86 @@ fun PartagesRecus(onOuvrirLaNote: (String) -> Unit) { val enAttente by modele.enAttente.collectAsState() val resultat by modele.resultat.collectAsState() - LaunchedEffect(enAttente) { - if (enAttente != null) modele.enregistrer() + val attente = enAttente + val fait = resultat + when { + fait != null -> Resultat(fait, onOuvrirLaNote, modele::vu) + attente != null && attente.notes.isEmpty() -> LaunchedEffect(attente) { modele.enregistrer() } + attente != null -> Confirmation(attente, onAjouter = modele::enregistrer, onAnnuler = modele::abandonner) } +} + +@Composable +private fun Confirmation(recu: PartageRecu, onAjouter: () -> Unit, onAnnuler: () -> Unit) { + val titreDeLaNote = recu.notes.singleOrNull()?.titre + AlertDialog( + onDismissRequest = onAnnuler, + title = { Text(stringResource(R.string.share_confirm_title)) }, + text = { + Text( + buildList { + add( + if (titreDeLaNote != null) { + stringResource( + R.string.share_confirm_one, + titreDeLaNote.ifBlank { stringResource(R.string.note_untitled) }, + ) + } else { + pluralStringResource(R.plurals.share_confirm_many, recu.notes.size, recu.notes.size) + }, + ) + if (recu.ignores > 0) { + add(pluralStringResource(R.plurals.share_result_ignored, recu.ignores, recu.ignores)) + } + }.joinToString("\n"), + ) + }, + confirmButton = { TextButton(onClick = onAjouter) { Text(stringResource(R.string.share_confirm_add)) } }, + dismissButton = { TextButton(onClick = onAnnuler) { Text(stringResource(R.string.common_cancel)) } }, + ) +} - when (val r = resultat) { - null -> Unit - is ResultatDUnPartage.UneNote -> LaunchedEffect(r) { - onOuvrirLaNote(r.id) - modele.vu() +@Composable +private fun Resultat(resultat: ResultatDUnPartage, onOuvrirLaNote: (String) -> Unit, onVu: () -> Unit) { + when (resultat) { + is ResultatDUnPartage.UneNote -> LaunchedEffect(resultat) { + onOuvrirLaNote(resultat.id) + onVu() } is ResultatDUnPartage.Resume -> AlertDialog( - onDismissRequest = modele::vu, + onDismissRequest = onVu, title = { Text(stringResource(R.string.share_result_title)) }, text = { Text( buildList { - if (r.ajoutees > - 0 - ) { - add(pluralStringResource(R.plurals.share_result_added, r.ajoutees, r.ajoutees)) + if (resultat.ajoutees > 0) { + add( + pluralStringResource( + R.plurals.share_result_added, + resultat.ajoutees, + resultat.ajoutees, + ), + ) } - if (r.ignorees > - 0 - ) { - add(pluralStringResource(R.plurals.share_result_ignored, r.ignorees, r.ignorees)) + if (resultat.ignorees > 0) { + add( + pluralStringResource( + R.plurals.share_result_ignored, + resultat.ignorees, + resultat.ignorees, + ), + ) } }.joinToString("\n"), ) }, - confirmButton = { TextButton(onClick = modele::vu) { Text(stringResource(R.string.common_ok)) } }, + confirmButton = { TextButton(onClick = onVu) { Text(stringResource(R.string.common_ok)) } }, ) ResultatDUnPartage.Echec -> AlertDialog( - onDismissRequest = modele::vu, + onDismissRequest = onVu, title = { Text(stringResource(R.string.share_result_title)) }, text = { Text(stringResource(R.string.share_result_failed)) }, - confirmButton = { TextButton(onClick = modele::vu) { Text(stringResource(R.string.common_ok)) } }, + confirmButton = { TextButton(onClick = onVu) { Text(stringResource(R.string.common_ok)) } }, ) } } @@ -95,18 +143,16 @@ class PartagesRecusViewModel @Inject constructor( private val dernier = MutableStateFlow(null) val resultat: StateFlow = dernier.asStateFlow() + /** "Add": the share is taken here, synchronously — a second tap finds nothing, it is written once. */ fun enregistrer() { - // Taken here, synchronously: a second call finds nothing, the share is written once. + // Expired (PartagesEnAttente.DUREE_DE_VIE_MS): taken and dropped, nothing to say. val recu = partages.prendre() ?: return viewModelScope.launch { dernier.value = try { val creees = if (recu.notes.isEmpty()) { emptyList() } else { - notes.importer( - FolderEntity.INBOX_ID, - recu.notes, - ) + notes.importer(FolderEntity.INBOX_ID, recu.notes) } if (creees.size == 1 && recu.ignores == 0) { ResultatDUnPartage.UneNote(creees.single().id) @@ -122,6 +168,8 @@ class PartagesRecusViewModel @Inject constructor( } } + fun abandonner() = partages.abandonner() + fun vu() { dernier.value = null } diff --git a/app/src/main/java/com/filestech/notes_tech/ui/share/RecevoirUnPartageActivity.kt b/app/src/main/java/com/filestech/notes_tech/ui/share/RecevoirUnPartageActivity.kt index 3dfdef0..0555884 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/share/RecevoirUnPartageActivity.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/share/RecevoirUnPartageActivity.kt @@ -13,8 +13,9 @@ import com.filestech.notes_tech.domain.importation.LectureDUnPartage import com.filestech.notes_tech.domain.importation.PartageRecu import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import timber.log.Timber import java.io.ByteArrayOutputStream import java.io.IOException @@ -26,15 +27,16 @@ import javax.inject.Inject * * 🔴 **`MainActivity` is not touched.** It keeps dropping every extra of its intent (audit of * 2026-09-26, E1): nothing an outside app sends reaches the navigation. What a share carries goes from - * here to memory, and becomes notes only once the app lock is passed ([PartagesRecus]). + * here to memory, and becomes notes only once the app lock is passed AND the user has confirmed + * ([PartagesRecus]): any app can start this activity by name, without the user choosing Notes Tech. * * What is read, and what is refused: * - `ACTION_SEND` / `ACTION_SEND_MULTIPLE` only, any `text` type (the manifest's filters). ⚠️ Not * written with its wildcard here: in Kotlin a slash-star opens a NESTED comment, and it turned the * rest of this file into one (detekt saw every import unused). * - Files: **`content://` only**, never a provider of this app (a share must not make it read its own - * files — its export cache, for one), at most [LectureDUnPartage.FICHIERS_MAX] files, each read up to - * [LectureDUnPartage.FICHIER_MAX] + 1 bytes and no further. + * files — its export cache, for one), read one by one under [LectureDUnPartage]'s bounds: a size per + * file, a budget for the whole share, a number of files, and [DELAI_MAX_MS] in all. * - Extras come from any app: a malformed one is caught and counted, it does not crash the app. */ @AndroidEntryPoint @@ -52,7 +54,12 @@ class RecevoirUnPartageActivity : ComponentActivity() { } val recu = intent lifecycleScope.launch { - val partage = withContext(Dispatchers.IO) { lire(recu) } + // 🔴 Bounded in time too (security review of 3.2.0, M1): a provider that never answers + // must not keep this window over the sending app. The read goes on in the background if + // a read call blocks, its result dropped; the app opens without it. + val lecture = async(Dispatchers.IO) { lire(recu) } + val partage = withTimeoutOrNull(DELAI_MAX_MS) { lecture.await() } + if (partage == null) lecture.cancel() if (partage != null) enAttente.deposer(partage) ouvrirLAppli() finish() @@ -70,9 +77,13 @@ class RecevoirUnPartageActivity : ComponentActivity() { ) else -> IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java).orEmpty() } - val fichiers = flux.take(LectureDUnPartage.FICHIERS_MAX).map(::lireUnFichier) + - List((flux.size - LectureDUnPartage.FICHIERS_MAX).coerceAtLeast(0)) { FichierPartage(null, null) } - LectureDUnPartage.lire(texte, sujet, fichiers) + if (flux.isEmpty()) { + LectureDUnPartage.lireUnTexte(texte, sujet) + } else { + LectureDUnPartage.lireDesFichiers(flux.size) { index, maxOctets -> + lireUnFichier(flux[index], maxOctets) + } + } } catch (e: RuntimeException) { // BadParcelableException, ClassCastException…: what another app put in its intent. Timber.w(e, "partage illisible") @@ -80,21 +91,19 @@ class RecevoirUnPartageActivity : ComponentActivity() { } } - private fun lireUnFichier(uri: Uri): FichierPartage { - if (uri.scheme != ContentResolver.SCHEME_CONTENT || uri.authority.orEmpty().startsWith(packageName)) { - return FichierPartage(null, null) - } - return FichierPartage(nomAffiche(uri), lireBorne(uri)) + private fun lireUnFichier(uri: Uri, maxOctets: Int): FichierPartage { + if (!estDUneAutreAppli(uri, packageName)) return FichierPartage(null, null) + return FichierPartage(nomAffiche(uri), lireBorne(uri, maxOctets)) } - /** At most FICHIER_MAX + 1 bytes: one more than the bound is enough to know it is over. */ - private fun lireBorne(uri: Uri): ByteArray? = try { + /** At most [maxOctets] + 1 bytes: one more than allowed is enough to know it is over. */ + private fun lireBorne(uri: Uri, maxOctets: Int): ByteArray? = try { contentResolver.openInputStream(uri)?.use { flux -> val sortie = ByteArrayOutputStream() val tampon = ByteArray(TAMPON) var lus = 0 - while (lus <= LectureDUnPartage.FICHIER_MAX) { - val n = flux.read(tampon, 0, minOf(tampon.size, LectureDUnPartage.FICHIER_MAX + 1 - lus)) + while (lus <= maxOctets) { + val n = flux.read(tampon, 0, minOf(tampon.size, maxOctets + 1 - lus)) if (n < 0) break sortie.write(tampon, 0, n) lus += n @@ -125,7 +134,21 @@ class RecevoirUnPartageActivity : ComponentActivity() { startActivity(lancement) } - private companion object { - const val TAMPON = 64 * 1024 + internal companion object { + /** + * 🔴 **A `content://` of another app, never one of this app's own providers** — its export cache + * holds plaintext. Compared on the HOST, and any user-info refused (security review of 3.2.0, + * L1): `content://0@.exports/…` carries the authority `0@…`, which a `startsWith` on + * it let through, while the content resolver strips the `0@` and reaches this app's provider. + */ + fun estDUneAutreAppli(uri: Uri, paquet: String): Boolean = uri.scheme == ContentResolver.SCHEME_CONTENT && + uri.userInfo == null && + '@' !in uri.authority.orEmpty() && + !uri.host.orEmpty().startsWith(paquet, ignoreCase = true) + + private const val TAMPON = 64 * 1024 + + /** Past this, a share is dropped: the app opens without it. */ + private const val DELAI_MAX_MS = 20_000L } } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 82d8423..fb360b8 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -535,7 +535,14 @@ %1$d Notizen zum Posteingang hinzugefügt. - %1$d geteiltes Element konnte nicht gelesen werden: kein Text oder größer als 5 MB. - %1$d geteilte Elemente konnten nicht gelesen werden: kein Text oder größer als 5 MB. + %1$d geteiltes Element konnte nicht hinzugefügt werden: kein Text oder zu groß. + %1$d geteilte Elemente konnten nicht hinzugefügt werden: kein Text, zu groß oder zu viele. + + Zu Notes Tech hinzufügen? + „%1$s“ wird zum Posteingang hinzugefügt. + Hinzufügen + + %1$d Notiz wird zum Posteingang hinzugefügt. + %1$d Notizen werden zum Posteingang hinzugefügt. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index af5dbaf..9500b21 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -548,8 +548,16 @@ %1$d notas añadidas a la Bandeja de entrada. - %1$d elemento compartido no se pudo leer: no es texto, o supera 5 MB. - %1$d de elementos compartidos no se pudieron leer: no son texto, o superan 5 MB. - %1$d elementos compartidos no se pudieron leer: no son texto, o superan 5 MB. + %1$d elemento compartido no se pudo añadir: no es texto, o es demasiado grande. + %1$d de elementos compartidos no se pudieron añadir: no son texto, demasiado grandes o demasiados. + %1$d elementos compartidos no se pudieron añadir: no son texto, demasiado grandes o demasiados. + + ¿Añadir a Notes Tech? + «%1$s» se añadirá a la Bandeja de entrada. + Añadir + + %1$d nota se añadirá a la Bandeja de entrada. + %1$d de notas se añadirán a la Bandeja de entrada. + %1$d notas se añadirán a la Bandeja de entrada. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 4e87efc..f161fd0 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -581,8 +581,16 @@ %1$d notes ajoutées à la Boîte de réception. - %1$d élément partagé n\'a pas pu être lu : ce n\'est pas du texte, ou il dépasse 5 Mo. - %1$d d\'éléments partagés n\'ont pas pu être lus : ce n\'est pas du texte, ou ils dépassent 5 Mo. - %1$d éléments partagés n\'ont pas pu être lus : ce n\'est pas du texte, ou ils dépassent 5 Mo. + %1$d élément partagé n\'a pas pu être ajouté : ce n\'est pas du texte, ou il est trop gros. + %1$d d\'éléments partagés n\'ont pas pu être ajoutés : pas du texte, trop gros, ou trop nombreux. + %1$d éléments partagés n\'ont pas pu être ajoutés : pas du texte, trop gros, ou trop nombreux. + + Ajouter à Notes Tech ? + « %1$s » sera ajoutée à la Boîte de réception. + Ajouter + + %1$d note sera ajoutée à la Boîte de réception. + %1$d de notes seront ajoutées à la Boîte de réception. + %1$d notes seront ajoutées à la Boîte de réception. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 87911fd..11f6416 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -548,8 +548,16 @@ %1$d note aggiunte alla Posta in arrivo. - %1$d elemento condiviso non è stato letto: non è testo, o supera 5 MB. - %1$d di elementi condivisi non sono stati letti: non sono testo, o superano 5 MB. - %1$d elementi condivisi non sono stati letti: non sono testo, o superano 5 MB. + %1$d elemento condiviso non è stato aggiunto: non è testo, o è troppo grande. + %1$d di elementi condivisi non sono stati aggiunti: non testo, troppo grandi o troppi. + %1$d elementi condivisi non sono stati aggiunti: non testo, troppo grandi o troppi. + + Aggiungere a Notes Tech? + «%1$s» sarà aggiunta alla Posta in arrivo. + Aggiungi + + %1$d nota sarà aggiunta alla Posta in arrivo. + %1$d di note saranno aggiunte alla Posta in arrivo. + %1$d note saranno aggiunte alla Posta in arrivo. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index aa4e626..da13239 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -716,7 +716,14 @@ %1$d notes added to the Inbox. - %1$d shared item could not be read: not text, or over 5 MB. - %1$d shared items could not be read: not text, or over 5 MB. + %1$d shared item could not be added: not text, or too large. + %1$d shared items could not be added: not text, too large, or too many at once. + + Add to Notes Tech? + “%1$s” will be added to the Inbox. + Add + + %1$d note will be added to the Inbox. + %1$d notes will be added to the Inbox. diff --git a/app/src/test/java/com/filestech/notes_tech/domain/importation/LecteurMarkdownTest.kt b/app/src/test/java/com/filestech/notes_tech/domain/importation/LecteurMarkdownTest.kt index 2f2dacc..0ee8133 100644 --- a/app/src/test/java/com/filestech/notes_tech/domain/importation/LecteurMarkdownTest.kt +++ b/app/src/test/java/com/filestech/notes_tech/domain/importation/LecteurMarkdownTest.kt @@ -106,6 +106,47 @@ class LecteurMarkdownTest { assertThat(lue.titre.length).isAtMost(LecteurMarkdown.TITRE_MAX) } + /** + * M1 of the 3.2.0 security review: a block list used to be appended to the string read so far — + * quadratic, minutes of CPU for a 5 MiB file of `- x` lines. Linear now, and the tags bounded. + */ + @Test + fun `a huge block list is read in linear time and its tags are bounded`() { + val lignes = 200_000 + val texte = buildString(lignes * 10) { + append("---\ntags:\n") + repeat(lignes) { append(" - t").append(it).append('\n') } + append("---\ncorps") + } + // Past ENTETE_MAX, so not a frontmatter at all: the whole text is the body, read at once. + val debut = System.nanoTime() + val lue = LecteurMarkdown.lire(texte, "liste.md", zone) + val millis = (System.nanoTime() - debut) / 1_000_000 + assertThat(lue.etiquettes).isEmpty() + assertThat(lue.contenu).isEqualTo(texte) + assertThat(millis).isLessThan(2_000) + + // Within ENTETE_MAX: read as a block list, and cut at ETIQUETTES_MAX tags of ETIQUETTE_MAX chars. + val court = buildString { + append("---\ntags:\n") + repeat(LecteurMarkdown.ETIQUETTES_MAX + 50) { append(" - ").append("e$it").append('\n') } + append(" - ").append("x".repeat(LecteurMarkdown.ETIQUETTE_MAX + 20)).append('\n') + append("---\ncorps") + } + assertThat(court.length).isLessThan(LecteurMarkdown.ENTETE_MAX) + val bornee = LecteurMarkdown.lire(court, null, zone) + assertThat(bornee.etiquettes).hasSize(LecteurMarkdown.ETIQUETTES_MAX) + assertThat(bornee.etiquettes.first()).isEqualTo("e0") + assertThat(bornee.contenu).isEqualTo("corps") + } + + @Test + fun `a tag longer than the bound is cut`() { + val long = "y".repeat(LecteurMarkdown.ETIQUETTE_MAX + 20) + val lue = LecteurMarkdown.lire("---\ntags: [\"$long\"]\n---\n", null, zone) + assertThat(lue.etiquettes.single()).hasLength(LecteurMarkdown.ETIQUETTE_MAX) + } + @Test fun `a shared text is titled by its subject, else its first non-blank line`() { assertThat(LecteurMarkdown.titreDUnTexte("Sujet", "corps")).isEqualTo("Sujet") diff --git a/app/src/test/java/com/filestech/notes_tech/domain/importation/LectureDUnPartageTest.kt b/app/src/test/java/com/filestech/notes_tech/domain/importation/LectureDUnPartageTest.kt index 4f61246..e17c3f5 100644 --- a/app/src/test/java/com/filestech/notes_tech/domain/importation/LectureDUnPartageTest.kt +++ b/app/src/test/java/com/filestech/notes_tech/domain/importation/LectureDUnPartageTest.kt @@ -10,32 +10,26 @@ class LectureDUnPartageTest { private val zone = ZoneId.of("Europe/Paris") @Test - fun `a shared text becomes one note titled by its subject`() { - val recu = LectureDUnPartage.lire("le corps", "Le sujet", emptyList(), zone) - assertThat(recu.ignores).isEqualTo(0) - assertThat(recu.notes.single().titre).isEqualTo("Le sujet") - assertThat(recu.notes.single().contenu).isEqualTo("le corps") - } - - @Test - fun `a text without subject is titled by its first line`() { - val recu = LectureDUnPartage.lire("Liste de courses\npain\nlait", null, emptyList(), zone) - assertThat(recu.notes.single().titre).isEqualTo("Liste de courses") + fun `a shared text becomes one note titled by its subject, else its first line`() { + assertThat(LectureDUnPartage.lireUnTexte("le corps", "Le sujet").notes.single().titre).isEqualTo("Le sujet") + val sansSujet = LectureDUnPartage.lireUnTexte("Liste de courses\npain\nlait", null).notes.single() + assertThat(sansSujet.titre).isEqualTo("Liste de courses") + assertThat(sansSujet.contenu).isEqualTo("Liste de courses\npain\nlait") } @Test fun `an empty text, or one over the bound, is counted and never cut`() { - assertThat(LectureDUnPartage.lire(" ", null, emptyList(), zone)).isEqualTo(PartageRecu(emptyList(), 1)) + assertThat(LectureDUnPartage.lireUnTexte(" ", null)).isEqualTo(PartageRecu(emptyList(), 1)) val tropLong = "a".repeat(LectureDUnPartage.TEXTE_MAX + 1) - assertThat(LectureDUnPartage.lire(tropLong, null, emptyList(), zone)).isEqualTo(PartageRecu(emptyList(), 1)) + assertThat(LectureDUnPartage.lireUnTexte(tropLong, null)).isEqualTo(PartageRecu(emptyList(), 1)) val pile = "a".repeat(LectureDUnPartage.TEXTE_MAX) - assertThat(LectureDUnPartage.lire(pile, null, emptyList(), zone).notes.single().contenu).hasLength(pile.length) + assertThat(LectureDUnPartage.lireUnTexte(pile, null).notes.single().contenu).hasLength(pile.length) } @Test - fun `files win over the text that comes with them, and are read like imported files`() { - val fichier = FichierPartage("Recette.md", "---\ntitle: \"Tarte\"\ntags: [cuisine]\n---\n\nPâte".toByteArray()) - val recu = LectureDUnPartage.lire("Regarde ce fichier", "Partagé", listOf(fichier), zone) + fun `a file is read like an imported file`() { + val recu = + lire(FichierPartage("Recette.md", "---\ntitle: \"Tarte\"\ntags: [cuisine]\n---\n\nPâte".toByteArray())) val note = recu.notes.single() assertThat(note.titre).isEqualTo("Tarte") assertThat(note.etiquettes).containsExactly("cuisine") @@ -45,23 +39,53 @@ class LectureDUnPartageTest { @Test fun `a file that is not text, unreadable, or over the bound is counted, the others kept`() { - val image = FichierPartage("photo.jpg", byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xE0.toByte())) - val binaire = FichierPartage("data.bin", "abc\u0000def".toByteArray()) - val illisible = FichierPartage("ailleurs.txt", null) - val tropGros = FichierPartage("gros.txt", ByteArray(LectureDUnPartage.FICHIER_MAX + 1) { 'a'.code.toByte() }) - val bon = FichierPartage("Idée.txt", "une idée".toByteArray()) - - val recu = LectureDUnPartage.lire(null, null, listOf(image, binaire, illisible, tropGros, bon), zone) - + val recu = lire( + FichierPartage("photo.jpg", byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xE0.toByte())), + FichierPartage("data.bin", "abc\u0000def".toByteArray()), + FichierPartage("ailleurs.txt", null), + FichierPartage("gros.txt", ByteArray(LectureDUnPartage.FICHIER_MAX + 1) { 'a'.code.toByte() }), + FichierPartage("Idée.txt", "une idée".toByteArray()), + ) assertThat(recu.notes.map { it.titre }).containsExactly("Idée") assertThat(recu.ignores).isEqualTo(4) } @Test - fun `at most FICHIERS_MAX files are read, the rest counted`() { - val fichiers = (1..LectureDUnPartage.FICHIERS_MAX + 3).map { FichierPartage("n$it.txt", "t$it".toByteArray()) } - val recu = LectureDUnPartage.lire(null, null, fichiers, zone) + fun `at most FICHIERS_MAX files are read, the rest counted and never opened`() { + val ouverts = mutableListOf() + val nombre = LectureDUnPartage.FICHIERS_MAX + 30 + val recu = LectureDUnPartage.lireDesFichiers(nombre, zone) { index, _ -> + ouverts += index + FichierPartage("n$index.txt", "t$index".toByteArray()) + } assertThat(recu.notes).hasSize(LectureDUnPartage.FICHIERS_MAX) - assertThat(recu.ignores).isEqualTo(3) + assertThat(recu.ignores).isEqualTo(30) + assertThat(ouverts).hasSize(LectureDUnPartage.FICHIERS_MAX) } + + /** M2 of the 3.2.0 review: four full files spend the budget; the fifth is not even opened. */ + @Test + fun `once the budget is spent, nothing more is read`() { + val ouverts = mutableListOf() + val bornes = mutableListOf() + val recu = LectureDUnPartage.lireDesFichiers(6, zone) { index, maxOctets -> + ouverts += index + bornes += maxOctets + // A reader that honours the bound: the file is as large as it is allowed to be. + FichierPartage("plein$index.txt", ByteArray(maxOctets) { 'a'.code.toByte() }) + } + // 16 MiB = three files of 5 MiB, then a fourth of the 1 MiB left. + assertThat(bornes).containsExactly( + LectureDUnPartage.FICHIER_MAX, + LectureDUnPartage.FICHIER_MAX, + LectureDUnPartage.FICHIER_MAX, + LectureDUnPartage.BUDGET - 3 * LectureDUnPartage.FICHIER_MAX, + ).inOrder() + assertThat(ouverts).containsExactly(0, 1, 2, 3).inOrder() + assertThat(recu.notes).hasSize(4) + assertThat(recu.ignores).isEqualTo(2) + } + + private fun lire(vararg fichiers: FichierPartage) = + LectureDUnPartage.lireDesFichiers(fichiers.size, zone) { index, _ -> fichiers[index] } } diff --git a/app/src/test/java/com/filestech/notes_tech/ui/share/PartagesEnAttenteTest.kt b/app/src/test/java/com/filestech/notes_tech/ui/share/PartagesEnAttenteTest.kt new file mode 100644 index 0000000..83618bc --- /dev/null +++ b/app/src/test/java/com/filestech/notes_tech/ui/share/PartagesEnAttenteTest.kt @@ -0,0 +1,50 @@ +package com.filestech.notes_tech.ui.share + +import com.filestech.notes_tech.domain.importation.NoteImportee +import com.filestech.notes_tech.domain.importation.PartageRecu +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** A share waits in memory, is taken once, and expires (3.2.0, security review L3). */ +class PartagesEnAttenteTest { + + private var maintenant = 1_000_000L + private val attente = PartagesEnAttente().also { it.horloge = { maintenant } } + private val recu = PartageRecu(listOf(NoteImportee("t", "c", emptyList(), null, null, null, false, false)), 0) + + @Test + fun `a fresh share is taken once, and only once`() { + attente.deposer(recu) + assertThat(attente.partage.value).isEqualTo(recu) + maintenant += PartagesEnAttente.DUREE_DE_VIE_MS + assertThat(attente.prendre()).isEqualTo(recu) + assertThat(attente.prendre()).isNull() + assertThat(attente.partage.value).isNull() + } + + @Test + fun `an expired share is dropped when taken`() { + attente.deposer(recu) + maintenant += PartagesEnAttente.DUREE_DE_VIE_MS + 1 + assertThat(attente.prendre()).isNull() + assertThat(attente.partage.value).isNull() + } + + @Test + fun `an abandoned share is gone, unwritten`() { + attente.deposer(recu) + attente.abandonner() + assertThat(attente.partage.value).isNull() + assertThat(attente.prendre()).isNull() + } + + @Test + fun `a newer share replaces one not yet taken, and its own time counts`() { + attente.deposer(recu) + maintenant += PartagesEnAttente.DUREE_DE_VIE_MS + val nouveau = recu.copy(ignores = 3) + attente.deposer(nouveau) + maintenant += 1 + assertThat(attente.prendre()).isEqualTo(nouveau) + } +} From 2f01e5f24cf6c47161d4a2841c3b1b69f6257add Mon Sep 17 00:00:00 2001 From: Patrice Haltaya Date: Thu, 29 Oct 2026 16:51:18 +0100 Subject: [PATCH 04/16] docs(3.2.0): a generated backup code by default, a chosen phrase as an option Patrice, 2026-10-29: "do what is best". The backup file leaves the phone and can be attacked offline without a limit on attempts: only a secret drawn at random is sure to hold. 24 characters, six groups of four, a 32-sign alphabet without ambiguous signs (120 bits), shown once and retyped before the file is written. A chosen phrase stays possible: 12 characters, NFC, obvious ones refused. PIN vaults: backed up only while open, a new PIN chosen at restore. Co-Authored-By: Claude Opus 5.5 --- docs/13-VERSION-3.2.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/13-VERSION-3.2.md b/docs/13-VERSION-3.2.md index 7b29b6d..325c087 100644 --- a/docs/13-VERSION-3.2.md +++ b/docs/13-VERSION-3.2.md @@ -27,11 +27,17 @@ corbeille, avec sa date. Pas les réglages, pas le modèle de dictée, pas le verrou de l'appli. - **Restaurer ramène ce que contient le fichier**, y compris ce qui a été supprimé depuis sa création : un fichier ne peut pas savoir ce qui sera supprimé après lui (R-2). L'écran de restauration le dit. -- **Phrase de passe** saisie deux fois, **normalisée en Unicode NFC** avant usage (un même « é » tapé - sur deux claviers donne deux suites d'octets ; R-15), **12 caractères au moins**, et refusée si elle - est évidente : un seul caractère répété, une suite (`123456789012`, `abcdefghijkl`), ou une phrase de - la courte liste des plus courantes (R-14). L'écran dit pourquoi : le fichier peut être attaqué hors - ligne, sans limite d'essais. +- **Un code de sauvegarde GÉNÉRÉ, par défaut** (Patrice, 2026-10-29 : « fais ce qu'il y a de mieux » ; + R-14) : 24 caractères en 6 groupes de 4 (`K7QM-T2XB-…`), tirés par `SecureRandom` dans un alphabet de + 32 signes sans ambiguïté (ni `0`/`O`, ni `1`/`I`/`L`), soit 120 bits. Affiché **une fois**, à noter ; + la création exige de le **recopier** avant d'écrire le fichier — un code mal noté, c'est une sauvegarde + perdue. À la restauration, la saisie ignore la casse, les espaces et les tirets. Le fichier quitte le + téléphone et s'attaque hors ligne, sans limite d'essais : seul un secret tiré au hasard y résiste à + coup sûr. +- **Ou une phrase choisie**, en option : saisie deux fois, **normalisée en Unicode NFC** avant usage (un + même « é » tapé sur deux claviers donne deux suites d'octets ; R-15), **12 caractères au moins**, et + refusée si elle est évidente : un seul caractère répété, une suite (`123456789012`, `abcdefghijkl`), ou + une phrase de la courte liste des plus courantes. L'écran dit pourquoi. - **Argon2id v1.3, t = 3, m = 64 Mio, p = 1**, sortie 32 octets (les paramètres des coffres à phrase, `VaultParams`) ; **AES-256-GCM, étiquette de 128 bits**, par Bouncy Castle (déjà dans l'appli pour Argon2) en flux. From a1a7b1a63e8b13994482bd425b13211d4dde2576 Mon Sep 17 00:00:00 2001 From: Patrice Haltaya Date: Thu, 29 Oct 2026 18:00:18 +0100 Subject: [PATCH 05/16] feat(editor): "Find in note", highlighted, from the menu and from a search result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hcarrega's idea (issue #10), D-033 of docs/13-VERSION-3.2.md. The editor's menu opens a bar in place of the top bar: the occurrences highlighted in the note, "3 / 12", previous and next. A note opened from a search result (the search screen, or the home list filtered by its field: the same full-text search) opens with the bar filled and its first occurrence on screen. - What is searched is what is shown: while writing, the title and the Markdown as typed; while reading, the title and the Markdown as drawn (a link's address is not on screen there, so not found there). - Insensitive to case and accents, word by word, inside words too, and the typographic apostrophes fold to "'" (FTS5 treats the apostrophe as a separator: a note found by "l'été" shows its "l’été"). Positions in the text as written; nothing is copied, a note having no size limit. - At most 1000 occurrences ("1000+"), computed off the main thread and cancelled by a newer text or search; an answer is never drawn on another text than the one it was computed on. - The bar moves the cursor or the list only when asked (new search, previous, next, arrival): typing in the body with the bar open does not pull the cursor back at every keystroke. While reading, the list goes to the item, then its text brings the very line into view, once. - Back closes the bar before leaving the note; the bar closes if the vault locks underneath. The bar's field keeps its name with text in it. - Nothing is indexed or written; strings in the five languages. Tests: RechercheDansLeTexteTest (8) and RechercheDansLaNoteTest (3) on the JVM, the latter through the real Markdown reader over every kind of block; RechercheDansLaNoteEcranTest (5, the far occurrence measured on the pixels while writing) and RechercheDeBoutEnBoutTest (2, the real app). Each seen failing under a sabotage: no fold of the apostrophe, no accent kept, no merge; a move on every answer, no scroll to the item, no Back handler; no search in the route. Gate: 498 JVM tests, detekt, ktlint, lint clean. Co-Authored-By: Claude Opus 5.5 --- .../notes_tech/ui/editor/EditeurTest.kt | 1 + .../ui/editor/RechercheDansLaNoteEcranTest.kt | 270 +++++++++ .../ui/editor/RechercheDeBoutEnBoutTest.kt | 130 +++++ .../domain/recherche/RechercheDansLeTexte.kt | 119 ++++ .../notes_tech/ui/NotesTechNavHost.kt | 14 +- .../notes_tech/ui/editor/ApercuMarkdown.kt | 69 ++- .../notes_tech/ui/editor/NoteEditorScreen.kt | 95 +++- .../ui/editor/NoteEditorViewModel.kt | 3 + .../ui/editor/RechercheDansLaNote.kt | 517 ++++++++++++++++++ .../filestech/notes_tech/ui/home/HomeRoute.kt | 10 +- .../notes_tech/ui/navigation/Destination.kt | 16 +- .../notes_tech/ui/search/SearchScreen.kt | 5 +- app/src/main/res/values-de/strings.xml | 9 + app/src/main/res/values-es/strings.xml | 9 + app/src/main/res/values-fr/strings.xml | 9 + app/src/main/res/values-it/strings.xml | 9 + app/src/main/res/values/strings.xml | 9 + .../recherche/RechercheDansLeTexteTest.kt | 68 +++ .../ui/editor/RechercheDansLaNoteTest.kt | 73 +++ docs/13-VERSION-3.2.md | 17 + 20 files changed, 1413 insertions(+), 39 deletions(-) create mode 100644 app/src/androidTest/java/com/filestech/notes_tech/ui/editor/RechercheDansLaNoteEcranTest.kt create mode 100644 app/src/androidTest/java/com/filestech/notes_tech/ui/editor/RechercheDeBoutEnBoutTest.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/domain/recherche/RechercheDansLeTexte.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/ui/editor/RechercheDansLaNote.kt create mode 100644 app/src/test/java/com/filestech/notes_tech/domain/recherche/RechercheDansLeTexteTest.kt create mode 100644 app/src/test/java/com/filestech/notes_tech/ui/editor/RechercheDansLaNoteTest.kt diff --git a/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/EditeurTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/EditeurTest.kt index df54578..3a6aeab 100644 --- a/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/EditeurTest.kt +++ b/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/EditeurTest.kt @@ -181,6 +181,7 @@ class EditeurTest { lectureCourante.value = true }, onLienDeLApercu = { liensDeLApercu += it }, + rechercheALOuverture = null, ) } } diff --git a/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/RechercheDansLaNoteEcranTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/RechercheDansLaNoteEcranTest.kt new file mode 100644 index 0000000..50da5d4 --- /dev/null +++ b/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/RechercheDansLaNoteEcranTest.kt @@ -0,0 +1,270 @@ +package com.filestech.notes_tech.ui.editor + +import androidx.activity.ComponentActivity +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotFocused +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.test.espresso.Espresso +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.filestech.notes_tech.R +import com.filestech.notes_tech.domain.model.Folder +import com.filestech.notes_tech.domain.model.Note +import com.filestech.notes_tech.ui.CHAMP_DE_SAISIE +import com.filestech.notes_tech.ui.champsDeSaisieSansNom +import com.filestech.notes_tech.ui.theme.NotesTechTheme +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.time.Instant + +/** + * **"Find in note" on the editor's screen** (3.2.0, D-033): what the bar counts, highlights and + * moves to, while reading and while writing, and what it must not do. + */ +@RunWith(AndroidJUnit4::class) +class RechercheDansLaNoteEcranTest { + + @get:Rule + val regle = createAndroidComposeRule() + + private val etat = mutableStateOf(EditorUiState()) + private val contenus = mutableListOf() + private val sorties = mutableListOf() + + /** The current occurrence's background, read from the theme the screen is drawn with. */ + private var fondDeLaCourante = Color.Unspecified + + private fun texte(id: Int, vararg arguments: Any): String = regle.activity.getString(id, *arguments) + + /** The screen over a loaded note; the fields write back into it, as the ViewModel does. */ + private fun poser(corps: String, lecture: Boolean, recherche: String? = null) { + etat.value = EditorUiState( + loading = false, + titre = TextFieldValue(TITRE), + content = TextFieldValue(corps), + note = note(corps), + folder = dossier(), + originalTitle = TITRE, + originalContent = corps, + ) + regle.setContent { + NotesTechTheme { + fondDeLaCourante = stylesDeSurlignage().courante.background + NoteEditorScreen( + state = etat.value, + liens = PanneauDeLiens(), + messages = remember { SnackbarHostState() }, + dicteeActive = false, + onQuitter = { sorties += Unit }, + onTitreChange = { etat.value = etat.value.copy(titre = it) }, + onContenuChange = { + contenus += it + etat.value = etat.value.copy(content = it) + }, + onDicter = {}, + onInsererUnLien = {}, + onEpingler = {}, + onFavori = {}, + onInfos = {}, + onDeplacer = {}, + onCouleur = {}, + onExporter = {}, + onCopier = {}, + onCorbeille = {}, + onSupprimerDefinitivement = {}, + onOuvrirNote = {}, + onLienFantome = {}, + lecture = lecture, + onModifier = {}, + onTerminer = {}, + onLienDeLApercu = {}, + rechercheALOuverture = recherche, + ) + } + } + regle.waitForIdle() + } + + private fun ouvrirLaBarre() { + regle.onNodeWithContentDescription(texte(R.string.note_editor_tooltip_more)).performClick() + regle.onNodeWithText(texte(R.string.note_editor_menu_find)).performClick() + } + + private fun attendreLeCompte(rang: Int, total: Int) { + val dit = listOf(texte(R.string.note_find_count_description, rang, total)) + regle.waitUntil(DELAI_MS) { + regle.onAllNodes(SemanticsMatcher.expectValue(SemanticsProperties.ContentDescription, dit)) + .fetchSemanticsNodes() + .isNotEmpty() + } + } + + /** The highlighted pieces of the drawn texts, top to bottom: their text and their background. */ + private fun surlignages(): List> = regle + .onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsProperties.Text), useUnmergedTree = true) + .fetchSemanticsNodes() + .sortedBy { it.boundsInRoot.top } + .flatMap { noeud -> + noeud.config[SemanticsProperties.Text].flatMap { annote -> + annote.spanStyles + .filter { it.item.background != Color.Unspecified } + .sortedBy { it.start } + .map { annote.text.substring(it.start, it.end) to it.item.background } + } + } + + /** The current occurrence: the one highlighted in the colour no other one has. */ + private fun courante(): String { + val pieces = surlignages() + val couleur = pieces.groupBy { it.second }.values.single { it.size == 1 }.single() + return "${pieces.indexOf(couleur)}:${couleur.first}" + } + + @Test + fun en_lecture_la_barre_compte_surligne_et_avance_d_une_occurrence() { + poser("Une pomme.\n\nDeux pommes et une POMME.", lecture = true) + + ouvrirLaBarre() + regle.onNode(hasSetTextAction()).performTextInput("pomme") + + attendreLeCompte(1, 3) + assertThat(surlignages().map { it.first }).containsExactly("pomme", "pomme", "POMME").inOrder() + assertThat(courante()).isEqualTo("0:pomme") + + regle.onNodeWithContentDescription(texte(R.string.note_find_next)).performClick() + + attendreLeCompte(2, 3) + assertThat(courante()).isEqualTo("1:pomme") + // The bar's field keeps its name with text in it — the editor's own fields' defect (§80). + assertThat(regle.onAllNodes(CHAMP_DE_SAISIE).fetchSemanticsNodes()).hasSize(1) + assertThat(regle.champsDeSaisieSansNom()).isEmpty() + } + + /** From a search result: the bar is filled, the far occurrence brought on screen, no keyboard. */ + @Test + fun ouverte_depuis_un_resultat_la_barre_amene_l_occurrence_lointaine_a_l_ecran() { + val corps = (1..PARAGRAPHES).joinToString("\n\n") { "Paragraphe $it." } + "\n\nLa cible est ici." + poser(corps, lecture = true, recherche = "cible") + + attendreLeCompte(1, 1) + regle.waitUntil(DELAI_MS) { surlignages().isNotEmpty() } + regle.onNodeWithText("La cible est ici.").assertIsDisplayed() + regle.onNode(hasSetTextAction()).assertIsNotFocused() + } + + /** 🔴 The cursor goes to the occurrence when the bar moves — and not when the user types. */ + @Test + fun en_ecriture_la_barre_place_le_curseur_mais_une_frappe_ne_l_y_ramene_pas() { + poser("début pomme fin", lecture = false) + + ouvrirLaBarre() + // Three fields while writing: the bar's is the empty one. + val vide = SemanticsMatcher.expectValue(SemanticsProperties.EditableText, AnnotatedString("")) + regle.onNode(hasSetTextAction() and vide).performTextInput("pomme") + attendreLeCompte(1, 1) + regle.waitUntil(DELAI_MS) { contenus.any { it.selection == TextRange(6) } } + + // Typed at the start of the body: the occurrence moves to 8, and the cursor stays where it is. + regle.runOnIdle { etat.value = etat.value.copy(content = TextFieldValue("XY début pomme fin", TextRange(2))) } + regle.waitForIdle() + + assertThat(contenus.map { it.selection }).doesNotContain(TextRange(8)) + assertThat(etat.value.content.selection).isEqualTo(TextRange(2)) + } + + /** + * While writing, the bar puts the cursor on the occurrence and counts on the body field to scroll + * to its cursor by itself — the field being unfocused, the bar's having the keyboard. Measured on + * the pixels: the current occurrence's colour must be on screen. + */ + @Test + fun en_ecriture_une_occurrence_lointaine_vient_a_l_ecran() { + val corps = (1..PARAGRAPHES).joinToString("\n") { "Ligne $it." } + "\nLa cible est ici." + poser(corps, lecture = false) + assertThat(pixelsDe(fondDeLaCourante)).isEqualTo(0) + + ouvrirLaBarre() + val vide = SemanticsMatcher.expectValue(SemanticsProperties.EditableText, AnnotatedString("")) + regle.onNode(hasSetTextAction() and vide).performTextInput("cible") + attendreLeCompte(1, 1) + regle.waitUntil(DELAI_MS) { contenus.any { it.selection == TextRange(corps.indexOf("cible")) } } + + regle.waitUntil(DELAI_MS) { pixelsDe(fondDeLaCourante) > 0 } + } + + private fun pixelsDe(couleur: Color): Int { + val image = regle.onRoot().captureToImage().asAndroidBitmap() + val argb = couleur.toArgb() + val pixels = IntArray(image.width * image.height) + image.getPixels(pixels, 0, image.width, 0, 0, image.width, image.height) + return pixels.count { it == argb } + } + + @Test + fun le_retour_ferme_la_barre_avant_de_quitter_la_note() { + poser("Une pomme.", lecture = true) + ouvrirLaBarre() + regle.onNodeWithContentDescription(texte(R.string.note_find_close)).assertIsDisplayed() + // Opened from the menu, the field has the keyboard: the system's first Back closes the keyboard. + Espresso.closeSoftKeyboard() + + Espresso.pressBack() + + regle.onNodeWithContentDescription(texte(R.string.note_editor_tooltip_more)).assertIsDisplayed() + assertThat(regle.onAllNodes(hasSetTextAction()).fetchSemanticsNodes()).isEmpty() + assertThat(sorties).isEmpty() + } + + private fun note(corps: String) = Note( + id = "a", + title = TITRE, + content = corps, + folderId = "dossier", + tags = emptyList(), + pinned = false, + favorite = false, + archived = false, + trashedAt = null, + createdAt = Instant.ofEpochMilli(HORODATAGE), + updatedAt = Instant.ofEpochMilli(HORODATAGE), + encrypted = null, + encVersion = 1, + ) + + private fun dossier() = Folder( + id = "dossier", + name = "Dossier", + parentId = null, + color = null, + icon = null, + createdAt = Instant.ofEpochMilli(HORODATAGE), + updatedAt = Instant.ofEpochMilli(HORODATAGE), + vault = null, + ) + + private companion object { + const val TITRE = "Courses" + const val PARAGRAPHES = 300 + const val DELAI_MS = 5_000L + const val HORODATAGE = 1_700_000_000_000L + } +} diff --git a/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/RechercheDeBoutEnBoutTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/RechercheDeBoutEnBoutTest.kt new file mode 100644 index 0000000..6a10429 --- /dev/null +++ b/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/RechercheDeBoutEnBoutTest.kt @@ -0,0 +1,130 @@ +package com.filestech.notes_tech.ui.editor + +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.filestech.notes_tech.R +import com.filestech.notes_tech.data.prefs.AppSettings +import com.filestech.notes_tech.data.prefs.LegacyPreferences +import com.filestech.notes_tech.data.repository.NotesRepository +import com.filestech.notes_tech.domain.model.Folder +import com.filestech.notes_tech.security.applock.AppLockStore +import com.filestech.notes_tech.ui.MainActivity +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.util.UUID +import javax.inject.Inject + +/** + * **A search result opens with "Find in note" on what was searched** (3.2.0, D-033), through the + * real app: the route's argument, the editor's `SavedStateHandle`, the bar — what no screen test + * connects. From the search screen, and from the home list filtered by its field, the same search. + * + * ⚠️ It writes to the app's real database: one note, with a random suffix, deleted in [tearDown]. + */ +@OptIn(ExperimentalTestApi::class) +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class RechercheDeBoutEnBoutTest { + + @get:Rule(order = 0) + val hilt = HiltAndroidRule(this) + + @get:Rule(order = 1) + val compose = createEmptyComposeRule() + + @Inject + lateinit var notes: NotesRepository + + @Inject + lateinit var store: AppLockStore + + @Inject + lateinit var prefs: LegacyPreferences + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val suffixe = UUID.randomUUID().toString().take(8) + private val titre = "E2E find $suffixe" + private val mot = "zorglub$suffixe" + private var scenario: ActivityScenario? = null + private var presentationDejaVue = false + + @Before + fun setUp() { + hilt.inject() + check(!store.configuredNow()) { "An app lock is configured on this device: turn it off first." } + val settings = AppSettings(prefs) + presentationDejaVue = !settings.shouldShowSplash() + settings.markSplashShown() + runBlocking { + notes.create(folderId = Folder.INBOX_ID, title = titre, content = "Début.\n\nLe mot $mot est ici.") + } + } + + @After + fun tearDown() { + scenario?.close() + runBlocking { notes.listAllAlive().filter { it.title == titre }.forEach { notes.deletePermanently(it.id) } } + if (!presentationDejaVue) prefs.remove(SPLASH_SHOWN_KEY) + } + + private fun texte(id: Int, vararg arguments: Any): String = context.getString(id, *arguments) + + private fun laNoteSOuvreAvecLaBarre() { + val compte = listOf(texte(R.string.note_find_count_description, 1, 1)) + compose.waitUntilAtLeastOneExists( + SemanticsMatcher.expectValue(SemanticsProperties.ContentDescription, compte), + TIMEOUT_MILLIS, + ) + compose.onNode(hasSetTextAction() and hasText(mot)).assertExists() + } + + @Test + fun depuis_l_ecran_de_recherche() { + scenario = ActivityScenario.launch(MainActivity::class.java) + compose.waitUntilAtLeastOneExists(hasText(titre), TIMEOUT_MILLIS) + compose.onNodeWithContentDescription(texte(R.string.search_title)).performClick() + compose.onNode(hasSetTextAction() and hasText(texte(R.string.search_hint))).performTextInput(mot) + compose.waitUntilAtLeastOneExists(hasText(titre), TIMEOUT_MILLIS) + + compose.onNodeWithText(titre).performClick() + + laNoteSOuvreAvecLaBarre() + } + + @Test + fun depuis_la_liste_filtree_de_l_accueil() { + scenario = ActivityScenario.launch(MainActivity::class.java) + compose.waitUntilAtLeastOneExists(hasText(titre), TIMEOUT_MILLIS) + compose.onNode(hasSetTextAction() and hasText(texte(R.string.home_search_hint))).performTextInput(mot) + // The list filtered: the note alone, its card the one carrying its title. + compose.waitUntilExactlyOneExists(hasText(titre), TIMEOUT_MILLIS) + + compose.onNodeWithText(titre).performClick() + + laNoteSOuvreAvecLaBarre() + } + + private companion object { + const val TIMEOUT_MILLIS = 15_000L + + /** `AppSettings.KEY_SPLASH_SHOWN`, written out: the test restores exactly what it set. */ + const val SPLASH_SHOWN_KEY = "splash_shown_v1" + } +} diff --git a/app/src/main/java/com/filestech/notes_tech/domain/recherche/RechercheDansLeTexte.kt b/app/src/main/java/com/filestech/notes_tech/domain/recherche/RechercheDansLeTexte.kt new file mode 100644 index 0000000..0d04e47 --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/domain/recherche/RechercheDansLeTexte.kt @@ -0,0 +1,119 @@ +package com.filestech.notes_tech.domain.recherche + +import com.filestech.notes_tech.core.text.DartTextSemantics +import java.text.Normalizer +import java.util.Locale + +/** + * Where the words of a search appear in a text (3.2.0, D-033: "Find in note"), insensitive to case + * and accents, as the app's search is, and **in the text as written**: positions in the original + * string, the one the screen draws, never in a folded copy of it. + * + * - The search is cut into words at the spaces — Dart's set of them, as the app's search + * ([DartTextSemantics.WHITESPACE]) — and each word is looked for on its own, **inside** words + * too: "budget" finds "budgets", as a find-in-page does. + * - A character is folded on its own: decomposed (NFD), its accents dropped, lower-cased. So "ete" + * finds "Été", "été" finds "ete", and a text typed in decomposed form ("e" + a combining acute) + * is found too, its accent inside the occurrence. + * - ⚠️ **The typographic apostrophes fold to `'`.** The app's search (FTS5, `unicode61`) treats an + * apostrophe as a separator, so "l'été" typed on a keyboard finds a note that writes "l’été"; the + * note must then show that occurrence, not "0 / 0". + * + * ⚠️ Nothing is copied: a note has no size limit, and a folded copy plus its table back to the + * original would weigh several times the note. Each character is folded where it is read, a cache + * keeping the folds already made. + */ +object RechercheDansLeTexte { + + /** An occurrence: from [debut] included to [fin] excluded, positions in the original text. */ + data class Plage(val debut: Int, val fin: Int) + + /** The words of [saisie], folded, without repeats. Empty for a search of spaces only. */ + fun mots(saisie: String): List = saisie.split(DartTextSemantics.WHITESPACE) + .map { mot -> buildString { forEachPoint(mot) { append(replier(it, null)) } } } + .filter { it.isNotEmpty() } + .distinct() + + /** + * The occurrences of [mots] in [texte], in order, at most [max] of them. Two occurrences that + * overlap — "bud" and "budget" — are one. + * + * @param verifier called now and then on a long text: the caller's cancellation, as in + * `MarkdownPreviewReader.read`. + */ + fun occurrences(texte: String, mots: List, max: Int, verifier: () -> Unit = {}): List { + if (mots.isEmpty() || max <= 0) return emptyList() + val cache = HashMap() + val plages = ArrayList() + var debut = 0 + var lus = 0 + while (debut < texte.length) { + if (++lus % PAS_DE_VERIFICATION == 0) verifier() + val point = texte.codePointAt(debut) + // An occurrence starts on a letter, never on the accent that follows one. + val fin = if (replier(point, cache).isEmpty()) -1 else mots.maxOf { finDe(texte, debut, it, cache) } + if (fin > debut) { + val derniere = plages.lastOrNull() + if (derniere != null && debut < derniere.fin) { + plages[plages.lastIndex] = Plage(derniere.debut, maxOf(derniere.fin, fin)) + } else { + if (plages.size == max) break + plages += Plage(debut, fin) + } + } + debut += Character.charCount(point) + } + return plages + } + + /** Where [mot] ends if it starts at [debut], or -1; the accents right after it are part of it. */ + private fun finDe(texte: String, debut: Int, mot: String, cache: HashMap): Int { + var position = debut + var k = 0 + while (k < mot.length) { + if (position >= texte.length) return -1 + val point = texte.codePointAt(position) + for (lettre in replier(point, cache)) { + // A character folding to several letters may end past the word: it is in. + if (k == mot.length) break + if (lettre != mot[k]) return -1 + k++ + } + position += Character.charCount(point) + } + while (position < texte.length) { + val point = texte.codePointAt(position) + if (replier(point, cache).isNotEmpty()) break + position += Character.charCount(point) + } + return position + } + + private fun replier(point: Int, cache: HashMap?): String { + if (point < ASCII_REPLIE.size) return ASCII_REPLIE[point] + cache?.get(point)?.let { return it } + val replie = when (point) { + APOSTROPHE_OUVRANTE, APOSTROPHE, APOSTROPHE_MODIFICATIVE -> "'" + else -> Normalizer.normalize(String(Character.toChars(point)), Normalizer.Form.NFD) + .filterNot { Character.getType(it) == Character.NON_SPACING_MARK.toInt() } + .lowercase(Locale.ROOT) + } + cache?.put(point, replie) + return replie + } + + private inline fun forEachPoint(texte: String, action: (Int) -> Unit) { + var i = 0 + while (i < texte.length) { + val point = texte.codePointAt(i) + action(point) + i += Character.charCount(point) + } + } + + private val ASCII_REPLIE = Array(128) { it.toChar().lowercaseChar().toString() } + private const val APOSTROPHE_OUVRANTE = 0x2018 + private const val APOSTROPHE = 0x2019 + private const val APOSTROPHE_MODIFICATIVE = 0x02BC + private const val PAS_DE_VERIFICATION = 64 * 1024 +} diff --git a/app/src/main/java/com/filestech/notes_tech/ui/NotesTechNavHost.kt b/app/src/main/java/com/filestech/notes_tech/ui/NotesTechNavHost.kt index 177cb07..1c10509 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/NotesTechNavHost.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/NotesTechNavHost.kt @@ -28,7 +28,9 @@ fun NotesTechNavHost(navController: NavHostController) { NavHost(navController = navController, startDestination = Destination.Home.route) { composable(Destination.Home.route) { HomeRoute( - onOpenNote = { navController.navigate(Destination.Editor(it.id).route) }, + onOpenNote = { note, recherche -> + navController.navigate(Destination.Editor(note.id, recherche = recherche).route) + }, onEditNote = { navController.navigate(Destination.Editor(it.id, ecrire = true).route) }, onOpenSearch = { navController.navigate(Destination.Search.route) }, onOpenTrash = { navController.navigate(Destination.Trash.route) }, @@ -45,6 +47,11 @@ fun NotesTechNavHost(navController: NavHostController) { type = NavType.BoolType defaultValue = false }, + navArgument(Destination.ARG_RECHERCHE) { + type = NavType.StringType + nullable = true + defaultValue = null + }, ), ) { // L'identifiant n'est pas relu ici : `SavedStateHandle` le remet au ViewModel, qui est @@ -65,7 +72,10 @@ fun NotesTechNavHost(navController: NavHostController) { composable(Destination.Search.route) { SearchRoute( onBack = { navController.popBackStack() }, - onOpenNote = { navController.navigate(Destination.Editor(it.id).route) }, + // The note opens with "Find in note" on what was searched (3.2.0, D-033). + onOpenNote = { note, recherche -> + navController.navigate(Destination.Editor(note.id, recherche = recherche).route) + }, ) } diff --git a/app/src/main/java/com/filestech/notes_tech/ui/editor/ApercuMarkdown.kt b/app/src/main/java/com/filestech/notes_tech/ui/editor/ApercuMarkdown.kt index 4b0a246..c98fdde 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/editor/ApercuMarkdown.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/editor/ApercuMarkdown.kt @@ -117,6 +117,42 @@ private fun typeDeContenu(element: ElementDApercu): String = when (element.bloc) else -> "bloc" } +/** + * The texts [Element] draws for [element], in the order it draws them, each with the key its drawing + * reads its highlight by (3.2.0, "Find in note"): a paragraph's runs, a code block itself. Walks the + * blocks as [Element] and [Bloc] do — keep the two together; their `when`s are both exhaustive, so a + * new kind of block does not compile until both know it. + */ +internal fun textesDeLElement(element: ElementDApercu, ajouter: (cle: Any, texte: String) -> Unit) { + when (val bloc = element.bloc) { + is PreviewBlock.ListBlock -> bloc.items[element.partie].blocks.forEach { textesDuBloc(it, ajouter) } + is PreviewBlock.Table -> { + val cellules = if (element.partie == 0) bloc.header else bloc.rows[element.partie - 1] + cellules.forEach { ajouter(it, texteDesRuns(it)) } + } + else -> textesDuBloc(bloc, ajouter) + } +} + +private fun textesDuBloc(bloc: PreviewBlock, ajouter: (cle: Any, texte: String) -> Unit) { + when (bloc) { + is PreviewBlock.Heading -> ajouter(bloc.runs, texteDesRuns(bloc.runs)) + is PreviewBlock.Paragraph -> ajouter(bloc.runs, texteDesRuns(bloc.runs)) + is PreviewBlock.ListBlock -> bloc.items.forEach { item -> item.blocks.forEach { textesDuBloc(it, ajouter) } } + is PreviewBlock.Quote -> bloc.blocks.forEach { textesDuBloc(it, ajouter) } + is PreviewBlock.Code -> ajouter(bloc, bloc.text) + is PreviewBlock.RawHtml -> ajouter(bloc, bloc.text) + is PreviewBlock.Table -> (listOf(bloc.header) + bloc.rows).forEach { ligne -> + ligne.forEach { ajouter(it, texteDesRuns(it)) } + } + PreviewBlock.Rule -> Unit + is PreviewBlock.AsWritten -> ajouter(bloc, bloc.text) + } +} + +/** What [texteDe] writes for [runs]: their texts, end to end. */ +private fun texteDesRuns(runs: List): String = runs.joinToString("") { it.text } + @Composable private fun Element(element: ElementDApercu, onLien: (LinkTarget) -> Unit) { when (val bloc = element.bloc) { @@ -158,33 +194,38 @@ private fun Lecture(modifier: Modifier) { private fun Bloc(bloc: PreviewBlock, onLien: (LinkTarget) -> Unit) { // ⚠️ Exhaustive, no `else`: a new kind of block must not compile until it is drawn. when (bloc) { - is PreviewBlock.Heading -> Text( - text = texteDe(bloc.runs, onLien), + is PreviewBlock.Heading -> TexteSurligne( + texte = texteDe(bloc.runs, onLien), + cle = bloc.runs, style = styleDeTitre(bloc.level), modifier = Modifier.semantics { heading() }, ) - is PreviewBlock.Paragraph -> Text(text = texteDe(bloc.runs, onLien), style = MaterialTheme.typography.bodyLarge) + is PreviewBlock.Paragraph -> TexteSurligne( + texte = texteDe(bloc.runs, onLien), + cle = bloc.runs, + style = MaterialTheme.typography.bodyLarge, + ) is PreviewBlock.ListBlock -> Liste(bloc, onLien) is PreviewBlock.Quote -> Citation(bloc, onLien) - is PreviewBlock.Code -> BlocDeCode(bloc.text) + is PreviewBlock.Code -> BlocDeCode(bloc.text, cle = bloc) // Shown as written, like code: it is markup the preview does not interpret. - is PreviewBlock.RawHtml -> BlocDeCode(bloc.text) + is PreviewBlock.RawHtml -> BlocDeCode(bloc.text, cle = bloc) is PreviewBlock.Table -> Tableau(bloc, onLien) PreviewBlock.Rule -> HorizontalDivider(Modifier.padding(vertical = 4.dp)) - is PreviewBlock.AsWritten -> TelQuel(bloc.text) + is PreviewBlock.AsWritten -> TelQuel(bloc) } } /** A note the preview did not parse — see [MarkdownPreviewReader.read] — with the reason, then its text. */ @Composable -private fun TelQuel(texte: String) { +private fun TelQuel(bloc: PreviewBlock.AsWritten) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( text = stringResource(R.string.note_preview_as_written), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - BlocDeCode(texte) + BlocDeCode(bloc.text, cle = bloc) } } @@ -266,14 +307,15 @@ private fun Citation(citation: PreviewBlock.Quote, onLien: (LinkTarget) -> Unit) * sideways; on a phone that hides the end of every line behind a gesture nothing announces. */ @Composable -private fun BlocDeCode(texte: String) { +private fun BlocDeCode(texte: String, cle: PreviewBlock) { Surface( color = MaterialTheme.colorScheme.surfaceContainerHighest, shape = RoundedCornerShape(8.dp), modifier = Modifier.fillMaxWidth(), ) { - Text( - text = texte, + TexteSurligne( + texte = remember(texte) { AnnotatedString(texte) }, + cle = cle, style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), modifier = Modifier.padding(12.dp), ) @@ -304,8 +346,9 @@ private fun LigneDeTableau( Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min)) { cellules.forEachIndexed { index, cellule -> Box(Modifier.weight(1f).fillMaxHeight().border(0.5.dp, bord).padding(6.dp)) { - Text( - text = texteDe(cellule, onLien), + TexteSurligne( + texte = texteDe(cellule, onLien), + cle = cellule, style = MaterialTheme.typography.bodyMedium.copy( fontWeight = if (entete) FontWeight.Bold else null, ), diff --git a/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorScreen.kt b/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorScreen.kt index cbbff17..d2302ec 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorScreen.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorScreen.kt @@ -48,6 +48,7 @@ import androidx.compose.material.icons.outlined.LockOpen import androidx.compose.material.icons.outlined.Mic import androidx.compose.material.icons.outlined.Palette import androidx.compose.material.icons.outlined.PushPin +import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.StarBorder import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator @@ -66,6 +67,7 @@ import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -92,6 +94,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.input.TextFieldValue @@ -444,6 +447,7 @@ fun NoteEditorRoute(onBack: () -> Unit, onOpenNote: (String) -> Unit, onInstalle lectureChoisie = true }, onLienDeLApercu = lienDeLApercu, + rechercheALOuverture = viewModel.rechercheALOuverture, ) } @@ -509,6 +513,8 @@ fun NoteEditorScreen( /** Writing → reading: the ✓ tick, which saves first. */ onTerminer: () -> Unit, onLienDeLApercu: (LinkTarget) -> Unit, + /** The search of the result this note was opened from (3.2.0): the "Find in note" bar opens with it. */ + rechercheALOuverture: String?, modifier: Modifier = Modifier, ) { val focus = LocalFocusManager.current @@ -523,6 +529,52 @@ fun NoteEditorScreen( } } val noteOuverte = state.note != null && state.lockedVault == null && state.loadError == null && !state.loading + + // Above the Edit / Preview branch, not inside it: the branch leaving the composition took the + // preview's position with it, and every switch put a long note back at its top (GPT-5.6 review, + // 2026-09-25). Saveable, so a rotation keeps it too. The Edit side has no such state to keep: its + // field scrolls itself and exposes none — as in 2.0.9, where the field is rebuilt on each switch. + // Up here since 3.2.0: "Find in note" scrolls it from the bar. + val defilementDeLApercu = rememberLazyListState() + // ⚠️ The text on screen, not the last saved one: what the preview draws is what the user has just + // typed — the save is 500 ms behind. Called in both modes: a reading, and so the preview's + // position, survives a trip to Edit when nothing was typed (see `rememberLectureDeLApercu`). Up + // here since 3.2.0: the search bar, in the top bar, searches what it draws. + val rendu = rememberLectureDeLApercu(state.content.text, actif = lecture) + + val recherche = rememberEtatDeRechercheDansLaNote(rechercheALOuverture) + val source = sourceDeRecherche(recherche, noteOuverte, lecture, state, rendu) + val resultat = rememberResultatDeRecherche(recherche, source) + val courante = resultat?.occurrences?.getOrNull(recherche.indexParmi(resultat.occurrences.size)) + DeplacementDeLaRecherche( + recherche = recherche, + resultat = resultat, + courante = courante, + lecture = lecture, + defilement = defilementDeLApercu, + onPlacerLeCurseur = { cible -> + when (cible.cle) { + CleDuCorps -> onContenuChange(state.content.copy(selection = TextRange(cible.plage.debut))) + CleDuTitre -> onTitreChange(state.titre.copy(selection = TextRange(cible.plage.debut))) + } + }, + ) + // The bar closes when the note stops being readable — a vault relocked underneath: it would + // otherwise come back over the unlock sheet's answer. Not before the note was ever readable: the + // bar opened by a search result waits for the note to load. + var dejaLisible by remember { mutableStateOf(false) } + LaunchedEffect(noteOuverte) { + when { + noteOuverte -> dejaLisible = true + dejaLisible -> recherche.fermer() + } + } + val rechercheVisible = recherche.ouverte && noteOuverte + BackHandler(enabled = rechercheVisible, onBack = recherche::fermer) + val surlignage = remember(resultat, courante, recherche.aAmener) { + if (resultat == null) Surlignage.AUCUN else Surlignage(resultat, courante, recherche.aAmener, recherche::amenee) + } + Scaffold( modifier = modifier, snackbarHost = { HoteDeMessages(messages) }, @@ -554,6 +606,12 @@ fun NoteEditorScreen( } }, topBar = { + // "Find in note" takes the top bar's place while open, as a find-in-page does: the system + // Back closes it first (above), then leaves the note. + if (rechercheVisible) { + BarreDeRechercheDansLaNote(recherche, resultat) + return@Scaffold + } TopAppBar( navigationIcon = { IconButton(onClick = onQuitter) { @@ -664,6 +722,7 @@ fun NoteEditorScreen( } if (ouvrable) { MenuDeDebordement( + onRechercher = recherche::ouvrir, epinglee = note.pinned, favorite = note.favorite, // ⚠️ **La valeur inverse est calculée ICI**, où la note est connue, et le @@ -688,13 +747,6 @@ fun NoteEditorScreen( Box(Modifier.fillMaxSize().padding(padding)) { val chargementEnCours = stringResource(R.string.common_loading) - // Above the Edit / Preview branch, not inside it: the branch leaving the composition took - // the preview's position with it, and every switch put a long note back at its top - // (GPT-5.6 review, 2026-09-25). Saveable, so a rotation keeps it too. The Edit side has no - // such state to keep: its field scrolls itself and exposes none — as in 2.0.9, where the - // field is rebuilt on each switch. - val defilementDeLApercu = rememberLazyListState() - // Recopie locale : `state` est un délégué, le lissage de type ne s'y applique pas. val erreurDeChargement = state.loadError @@ -758,12 +810,7 @@ fun NoteEditorScreen( // // ⚠️ With a floor 2.0.9 lacks: in a window too short for the header, the body and // the panel, the three scroll together — see `MiseEnPageDeLEditeur`. - else -> { - // ⚠️ The text on screen, not the last saved one: what the preview draws is what - // the user has just typed — the save is 500 ms behind. Called in both modes: a - // reading, and so the preview's position, survives a trip to Edit when nothing - // was typed (see `rememberLectureDeLApercu`). - val rendu = rememberLectureDeLApercu(state.content.text, actif = lecture) + else -> CompositionLocalProvider(LocalSurlignage provides surlignage) { MiseEnPageDeLEditeur( enTete = { EnTeteDeLEditeur(state, onTitreChange, lecture) }, pied = { @@ -788,6 +835,7 @@ fun NoteEditorScreen( label = { Text(stringResource(R.string.note_editor_content)) }, placeholder = { Text(stringResource(R.string.note_editor_content_hint)) }, textStyle = MaterialTheme.typography.bodyLarge, + visualTransformation = surlignageDuChamp(CleDuCorps), colors = champSansDecor(), modifier = Modifier.fillMaxSize().focusRequester(focusDuCorps), ) @@ -879,8 +927,12 @@ private fun FicheDeLecture( Column(Modifier.fillMaxSize()) { // The title the note has; an empty one says so, in italics, as the cards do. val sansTitre = state.titre.text.isEmpty() - Text( - text = if (sansTitre) stringResource(R.string.note_untitled) else state.titre.text, + val titre = if (sansTitre) stringResource(R.string.note_untitled) else state.titre.text + TexteSurligne( + texte = remember(titre) { AnnotatedString(titre) }, + // An empty title shows "Untitled", which is not the note's: the search reads the title, + // an empty one finds nothing, so nothing is highlighted in those words. + cle = CleDuTitre, style = MaterialTheme.typography.headlineSmall, fontStyle = if (sansTitre) FontStyle.Italic else null, color = if (sansTitre) schema.onSurfaceVariant else schema.onSurface, @@ -981,6 +1033,7 @@ private fun EnTeteDeLEditeur(state: EditorUiState, onTitreChange: (TextFieldValu label = { Text(stringResource(R.string.note_editor_title)) }, textStyle = MaterialTheme.typography.headlineSmall, singleLine = true, + visualTransformation = surlignageDuChamp(CleDuTitre), colors = champSansDecor(), modifier = Modifier.fillMaxWidth(), ) @@ -1087,7 +1140,7 @@ private fun DialogueDeSortieDeCoffre(onConfirmer: () -> Unit, onAnnuler: () -> U /** Deux champs de texte qui ne ressemblent pas à un formulaire. */ @Composable -private fun champSansDecor() = TextFieldDefaults.colors( +internal fun champSansDecor() = TextFieldDefaults.colors( focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, disabledContainerColor = Color.Transparent, @@ -1319,6 +1372,7 @@ private fun TitreDeLEditeur(dossier: String, enregistrement: Boolean, echec: Boo */ @Composable private fun MenuDeDebordement( + onRechercher: () -> Unit, epinglee: Boolean, favorite: Boolean, onEpingler: () -> Unit, @@ -1340,6 +1394,15 @@ private fun MenuDeDebordement( ) } DropdownMenu(expanded = ouvert, onDismissRequest = { ouvert = false }) { + // First (3.2.0): the one entry about the note's TEXT, the gesture a reader comes to the menu for. + DropdownMenuItem( + text = { Text(stringResource(R.string.note_editor_menu_find)) }, + leadingIcon = { Icon(Icons.Outlined.Search, contentDescription = null) }, + onClick = { + ouvert = false + onRechercher() + }, + ) // 🔴🔴 **Épingle et favori sont ICI, et non dans la barre d'actions.** // // Ils y étaient. Mesuré sur le S9 après l'ajout du micro : six actions dans la barre diff --git a/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorViewModel.kt b/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorViewModel.kt index 315590d..d48358c 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorViewModel.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorViewModel.kt @@ -202,6 +202,9 @@ class NoteEditorViewModel @Inject constructor( /** Opened by the long press's "Edit" (3.1.0): the note starts on the writing side. */ val ouvrirEnEcriture: Boolean = savedState[Destination.ARG_ECRIRE] ?: false + /** Opened from a search result (3.2.0): what was searched, for the "Find in note" bar. */ + val rechercheALOuverture: String? = savedState[Destination.ARG_RECHERCHE] + private val noteId: String = checkNotNull(savedState[Destination.ARG_NOTE_ID]) { "l'editeur a ete ouvert sans identifiant de note" } diff --git a/app/src/main/java/com/filestech/notes_tech/ui/editor/RechercheDansLaNote.kt b/app/src/main/java/com/filestech/notes_tech/ui/editor/RechercheDansLaNote.kt new file mode 100644 index 0000000..3d269d4 --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/ui/editor/RechercheDansLaNote.kt @@ -0,0 +1,517 @@ +package com.filestech.notes_tech.ui.editor + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.filestech.notes_tech.R +import com.filestech.notes_tech.domain.recherche.RechercheDansLeTexte +import com.filestech.notes_tech.domain.recherche.RechercheDansLeTexte.Plage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import java.util.IdentityHashMap + +/* + * "Find in note" (3.2.0, D-033; hcarrega's idea, issue #10): the occurrences of a search highlighted + * in the note on screen, "3 / 12", previous and next — while reading as while writing, and opened + * already filled when the note comes from a search result. + * + * What is searched is what the screen shows: the title and the body field while writing, the title + * and the DRAWN Markdown while reading (a link's address, a `**`, are not on screen, so not found + * there). Nothing is indexed nor written: a vault note is searched in the memory it is read from. + */ + +/** The title, while reading and while writing: one key, the title being one text in both. */ +internal object CleDuTitre + +/** The body field, while writing. While reading, each text the preview draws is its own key. */ +internal object CleDuCorps + +/** What the search reads, the screen's own texts — never a copy of them. */ +internal sealed interface SourceDeRecherche { + val titre: String + + data class Ecriture(override val titre: String, val corps: String) : SourceDeRecherche + + /** + * The preview's items, compared by IDENTITY: a reading is never changed, a new text makes a new + * one, and comparing a long note's items one by one at every recomposition would cost its size. + */ + class Lecture(override val titre: String, val elements: List) : SourceDeRecherche { + override fun equals(other: Any?): Boolean = + other is Lecture && other.elements === elements && other.titre == titre + + override fun hashCode(): Int = 31 * System.identityHashCode(elements) + titre.hashCode() + } +} + +/** + * One occurrence, where it is drawn: the text whose key is [cle], and for the preview the lazy item + * holding it ([element]; -1 outside the list — the title, the body field). + */ +internal data class Occurrence(val cle: Any, val element: Int, val plage: Plage) + +/** The answer to [mots] in [source]: the first [MAX_OCCURRENCES] occurrences, in the order shown. */ +internal class ResultatDeRecherche( + val source: SourceDeRecherche, + val mots: List, + val occurrences: List, + /** More than [MAX_OCCURRENCES]: the counter says "1000+". */ + val tronque: Boolean, +) { + private val parCle = IdentityHashMap>().also { carte -> + occurrences.forEach { carte.getOrPut(it.cle) { ArrayList() } += it.plage } + } + + fun plagesDe(cle: Any): List = parCle[cle].orEmpty() +} + +/** + * Looks for [mots] in [source], text by text, in the order the screen shows them. Pure, and off the + * main thread by its caller: a note has no size limit. + */ +internal fun chercherDansLaNote( + source: SourceDeRecherche, + mots: List, + max: Int = MAX_OCCURRENCES, + verifier: () -> Unit = {}, +): ResultatDeRecherche { + val occurrences = ArrayList() + var tronque = false + fun chercher(cle: Any, element: Int, texte: String) { + if (tronque) return + // One more than what is left: finding it is what says the count is cut. + for (plage in RechercheDansLeTexte.occurrences(texte, mots, max - occurrences.size + 1, verifier)) { + if (occurrences.size == max) { + tronque = true + return + } + occurrences += Occurrence(cle, element, plage) + } + } + chercher(CleDuTitre, element = -1, source.titre) + when (source) { + is SourceDeRecherche.Ecriture -> chercher(CleDuCorps, element = -1, source.corps) + is SourceDeRecherche.Lecture -> source.elements.forEachIndexed { index, element -> + textesDeLElement(element) { cle, texte -> chercher(cle, index, texte) } + } + } + return ResultatDeRecherche(source, mots, occurrences, tronque) +} + +/** + * The bar's state: open or not, what is searched, the current occurrence, and whether a move to it + * is owed. Saved: a rotation keeps the search — as the app's search keeps its own. + */ +@Stable +internal class EtatDeRechercheDansLaNote(ouverte: Boolean, saisie: String, index: Int, deplacer: Boolean) { + var ouverte by mutableStateOf(ouverte) + private set + var saisie by mutableStateOf(saisie) + private set + private var index by mutableIntStateOf(index) + + /** + * 🔴 **The screen moves to the current occurrence only when the user asked**: a new search, next, + * previous, the arrival from a result. Typing in the body while the bar is open recomputes the + * occurrences, and must not throw the cursor back to one at every keystroke. + */ + var deplacementDemande by mutableStateOf(deplacer) + private set + + /** The field takes the focus once, when the bar is opened from the menu — not on arrival. */ + var focaliser by mutableStateOf(false) + private set + + /** + * While reading: the occurrence the text holding it must bring into view, once its line is laid + * out. Taken by that text, and only once — a lazy item composed again later (scrolled back to, + * prefetched) must not pull the list back to it. + */ + var aAmener by mutableStateOf(null) + private set + + fun ouvrir() { + ouverte = true + focaliser = true + deplacementDemande = true + } + + fun fermer() { + ouverte = false + focaliser = false + aAmener = null + } + + fun amener(occurrence: Occurrence) { + aAmener = occurrence + } + + fun amenee(occurrence: Occurrence) { + if (aAmener == occurrence) aAmener = null + } + + fun saisir(texte: String) { + saisie = texte + index = 0 + deplacementDemande = true + } + + /** The current occurrence's index among [total] — the occurrences shrink as the user types. */ + fun indexParmi(total: Int): Int = if (total == 0) 0 else index.coerceIn(0, total - 1) + + fun suivante(total: Int) { + if (total == 0) return + index = (indexParmi(total) + 1) % total + deplacementDemande = true + } + + fun precedente(total: Int) { + if (total == 0) return + index = (indexParmi(total) - 1 + total) % total + deplacementDemande = true + } + + fun deplacementFait() { + deplacementDemande = false + } + + fun focalise() { + focaliser = false + } + + companion object { + /** A move owed is not saved: it was made before the rotation. */ + val Saver = listSaver( + save = { listOf(it.ouverte, it.saisie, it.index) }, + restore = { EtatDeRechercheDansLaNote(it[0] as Boolean, it[1] as String, it[2] as Int, deplacer = false) }, + ) + } +} + +/** [initiale]: the search of the result the note was opened from — the bar then opens with it. */ +@Composable +internal fun rememberEtatDeRechercheDansLaNote(initiale: String?): EtatDeRechercheDansLaNote = + rememberSaveable(saver = EtatDeRechercheDansLaNote.Saver) { + val ouverte = !initiale.isNullOrBlank() + EtatDeRechercheDansLaNote(ouverte, initiale.orEmpty(), index = 0, deplacer = ouverte) + } + +/** + * The answer to the bar's search in [source], computed off the main thread — or `null` while there + * is none for THIS source and THESE words: an answer computed for another text is never drawn, its + * positions belonging to that text. + */ +@Composable +internal fun rememberResultatDeRecherche( + etat: EtatDeRechercheDansLaNote, + source: SourceDeRecherche?, +): ResultatDeRecherche? { + val mots = remember(etat.saisie) { RechercheDansLeTexte.mots(etat.saisie) } + val resultat = remember { mutableStateOf(null) } + LaunchedEffect(source, mots, etat.ouverte) { + if (!etat.ouverte || source == null || mots.isEmpty()) { + resultat.value = null + return@LaunchedEffect + } + // A new text or new words cancel this effect; `ensureActive` hands that to the search. + resultat.value = withContext(Dispatchers.Default) { chercherDansLaNote(source, mots) { ensureActive() } } + } + return resultat.value?.takeIf { it.source == source && it.mots == mots } +} + +/** + * What the bar searches now: the fields while writing, the drawn note while reading — or `null` + * while there is nothing to search yet. A note still being read is not searched by its title alone: + * the move owed on arrival from a result would be spent there before the text is in. + */ +internal fun sourceDeRecherche( + etat: EtatDeRechercheDansLaNote, + noteOuverte: Boolean, + lecture: Boolean, + state: EditorUiState, + rendu: LectureDeLApercu, +): SourceDeRecherche? = when { + !etat.ouverte || !noteOuverte -> null + !lecture -> SourceDeRecherche.Ecriture(state.titre.text, state.content.text) + rendu is LectureDeLApercu.Lue -> SourceDeRecherche.Lecture(state.titre.text, rendu.elements) + rendu is LectureDeLApercu.Vide -> SourceDeRecherche.Lecture(state.titre.text, emptyList()) + else -> null +} + +/** + * Takes the screen to the current occurrence when a move is owed (see + * [EtatDeRechercheDansLaNote.deplacementDemande]), once the answer is in. While writing, the cursor + * goes to it, and the field scrolls itself to its cursor; while reading, the list goes to the item + * holding it, and the text there brings the line into view ([TexteSurligne]). + */ +@Composable +internal fun DeplacementDeLaRecherche( + recherche: EtatDeRechercheDansLaNote, + resultat: ResultatDeRecherche?, + courante: Occurrence?, + lecture: Boolean, + defilement: LazyListState, + onPlacerLeCurseur: (Occurrence) -> Unit, +) { + val placer by rememberUpdatedState(onPlacerLeCurseur) + LaunchedEffect(recherche.deplacementDemande, resultat) { + if (!recherche.deplacementDemande || resultat == null) return@LaunchedEffect + recherche.deplacementFait() + val cible = courante ?: return@LaunchedEffect + if (!lecture) { + placer(cible) + return@LaunchedEffect + } + if (cible.element >= 0 && defilement.layoutInfo.visibleItemsInfo.none { it.index == cible.element }) { + defilement.scrollToItem(cible.element) + } + recherche.amener(cible) + } +} + +/** The highlight of the text field whose key is [cle] — none while the bar is closed. */ +@Composable +internal fun surlignageDuChamp(cle: Any): VisualTransformation { + val surlignage = LocalSurlignage.current + val plages = surlignage.plagesDe(cle) + val courante = surlignage.couranteDans(cle) + val styles = stylesDeSurlignage() + return remember(plages, courante, styles) { + if (plages.isEmpty()) VisualTransformation.None else TransformationDeSurlignage(plages, courante, styles) + } +} + +/** + * What the texts of the note highlight: every occurrence, and the current one more strongly; and, + * while reading, the occurrence to bring into view ([aAmener]), reported done through [onAmenee]. + */ +@Immutable +internal class Surlignage( + private val resultat: ResultatDeRecherche?, + val courante: Occurrence?, + val aAmener: Occurrence? = null, + val onAmenee: (Occurrence) -> Unit = {}, +) { + fun plagesDe(cle: Any): List = resultat?.plagesDe(cle).orEmpty() + + /** The current occurrence's range, if it is in the text whose key is [cle]. */ + fun couranteDans(cle: Any): Plage? = courante?.takeIf { it.cle === cle }?.plage + + companion object { + val AUCUN = Surlignage(null, null) + } +} + +/** Read by the preview's texts: the legal pages draw the same preview, and highlight nothing. */ +internal val LocalSurlignage = compositionLocalOf { Surlignage.AUCUN } + +/** The highlight's two styles, from the theme: readable in light and dark, on every note colour. */ +@Immutable +internal data class StylesDeSurlignage(val autre: SpanStyle, val courante: SpanStyle) + +@Composable +internal fun stylesDeSurlignage(): StylesDeSurlignage { + val schema = MaterialTheme.colorScheme + return remember(schema) { + StylesDeSurlignage( + autre = SpanStyle(background = schema.tertiaryContainer, color = schema.onTertiaryContainer), + courante = SpanStyle(background = schema.tertiary, color = schema.onTertiary), + ) + } +} + +/** [this] with [plages] highlighted; a range past its end — never expected — is left out. */ +internal fun AnnotatedString.surligne( + plages: List, + courante: Plage?, + styles: StylesDeSurlignage, +): AnnotatedString { + if (plages.isEmpty()) return this + val texte = this + return buildAnnotatedString { + append(texte) + plages.forEach { plage -> + if (plage.fin <= texte.length) { + addStyle(if (plage == courante) styles.courante else styles.autre, plage.debut, plage.fin) + } + } + } +} + +/** + * A text of the note being read, its occurrences highlighted — [cle] is how the search knows it — + * and the current one brought into view when the bar moved to it: the list was scrolled to the item, + * this finishes the move inside a block taller than the screen. + */ +@Composable +internal fun TexteSurligne( + texte: AnnotatedString, + cle: Any, + style: TextStyle, + modifier: Modifier = Modifier, + color: Color = Color.Unspecified, + fontStyle: FontStyle? = null, + textAlign: TextAlign = TextAlign.Unspecified, +) { + val surlignage = LocalSurlignage.current + val plages = surlignage.plagesDe(cle) + val courante = surlignage.couranteDans(cle) + val styles = stylesDeSurlignage() + val affiche = remember(texte, plages, courante, styles) { texte.surligne(plages, courante, styles) } + val requete = remember { BringIntoViewRequester() } + val mise = remember { mutableStateOf(null) } + val aAmener = surlignage.aAmener?.takeIf { it.cle === cle } + if (aAmener != null) { + LaunchedEffect(aAmener) { + val disposition = snapshotFlow { mise.value }.filterNotNull().first() + val plage = aAmener.plage + if (plage.fin <= disposition.layoutInput.text.length) { + requete.bringIntoView(disposition.getBoundingBox(plage.debut)) + } + surlignage.onAmenee(aAmener) + } + } + Text( + text = affiche, + style = style, + color = color, + fontStyle = fontStyle, + textAlign = textAlign, + onTextLayout = { mise.value = it }, + modifier = modifier.bringIntoViewRequester(requete), + ) +} + +/** The highlight on a text field: styles only, so every offset maps to itself. */ +internal data class TransformationDeSurlignage( + private val plages: List, + private val courante: Plage?, + private val styles: StylesDeSurlignage, +) : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText = + TransformedText(text.surligne(plages, courante, styles), OffsetMapping.Identity) +} + +/** The bar, in place of the editor's top bar while it is open. */ +@Composable +internal fun BarreDeRechercheDansLaNote(etat: EtatDeRechercheDansLaNote, resultat: ResultatDeRecherche?) { + val total = resultat?.occurrences?.size ?: 0 + val champ = remember { FocusRequester() } + LaunchedEffect(etat.focaliser) { + if (etat.focaliser) { + champ.requestFocus() + etat.focalise() + } + } + TopAppBar( + navigationIcon = { + IconButton(onClick = etat::fermer) { + Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.note_find_close)) + } + }, + title = { + // `label`, not `placeholder`: a placeholder goes at the first letter, and the field is + // then announced by its text alone — the editor's fields learnt it (`champsDeSaisieSansNom`). + TextField( + value = etat.saisie, + onValueChange = etat::saisir, + label = { Text(stringResource(R.string.note_editor_menu_find)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { etat.suivante(total) }), + colors = champSansDecor(), + modifier = Modifier.fillMaxWidth().focusRequester(champ), + ) + }, + actions = { + if (resultat != null) Compteur(etat.indexParmi(total), total, resultat.tronque) + IconButton(onClick = { etat.precedente(total) }, enabled = total > 0) { + Icon(Icons.Filled.KeyboardArrowUp, contentDescription = stringResource(R.string.note_find_previous)) + } + IconButton(onClick = { etat.suivante(total) }, enabled = total > 0) { + Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.note_find_next)) + } + }, + ) +} + +/** "3 / 12", said in words, and said again when it changes. */ +@Composable +private fun Compteur(index: Int, total: Int, tronque: Boolean) { + val visible = when { + total == 0 -> stringResource(R.string.note_find_count, 0, 0) + tronque -> stringResource(R.string.note_find_count_more, index + 1, total) + else -> stringResource(R.string.note_find_count, index + 1, total) + } + val dit = when { + total == 0 -> stringResource(R.string.note_find_none) + tronque -> stringResource(R.string.note_find_count_more_description, index + 1, total) + else -> stringResource(R.string.note_find_count_description, index + 1, total) + } + Text( + text = visible, + style = MaterialTheme.typography.labelLarge, + color = if (total == 0) MaterialTheme.colorScheme.error else Color.Unspecified, + modifier = Modifier + .padding(horizontal = 4.dp) + .semantics { + contentDescription = dit + liveRegion = LiveRegionMode.Polite + }, + ) +} + +/** Past this, the count shows "1000+" and the occurrences after it are neither listed nor drawn. */ +internal const val MAX_OCCURRENCES = 1000 diff --git a/app/src/main/java/com/filestech/notes_tech/ui/home/HomeRoute.kt b/app/src/main/java/com/filestech/notes_tech/ui/home/HomeRoute.kt index d6df760..a73a03f 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/home/HomeRoute.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/home/HomeRoute.kt @@ -54,7 +54,11 @@ import kotlinx.coroutines.launch */ @Composable fun HomeRoute( - onOpenNote: (Note) -> Unit, + /** + * [recherche]: the search the list was filtered by when the note was tapped — the same full-text + * search as the search screen's — for "Find in note" (3.2.0); `null` for a note just created. + */ + onOpenNote: (Note, recherche: String?) -> Unit, /** The long press's "Edit": the note opens to be written (3.1.0). */ onEditNote: (Note) -> Unit, onOpenSearch: () -> Unit, @@ -139,7 +143,7 @@ fun HomeRoute( homeViewModel.eventFlow.collect { evenement -> when (evenement) { is HomeEvent.NoteCreated -> { - onOpenNote(evenement.note) + onOpenNote(evenement.note, null) // ⚠️ `showSnackbar` SUSPEND jusqu'a la fermeture du message. L'appeler dans le // `collect` bloquerait la collecte plusieurs secondes ; les evenements suivants // s'empileraient dans un tampon de 4, apres quoi `emit` bloquerait le ViewModel. @@ -214,7 +218,7 @@ fun HomeRoute( state = state.copy(coffresOuverts = coffresOuverts), onQueryChange = homeViewModel::onQueryChange, onSortSelected = homeViewModel::onSortSelected, - onOpenNote = onOpenNote, + onOpenNote = { note -> onOpenNote(note, state.query.takeIf { it.isNotBlank() }) }, onLongPressNote = { note -> val coffreAOuvrir = gestes.appuiLong(note, foldersState.folders, foldersState.unlockedFolderIds) if (coffreAOuvrir != null) dossierAOuvrir = coffreAOuvrir diff --git a/app/src/main/java/com/filestech/notes_tech/ui/navigation/Destination.kt b/app/src/main/java/com/filestech/notes_tech/ui/navigation/Destination.kt index d34c2ef..4da8beb 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/navigation/Destination.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/navigation/Destination.kt @@ -66,16 +66,26 @@ sealed interface Destination { * [ecrire]: open the note to be written rather than read (3.1.0) — the long press's "Edit". Absent * from the route when false, so every other way into the editor keeps the route it had. */ - data class Editor(val noteId: String, val ecrire: Boolean = false) : Destination { - override val route = "$EDITOR_PREFIX/${Uri.encode(noteId)}" + if (ecrire) "?$ARG_ECRIRE=true" else "" + /** + * [recherche]: the search of the result the note is opened from (3.2.0), which "Find in note" + * opens with. Encoded, like [noteId]: it is whatever the user typed. Absent when null or blank. + */ + data class Editor(val noteId: String, val ecrire: Boolean = false, val recherche: String? = null) : Destination { + override val route = "$EDITOR_PREFIX/${Uri.encode(noteId)}" + + listOfNotNull( + "$ARG_ECRIRE=true".takeIf { ecrire }, + recherche?.takeIf { it.isNotBlank() }?.let { "$ARG_RECHERCHE=${Uri.encode(it)}" }, + ).joinToString("&").let { if (it.isEmpty()) "" else "?$it" } } companion object { const val EDITOR_PREFIX = "editor" const val ARG_NOTE_ID = "noteId" const val ARG_ECRIRE = "ecrire" + const val ARG_RECHERCHE = "recherche" /** Le motif déclaré au `NavHost`. Une seule déclaration, ici. */ - const val EDITOR_PATTERN = "$EDITOR_PREFIX/{$ARG_NOTE_ID}?$ARG_ECRIRE={$ARG_ECRIRE}" + const val EDITOR_PATTERN = + "$EDITOR_PREFIX/{$ARG_NOTE_ID}?$ARG_ECRIRE={$ARG_ECRIRE}&$ARG_RECHERCHE={$ARG_RECHERCHE}" } } diff --git a/app/src/main/java/com/filestech/notes_tech/ui/search/SearchScreen.kt b/app/src/main/java/com/filestech/notes_tech/ui/search/SearchScreen.kt index 9556e74..ab6188b 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/search/SearchScreen.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/search/SearchScreen.kt @@ -50,7 +50,7 @@ import com.filestech.notes_tech.ui.home.couleurVisible * l'index. Cf. `04-PIEGES.md` §76. */ @Composable -fun SearchRoute(onBack: () -> Unit, onOpenNote: (Note) -> Unit) { +fun SearchRoute(onBack: () -> Unit, onOpenNote: (Note, recherche: String) -> Unit) { val viewModel: SearchViewModel = hiltViewModel() val state by viewModel.state.collectAsStateWithLifecycle() @@ -58,7 +58,8 @@ fun SearchRoute(onBack: () -> Unit, onOpenNote: (Note) -> Unit) { state = state, onQueryChange = viewModel::onQueryChange, onBack = onBack, - onOpenNote = onOpenNote, + // With the search typed: the note opens with it in its "Find in note" bar (3.2.0). + onOpenNote = { note -> onOpenNote(note, state.query) }, ) } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index fb360b8..386f793 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -171,6 +171,15 @@ In einen anderen Ordner verschieben In einen anderen Ordner verschieben Info + In der Notiz suchen + Vorheriger Treffer + Nächster Treffer + Suche schließen + Treffer %1$d von %2$d + Treffer %1$d von mehr als %2$d + Keine Treffer + %1$d / %2$d + %1$d / %2$d+ Notizinfo Ordner Erstellt diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 9500b21..2e73b29 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -179,6 +179,15 @@ Mover a otra carpeta Mover a otra carpeta Información + Buscar en la nota + Coincidencia anterior + Coincidencia siguiente + Cerrar la búsqueda + Coincidencia %1$d de %2$d + Coincidencia %1$d de más de %2$d + Sin coincidencias + %1$d / %2$d + %1$d / %2$d+ Información de la nota Carpeta Creada diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index f161fd0..61ebbed 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -184,6 +184,15 @@ Déplacer vers un autre dossier Déplacer vers un autre dossier Infos + Rechercher dans la note + Occurrence précédente + Occurrence suivante + Fermer la recherche + Occurrence %1$d sur %2$d + Occurrence %1$d sur plus de %2$d + Aucune occurrence + %1$d / %2$d + %1$d / %2$d+ Infos de la note Dossier Créée le diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 11f6416..512c7f6 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -179,6 +179,15 @@ Sposta in un\'altra cartella Sposta in un\'altra cartella Info + Cerca nella nota + Occorrenza precedente + Occorrenza successiva + Chiudi la ricerca + Occorrenza %1$d di %2$d + Occorrenza %1$d di oltre %2$d + Nessuna occorrenza + %1$d / %2$d + %1$d / %2$d+ Info sulla nota Cartella Creata diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index da13239..535cb5d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -216,6 +216,15 @@ from the text on screen, so a vault note is counted as the user reads it. --> Info + Find in note + Previous match + Next match + Close search + Match %1$d of %2$d + Match %1$d of more than %2$d + No match + %1$d / %2$d + %1$d / %2$d+ Note info Folder Created diff --git a/app/src/test/java/com/filestech/notes_tech/domain/recherche/RechercheDansLeTexteTest.kt b/app/src/test/java/com/filestech/notes_tech/domain/recherche/RechercheDansLeTexteTest.kt new file mode 100644 index 0000000..0f4e92a --- /dev/null +++ b/app/src/test/java/com/filestech/notes_tech/domain/recherche/RechercheDansLeTexteTest.kt @@ -0,0 +1,68 @@ +package com.filestech.notes_tech.domain.recherche + +import com.filestech.notes_tech.domain.recherche.RechercheDansLeTexte.Plage +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** "Find in note" (D-033): the same rule as the app's search, and positions in the text as written. */ +class RechercheDansLeTexteTest { + + private fun trouve(texte: String, saisie: String, max: Int = 1000): List = + RechercheDansLeTexte.occurrences(texte, RechercheDansLeTexte.mots(saisie), max) + .map { texte.substring(it.debut, it.fin) } + + @Test + fun `case and accents do not count, either way`() { + assertThat(trouve("Été, ete, ÉTÉ et éte.", "ete")).containsExactly("Été", "ete", "ÉTÉ", "éte").inOrder() + assertThat(trouve("Une fete, une FÊTE.", "fête")).containsExactly("fete", "FÊTE").inOrder() + } + + @Test + fun `a decomposed accent is found, and kept inside the occurrence`() { + val decompose = "café noir" + + assertThat(trouve(decompose, "café")).containsExactly("café") + assertThat(trouve(decompose, "cafe")).containsExactly("café") + } + + @Test + fun `the typographic apostrophe is the keyboard's`() { + assertThat(trouve("Souvenirs de l’été", "l'été")).containsExactly("l’été") + assertThat(trouve("l'été", "l’ete")).containsExactly("l'été") + } + + @Test + fun `each word is looked for on its own, inside words too`() { + assertThat(trouve("Budget 2026 : les budgets du réunion", "réunion budget")) + .containsExactly("Budget", "budget", "réunion").inOrder() + } + + @Test + fun `positions are in the original text, past characters of two UTF-16 units`() { + val texte = "😀 İstanbul et 😀 istanbul" + + val plages = RechercheDansLeTexte.occurrences(texte, RechercheDansLeTexte.mots("istanbul"), 10) + + // Each 😀 is two UTF-16 units: "İstanbul" starts at 3, the second "istanbul" at 18. + assertThat(plages).containsExactly(Plage(3, 11), Plage(18, 26)).inOrder() + assertThat(plages.map { texte.substring(it.debut, it.fin) }).containsExactly("İstanbul", "istanbul").inOrder() + } + + @Test + fun `overlapping occurrences of two words are one`() { + assertThat(trouve("budgétaire", "budget geta")).containsExactly("budgéta") + } + + @Test + fun `at most max occurrences, the first ones`() { + val plages = RechercheDansLeTexte.occurrences("a a a a a", RechercheDansLeTexte.mots("a"), 3) + + assertThat(plages).containsExactly(Plage(0, 1), Plage(2, 3), Plage(4, 5)).inOrder() + } + + @Test + fun `a search of spaces only finds nothing`() { + assertThat(RechercheDansLeTexte.mots("   \t")).isEmpty() + assertThat(trouve("du texte", " ")).isEmpty() + } +} diff --git a/app/src/test/java/com/filestech/notes_tech/ui/editor/RechercheDansLaNoteTest.kt b/app/src/test/java/com/filestech/notes_tech/ui/editor/RechercheDansLaNoteTest.kt new file mode 100644 index 0000000..f640cd9 --- /dev/null +++ b/app/src/test/java/com/filestech/notes_tech/ui/editor/RechercheDansLaNoteTest.kt @@ -0,0 +1,73 @@ +package com.filestech.notes_tech.ui.editor + +import com.filestech.notes_tech.domain.markdown.MarkdownPreviewReader +import com.filestech.notes_tech.domain.recherche.RechercheDansLeTexte +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +/** "Find in note" (D-033): what the screen shows is searched, in the order it shows it. */ +@DisplayName("Find in note") +class RechercheDansLaNoteTest { + + private fun mots(saisie: String) = RechercheDansLeTexte.mots(saisie) + + @Test + fun `while reading, every kind of block is searched as drawn, a link's address is not`() { + val note = listOf( + "# Titre pomme", + "", + "Un paragraphe avec **pomme** et un [lien pomme](https://pomme.example).", + "", + "- item pomme", + " - imbriqué pomme", + "", + "> citation pomme", + "", + "```", + "code pomme", + "```", + "", + "| a | b |", + "|---|---|", + "| pomme | x |", + "", + "---", + ).joinToString("\n") + val elements = aplatir(MarkdownPreviewReader.read(note)) + + val resultat = chercherDansLaNote(SourceDeRecherche.Lecture("Pomme du titre", elements), mots("pomme")) + + // The note's title first, outside the list; then heading, paragraph (twice: the bold word and + // the link's text, not its address), the list item and its nested one, the quote, the code, + // the table's row — its header has none. + assertThat(resultat.occurrences.map { it.element }).containsExactly(-1, 0, 1, 1, 2, 2, 3, 4, 6).inOrder() + assertThat(resultat.occurrences.first().cle).isSameInstanceAs(CleDuTitre) + assertThat(resultat.tronque).isFalse() + } + + @Test + fun `while writing, the title then the body field, the source as typed`() { + val resultat = chercherDansLaNote( + SourceDeRecherche.Ecriture(titre = "Courses", corps = "**courses** du [samedi](https://courses.example)"), + mots("courses"), + ) + + // Writing shows the Markdown as typed: the link's address is on screen, so it is found. + assertThat(resultat.occurrences.map { it.cle }).containsExactly(CleDuTitre, CleDuCorps, CleDuCorps).inOrder() + assertThat(resultat.plagesDe(CleDuCorps).map { it.debut }).containsExactly(2, 32).inOrder() + } + + @Test + fun `the ceiling is shared by the title and the body, and says it was reached`() { + val source = SourceDeRecherche.Ecriture(titre = "a a", corps = "a a a") + + val coupe = chercherDansLaNote(source, mots("a"), max = 4) + val juste = chercherDansLaNote(source, mots("a"), max = 5) + + assertThat(coupe.occurrences).hasSize(4) + assertThat(coupe.tronque).isTrue() + assertThat(juste.occurrences).hasSize(5) + assertThat(juste.tronque).isFalse() + } +} diff --git a/docs/13-VERSION-3.2.md b/docs/13-VERSION-3.2.md index 325c087..3855877 100644 --- a/docs/13-VERSION-3.2.md +++ b/docs/13-VERSION-3.2.md @@ -173,6 +173,23 @@ octets taille champ - Rien n'est indexé ni écrit : la recherche porte sur le texte affiché (une note de coffre ouverte aussi — elle n'est déjà déchiffrée qu'en mémoire). +**Fait (2026-10-29)** — `domain/recherche/RechercheDansLeTexte.kt`, `ui/editor/RechercheDansLaNote.kt`. +Tranché en codant : +- « Texte affiché » au pied de la lettre : en **écriture**, le titre et le Markdown tel que tapé (l'adresse + d'un lien comprise) ; en **lecture**, le titre et le Markdown **dessiné** (ni `**`, ni l'adresse d'un lien). + Le compte peut donc différer d'un mode à l'autre, et c'est juste : il compte ce qu'on voit. +- Dans les mots aussi (« budget » trouve « budgets »), comme une recherche dans la page. Apostrophes + typographiques repliées sur `'` : FTS5 traite l'apostrophe comme un séparateur, une note trouvée par + « l'été » doit montrer « l’été », pas « 0 / 0 ». +- Au plus **1 000 occurrences** (« 1000+ ») ; calcul hors du fil principal, annulable, positions dans le + texte d'origine, jamais appliquées à un autre texte que celui calculé. +- La barre ne déplace curseur ou liste que **sur demande** (nouvelle recherche, précédente, suivante, + arrivée) : taper dans le corps barre ouverte ne ramène pas le curseur à chaque frappe. +- Depuis **deux** résultats de recherche : l'écran Recherche et la liste de l'accueil filtrée par son + champ — c'est la même recherche plein texte (`search.observe`). Argument `recherche` de la route. +- Le Retour ferme la barre avant de quitter la note ; la barre se ferme si le coffre se referme dessous. +Tests : 8 + 3 JVM, 5 écran, 2 de bout en bout (appli réelle), chacun vu échouer sous sabotage. + ## 4. Partage entrant - « Partager → Notes Tech » depuis une autre appli : un **texte** (`text/plain`) crée une note dans la From d683f031f70823b848a6a4fa6be1ec1d7f1e6905 Mon Sep 17 00:00:00 2001 From: Patrice Haltaya Date: Thu, 29 Oct 2026 18:29:31 +0100 Subject: [PATCH 06/16] feat(settings): import Markdown or text files, or a ZIP of them D-032 of docs/13-VERSION-3.2.md. Settings, "Export and import" (the section had borne its only row's label until now), "Import notes": the system picker through the app's seam, several documents at once, then a summary: notes imported, folders created, notes sent to the Inbox, files not imported, and whether a bound stopped it. - A file alone goes to the Inbox. In a ZIP, the first level of the path names an ordinary folder, found by name or created; `inbox/`, the export's own name, is the Inbox, whatever language the export was written in (its frontmatter carries the translated name, so the path decides). An export of Notes Tech comes back in its folders, its README recognised by its heading, now shared with the export. - Never into a vault: a name that is only a vault's sends the note to the Inbox, and the summary says so; an ordinary folder of the same name wins. Folders and notes in ONE transaction, all or nothing. - Bounded on what is really read: 5,000 files, 5 MiB per file, 100 MiB in all. A ZIP read as a stream inflates the rest of an entry to reach the next one, so a skipped entry counts too: one entry of zeros, a few hundred kilobytes compressed, would otherwise keep the import inflating for hours. Past a bound, the import keeps what it read. - Strict UTF-8 (the share's decoder, now shared), not a NUL; hidden entries (`__MACOSX/`, `.DS_Store`, `.obsidian/`) are neither read nor counted. Nothing is copied to the app's storage. Tests: LectureDUnImportTest (7: the real export read back, an archive bomb under a second, the file bound, a download cut short), three on real SQLCipher in NotesRepositoryTest; each seen failing under a sabotage (skipped bytes uncounted, no hidden filter, no README rule, no vault rule). Tried on the emulator through the real picker. Gate: 505 JVM tests, detekt, ktlint, lint clean; NotesRepositoryTest 65/65, ReglagesTest 15/15 on the emulator. Co-Authored-By: Claude Opus 5.5 --- .../data/repository/NotesRepositoryTest.kt | 74 +++++++ .../data/importation/NoteImporter.kt | 76 ++++++++ .../data/repository/NotesRepository.kt | 92 ++++++--- .../notes_tech/domain/export/NoteArchive.kt | 4 +- .../notes_tech/domain/export/NoteMarkdown.kt | 5 +- .../domain/importation/LectureDUnImport.kt | 171 ++++++++++++++++ .../domain/importation/LectureDUnPartage.kt | 26 +-- .../notes_tech/ui/settings/ImportViewModel.kt | 58 ++++++ .../notes_tech/ui/settings/SettingsScreen.kt | 106 +++++++++- app/src/main/res/values-de/strings.xml | 23 +++ app/src/main/res/values-es/strings.xml | 27 +++ app/src/main/res/values-fr/strings.xml | 27 +++ app/src/main/res/values-it/strings.xml | 27 +++ app/src/main/res/values/strings.xml | 23 +++ .../importation/LectureDUnImportTest.kt | 182 ++++++++++++++++++ docs/13-VERSION-3.2.md | 17 ++ 16 files changed, 895 insertions(+), 43 deletions(-) create mode 100644 app/src/main/java/com/filestech/notes_tech/data/importation/NoteImporter.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/domain/importation/LectureDUnImport.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/ui/settings/ImportViewModel.kt create mode 100644 app/src/test/java/com/filestech/notes_tech/domain/importation/LectureDUnImportTest.kt diff --git a/app/src/androidTest/java/com/filestech/notes_tech/data/repository/NotesRepositoryTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/data/repository/NotesRepositoryTest.kt index 1d0bd2c..f230cf6 100644 --- a/app/src/androidTest/java/com/filestech/notes_tech/data/repository/NotesRepositoryTest.kt +++ b/app/src/androidTest/java/com/filestech/notes_tech/data/repository/NotesRepositoryTest.kt @@ -11,6 +11,9 @@ import com.filestech.notes_tech.data.local.LegacyDatabaseLocation import com.filestech.notes_tech.data.local.NotesDatabase import com.filestech.notes_tech.data.local.NotesDatabaseFactory import com.filestech.notes_tech.data.local.SqlCipherRawKey +import com.filestech.notes_tech.data.local.entity.FolderEntity +import com.filestech.notes_tech.domain.importation.NoteAImporter +import com.filestech.notes_tech.domain.importation.NoteImportee import com.filestech.notes_tech.domain.model.EncryptedBody import com.filestech.notes_tech.domain.model.EncryptedFormat import com.filestech.notes_tech.domain.model.Note @@ -106,6 +109,77 @@ class NotesRepositoryTest { LegacyDatabaseLocation.SIDECAR_SUFFIXES.forEach { File(databaseFile.path + it).delete() } } + // ── The Settings' import (3.2.0, D-032) ───────────────────────────────── + + private fun importee(titre: String, dossier: String?) = NoteAImporter( + NoteImportee( + titre = titre, + contenu = "Le texte de $titre.", + etiquettes = emptyList(), + dossier = null, + creeeLe = null, + modifieeLe = null, + epinglee = false, + favorite = false, + ), + dossier, + ) + + /** 🔴 Filed by name, the missing folder created once — and never into a vault. */ + @Test + fun l_import_range_par_nom_cree_ce_qui_manque_et_n_ecrit_jamais_dans_un_coffre(): Unit = runBlocking { + val bilan = notes.importerDansDesDossiers( + listOf( + importee("A", "Travail"), + importee("B", "Nouveau"), + importee("C", "Nouveau"), + importee("D", "Coffre"), + importee("E", null), + ), + ) + + val nouveaux = dossiers.listAll().filter { it.name == "Nouveau" } + assertThat(nouveaux).hasSize(1) + assertThat(nouveaux.single().isVault).isFalse() + assertThat(bilan.notes.associate { it.title to it.folderId }).containsExactly( + "A", LegacyDatabaseFixture.Fixtures.FOLDER_WORK, + "B", nouveaux.single().id, + "C", nouveaux.single().id, + "D", FolderEntity.INBOX_ID, + "E", FolderEntity.INBOX_ID, + ) + assertThat(bilan.dossiersCrees).isEqualTo(1) + assertThat(bilan.versLaBoiteDeReception).isEqualTo(1) + // Written in clear in the Inbox, nothing near the vault. + val dansLeCoffre = notes.listAllAlive().filter { it.folderId == LegacyDatabaseFixture.Fixtures.FOLDER_VAULT } + assertThat(dansLeCoffre.map { it.title }).doesNotContain("D") + } + + @Test + fun un_dossier_ordinaire_du_meme_nom_qu_un_coffre_l_emporte(): Unit = runBlocking { + val ordinaire = dossiers.create("Coffre") + + val bilan = notes.importerDansDesDossiers(listOf(importee("F", "Coffre"))) + + assertThat(bilan.notes.single().folderId).isEqualTo(ordinaire.id) + assertThat(bilan.versLaBoiteDeReception).isEqualTo(0) + } + + /** All or nothing: a refused import leaves no note and no folder behind. */ + @Test + fun un_import_refuse_n_ecrit_rien_pas_meme_ses_dossiers(): Unit = runBlocking { + val avant = notes.listAllAlive().size + val tropLong = "T".repeat(NotesRepository.TITLE_MAX_LENGTH + 1) + + val echec = runCatching { + notes.importerDansDesDossiers(listOf(importee("G", "Autre"), importee(tropLong, "Autre"))) + }.exceptionOrNull() + + assertThat(echec).isInstanceOf(IllegalArgumentException::class.java) + assertThat(notes.listAllAlive()).hasSize(avant) + assertThat(dossiers.listAll().map { it.name }).doesNotContain("Autre") + } + // ── Indexation des liens dans la transaction d'écriture ────────────────── @Test diff --git a/app/src/main/java/com/filestech/notes_tech/data/importation/NoteImporter.kt b/app/src/main/java/com/filestech/notes_tech/data/importation/NoteImporter.kt new file mode 100644 index 0000000..1e1c2df --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/data/importation/NoteImporter.kt @@ -0,0 +1,76 @@ +package com.filestech.notes_tech.data.importation + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import com.filestech.notes_tech.data.repository.NotesRepository +import com.filestech.notes_tech.domain.importation.LectureDUnImport +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.FileNotFoundException +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The Settings' "Import" (3.2.0, `docs/13-VERSION-3.2.md` D-032): the documents the user chose in the + * system picker, read one by one under [LectureDUnImport]'s bounds, then written in one transaction + * ([NotesRepository.importerDansDesDossiers]) — all the notes, or none. + * + * Nothing is copied to the app's storage: each document is read from its provider, and let go. + */ +@Singleton +class NoteImporter @Inject constructor( + @ApplicationContext private val context: Context, + private val notes: NotesRepository, +) { + + /** What an import did, for the summary. */ + data class Resultat( + val importees: Int, + val ignores: Int, + /** A bound stopped the reading before the end: what was read is imported, the rest is not. */ + val arrete: Boolean, + val dossiersCrees: Int, + val versLaBoiteDeReception: Int, + ) + + /** @throws Exception when the write fails: nothing was imported then. */ + suspend fun importer(documents: List): Resultat = withContext(Dispatchers.IO) { + val lecture = LectureDUnImport() + documents.forEach { document -> + ensureActive() + val flux = try { + context.contentResolver.openInputStream(document) + } catch (e: FileNotFoundException) { + Timber.w(e, "document a importer introuvable") + null + } catch (e: SecurityException) { + Timber.w(e, "document a importer refuse") + null + } + val nom = nomAffiche(document) + if (flux == null) lecture.ajouter(nom, null) else flux.use { lecture.ajouter(nom, it) } + } + val lu = lecture.resultat() + val ecrit = if (lu.notes.isEmpty()) null else notes.importerDansDesDossiers(lu.notes) + Resultat( + importees = ecrit?.notes?.size ?: 0, + ignores = lu.ignores, + arrete = lu.arrete, + dossiersCrees = ecrit?.dossiersCrees ?: 0, + versLaBoiteDeReception = ecrit?.versLaBoiteDeReception ?: 0, + ) + } + + private fun nomAffiche(document: Uri): String? = try { + context.contentResolver.query(document, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { + if (it.moveToFirst()) it.getString(0) else null + } + } catch (e: RuntimeException) { + Timber.w(e, "nom du document a importer illisible") + null + } ?: document.lastPathSegment +} diff --git a/app/src/main/java/com/filestech/notes_tech/data/repository/NotesRepository.kt b/app/src/main/java/com/filestech/notes_tech/data/repository/NotesRepository.kt index 5fa2dc6..789ddec 100644 --- a/app/src/main/java/com/filestech/notes_tech/data/repository/NotesRepository.kt +++ b/app/src/main/java/com/filestech/notes_tech/data/repository/NotesRepository.kt @@ -8,10 +8,13 @@ import com.filestech.notes_tech.data.local.OutgoingLink import com.filestech.notes_tech.data.local.entity.NoteEntity import com.filestech.notes_tech.data.local.mapper.TagCodec import com.filestech.notes_tech.data.local.mapper.toDomain +import com.filestech.notes_tech.domain.importation.BilanDImport +import com.filestech.notes_tech.domain.importation.NoteAImporter import com.filestech.notes_tech.domain.importation.NoteImportee import com.filestech.notes_tech.domain.links.TitleNormalizer import com.filestech.notes_tech.domain.links.WikiLinkParser import com.filestech.notes_tech.domain.model.EncryptedFormat +import com.filestech.notes_tech.domain.model.Folder import com.filestech.notes_tech.domain.model.Note import com.filestech.notes_tech.domain.model.NoteColor import com.filestech.notes_tech.domain.model.NoteSortMode @@ -330,35 +333,76 @@ class NotesRepository @Inject constructor( require(!folders.isVaultFolder(folderId)) { "import refused into folder $folderId: a vault, or unknown" } notes.forEach { requireTitleWithinLimit(it.titre) } val now = clock.instant() + return inTransaction { database -> notes.map { inserer(database, it, folderId, now) } } + } + /** + * Adds the notes of the Settings' import (3.2.0, D-032), each to the ordinary folder its archive + * named — found by name, created when there is none — or the Inbox: all in one transaction, all + * or nothing, the folders created with it. + * + * 🔴 **Never into a vault**, here either: a name that is only a vault's sends the note to the Inbox, + * counted in [BilanDImport.versLaBoiteDeReception] for the summary to say it. An ordinary folder of + * the same name wins over a vault. + */ + suspend fun importerDansDesDossiers(notes: List): BilanDImport { + notes.forEach { requireTitleWithinLimit(it.note.titre) } + val now = clock.instant() + return inTransaction { database -> + val existants = database.folderDao().listAll().toDomain() + val parNom = HashMap() + var crees = 0 + var versLaBoite = 0 + val ecrites = notes.map { importee -> + val nom = importee.dossier + val dossier = when { + nom == null -> Folder.INBOX_ID + else -> parNom.getOrPut(nom) { + val memeNom = existants.filter { it.name == nom } + memeNom.firstOrNull { !it.isVault }?.id + ?: if (memeNom.isNotEmpty()) { + null + } else { + crees++ + folders.create(nom).id + } + } ?: Folder.INBOX_ID.also { versLaBoite++ } + } + inserer(database, importee.note, dossier, now) + } + BilanDImport(notes = ecrites, dossiersCrees = crees, versLaBoiteDeReception = versLaBoite) + } + } + + /** + * One imported note, written: a new id, the dates and flags the file carries (now, otherwise), the + * links indexed like any write. Inside the caller's transaction. + */ + private suspend fun inserer(database: NotesDatabase, lue: NoteImportee, folderId: String, now: Instant): Note { // A date from a file is believed only between 1970 and now (security review of 3.2.0, L2): a // year 200 000 000 would keep a note above all others forever, and a larger one overflows the // epoch milliseconds the database stores. fun plausible(date: Instant?): Instant? = date?.takeIf { !it.isBefore(Instant.EPOCH) && !it.isAfter(now) } - return inTransaction { database -> - notes.map { lue -> - val creeeLe = plausible(lue.creeeLe) ?: now - val note = Note( - id = UUID.randomUUID().toString(), - title = lue.titre, - content = lue.contenu, - folderId = folderId, - tags = lue.etiquettes, - pinned = lue.epinglee, - favorite = lue.favorite, - archived = false, - trashedAt = null, - createdAt = creeeLe, - updatedAt = plausible(lue.modifieeLe) ?: creeeLe, - encrypted = null, - encVersion = EncryptedFormat.CONTENT_ONLY, - ) - database.noteWriteDao().insert(note.toNewEntity()) - reindexLinks(database, note) - resolveIncoming(database, note) - note - } - } + val creeeLe = plausible(lue.creeeLe) ?: now + val note = Note( + id = UUID.randomUUID().toString(), + title = lue.titre, + content = lue.contenu, + folderId = folderId, + tags = lue.etiquettes, + pinned = lue.epinglee, + favorite = lue.favorite, + archived = false, + trashedAt = null, + createdAt = creeeLe, + updatedAt = plausible(lue.modifieeLe) ?: creeeLe, + encrypted = null, + encVersion = EncryptedFormat.CONTENT_ONLY, + ) + database.noteWriteDao().insert(note.toNewEntity()) + reindexLinks(database, note) + resolveIncoming(database, note) + return note } /** diff --git a/app/src/main/java/com/filestech/notes_tech/domain/export/NoteArchive.kt b/app/src/main/java/com/filestech/notes_tech/domain/export/NoteArchive.kt index 9ca8041..e743ad1 100644 --- a/app/src/main/java/com/filestech/notes_tech/domain/export/NoteArchive.kt +++ b/app/src/main/java/com/filestech/notes_tech/domain/export/NoteArchive.kt @@ -35,8 +35,8 @@ object NoteArchive { */ data class Summary(val exported: Int, val skippedLocked: Int) - /** Le nom du fichier d'accueil, à la racine de l'archive. */ - private const val README_NAME = "README.md" + /** Le nom du fichier d'accueil, à la racine de l'archive — que l'import (3.2.0) reconnaît. */ + const val README_NAME = "README.md" /** * Écrit l'archive dans [output], qui est **fermé** en sortie. diff --git a/app/src/main/java/com/filestech/notes_tech/domain/export/NoteMarkdown.kt b/app/src/main/java/com/filestech/notes_tech/domain/export/NoteMarkdown.kt index f36f7f5..c543856 100644 --- a/app/src/main/java/com/filestech/notes_tech/domain/export/NoteMarkdown.kt +++ b/app/src/main/java/com/filestech/notes_tech/domain/export/NoteMarkdown.kt @@ -34,6 +34,9 @@ object NoteMarkdown { */ const val INBOX_DIR_NAME = "inbox" + /** The first line of the export's README: how the import (3.2.0) knows it, and leaves it out. */ + const val README_HEADING = "# Notes Tech export" + /** * The name of a folder that cleaning left unusable — empty, dots only, reserved by Windows. * @@ -283,7 +286,7 @@ object NoteMarkdown { */ fun readme(noteCount: Int, exportedAt: Instant, zone: ZoneId = ZoneId.systemDefault()): String = """ - |# Notes Tech export + |$README_HEADING | |- Exported: ${iso(exportedAt, zone)} |- Notes: $noteCount diff --git a/app/src/main/java/com/filestech/notes_tech/domain/importation/LectureDUnImport.kt b/app/src/main/java/com/filestech/notes_tech/domain/importation/LectureDUnImport.kt new file mode 100644 index 0000000..c748b53 --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/domain/importation/LectureDUnImport.kt @@ -0,0 +1,171 @@ +package com.filestech.notes_tech.domain.importation + +import com.filestech.notes_tech.core.text.DartTextSemantics +import com.filestech.notes_tech.domain.export.NoteArchive +import com.filestech.notes_tech.domain.export.NoteMarkdown +import com.filestech.notes_tech.domain.model.Note +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream +import java.time.ZoneId +import java.util.zip.ZipInputStream + +/** + * What the Settings' "Import" reads (3.2.0, `docs/13-VERSION-3.2.md` D-032): Markdown or text files, + * or ZIPs of them — an export of Notes Tech, an Obsidian vault — turned into notes, each with the + * ordinary folder it goes to. Documents are added one by one ([ajouter]), the answer read at the end + * ([resultat]). + * + * Pure: the Android side opens the documents the user chose; everything decided here — the bounds, + * the folders, what is a note and what is not — is tested on the JVM. + * + * - **A file alone goes to the Inbox.** In a ZIP, the first level of its path names an ordinary folder + * (`inbox/`, the export's own name for the Inbox, the Inbox); a file at the root, the Inbox. + * - The export's `README.md` is left out; so is what is not a document — `__MACOSX/`, `.DS_Store`, a + * `.obsidian/` folder: anything hidden, by a name starting with a dot. Neither counts as ignored. + * - **Strict UTF-8**, 5 MiB per file: a file that is not text, or too long, is counted as ignored. + * - 🔴 **Bounded on what is really read** — 100 MiB for the whole import, [FICHIERS_MAX] files. + * A ZIP read as a stream inflates the rest of an entry to reach the next one: an entry skipped is + * read all the same, so its bytes count too. Without that, one entry of zeros, a few kilobytes + * compressed, would keep the import inflating for hours. Past a bound, the import stops ([arrete]), + * and keeps what it had. + */ +class LectureDUnImport(private val zone: ZoneId = ZoneId.systemDefault()) { + + private val notes = ArrayList() + private var ignores = 0 + private var fichiers = 0 + private var lus = 0L + private var arrete = false + + /** + * One document the user chose: [nom], its display name; [flux], its bytes — `null` when it could + * not be opened. The caller closes [flux]. + */ + fun ajouter(nom: String?, flux: InputStream?) { + val nomDuDocument = nom.orEmpty() + try { + when { + arrete || flux == null -> ignores++ + nomDuDocument.estUneArchive() -> lireUneArchive(flux) + !nomDuDocument.estUneNote() -> ignores++ + compterUnFichier() -> lireUneNote(nomDuDocument, flux, dossier = null) + else -> ignores++ + } + } catch (_: IOException) { + // A provider failing in the middle of a read (a cloud document, a removed card). + ignores++ + } + } + + fun resultat(): Import = Import(notes = notes.toList(), ignores = ignores, arrete = arrete) + + private fun lireUneArchive(flux: InputStream) { + try { + val zip = ZipInputStream(flux) + while (!arrete) { + val entree = zip.nextEntry ?: break + val chemin = entree.name.replace('\\', '/').split('/').filter { it.isNotEmpty() } + when { + entree.isDirectory -> Unit + // Not a document: read through — its bytes count — and not counted. + chemin.isEmpty() || chemin.any { it.startsWith('.') || it == DOSSIER_MACOS } -> lireBorne(zip, 0) + !compterUnFichier() -> Unit + !chemin.last().estUneNote() -> { + ignores++ + lireBorne(zip, 0) + } + chemin.size == 1 -> lireUneNote(chemin.single(), zip, dossier = null, racine = true) + else -> lireUneNote(chemin.last(), zip, dossier = dossierDe(chemin.first())) + } + } + } catch (_: IOException) { + // A damaged archive (ZipException is one): what was read before it is kept. + ignores++ + } catch (_: IllegalArgumentException) { + // An entry name that is not valid in the archive's encoding. + ignores++ + } + } + + private fun lireUneNote(nom: String, flux: InputStream, dossier: String?, racine: Boolean = false) { + val texte = lireBorne(flux, FICHIER_MAX)?.let(::utf8Strict) + when { + arrete -> Unit + texte == null -> ignores++ + racine && nom == NoteArchive.README_NAME && texte.startsWith(NoteMarkdown.README_HEADING) -> Unit + else -> notes += NoteAImporter(LecteurMarkdown.lire(texte, nom, zone), dossier) + } + } + + /** One more file; past [FICHIERS_MAX], the import stops. */ + private fun compterUnFichier(): Boolean { + if (fichiers == FICHIERS_MAX) { + arrete = true + return false + } + fichiers++ + return true + } + + /** + * Reads [flux] to its end, keeping at most [max] bytes: what it holds, or `null` past [max]. Every + * byte read counts toward [LUS_MAX], kept or not; past it, the import stops. + */ + private fun lireBorne(flux: InputStream, max: Int): ByteArray? { + val gardes = ByteArrayOutputStream() + val tampon = ByteArray(TAMPON) + var trop = false + while (true) { + val n = flux.read(tampon) + if (n < 0) break + lus += n + if (lus > LUS_MAX) { + arrete = true + return null + } + if (!trop && gardes.size() + n <= max) gardes.write(tampon, 0, n) else trop = true + } + return if (trop) null else gardes.toByteArray() + } + + /** The folder a ZIP's first-level directory names: `null` for the Inbox. */ + private fun dossierDe(repertoire: String): String? { + val nom = DartTextSemantics.trim(repertoire) + return if (nom.isEmpty() || nom == NoteMarkdown.INBOX_DIR_NAME) null else nom + } + + private fun String.estUneArchive() = endsWith(".zip", ignoreCase = true) + + private fun String.estUneNote() = EXTENSIONS.any { endsWith(it, ignoreCase = true) } + + companion object { + /** Files considered in one import, the documents chosen and the entries of their archives. */ + const val FICHIERS_MAX = 5_000 + + /** Per file. */ + const val FICHIER_MAX = 5 * 1024 * 1024 + + /** Bytes read in all, inflated ones included: an archive is bounded by what it holds, not its size. */ + const val LUS_MAX = 100L * 1024 * 1024 + + private val EXTENSIONS = listOf(".md", ".markdown", ".txt") + private const val DOSSIER_MACOS = "__MACOSX" + private const val TAMPON = 64 * 1024 + } +} + +/** A note read by the import, and the ordinary folder it goes to by name — `null`: the Inbox. */ +data class NoteAImporter(val note: NoteImportee, val dossier: String?) + +/** + * What an import read: its notes; how many documents or entries could not be read as one; and + * whether a bound stopped it before the end ([LectureDUnImport.LUS_MAX], [LectureDUnImport.FICHIERS_MAX]). + */ +data class Import(val notes: List, val ignores: Int, val arrete: Boolean) + +/** + * What an import wrote: its [notes]; the ordinary folders it had to create; and the notes sent to the + * Inbox because the folder their archive named is a vault's. + */ +data class BilanDImport(val notes: List, val dossiersCrees: Int, val versLaBoiteDeReception: Int) diff --git a/app/src/main/java/com/filestech/notes_tech/domain/importation/LectureDUnPartage.kt b/app/src/main/java/com/filestech/notes_tech/domain/importation/LectureDUnPartage.kt index b8d16be..8971445 100644 --- a/app/src/main/java/com/filestech/notes_tech/domain/importation/LectureDUnPartage.kt +++ b/app/src/main/java/com/filestech/notes_tech/domain/importation/LectureDUnPartage.kt @@ -80,18 +80,22 @@ object LectureDUnPartage { } return PartageRecu(notes = notes, ignores = nombre - notes.size) } +} - private fun utf8Strict(octets: ByteArray): String? = try { - Charsets.UTF_8.newDecoder() - .onMalformedInput(CodingErrorAction.REPORT) - .onUnmappableCharacter(CodingErrorAction.REPORT) - .decode(ByteBuffer.wrap(octets)) - .toString() - // A NUL is what a binary file has and a text never does. - .takeIf { '\u0000' !in it } - } catch (_: CharacterCodingException) { - null - } +/** + * [octets] as text if they are UTF-8 and nothing else — never a text full of replacement characters + * from a file that is not one. Shared by the share and the import (3.2.0). + */ +internal fun utf8Strict(octets: ByteArray): String? = try { + Charsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(octets)) + .toString() + // A NUL is what a binary file has and a text never does. + .takeIf { '\u0000' !in it } +} catch (_: CharacterCodingException) { + null } /** One file of a share: its display name, and its bytes (`null` when it could not be read). */ diff --git a/app/src/main/java/com/filestech/notes_tech/ui/settings/ImportViewModel.kt b/app/src/main/java/com/filestech/notes_tech/ui/settings/ImportViewModel.kt new file mode 100644 index 0000000..82b9481 --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/ui/settings/ImportViewModel.kt @@ -0,0 +1,58 @@ +package com.filestech.notes_tech.ui.settings + +import android.net.Uri +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.filestech.notes_tech.data.importation.NoteImporter +import com.filestech.notes_tech.ui.common.userMessageFor +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +/** + * The outcome of an import, as the screen shows it. [result] and [error] are **consumed** by the + * screen, as the export's are: a rotation must not show the summary twice. + */ +data class ImportUiState( + val busy: Boolean = false, + val result: NoteImporter.Resultat? = null, + @StringRes val error: Int? = null, +) + +/** The Settings' "Import" (3.2.0, D-032). */ +@HiltViewModel +class ImportViewModel @Inject constructor(private val importer: NoteImporter) : ViewModel() { + + private val _state = MutableStateFlow(ImportUiState()) + val state: StateFlow = _state.asStateFlow() + + private var enCours: Job? = null + + /** The documents chosen in the picker; nothing chosen, nothing done. */ + fun importer(documents: List) { + if (documents.isEmpty() || enCours?.isActive == true) return + _state.value = ImportUiState(busy = true) + enCours = viewModelScope.launch { + try { + _state.value = ImportUiState(busy = false, result = importer.importer(documents)) + } catch (e: CancellationException) { + // The screen left: the transaction, if it had begun, is rolled back with it. + throw e + } catch (e: Exception) { + Timber.w(e, "import de notes") + _state.value = ImportUiState(busy = false, error = userMessageFor(e)) + } + } + } + + fun consume() { + _state.value = _state.value.copy(result = null, error = null) + } +} diff --git a/app/src/main/java/com/filestech/notes_tech/ui/settings/SettingsScreen.kt b/app/src/main/java/com/filestech/notes_tech/ui/settings/SettingsScreen.kt index 1924a13..a9dfb07 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/settings/SettingsScreen.kt @@ -1,6 +1,7 @@ package com.filestech.notes_tech.ui.settings import androidx.activity.compose.LocalActivity +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -17,6 +18,7 @@ import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.outlined.Archive import androidx.compose.material.icons.outlined.DarkMode +import androidx.compose.material.icons.outlined.FileUpload import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.LocalFireDepartment @@ -38,6 +40,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TooltipAnchorPosition import androidx.compose.material3.TooltipBox import androidx.compose.material3.TooltipDefaults @@ -65,6 +68,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.filestech.notes_tech.R +import com.filestech.notes_tech.data.importation.NoteImporter import com.filestech.notes_tech.data.prefs.LocalePreference import com.filestech.notes_tech.data.prefs.ThemePreference import com.filestech.notes_tech.domain.model.NoteSortMode @@ -78,6 +82,7 @@ import com.filestech.notes_tech.ui.common.MIME_ZIP import com.filestech.notes_tech.ui.common.TitreDeSection import com.filestech.notes_tech.ui.common.libelleDeTri import com.filestech.notes_tech.ui.common.partagerUnFichier +import com.filestech.notes_tech.ui.common.rememberAppResultLauncher import com.filestech.notes_tech.ui.panic.PanicConfirmDialog import com.filestech.notes_tech.ui.panic.activityPanicViewModel import kotlinx.coroutines.launch @@ -169,6 +174,7 @@ fun SettingsRoute(onBack: () -> Unit, onOpenAbout: () -> Unit, onOpenVoiceSetup: onOpenVoiceSetup = onOpenVoiceSetup, snackbarHost = { HoteDeMessages(snackbars) }, ligneDExport = { LigneDExport(snackbars) }, + ligneDImport = { LigneDImport(snackbars) }, sectionDeVerrouillage = { AppLockSection(snackbars) }, ) } @@ -202,6 +208,8 @@ fun SettingsScreen( fenetreImposeeParLeVerrou: Boolean = false, snackbarHost: @Composable () -> Unit = {}, ligneDExport: @Composable () -> Unit = {}, + /** A slot, like [ligneDExport]: the import has its own ViewModel and the system picker. */ + ligneDImport: @Composable () -> Unit = {}, sectionDeVerrouillage: @Composable () -> Unit = {}, ) { var choixDeTheme by remember { mutableStateOf(false) } @@ -359,11 +367,17 @@ fun SettingsScreen( // The app lock (D-023): after the other protections, before the export. sectionDeVerrouillage() - // ⚠️ La section porte le libellé de son unique ligne, comme dans l'application - // publiée (`settings_screen.dart:121`). Inventer un titre demanderait une clé i18n - // nouvelle, donc une modification de l'ARB gelé de `notes_tech` — cf. docs/05-PARITE.md. - TitreDeSection(stringResource(R.string.settings_export_all)) - CarteFilesTech { ligneDExport() } + // Until 3.2.0 the section bore the label of its only row, as in notes_tech 2.0.9 + // (`settings_screen.dart:121`), whose strings were frozen. With the import (D-032) it has + // two, and a title of its own. + TitreDeSection(stringResource(R.string.settings_section_export_import)) + CarteFilesTech { + Column { + ligneDExport() + HorizontalDivider() + ligneDImport() + } + } TitreDeSection(stringResource(R.string.settings_panic)) // 🔴 **La seule carte cerclée de rouge de l'écran**, comme dans la référence @@ -595,6 +609,88 @@ private fun LigneDExport(snackbars: SnackbarHostState) { ) } +/** + * The Settings' "Import" (3.2.0, D-032): the system picker, several documents at once, then a summary + * that stays until read — up to five lines, more than a message bar holds. + * + * Through the app's seam ([rememberAppResultLauncher]): the picker is another app, and without the + * seam the app lock would ask for the PIN on the way back, the chosen documents lost. + */ +@Composable +private fun LigneDImport(snackbars: SnackbarHostState) { + val viewModel: ImportViewModel = hiltViewModel() + val state by viewModel.state.collectAsStateWithLifecycle() + val portee = rememberCoroutineScope() + val ressources = LocalResources.current + val selecteur = rememberAppResultLauncher(ActivityResultContracts.OpenMultipleDocuments(), viewModel::importer) + + // Consumed before it is shown, as the export's (see [LigneDExport]): a rotation does not say it twice. + LaunchedEffect(state.error) { + val erreur = state.error ?: return@LaunchedEffect + val message = ressources.getString(R.string.settings_import_error, ressources.getString(erreur)) + viewModel.consume() + portee.launch { snackbars.showSnackbar(message) } + } + state.result?.let { BilanDeLImport(it, onFermer = viewModel::consume) } + + ListItem( + headlineContent = { Text(stringResource(R.string.settings_import)) }, + supportingContent = { Text(stringResource(R.string.settings_import_subtitle)) }, + leadingContent = { Icon(Icons.Outlined.FileUpload, contentDescription = null) }, + trailingContent = { + if (state.busy) CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(20.dp)) + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = Modifier.clickable(enabled = !state.busy) { selecteur.launch(TYPES_IMPORTABLES) }, + ) +} + +@Composable +private fun BilanDeLImport(bilan: NoteImporter.Resultat, onFermer: () -> Unit) { + AlertDialog( + onDismissRequest = onFermer, + title = { Text(stringResource(R.string.settings_import_result_title)) }, + text = { + Text( + buildList { + add( + if (bilan.importees == 0) { + stringResource(R.string.settings_import_none) + } else { + pluralStringResource(R.plurals.settings_import_done, bilan.importees, bilan.importees) + }, + ) + if (bilan.dossiersCrees > 0) { + val n = bilan.dossiersCrees + add(pluralStringResource(R.plurals.settings_import_folders_created, n, n)) + } + if (bilan.versLaBoiteDeReception > 0) { + val n = bilan.versLaBoiteDeReception + add(pluralStringResource(R.plurals.settings_import_to_inbox, n, n)) + } + if (bilan.ignores > 0) { + add(pluralStringResource(R.plurals.settings_import_ignored, bilan.ignores, bilan.ignores)) + } + if (bilan.arrete) add(stringResource(R.string.settings_import_stopped)) + }.joinToString("\n"), + ) + }, + confirmButton = { TextButton(onClick = onFermer) { Text(stringResource(R.string.common_ok)) } }, + ) +} + +/** + * What the picker offers: text — Markdown is `text/markdown` or `text/x-markdown` depending on the + * provider — ZIPs, and `application/octet-stream`, which several providers give a `.md` file. What is + * read is decided by the file's name and content ([LectureDUnImport]), not by this list. + */ +private val TYPES_IMPORTABLES = arrayOf( + "text/*", + "application/zip", + "application/x-zip-compressed", + "application/octet-stream", +) + /** * La ligne qui ouvre la confirmation du mode panique. * diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 386f793..9d468f2 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -297,6 +297,29 @@ Nie Alle meine Notizen exportieren Erstellt ein ZIP-Archiv mit Markdown-Dateien, nach Ordnern gegliedert. + Export und Import + Notizen importieren + Markdown- oder Textdateien oder ein ZIP davon, etwa ein Export von Notes Tech. + Import + Der Import hat an einer Grenze angehalten (5.000 Dateien oder 100 MB gelesen): Der Rest wurde nicht importiert. + Es wurde keine Notiz importiert. + Import fehlgeschlagen, nichts wurde importiert: %1$s + + %1$d Notiz importiert. + %1$d Notizen importiert. + + + %1$d Datei wurde nicht importiert: keine Notiz, kein Text oder zu groß. + %1$d Dateien wurden nicht importiert: keine Notizen, kein Text oder zu groß. + + + %1$d Ordner erstellt. + %1$d Ordner erstellt. + + + %1$d Notiz kam in den Eingang: Ihr Ordner trägt den Namen eines Tresors. + %1$d Notizen kamen in den Eingang: Ihr Ordner trägt den Namen eines Tresors. + Export abgeschlossen: %1$d Notizen Export abgeschlossen: %1$d Notizen (%2$d in gesperrten Tresoren übersprungen) Export fehlgeschlagen: %1$s diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 2e73b29..2a7f22a 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -307,6 +307,33 @@ Nunca Exportar todas mis notas Genera un archivo ZIP con archivos Markdown organizados por carpetas. + Exportar e importar + Importar notas + Archivos Markdown o de texto, o un ZIP con ellos, como una exportación de Notes Tech. + Importación + La importación se detuvo en un límite (5.000 archivos, o 100 MB leídos): el resto no se importó. + No se importó ninguna nota. + No se pudo importar, no se importó nada: %1$s + + %1$d nota importada. + %1$d de notas importadas. + %1$d notas importadas. + + + %1$d archivo no se importó: no es una nota, no es texto o es demasiado grande. + %1$d de archivos no se importaron: no son notas, no son texto o son demasiado grandes. + %1$d archivos no se importaron: no son notas, no son texto o son demasiado grandes. + + + %1$d carpeta creada. + %1$d de carpetas creadas. + %1$d carpetas creadas. + + + %1$d nota fue a la Bandeja de entrada: su carpeta lleva el nombre de una caja fuerte. + %1$d de notas fueron a la Bandeja de entrada: su carpeta lleva el nombre de una caja fuerte. + %1$d notas fueron a la Bandeja de entrada: su carpeta lleva el nombre de una caja fuerte. + Exportación completada: %1$d notas Exportación completada: %1$d notas (%2$d omitidas en cajas fuertes bloqueadas) No se pudo exportar: %1$s diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 61ebbed..e1eb75e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -312,6 +312,33 @@ Jamais Exporter toutes mes notes Génère une archive ZIP Markdown organisée par dossier. + Export et import + Importer des notes + Fichiers Markdown ou texte, ou une archive ZIP de ces fichiers, comme un export de Notes Tech. + Import + L\'import s\'est arrêté à une limite (5 000 fichiers, ou 100 Mo lus) : la suite n\'a pas été importée. + Aucune note n\'a été importée. + L\'import a échoué, rien n\'a été importé : %1$s + + %1$d note importée. + %1$d de notes importées. + %1$d notes importées. + + + %1$d fichier n\'a pas été importé : ce n\'est pas une note, pas du texte, ou il est trop gros. + %1$d de fichiers n\'ont pas été importés : pas des notes, pas du texte, ou trop gros. + %1$d fichiers n\'ont pas été importés : pas des notes, pas du texte, ou trop gros. + + + %1$d dossier créé. + %1$d de dossiers créés. + %1$d dossiers créés. + + + %1$d note est allée dans la Boîte de réception : son dossier porte le nom d\'un coffre. + %1$d de notes sont allées dans la Boîte de réception : leur dossier porte le nom d\'un coffre. + %1$d notes sont allées dans la Boîte de réception : leur dossier porte le nom d\'un coffre. + Export terminé : %1$d notes Export terminé : %1$d notes (%2$d ignorées dans des coffres verrouillés) L\'export a échoué : %1$s diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 512c7f6..1440fa0 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -307,6 +307,33 @@ Mai Esporta tutte le mie note Genera un archivio ZIP di file Markdown organizzato per cartelle. + Esportazione e importazione + Importa note + File Markdown o di testo, o un archivio ZIP di questi file, come un\'esportazione di Notes Tech. + Importazione + L\'importazione si è fermata a un limite (5.000 file, o 100 MB letti): il resto non è stato importato. + Nessuna nota è stata importata. + Importazione non riuscita, nulla è stato importato: %1$s + + %1$d nota importata. + %1$d di note importate. + %1$d note importate. + + + %1$d file non è stato importato: non è una nota, non è testo, o è troppo grande. + %1$d di file non sono stati importati: non note, non testo, o troppo grandi. + %1$d file non sono stati importati: non note, non testo, o troppo grandi. + + + %1$d cartella creata. + %1$d di cartelle create. + %1$d cartelle create. + + + %1$d nota è finita in In arrivo: la sua cartella ha il nome di una cassaforte. + %1$d di note sono finite in In arrivo: la loro cartella ha il nome di una cassaforte. + %1$d note sono finite in In arrivo: la loro cartella ha il nome di una cassaforte. + Esportazione completata: %1$d note Esportazione completata: %1$d note (%2$d saltate in casseforti bloccate) Esportazione non riuscita: %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 535cb5d..e3f32f1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -372,6 +372,29 @@ Never Export all my notes Generates a Markdown ZIP archive organized by folder. + Export and import + Import notes + Markdown or text files, or a ZIP of them, such as a Notes Tech export. + Import + The import stopped at a limit (5,000 files, or 100 MB read): what came after was not imported. + No note was imported. + Import failed, nothing was imported: %1$s + + %1$d note imported. + %1$d notes imported. + + + %1$d file was not imported: not a note, not text, or too large. + %1$d files were not imported: not notes, not text, or too large. + + + %1$d folder created. + %1$d folders created. + + + %1$d note went to the Inbox: its folder bears the name of a vault. + %1$d notes went to the Inbox: their folder bears the name of a vault. + Export complete: %1$d notes Export complete: %1$d notes (%2$d skipped in locked vaults) Export failed: %1$s diff --git a/app/src/test/java/com/filestech/notes_tech/domain/importation/LectureDUnImportTest.kt b/app/src/test/java/com/filestech/notes_tech/domain/importation/LectureDUnImportTest.kt new file mode 100644 index 0000000..725f971 --- /dev/null +++ b/app/src/test/java/com/filestech/notes_tech/domain/importation/LectureDUnImportTest.kt @@ -0,0 +1,182 @@ +package com.filestech.notes_tech.domain.importation + +import com.filestech.notes_tech.domain.export.NoteArchive +import com.filestech.notes_tech.domain.model.EncryptedFormat +import com.filestech.notes_tech.domain.model.Folder +import com.filestech.notes_tech.domain.model.Note +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.time.Instant +import java.time.ZoneId +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +/** The Settings' import (D-032): what becomes a note, in which folder, and where it stops. */ +class LectureDUnImportTest { + + private val zone = ZoneId.of("Europe/Paris") + + private fun zip(vararg entrees: Pair): ByteArray { + val sortie = ByteArrayOutputStream() + ZipOutputStream(sortie).use { zip -> + entrees.forEach { (nom, octets) -> + zip.putNextEntry(ZipEntry(nom)) + zip.write(octets) + zip.closeEntry() + } + } + return sortie.toByteArray() + } + + private fun lire(vararg documents: Pair): Import { + val lecture = LectureDUnImport(zone) + documents.forEach { (nom, octets) -> lecture.ajouter(nom, ByteArrayInputStream(octets)) } + return lecture.resultat() + } + + @Test + fun `an export of Notes Tech comes back in its folders, its README left out`() { + val maintenant = Instant.parse("2026-10-29T10:00:00Z") + val perso = Folder( + id = "f-perso", + name = "Perso", + parentId = null, + color = null, + icon = null, + createdAt = maintenant, + updatedAt = maintenant, + vault = null, + ) + val archive = ByteArrayOutputStream().also { sortie -> + NoteArchive.write( + output = sortie, + notes = listOf( + note("1", "Courses", Folder.INBOX_ID, maintenant), + note("2", "Reiki", "f-perso", maintenant), + ), + foldersById = mapOf(perso.id to perso), + exportedAt = maintenant, + zone = zone, + ) + }.toByteArray() + + val lu = lire("notes-tech-export.zip" to archive) + + assertThat(lu.notes.map { it.note.titre to it.dossier }).containsExactly("Courses" to null, "Reiki" to "Perso") + assertThat(lu.notes.first().note.etiquettes).containsExactly("maison") + assertThat(lu.ignores).isEqualTo(0) + assertThat(lu.arrete).isFalse() + } + + @Test + fun `a file alone goes to the Inbox, what is not text is counted`() { + val lu = lire( + "Recette.md" to "# Tarte\n\nPâte".toByteArray(), + "liste.TXT" to "pain".toByteArray(), + "photo.jpg" to byteArrayOf(1, 2, 3), + "faux.md" to byteArrayOf(0xC3.toByte(), 0x28), + ) + + assertThat(lu.notes.map { it.note.titre to it.dossier }).containsExactly("Recette" to null, "liste" to null) + assertThat(lu.ignores).isEqualTo(2) + } + + @Test + fun `in a ZIP, hidden entries are not documents, other files are counted`() { + val lu = lire( + "coffre-obsidian.zip" to zip( + "Projets/Idée.md" to "Une idée".toByteArray(), + "Projets/.obsidian/app.json" to "{}".toByteArray(), + ".DS_Store" to byteArrayOf(0), + "__MACOSX/Projets/._Idée.md" to byteArrayOf(0), + "Projets/schema.png" to byteArrayOf(1), + "inbox/Note.md" to "x".toByteArray(), + ), + ) + + assertThat(lu.notes.map { it.note.titre to it.dossier }).containsExactly("Idée" to "Projets", "Note" to null) + assertThat(lu.ignores).isEqualTo(1) + } + + @Test + fun `an entry too long is left out, and the next one is read`() { + val lu = lire( + "a.zip" to zip( + "Grand.md" to ByteArray(LectureDUnImport.FICHIER_MAX + 1) { 'a'.code.toByte() }, + "Petit.md" to "ok".toByteArray(), + ), + ) + + assertThat(lu.notes.map { it.note.titre }).containsExactly("Petit") + assertThat(lu.ignores).isEqualTo(1) + } + + /** 🔴 A few hundred kilobytes that inflate past the bound: read once, counted, and the import stops. */ + @Test + fun `an archive bomb stops the import on what it inflates, skipped entries included`() { + val bombe = ByteArrayOutputStream().also { sortie -> + ZipOutputStream(sortie).use { zip -> + zip.putNextEntry(ZipEntry("Avant.md")) + zip.write("gardée".toByteArray()) + zip.closeEntry() + // Not a note: skipped, so its bytes would go uncounted if skipping were not reading. + zip.putNextEntry(ZipEntry("zeros.bin")) + val zeros = ByteArray(1024 * 1024) + repeat((LectureDUnImport.LUS_MAX / zeros.size).toInt() + 1) { zip.write(zeros) } + zip.closeEntry() + zip.putNextEntry(ZipEntry("Apres.md")) + zip.write("jamais lue".toByteArray()) + zip.closeEntry() + } + }.toByteArray() + assertThat(bombe.size).isLessThan(1024 * 1024) + + val lu = lire("bombe.zip" to bombe, "Suivant.md" to "après l'arrêt".toByteArray()) + + assertThat(lu.arrete).isTrue() + assertThat(lu.notes.map { it.note.titre }).containsExactly("Avant") + assertThat(lu.ignores).isEqualTo(2) + } + + @Test + fun `past the number of files, the import stops on the ones it read`() { + val entrees = (1..LectureDUnImport.FICHIERS_MAX + 1).map { "n$it.md" to "x".toByteArray() }.toTypedArray() + + val lu = lire("beaucoup.zip" to zip(*entrees)) + + assertThat(lu.notes).hasSize(LectureDUnImport.FICHIERS_MAX) + assertThat(lu.arrete).isTrue() + } + + /** A download cut short: cut inside the second entry's data, not just its central directory. */ + @Test + fun `a damaged archive keeps what was read before the damage`() { + val hasard = java.util.Random(7) + val long = ByteArray(20_000) { ('a' + hasard.nextInt(26)).code.toByte() } + val entier = zip("Un.md" to "un".toByteArray(), "Deux.md" to long) + val tronque = entier.copyOf(entier.size - 6_000) + + val lu = lire("abime.zip" to tronque) + + assertThat(lu.notes.map { it.note.titre }).containsExactly("Un") + assertThat(lu.ignores).isEqualTo(1) + } + + private fun note(id: String, titre: String, dossier: String, quand: Instant) = Note( + id = id, + title = titre, + content = "Le texte de $titre.", + folderId = dossier, + tags = listOf("maison"), + pinned = false, + favorite = false, + archived = false, + trashedAt = null, + createdAt = quand, + updatedAt = quand, + encrypted = null, + encVersion = EncryptedFormat.CONTENT_ONLY, + ) +} diff --git a/docs/13-VERSION-3.2.md b/docs/13-VERSION-3.2.md index 3855877..7e1227d 100644 --- a/docs/13-VERSION-3.2.md +++ b/docs/13-VERSION-3.2.md @@ -160,6 +160,23 @@ octets taille champ - Toujours des **nouvelles** notes (nouveaux ids) : importer deux fois crée deux copies — c'est un import, pas une restauration. +**Fait (2026-10-29)** — `domain/importation/LectureDUnImport.kt`, `data/importation/NoteImporter.kt`, +`NotesRepository.importerDansDesDossiers`, Réglages → « Export et import ». Tranché en codant : +- 🔴 **Les 100 Mio comptent tout octet lu, sauté compris** : un ZIP lu en flux décompresse le reste d'une + entrée pour atteindre la suivante, donc une entrée de zéros sautée sans compter tenait l'import des + heures. Une borne atteinte arrête l'import, qui garde ce qu'il a lu et le dit. +- Le **bruit** n'est ni lu comme note ni compté : `__MACOSX/`, `.DS_Store`, tout nom commençant par un + point (`.obsidian/`). Le reste qui n'est pas une note est compté dans le bilan. +- Le **README** reconnu à son titre (`NoteMarkdown.README_HEADING`, partagé avec l'export), à la racine. +- Le dossier vient du **chemin**, pas du `folder:` du frontmatter : `inbox/` désigne la Boîte de réception + quelle que soit la langue de l'export, là où le frontmatter porte son nom traduit. +- Un dossier ordinaire de même nom l'emporte sur un coffre ; un nom qui n'est que celui d'un coffre envoie + la note dans la Boîte de réception, compté et dit. Dossiers créés et notes écrites dans UNE transaction. +- Sélecteur : `text/*`, ZIP et `application/octet-stream` (plusieurs fournisseurs typent ainsi un `.md`) ; + ce qui est lu se décide au nom et au contenu. +Tests : 7 JVM (export réel relu, bombe, borne de fichiers, archive abîmée…), 3 sur SQLCipher réel ; +sabotages tous vus rouges. Essayé sur émulateur par le vrai sélecteur. + ## 3. Recherche dans la note ### D-033 — Une barre « Rechercher dans la note », et l'arrivée depuis un résultat From ebb828d2e1299b15fbfd88bc68ca4c38be80758c Mon Sep 17 00:00:00 2001 From: Patrice Haltaya Date: Thu, 29 Oct 2026 18:38:01 +0100 Subject: [PATCH 07/16] feat(backup): the .ntbackup file, version 1, and its secret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pure layer of the encrypted backup (D-029 of docs/13-VERSION-3.2.md), not yet used by the app. FichierDeSauvegarde: the 48-byte header (magic, version 1, Argon2id with exactly t = 3, m = 64 MiB, p = 1 — the passphrase vaults' parameters, derived by VaultCrypto), authenticated as the AAD of AES-256-GCM, streamed both ways by Bouncy Castle. The header is refused before any derivation when it is not what version 1 writes (R-10): a forged file cannot make the phone allocate more, or compute longer, than a real backup. The content is not authenticated until its stream ends, where a wrong secret and a damaged file fail alike. 64 MiB at most, counted on what is really read (R-9). The header is read by a loop, not readNBytes (Android 13+). SecretDeSauvegarde: the code drawn by default, 24 signs of Crockford's base 32 (120 bits; removing 0 O 1 I L leaves only 31 signs, so the look-alikes are folded when typed instead), read back whatever its case, spaces and dashes; or a chosen phrase, in NFC, at least 12 characters, not a repeated pattern, a run or one of the commonest. One rule for creating and restoring: what has the shape of a code is a code. Tests: FichierDeSauvegardeTest (5) and SecretDeSauvegardeTest (4), the R-10 check and the NFC rule seen failing under a sabotage. The NFC test first used a literal its editor had precomposed, and proved nothing: it is written with escapes now. Co-Authored-By: Claude Opus 5.5 --- .../sauvegarde/FichierDeSauvegarde.kt | 136 ++++++++++++++++++ .../security/sauvegarde/SecretDeSauvegarde.kt | 101 +++++++++++++ .../sauvegarde/FichierDeSauvegardeTest.kt | 114 +++++++++++++++ .../sauvegarde/SecretDeSauvegardeTest.kt | 56 ++++++++ docs/13-VERSION-3.2.md | 6 +- 5 files changed, 412 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/filestech/notes_tech/security/sauvegarde/FichierDeSauvegarde.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/security/sauvegarde/SecretDeSauvegarde.kt create mode 100644 app/src/test/java/com/filestech/notes_tech/security/sauvegarde/FichierDeSauvegardeTest.kt create mode 100644 app/src/test/java/com/filestech/notes_tech/security/sauvegarde/SecretDeSauvegardeTest.kt diff --git a/app/src/main/java/com/filestech/notes_tech/security/sauvegarde/FichierDeSauvegarde.kt b/app/src/main/java/com/filestech/notes_tech/security/sauvegarde/FichierDeSauvegarde.kt new file mode 100644 index 0000000..6c6f06d --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/security/sauvegarde/FichierDeSauvegarde.kt @@ -0,0 +1,136 @@ +package com.filestech.notes_tech.security.sauvegarde + +import com.filestech.notes_tech.security.vault.VaultCrypto +import com.filestech.notes_tech.security.vault.VaultParams +import org.bouncycastle.crypto.engines.AESEngine +import org.bouncycastle.crypto.io.CipherInputStream +import org.bouncycastle.crypto.io.CipherOutputStream +import org.bouncycastle.crypto.modes.GCMBlockCipher +import org.bouncycastle.crypto.params.AEADParameters +import org.bouncycastle.crypto.params.KeyParameter +import java.io.FilterInputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.nio.ByteBuffer + +/** + * A `.ntbackup` file, version 1 (3.2.0, `docs/13-VERSION-3.2.md` "Format, version 1"): + * + * ``` + * 0 8 "NTBACKUP" 20 16 salt + * 8 2 version = 1 36 12 GCM nonce + * 10 1 KDF = 1, Argon2id 48 … AES-256-GCM of the content, AAD = these 48 bytes, tag at the end + * 11 4 t 15 4 m (KiB) 19 1 p + * ``` + * + * - **Streamed both ways** (Bouncy Castle's GCM): the content is never held whole, encrypted or not. + * - 🔴 **Read as a stream, the content is NOT authenticated until its end.** The tag is checked when + * the stream closes — [ouvrir]'s stream throws then, an [IOException]. Hence the two passes of the + * restore (D-031): nothing read from it is believed, or written for good, before that. + * - 🔴 **Exactly t = 3, m = 64 MiB, p = 1 before any derivation** (R-10): nothing is authenticated yet + * at that point, and a forged header must not make the phone allocate more, or compute longer, than a + * real backup does. Other parameters will come with another version of the format. + * - **64 MiB at most, counted on what is really read** (R-9): a provider's announced size is only a + * first refusal. + */ +internal object FichierDeSauvegarde { + + const val TAILLE_DE_L_EN_TETE = 48 + + /** The file, header and tag included. */ + const val TAILLE_MAX = 64L * 1024 * 1024 + + const val VERSION = 1 + private const val KDF_ARGON2ID: Byte = 1 + private val MAGIQUE = "NTBACKUP".toByteArray(Charsets.US_ASCII) + + /** What a file is refused for before its tag is even reached. */ + enum class Refus { PAS_UNE_SAUVEGARDE, VERSION_INCONNUE, PARAMETRES_INCONNUS, TRONQUE, TROP_GROS } + + class SauvegardeRefuseeException(val refus: Refus) : IOException("sauvegarde refusee : $refus") + + /** The salt and nonce of one file — drawn anew for every file. */ + class EnTete(val sel: ByteArray, val nonce: ByteArray) { + init { + require(sel.size == VaultParams.SALT_BYTES && nonce.size == VaultParams.NONCE_BYTES) + } + + fun octets(): ByteArray = ByteBuffer.allocate(TAILLE_DE_L_EN_TETE) + .put(MAGIQUE) + .putShort(VERSION.toShort()) + .put(KDF_ARGON2ID) + .putInt(VaultParams.PASSPHRASE_ITERATIONS) + .putInt(VaultParams.PASSPHRASE_MEMORY_KIB) + .put(VaultParams.PARALLELISM.toByte()) + .put(sel) + .put(nonce) + .array() + } + + fun nouvelEnTete(): EnTete = EnTete(VaultCrypto.newSalt(), VaultCrypto.newNonce()) + + /** The key of a file: Argon2id with the vaults' passphrase parameters. To be wiped by the caller. */ + fun cle(secret: ByteArray, enTete: EnTete): ByteArray = VaultCrypto.derivePassphraseKey(secret, enTete.sel) + + /** Writes [enTete] to [sortie], and returns the stream the content goes into; closing it writes the tag. */ + fun ecrire(sortie: OutputStream, cle: ByteArray, enTete: EnTete): OutputStream { + val octets = enTete.octets() + sortie.write(octets) + return CipherOutputStream(sortie, gcm(chiffrer = true, cle, enTete, octets)) + } + + /** Reads and checks the header of [entree] — strictly, nothing being authenticated yet. */ + fun lireLEnTete(entree: InputStream): Pair { + // A loop, not `readNBytes`: that one exists from Android 13 only, and the app runs from 7. + val octets = ByteArray(TAILLE_DE_L_EN_TETE) + var lus = 0 + while (lus < octets.size) { + val n = entree.read(octets, lus, octets.size - lus) + if (n < 0) throw SauvegardeRefuseeException(Refus.TRONQUE) + lus += n + } + val lecture = ByteBuffer.wrap(octets) + val magique = ByteArray(MAGIQUE.size).also { lecture.get(it) } + if (!magique.contentEquals(MAGIQUE)) throw SauvegardeRefuseeException(Refus.PAS_UNE_SAUVEGARDE) + if (lecture.short.toInt() != VERSION) throw SauvegardeRefuseeException(Refus.VERSION_INCONNUE) + val connus = lecture.get() == KDF_ARGON2ID && + lecture.int == VaultParams.PASSPHRASE_ITERATIONS && + lecture.int == VaultParams.PASSPHRASE_MEMORY_KIB && + lecture.get().toInt() == VaultParams.PARALLELISM + if (!connus) throw SauvegardeRefuseeException(Refus.PARAMETRES_INCONNUS) + val sel = ByteArray(VaultParams.SALT_BYTES).also { lecture.get(it) } + val nonce = ByteArray(VaultParams.NONCE_BYTES).also { lecture.get(it) } + return EnTete(sel, nonce) to octets + } + + /** + * The content of a file whose header [lireLEnTete] read from [entree] — the same stream, bounded by + * the caller ([borne]). Reading it to its end checks the tag, and throws if it does not hold: + * wrong secret and damaged file look the same. + */ + fun ouvrir(entree: InputStream, cle: ByteArray, enTete: EnTete, octetsDeLEnTete: ByteArray): InputStream = + CipherInputStream(entree, gcm(chiffrer = false, cle, enTete, octetsDeLEnTete)) + + /** [entree], refused past [TAILLE_MAX] bytes really read. */ + fun borne(entree: InputStream): InputStream = object : FilterInputStream(entree) { + private var lus = 0L + + override fun read(): Int = super.read().also { if (it >= 0) compter(1) } + + override fun read(b: ByteArray, off: Int, len: Int): Int = + super.read(b, off, len).also { if (it > 0) compter(it) } + + override fun skip(n: Long): Long = super.skip(n).also { compter(it.toInt()) } + + private fun compter(n: Int) { + lus += n + if (lus > TAILLE_MAX) throw SauvegardeRefuseeException(Refus.TROP_GROS) + } + } + + private fun gcm(chiffrer: Boolean, cle: ByteArray, enTete: EnTete, aad: ByteArray) = + GCMBlockCipher.newInstance(AESEngine.newInstance()).apply { + init(chiffrer, AEADParameters(KeyParameter(cle), VaultParams.TAG_BITS, enTete.nonce, aad)) + } +} diff --git a/app/src/main/java/com/filestech/notes_tech/security/sauvegarde/SecretDeSauvegarde.kt b/app/src/main/java/com/filestech/notes_tech/security/sauvegarde/SecretDeSauvegarde.kt new file mode 100644 index 0000000..c4cd7b9 --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/security/sauvegarde/SecretDeSauvegarde.kt @@ -0,0 +1,101 @@ +package com.filestech.notes_tech.security.sauvegarde + +import java.security.SecureRandom +import java.text.Normalizer + +/** + * The secret of a `.ntbackup` file (3.2.0, `docs/13-VERSION-3.2.md` D-029): a code drawn at random by + * default, or a phrase the user chooses. + * + * The file leaves the phone and can be attacked offline with no limit on attempts: only a secret drawn + * at random holds for sure. Hence the code — 24 signs of Crockford's base 32, 120 bits — shown once and + * typed back before the file is written. + * + * - **Crockford's base 32** (`0-9`, `A-Z` without `I`, `L`, `O`, `U`): 32 signs, so 5 bits each, and + * the look-alikes folded when typed — `O` is `0`, `I` and `L` are `1` — so that a code read off a + * piece of paper cannot be mistyped by one of them. Case, spaces and dashes do not count. + * - **One rule for creating and restoring** ([octets]): what has the shape of a code is used as its + * canonical form, anything else as a phrase in Unicode NFC — the same "é" typed on two keyboards + * gives two byte sequences otherwise. A restore never has to guess which kind of secret made the file. + */ +internal object SecretDeSauvegarde { + + const val LONGUEUR_DU_CODE = 24 + const val TAILLE_DES_GROUPES = 4 + const val LONGUEUR_MIN_DE_LA_PHRASE = 12 + + private const val ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + + /** A new code, shown as six groups of four: `K7QM-T2XB-…`. */ + fun nouveauCode(hasard: SecureRandom = SecureRandom()): String = + (1..LONGUEUR_DU_CODE).map { ALPHABET[hasard.nextInt(ALPHABET.length)] } + .chunked(TAILLE_DES_GROUPES) + .joinToString("-") { it.joinToString("") } + + /** [saisie] as a code, canonical — or `null` if it is not one. */ + fun codeCanonique(saisie: String): String? { + val signes = StringBuilder(LONGUEUR_DU_CODE) + for (c in saisie) { + // `isWhitespace` covers the no-break spaces a copy-paste brings along. + if (c.isWhitespace() || c == '-') continue + when (val signe = c.uppercaseChar()) { + 'O' -> signes.append('0') + 'I', 'L' -> signes.append('1') + else -> if (signe in ALPHABET) signes.append(signe) else return null + } + } + return signes.toString().takeIf { it.length == LONGUEUR_DU_CODE } + } + + /** The bytes the key is derived from — to be wiped by the caller. */ + fun octets(saisie: String): ByteArray = + (codeCanonique(saisie) ?: Normalizer.normalize(saisie, Normalizer.Form.NFC)).toByteArray(Charsets.UTF_8) + + enum class Refus { TROP_COURTE, EVIDENTE } + + /** + * Why a chosen phrase is refused, or `null` when it is accepted: at least [LONGUEUR_MIN_DE_LA_PHRASE] + * characters, and not an obvious one — a short pattern repeated (`aaaaaaaaaaaa`, `abcabcabcabc`), a + * run (`123456789012`, `abcdefghijkl`), or one of the commonest. Not a strength meter: only what is + * certainly weak is refused, and the screen says why. + */ + fun refusDeLaPhrase(phrase: String): Refus? { + val nfc = Normalizer.normalize(phrase, Normalizer.Form.NFC) + val points = nfc.codePoints().toArray() + val minuscules = nfc.lowercase() + return when { + points.size < LONGUEUR_MIN_DE_LA_PHRASE -> Refus.TROP_COURTE + estUnMotifRepete(minuscules) || estUneSuite(minuscules) || minuscules in COURANTES -> Refus.EVIDENTE + else -> null + } + } + + private fun estUnMotifRepete(texte: String): Boolean = (1..MOTIF_MAX).any { taille -> + texte.length % taille == 0 && texte.chunked(taille).toSet().size == 1 + } + + /** Digits only or letters only, each the next (or the previous) of the one before, wrapping around. */ + private fun estUneSuite(texte: String): Boolean { + val taille = when { + texte.all { it in '0'..'9' } -> CHIFFRES + texte.all { it in 'a'..'z' } -> LETTRES + else -> return false + } + val pas = texte.zipWithNext { a, b -> Math.floorMod(b - a, taille) }.toSet() + return pas == setOf(1) || pas == setOf(taille - 1) + } + + private const val MOTIF_MAX = 4 + private const val CHIFFRES = 10 + private const val LETTRES = 26 + + /** The commonest long passwords, lowercased: what a dictionary tries first. */ + private val COURANTES = setOf( + "password1234", "password12345", "password123!", "passwordpassword", + "motdepasse12", "motdepasse123", "motdepasse1234", "motdepassemotdepasse", + "azertyuiop12", "azertyuiop123", "azertyuiopqs", "qwertyuiop12", "qwertyuiop123", "qwertyuiopas", + "qwertzuiop12", "qwertzuiop123", "iloveyou1234", "jetaime12345", "jetaimejetaime", + "1234567890ab", "123456789abc", "abc123abc123", "azertyazerty", "qwertyqwerty", + "passwort1234", "contraseña12", "contrasena12", "password2024", "password2025", "password2026", + ) +} diff --git a/app/src/test/java/com/filestech/notes_tech/security/sauvegarde/FichierDeSauvegardeTest.kt b/app/src/test/java/com/filestech/notes_tech/security/sauvegarde/FichierDeSauvegardeTest.kt new file mode 100644 index 0000000..b95e64f --- /dev/null +++ b/app/src/test/java/com/filestech/notes_tech/security/sauvegarde/FichierDeSauvegardeTest.kt @@ -0,0 +1,114 @@ +package com.filestech.notes_tech.security.sauvegarde + +import com.filestech.notes_tech.security.sauvegarde.FichierDeSauvegarde.Refus +import com.filestech.notes_tech.security.sauvegarde.FichierDeSauvegarde.SauvegardeRefuseeException +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStream + +/** + * The `.ntbackup` file, version 1 (D-029): what it writes, and everything it refuses — most of it + * BEFORE any key is derived, nothing being authenticated at that point. + * + * The key is given directly here: Argon2id at 64 MiB is the vaults' own, tested with them. + */ +class FichierDeSauvegardeTest { + + private val cle = ByteArray(32) { (it * 7 + 1).toByte() } + private val autreCle = ByteArray(32) { (it * 5 + 2).toByte() } + + private fun fichier(contenu: ByteArray, cleDuFichier: ByteArray = cle): ByteArray { + val sortie = ByteArrayOutputStream() + FichierDeSauvegarde.ecrire(sortie, cleDuFichier, FichierDeSauvegarde.nouvelEnTete()).use { it.write(contenu) } + return sortie.toByteArray() + } + + /** Reads [octets] to the end, as both passes of a restore do. */ + private fun lire(octets: ByteArray, cleDeLecture: ByteArray = cle): ByteArray { + val entree = FichierDeSauvegarde.borne(ByteArrayInputStream(octets)) + val (enTete, brut) = FichierDeSauvegarde.lireLEnTete(entree) + return FichierDeSauvegarde.ouvrir(entree, cleDeLecture, enTete, brut).use(InputStream::readBytes) + } + + @Test + fun `a file reads back what was written, through a header of 48 bytes`() { + val contenu = ByteArray(300_000) { (it % 251).toByte() } + + val octets = fichier(contenu) + + assertThat(octets.size).isEqualTo(FichierDeSauvegarde.TAILLE_DE_L_EN_TETE + contenu.size + 16) + assertThat(octets.copyOf(8)).isEqualTo("NTBACKUP".toByteArray()) + assertThat(lire(octets)).isEqualTo(contenu) + } + + @Test + fun `two files of the same content share neither salt nor nonce`() { + val a = fichier("x".toByteArray()) + val b = fichier("x".toByteArray()) + + assertThat(a.copyOfRange(20, 48)).isNotEqualTo(b.copyOfRange(20, 48)) + } + + /** Wrong secret and damaged file look the same: the tag does not hold, at the END of the stream. */ + @Test + fun `a wrong key, a changed byte or a cut file fails at the end of the content`() { + val octets = fichier(ByteArray(10_000) { 1 }) + val abime = octets.copyOf().also { it[5_000] = (it[5_000] + 1).toByte() } + val sel = octets.copyOf().also { it[25] = (it[25] + 1).toByte() } + val coupe = octets.copyOf(octets.size - 1) + + assertThrows { lire(octets, autreCle) } + assertThrows { lire(abime) } + // The salt is in the AAD: changed, the header no longer matches what was sealed with it. + assertThrows { lire(sel) } + assertThrows { lire(coupe) } + } + + /** 🔴 R-10: nothing is derived from a header the version 1 does not write. */ + @Test + fun `a header with other parameters is refused before any derivation`() { + val octets = fichier("x".toByteArray()) + + fun refus(position: Int, valeur: Byte): Refus { + val forge = octets.copyOf().also { it[position] = valeur } + return assertThrows { + FichierDeSauvegarde.lireLEnTete(ByteArrayInputStream(forge)) + }.refus + } + + assertThat(refus(0, 'X'.code.toByte())).isEqualTo(Refus.PAS_UNE_SAUVEGARDE) + assertThat(refus(9, 2)).isEqualTo(Refus.VERSION_INCONNUE) + assertThat(refus(10, 2)).isEqualTo(Refus.PARAMETRES_INCONNUS) + // t = 4, m = 64 MiB × 256, p = 2: each alone is refused. + assertThat(refus(14, 4)).isEqualTo(Refus.PARAMETRES_INCONNUS) + assertThat(refus(15, 1)).isEqualTo(Refus.PARAMETRES_INCONNUS) + assertThat(refus(19, 2)).isEqualTo(Refus.PARAMETRES_INCONNUS) + assertThat( + assertThrows { + FichierDeSauvegarde.lireLEnTete(ByteArrayInputStream(octets.copyOf(47))) + }.refus, + ).isEqualTo(Refus.TRONQUE) + } + + /** R-9: counted on what is read, whatever size a provider announces. */ + @Test + fun `past 64 MiB really read, the file is refused`() { + val infini = object : InputStream() { + override fun read(): Int = 0 + + override fun read(b: ByteArray, off: Int, len: Int): Int = len + } + val borne = FichierDeSauvegarde.borne(infini) + val tampon = ByteArray(1024 * 1024) + + val refus = assertThrows { + repeat(65) { borne.read(tampon) } + }.refus + + assertThat(refus).isEqualTo(Refus.TROP_GROS) + } +} diff --git a/app/src/test/java/com/filestech/notes_tech/security/sauvegarde/SecretDeSauvegardeTest.kt b/app/src/test/java/com/filestech/notes_tech/security/sauvegarde/SecretDeSauvegardeTest.kt new file mode 100644 index 0000000..377cb66 --- /dev/null +++ b/app/src/test/java/com/filestech/notes_tech/security/sauvegarde/SecretDeSauvegardeTest.kt @@ -0,0 +1,56 @@ +package com.filestech.notes_tech.security.sauvegarde + +import com.filestech.notes_tech.security.sauvegarde.SecretDeSauvegarde.Refus +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test +import java.security.SecureRandom + +/** The secret of a backup (D-029): the code drawn by default, and what a chosen phrase must be. */ +class SecretDeSauvegardeTest { + + @Test + fun `a code is six groups of four signs of Crockford's base 32, drawn anew each time`() { + val codes = (1..50).map { SecretDeSauvegarde.nouveauCode(SecureRandom()) } + + codes.forEach { assertThat(it).matches("[0-9A-HJKMNP-TV-Z]{4}(-[0-9A-HJKMNP-TV-Z]{4}){5}") } + assertThat(codes.toSet()).hasSize(50) + } + + /** Read off a piece of paper: case, spaces, dashes and the look-alikes do not count. */ + @Test + fun `a code is read back however it is typed`() { + val code = "K7QM-T2XB-0R1A-ZZ9P-HHJK-MN4V" + val canonique = "K7QMT2XB0R1AZZ9PHHJKMN4V" + + assertThat(SecretDeSauvegarde.codeCanonique(code)).isEqualTo(canonique) + assertThat(SecretDeSauvegarde.codeCanonique("k7qm t2xb OrIa zz9p hhjk mn4v")).isEqualTo(canonique) + assertThat(SecretDeSauvegarde.codeCanonique("K7QM T2XB 0RLA ZZ9P HHJK MN4V")).isEqualTo(canonique) + assertThat(SecretDeSauvegarde.octets("k7qm-t2xb-0r1a-zz9p-hhjk-mn4v")).isEqualTo(canonique.toByteArray()) + } + + @Test + fun `what is not a code is a phrase, in NFC`() { + assertThat(SecretDeSauvegarde.codeCanonique("K7QM-T2XB-0R1A-ZZ9P-HHJK")).isNull() + assertThat(SecretDeSauvegarde.codeCanonique("K7QM-T2XB-0R1A-ZZ9P-HHJK-MN4U")).isNull() + + // "é" typed precomposed or as "e" + a combining acute: one phrase, one key. Written as escapes: + // a literal would be whatever the editor made of it. + val decompose = "un été ensoleillé" + val compose = "un été ensoleillé" + assertThat(decompose).isNotEqualTo(compose) + assertThat(SecretDeSauvegarde.octets(decompose)).isEqualTo(compose.toByteArray()) + } + + @Test + fun `a chosen phrase is refused when short or obvious, not otherwise`() { + assertThat(SecretDeSauvegarde.refusDeLaPhrase("onze signes")).isEqualTo(Refus.TROP_COURTE) + assertThat(SecretDeSauvegarde.refusDeLaPhrase("aaaaaaaaaaaa")).isEqualTo(Refus.EVIDENTE) + assertThat(SecretDeSauvegarde.refusDeLaPhrase("abcabcabcabc")).isEqualTo(Refus.EVIDENTE) + assertThat(SecretDeSauvegarde.refusDeLaPhrase("123456789012")).isEqualTo(Refus.EVIDENTE) + assertThat(SecretDeSauvegarde.refusDeLaPhrase("lkjihgfedcba")).isEqualTo(Refus.EVIDENTE) + assertThat(SecretDeSauvegarde.refusDeLaPhrase("MotDePasse123")).isEqualTo(Refus.EVIDENTE) + + assertThat(SecretDeSauvegarde.refusDeLaPhrase("le chat dort sur la valise")).isNull() + assertThat(SecretDeSauvegarde.refusDeLaPhrase("abc123def456")).isNull() + } +} diff --git a/docs/13-VERSION-3.2.md b/docs/13-VERSION-3.2.md index 7e1227d..61db986 100644 --- a/docs/13-VERSION-3.2.md +++ b/docs/13-VERSION-3.2.md @@ -29,7 +29,11 @@ un fichier ne peut pas savoir ce qui sera supprimé après lui (R-2). L'écran de restauration le dit. - **Un code de sauvegarde GÉNÉRÉ, par défaut** (Patrice, 2026-10-29 : « fais ce qu'il y a de mieux » ; R-14) : 24 caractères en 6 groupes de 4 (`K7QM-T2XB-…`), tirés par `SecureRandom` dans un alphabet de - 32 signes sans ambiguïté (ni `0`/`O`, ni `1`/`I`/`L`), soit 120 bits. Affiché **une fois**, à noter ; + 32 signes sans ambiguïté, soit 120 bits. ⚠️ **Tranché en codant** : retirer `0`, `O`, `1`, `I` et `L` + ne laisse que 31 signes ; c'est donc la **base 32 de Crockford** (`0-9`, `A-Z` sans `I L O U`), dont la + lecture replie les sosies (`O`→`0`, `I`/`L`→`1`) — l'ambiguïté disparaît à la saisie, pas à l'affichage. + Une seule règle pour créer et restaurer (`SecretDeSauvegarde.octets`) : ce qui a la forme d'un code est + pris sous sa forme canonique, le reste comme une phrase en NFC. Affiché **une fois**, à noter ; la création exige de le **recopier** avant d'écrire le fichier — un code mal noté, c'est une sauvegarde perdue. À la restauration, la saisie ignore la casse, les espaces et les tirets. Le fichier quitte le téléphone et s'attaque hors ligne, sans limite d'essais : seul un secret tiré au hasard y résiste à From 574eeae01c1ed273fe81838b05299dc610a8706d Mon Sep 17 00:00:00 2001 From: Patrice Haltaya Date: Thu, 29 Oct 2026 19:03:34 +0100 Subject: [PATCH 08/16] feat(backup): write a backup, read it back, restore it in two passes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The data layer of the encrypted backup (D-029 to D-031 of docs/13-VERSION-3.2.md); no screen yet. - ContenuDeSauvegarde: the content as streamed JSON (android.util JsonWriter / JsonReader, never a tree): folders, then notes. A passphrase vault travels as its columns, a PIN vault saved open as its folder key, a sealed note as its blob. Strict on reading: fields typed, duplicates refused, numbers never through a double. 🔴 It reads its stream to the very end and refuses any byte after the JSON: GCM checks its tag only there, and when the content ends on a 16-byte boundary its last block can come out before — a reader stopping at the last brace would leave the tag unchecked (LectureDuContenuTest, red under that sabotage; the round trip passes either way, which is why it exists). - SauvegardeChiffree: every folder and note, the trash included, in one read transaction, notes a page at a time (SauvegardeDao). A closed PIN vault is named first (apercu) and left out with its notes; an open one goes with a copy of its key, wiped after. Success only after the file is read back whole; a failed file is deleted where the provider allows. - PlanDeRestauration (pure, JVM): ONE place deciding for both passes. Folders reused by id or created parents first, a missing parent, a cycle or depth past 32 leaving them at the top (R-6). Each note checked against the folder it really goes to (R-4): in clear into an ordinary folder, sealed into the backup's own vault — same key by its verifier. Same id: identical left, different in clear added beside under a new id, different sealed ignored (R-3). A forged file repeating an id is read by its first description everywhere; a phone vault without a verifier takes no note at all (read as ordinary, it would have taken notes in clear past the repository's sealing). - RestaurationDeSauvegarde: a trial pass that writes nothing, then the writing pass in one transaction rolled back when the tag fails at the end. A PIN vault comes back with a PIN chosen then; its Keystore key is deleted if the transaction does not commit (R-11). - FolderVaultService: the PIN sealing shared by createPinVault and the restore (materielDUnCoffreACode), no second copy of it. - KeystoreEnMemoire: one shared test Keystore instead of two copies. Tests: PlanDeRestaurationTest (6, JVM); SauvegardeChiffreeTest (4, real SQLCipher and Argon2id: what was lost comes back, the PIN vault opens with its new PIN; a wrong secret or a changed last byte restores nothing and leaves no Keystore key; restoring twice adds nothing; a closed PIN vault named then left out); LectureDuContenuTest (2). Sabotages seen red: the key cleanup removed, the drain to the end removed. Co-Authored-By: Claude Opus 5.5 --- .../data/export/NoteExporteurTest.kt | 40 +- .../data/sauvegarde/LectureDuContenuTest.kt | 63 ++++ .../data/sauvegarde/SauvegardeChiffreeTest.kt | 209 ++++++++++ .../security/vault/FolderVaultServiceTest.kt | 66 ---- .../security/vault/KeystoreEnMemoire.kt | 69 ++++ .../notes_tech/data/local/NotesDatabase.kt | 4 + .../data/local/dao/SauvegardeDao.kt | 16 + .../data/repository/NotesRepository.kt | 32 ++ .../data/sauvegarde/ContenuDeSauvegarde.kt | 357 ++++++++++++++++++ .../data/sauvegarde/PlanDeRestauration.kt | 216 +++++++++++ .../sauvegarde/RestaurationDeSauvegarde.kt | 269 +++++++++++++ .../data/sauvegarde/SauvegardeChiffree.kt | 286 ++++++++++++++ .../security/vault/FolderVaultService.kt | 77 +++- .../data/sauvegarde/PlanDeRestaurationTest.kt | 154 ++++++++ 14 files changed, 1741 insertions(+), 117 deletions(-) create mode 100644 app/src/androidTest/java/com/filestech/notes_tech/data/sauvegarde/LectureDuContenuTest.kt create mode 100644 app/src/androidTest/java/com/filestech/notes_tech/data/sauvegarde/SauvegardeChiffreeTest.kt create mode 100644 app/src/androidTest/java/com/filestech/notes_tech/security/vault/KeystoreEnMemoire.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/data/local/dao/SauvegardeDao.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/data/sauvegarde/ContenuDeSauvegarde.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/data/sauvegarde/PlanDeRestauration.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/data/sauvegarde/RestaurationDeSauvegarde.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/data/sauvegarde/SauvegardeChiffree.kt create mode 100644 app/src/test/java/com/filestech/notes_tech/data/sauvegarde/PlanDeRestaurationTest.kt diff --git a/app/src/androidTest/java/com/filestech/notes_tech/data/export/NoteExporteurTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/data/export/NoteExporteurTest.kt index 8f46711..2ca7e95 100644 --- a/app/src/androidTest/java/com/filestech/notes_tech/data/export/NoteExporteurTest.kt +++ b/app/src/androidTest/java/com/filestech/notes_tech/data/export/NoteExporteurTest.kt @@ -3,7 +3,6 @@ package com.filestech.notes_tech.data.export import android.content.Context import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import com.filestech.notes_tech.core.crypto.SecretBytes import com.filestech.notes_tech.data.local.DatabaseProvider import com.filestech.notes_tech.data.local.LegacyDatabaseFixture import com.filestech.notes_tech.data.local.LegacyDatabaseLocation @@ -15,12 +14,8 @@ import com.filestech.notes_tech.data.repository.NotesRepository import com.filestech.notes_tech.security.kek.KekRepository import com.filestech.notes_tech.security.kek.WritableKekSource import com.filestech.notes_tech.security.vault.FolderVaultService -import com.filestech.notes_tech.security.vault.KeystoreUnavailableException +import com.filestech.notes_tech.security.vault.KeystoreEnMemoire import com.filestech.notes_tech.security.vault.MonotonicClock -import com.filestech.notes_tech.security.vault.SealedByKeystore -import com.filestech.notes_tech.security.vault.VaultCrypto -import com.filestech.notes_tech.security.vault.VaultKeystore -import com.filestech.notes_tech.security.vault.VaultParams import com.filestech.notes_tech.security.vault.VaultSessions import com.filestech.notes_tech.security.vault.VaultWipeJournal import com.google.common.truth.Truth.assertThat @@ -340,39 +335,6 @@ class NoteExporteurTest { override fun elapsedMillis(): Long = 0L } - private class KeystoreEnMemoire : VaultKeystore { - private val cles = mutableMapOf() - - override fun createKey(alias: String): Boolean { - if (cles.containsKey(alias)) return false - cles[alias] = SecretBytes.randomBytes(VaultParams.FOLDER_KEY_BYTES) - return true - } - - override fun seal(alias: String, plaintext: ByteArray): SealedByKeystore { - val cle = cles[alias] ?: throw KeystoreUnavailableException() - val nonce = VaultCrypto.newNonce() - return SealedByKeystore(VaultCrypto.seal(cle, nonce, plaintext, ByteArray(0)), nonce) - } - - override fun open(alias: String, sealed: SealedByKeystore): ByteArray { - val cle = cles[alias] ?: throw KeystoreUnavailableException() - return VaultCrypto.open(cle, sealed.nonce, sealed.ciphertext, ByteArray(0)) - } - - override fun deleteKey(alias: String) { - cles.remove(alias) - } - - override fun deleteKeysWithPrefix(prefix: String): Int { - val vises = cles.keys.filter { it.startsWith(prefix) } - vises.forEach(cles::remove) - return vises.size - } - - override fun hasKey(alias: String): Boolean = cles.containsKey(alias) - } - private companion object { const val DOSSIER_NOM = "Coffre" const val PHRASE = "une phrase secrete de test 2026" diff --git a/app/src/androidTest/java/com/filestech/notes_tech/data/sauvegarde/LectureDuContenuTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/data/sauvegarde/LectureDuContenuTest.kt new file mode 100644 index 0000000..0adffcb --- /dev/null +++ b/app/src/androidTest/java/com/filestech/notes_tech/data/sauvegarde/LectureDuContenuTest.kt @@ -0,0 +1,63 @@ +package com.filestech.notes_tech.data.sauvegarde + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.filestech.notes_tech.data.sauvegarde.LectureDuContenu.ContenuIllisibleException +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.runBlocking +import org.junit.Test +import org.junit.runner.RunWith +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.FilterInputStream + +/** + * **The content is read to the very end of its stream** — instrumented: `android.util.JsonReader`. + * + * 🔴 Why it matters: a GCM stream checks its tag only when its end is read. Bouncy Castle holds back + * the last bytes, so most of the time the JSON's last brace comes out only with the check — but when + * the content ends on a 16-byte boundary, its last block can come out first, and a JSON reader + * stopping at the brace would never ask for the end: the tag would go unchecked. The round trip of + * `SauvegardeChiffreeTest` passes either way, for that reason; this test does not. + */ +@RunWith(AndroidJUnit4::class) +class LectureDuContenuTest { + + private fun contenu(): ByteArray = ByteArrayOutputStream().also { sortie -> + EcritureDuContenu(sortie, creeLe = 1L).use { contenu -> + contenu.dossier(DossierSauvegarde("inbox", "Inbox", null, null, null, 1L, 2L, coffre = null)) + } + }.toByteArray() + + /** Hands its bytes in one go, as a buffer does, and records whether its end was asked for. */ + private class FluxQuiNoteSaFin(octets: ByteArray) : FilterInputStream(ByteArrayInputStream(octets)) { + var finDemandee = false + + override fun read(): Int = super.read().also { if (it < 0) finDemandee = true } + + override fun read(b: ByteArray, off: Int, len: Int): Int = + super.read(b, off, len).also { if (it < 0) finDemandee = true } + } + + @Test + fun le_contenu_est_lu_jusqu_a_la_fin_de_son_flux(): Unit = runBlocking { + val flux = FluxQuiNoteSaFin(contenu()) + var dossiers = 0 + + LectureDuContenu.lire(flux, surLesDossiers = { dossiers = it.size }, surUneNote = {}) + + assertThat(dossiers).isEqualTo(1) + assertThat(flux.finDemandee).isTrue() + } + + @Test + fun des_octets_apres_le_contenu_le_font_refuser(): Unit = runBlocking { + val avecUnReste = contenu() + " {}".toByteArray() + + val echec = runCatching { + LectureDuContenu.lire(ByteArrayInputStream(avecUnReste), surLesDossiers = {}, surUneNote = {}) + }.exceptionOrNull() + + assertThat(echec).isNotNull() + assertThat(echec is ContenuIllisibleException || echec is java.io.IOException).isTrue() + } +} diff --git a/app/src/androidTest/java/com/filestech/notes_tech/data/sauvegarde/SauvegardeChiffreeTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/data/sauvegarde/SauvegardeChiffreeTest.kt new file mode 100644 index 0000000..06f27cc --- /dev/null +++ b/app/src/androidTest/java/com/filestech/notes_tech/data/sauvegarde/SauvegardeChiffreeTest.kt @@ -0,0 +1,209 @@ +package com.filestech.notes_tech.data.sauvegarde + +import android.content.Context +import android.net.Uri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.filestech.notes_tech.data.local.DatabaseProvider +import com.filestech.notes_tech.data.local.LegacyDatabaseFixture +import com.filestech.notes_tech.data.local.LegacyDatabaseLocation +import com.filestech.notes_tech.data.local.NotesDatabaseFactory +import com.filestech.notes_tech.data.local.SqlCipherRawKey +import com.filestech.notes_tech.data.prefs.LegacyPreferences +import com.filestech.notes_tech.data.repository.FoldersRepository +import com.filestech.notes_tech.data.repository.NotesRepository +import com.filestech.notes_tech.security.kek.KekRepository +import com.filestech.notes_tech.security.kek.WritableKekSource +import com.filestech.notes_tech.security.vault.FolderVaultService +import com.filestech.notes_tech.security.vault.KeystoreEnMemoire +import com.filestech.notes_tech.security.vault.MonotonicClock +import com.filestech.notes_tech.security.vault.VaultParams +import com.filestech.notes_tech.security.vault.VaultSessions +import com.filestech.notes_tech.security.vault.VaultWipeJournal +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.io.IOException +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset + +/** + * **The encrypted backup, both ways, on real SQLCipher** (3.2.0, D-029 to D-031): a passphrase vault + * and a PIN vault in it, the PIN vault restored with a new PIN — and what a wrong secret or a damaged + * file leaves behind: nothing. + * + * The Keystore is in memory ([KeystoreEnMemoire]): a PIN vault needs no screen lock here. Argon2id + * at 64 MiB runs for real, once per pass — about a second each on an emulator. + */ +@RunWith(AndroidJUnit4::class) +class SauvegardeChiffreeTest { + + private lateinit var context: Context + private lateinit var databaseFile: File + private lateinit var fichier: File + private val kek = ByteArray(SqlCipherRawKey.KEY_SIZE_BYTES) { (it * 13 + 7).toByte() } + private val horloge = Clock.fixed(Instant.ofEpochMilli(1_700_000_000_000L), ZoneOffset.UTC) + + private lateinit var provider: DatabaseProvider + private lateinit var dossiers: FoldersRepository + private lateinit var notes: NotesRepository + private lateinit var sessions: VaultSessions + private lateinit var keystore: KeystoreEnMemoire + private lateinit var coffres: FolderVaultService + private lateinit var sauvegarde: SauvegardeChiffree + private lateinit var restauration: RestaurationDeSauvegarde + + @Before + fun setUp() { + context = InstrumentationRegistry.getInstrumentation().targetContext + System.loadLibrary("sqlcipher") + databaseFile = File(context.cacheDir, "backup-fixture/notes_tech.db") + LegacyDatabaseFixture.create(databaseFile, SqlCipherRawKey.encode(kek)) + fichier = File(context.cacheDir, "backup-fixture/test.ntbackup").also { it.delete() } + + val source = object : WritableKekSource { + override val name = "test" + override fun load(): ByteArray = kek.copyOf() + override fun store(kek: ByteArray) = Unit + override fun replaceKeyAndStore(kek: ByteArray) = Unit + override fun destroy() = Unit + } + provider = DatabaseProvider( + context = context, + factory = NotesDatabaseFactory( + kekRepository = KekRepository(listOf(source), source, databaseExists = { true }), + nowMillis = horloge::millis, + databaseFile = { databaseFile }, + ), + ioDispatcher = Dispatchers.IO, + ) + sessions = VaultSessions(MonotonicClock { 0L }) + keystore = KeystoreEnMemoire() + val journal = VaultWipeJournal(LegacyPreferences(context)) + coffres = FolderVaultService(provider, keystore, sessions, journal, horloge) + dossiers = FoldersRepository(provider, horloge) + notes = NotesRepository(provider, dossiers, coffres, coffres, horloge) + sauvegarde = SauvegardeChiffree(context, provider, sessions, horloge) + restauration = RestaurationDeSauvegarde(context, notes, coffres) + } + + @After + fun tearDown(): Unit = runBlocking { + sessions.lockAll() + provider.close() + LegacyDatabaseLocation.SIDECAR_SUFFIXES.forEach { File(databaseFile.path + it).delete() } + fichier.delete() + } + + private val document: Uri get() = Uri.fromFile(fichier) + + /** A PIN vault, open, holding one note sealed under its key. */ + private suspend fun unCoffreACode(): Pair { + val dossier = dossiers.create("Codes") + coffres.createPinVault(dossier.id, CODE_DU_COFFRE) + val note = notes.create(folderId = dossier.id, title = "Digicode", content = "4521B") + return dossier.id to note.id + } + + @Test + fun une_sauvegarde_restaure_ce_qui_a_ete_perdu_et_un_coffre_a_code_revient_avec_un_nouveau_code(): Unit = + runBlocking { + val (coffre, noteDuCoffre) = unCoffreACode() + val enClair = notes.create( + folderId = LegacyDatabaseFixture.Fixtures.FOLDER_WORK, + title = "Courses", + content = "pain", + ) + + val creee = sauvegarde.creer(document, CODE) + assertThat(creee.omises).isEqualTo(0) + + // Lost: a note in clear, and the PIN vault with its note and its Keystore key. + notes.deletePermanently(enClair.id) + sessions.lock(coffre) + dossiers.delete(coffre) + coffres.deletePinKey(coffre) + + val essai = restauration.essayer(document, CODE) + assertThat(essai.coffresACode.map { it.id }).containsExactly(coffre) + assertThat(essai.notesAjoutees).isEqualTo(2) + assertThat(notes.find(enClair.id)).isNull() + + val fait = restauration.restaurer(document, CODE, mapOf(coffre to NOUVEAU_CODE)) + + assertThat(fait.notesAjoutees).isEqualTo(2) + assertThat(fait.notesIgnorees).isEmpty() + assertThat(notes.find(enClair.id)?.content).isEqualTo("pain") + // The vault opens with the PIN chosen at the restore, its note sealed under the same key. + coffres.unlockWithPin(coffre, NOUVEAU_CODE) + val ouverte = coffres.decrypt(requireNotNull(notes.find(noteDuCoffre))) + assertThat(ouverte.title).isEqualTo("Digicode") + assertThat(ouverte.content).isEqualTo("4521B") + // The passphrase vault and its sealed note were there all along: not written twice. + assertThat(fait.notesDejaLa).isAtLeast(1) + } + + /** 🔴 Nothing unauthenticated is committed, and no Keystore key outlives a rolled back restore. */ + @Test + fun un_secret_faux_ou_une_fin_de_fichier_changee_ne_restaurent_rien(): Unit = runBlocking { + val (coffre, _) = unCoffreACode() + sauvegarde.creer(document, CODE) + sessions.lock(coffre) + dossiers.delete(coffre) + coffres.deletePinKey(coffre) + val avant = notes.listAllAlive().map { it.id }.toSet() + + val faux = runCatching { restauration.restaurer(document, AUTRE_CODE, mapOf(coffre to NOUVEAU_CODE)) } + // The last byte is the tag's: every note parses, every row is written, and the end refuses it. + val octets = fichier.readBytes() + fichier.writeBytes(octets.copyOf().also { it[it.lastIndex] = (it[it.lastIndex] + 1).toByte() }) + val abime = runCatching { restauration.restaurer(document, CODE, mapOf(coffre to NOUVEAU_CODE)) } + + assertThat(faux.exceptionOrNull()).isInstanceOf(IOException::class.java) + assertThat(abime.exceptionOrNull()).isInstanceOf(IOException::class.java) + assertThat(notes.listAllAlive().map { it.id }.toSet()).isEqualTo(avant) + assertThat(dossiers.find(coffre)).isNull() + assertThat(keystore.hasKey(VaultParams.pinKeystoreAlias(coffre))).isFalse() + } + + @Test + fun restaurer_deux_fois_n_ajoute_rien_la_seconde(): Unit = runBlocking { + notes.create(folderId = LegacyDatabaseFixture.Fixtures.FOLDER_WORK, title = "Courses", content = "pain") + sauvegarde.creer(document, CODE) + val avant = notes.listAllAlive().size + + val fait = restauration.restaurer(document, CODE, emptyMap()) + + assertThat(fait.notesAjoutees).isEqualTo(0) + assertThat(fait.notesACote).isEqualTo(0) + assertThat(fait.dossiersCrees).isEqualTo(0) + assertThat(notes.listAllAlive()).hasSize(avant) + } + + /** D-030: a closed PIN vault is named before the file is written, then left out with its notes. */ + @Test + fun un_coffre_a_code_ferme_est_nomme_puis_omis_avec_ses_notes(): Unit = runBlocking { + val (coffre, _) = unCoffreACode() + sessions.lock(coffre) + + val apercu = sauvegarde.apercu() + val creee = sauvegarde.creer(document, CODE) + + assertThat(apercu.coffresACodeFermes).containsExactly("Codes") + assertThat(creee.omises).isEqualTo(1) + assertThat(restauration.essayer(document, CODE).coffresACode).isEmpty() + } + + private companion object { + const val CODE = "K7QM-T2XB-0R1A-ZZ9P-HHJK-MN4V" + const val CODE_DU_COFFRE = "1234" + const val NOUVEAU_CODE = "5678" + const val AUTRE_CODE = "AUTR-ECOD-E000-0000-0000-0000" + } +} diff --git a/app/src/androidTest/java/com/filestech/notes_tech/security/vault/FolderVaultServiceTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/security/vault/FolderVaultServiceTest.kt index fe095d9..bcc24e6 100644 --- a/app/src/androidTest/java/com/filestech/notes_tech/security/vault/FolderVaultServiceTest.kt +++ b/app/src/androidTest/java/com/filestech/notes_tech/security/vault/FolderVaultServiceTest.kt @@ -928,72 +928,6 @@ class FolderVaultServiceTest { const val CONTENU_V1 = "Contenu hérité du format 1" } - /** - * Le Keystore, en mémoire. - * - * Le vrai est exercé par `AndroidVaultKeystoreTest`. Ici il faut pouvoir **provoquer** ses - * pannes — indisponibilité passagère, invalidation définitive — puisque c'est la distinction - * entre les deux qui décide de détruire ou non les notes de quelqu'un. - */ - private class KeystoreEnMemoire : VaultKeystore { - private val cles = mutableMapOf() - var panneTransitoire = false - var invalidationPermanente = false - - /** Only `seal` fails — after the key was created — keeping the array it was handed. */ - var scellementRefuse = false - var clairRecuAuScellement: ByteArray? = null - - override fun createKey(alias: String): Boolean { - garde() - if (cles.containsKey(alias)) return false - cles[alias] = SecretBytes.randomBytes(VaultParams.FOLDER_KEY_BYTES) - return true - } - - override fun seal(alias: String, plaintext: ByteArray): SealedByKeystore { - garde() - if (scellementRefuse) { - clairRecuAuScellement = plaintext - throw KeystoreUnavailableException() - } - val cle = cles[alias] ?: throw KeystoreUnavailableException() - val nonce = VaultCrypto.newNonce() - return SealedByKeystore(VaultCrypto.seal(cle, nonce, plaintext, ByteArray(0)), nonce) - } - - /** The last array `open` gave back — the inner seal, which the service must wipe. */ - var clairRenduALOuverture: ByteArray? = null - - override fun open(alias: String, sealed: SealedByKeystore): ByteArray { - garde() - // As the real one since 2026-09-25: an absent key is named, never "unavailable". - val cle = cles[alias] ?: throw KeystoreKeyMissingException() - return VaultCrypto.open(cle, sealed.nonce, sealed.ciphertext, ByteArray(0)) - .also { clairRenduALOuverture = it } - } - - /** `deleteKey` returns without deleting — a Keystore that refused in silence (audit P3). */ - var suppressionIgnoree = false - - override fun deleteKey(alias: String) { - if (!suppressionIgnoree) cles.remove(alias) - } - - override fun deleteKeysWithPrefix(prefix: String): Int { - val vises = cles.keys.filter { it.startsWith(prefix) } - vises.forEach(cles::remove) - return vises.size - } - - override fun hasKey(alias: String): Boolean = cles.containsKey(alias) - - private fun garde() { - if (invalidationPermanente) throw KeystorePermanentlyInvalidatedException() - if (panneTransitoire) throw KeystoreUnavailableException() - } - } - private class HorlogeReglable(private var maintenant: Instant) : Clock() { override fun getZone(): ZoneId = ZoneOffset.UTC override fun withZone(zone: ZoneId): Clock = this diff --git a/app/src/androidTest/java/com/filestech/notes_tech/security/vault/KeystoreEnMemoire.kt b/app/src/androidTest/java/com/filestech/notes_tech/security/vault/KeystoreEnMemoire.kt new file mode 100644 index 0000000..a543345 --- /dev/null +++ b/app/src/androidTest/java/com/filestech/notes_tech/security/vault/KeystoreEnMemoire.kt @@ -0,0 +1,69 @@ +package com.filestech.notes_tech.security.vault + +import com.filestech.notes_tech.core.crypto.SecretBytes + +/** + * Le Keystore, en mémoire. + * + * Le vrai est exercé par `AndroidVaultKeystoreTest`. Ici il faut pouvoir **provoquer** ses + * pannes — indisponibilité passagère, invalidation définitive — puisque c'est la distinction + * entre les deux qui décide de détruire ou non les notes de quelqu'un. + */ +internal class KeystoreEnMemoire : VaultKeystore { + private val cles = mutableMapOf() + var panneTransitoire = false + var invalidationPermanente = false + + /** Only `seal` fails — after the key was created — keeping the array it was handed. */ + var scellementRefuse = false + var clairRecuAuScellement: ByteArray? = null + + override fun createKey(alias: String): Boolean { + garde() + if (cles.containsKey(alias)) return false + cles[alias] = SecretBytes.randomBytes(VaultParams.FOLDER_KEY_BYTES) + return true + } + + override fun seal(alias: String, plaintext: ByteArray): SealedByKeystore { + garde() + if (scellementRefuse) { + clairRecuAuScellement = plaintext + throw KeystoreUnavailableException() + } + val cle = cles[alias] ?: throw KeystoreUnavailableException() + val nonce = VaultCrypto.newNonce() + return SealedByKeystore(VaultCrypto.seal(cle, nonce, plaintext, ByteArray(0)), nonce) + } + + /** The last array `open` gave back — the inner seal, which the service must wipe. */ + var clairRenduALOuverture: ByteArray? = null + + override fun open(alias: String, sealed: SealedByKeystore): ByteArray { + garde() + // As the real one since 2026-09-25: an absent key is named, never "unavailable". + val cle = cles[alias] ?: throw KeystoreKeyMissingException() + return VaultCrypto.open(cle, sealed.nonce, sealed.ciphertext, ByteArray(0)) + .also { clairRenduALOuverture = it } + } + + /** `deleteKey` returns without deleting — a Keystore that refused in silence (audit P3). */ + var suppressionIgnoree = false + + override fun deleteKey(alias: String) { + if (!suppressionIgnoree) cles.remove(alias) + } + + override fun deleteKeysWithPrefix(prefix: String): Int { + val vises = cles.keys.filter { it.startsWith(prefix) } + vises.forEach(cles::remove) + return vises.size + } + + override fun hasKey(alias: String): Boolean = cles.containsKey(alias) + + private fun garde() { + if (invalidationPermanente) throw KeystorePermanentlyInvalidatedException() + if (panneTransitoire) throw KeystoreUnavailableException() + } +} diff --git a/app/src/main/java/com/filestech/notes_tech/data/local/NotesDatabase.kt b/app/src/main/java/com/filestech/notes_tech/data/local/NotesDatabase.kt index fe8dc3c..e93b76e 100644 --- a/app/src/main/java/com/filestech/notes_tech/data/local/NotesDatabase.kt +++ b/app/src/main/java/com/filestech/notes_tech/data/local/NotesDatabase.kt @@ -9,6 +9,7 @@ import com.filestech.notes_tech.data.local.dao.NoteDao import com.filestech.notes_tech.data.local.dao.NoteLinkDao import com.filestech.notes_tech.data.local.dao.NoteSearchDao import com.filestech.notes_tech.data.local.dao.NoteWriteDao +import com.filestech.notes_tech.data.local.dao.SauvegardeDao import com.filestech.notes_tech.data.local.entity.FolderEntity import com.filestech.notes_tech.data.local.entity.NoteEntity @@ -71,6 +72,9 @@ abstract class NotesDatabase : RoomDatabase() { abstract fun noteSearchDao(): NoteSearchDao + /** The encrypted backup's reads (3.2.0). */ + abstract fun sauvegardeDao(): SauvegardeDao + /** * Lectures de `note_links` uniquement. Les écritures passent par [NoteLinkWriter] — la table * est hors du graphe d'entités, donc Room ne peut pas générer son SQL d'écriture. diff --git a/app/src/main/java/com/filestech/notes_tech/data/local/dao/SauvegardeDao.kt b/app/src/main/java/com/filestech/notes_tech/data/local/dao/SauvegardeDao.kt new file mode 100644 index 0000000..39320ab --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/data/local/dao/SauvegardeDao.kt @@ -0,0 +1,16 @@ +package com.filestech.notes_tech.data.local.dao + +import androidx.room.Dao +import androidx.room.Query +import com.filestech.notes_tech.data.local.entity.NoteEntity + +/** + * What the encrypted backup (3.2.0) reads, and nothing else does: every note, the trash included, a + * page at a time in a stable order — read inside one transaction, written to the file as it comes. + */ +@Dao +interface SauvegardeDao { + + @Query("SELECT * FROM notes ORDER BY id LIMIT :limite OFFSET :decalage") + suspend fun pageDeNotes(limite: Int, decalage: Int): List +} diff --git a/app/src/main/java/com/filestech/notes_tech/data/repository/NotesRepository.kt b/app/src/main/java/com/filestech/notes_tech/data/repository/NotesRepository.kt index 789ddec..16ed7c4 100644 --- a/app/src/main/java/com/filestech/notes_tech/data/repository/NotesRepository.kt +++ b/app/src/main/java/com/filestech/notes_tech/data/repository/NotesRepository.kt @@ -5,6 +5,7 @@ import com.filestech.notes_tech.core.text.DartTextSemantics import com.filestech.notes_tech.data.local.DatabaseProvider import com.filestech.notes_tech.data.local.NotesDatabase import com.filestech.notes_tech.data.local.OutgoingLink +import com.filestech.notes_tech.data.local.entity.FolderEntity import com.filestech.notes_tech.data.local.entity.NoteEntity import com.filestech.notes_tech.data.local.mapper.TagCodec import com.filestech.notes_tech.data.local.mapper.toDomain @@ -374,6 +375,37 @@ class NotesRepository @Inject constructor( } } + /** + * The restore of an encrypted backup (3.2.0, D-031): [ecrire] runs inside ONE transaction, rolled + * back if it throws — and it does throw when the file's tag does not hold at its very end, after + * everything was written: nothing unauthenticated is ever committed. + * + * 🔴 **Rows go in as the backup has them** — a vault's columns, a sealed blob — which no other write + * of this repository does: [sealIfVault] is not applied, each note having been checked against its + * real destination by the restore's plan (`PlanDeRestauration`). Nothing else may write through it. + */ + suspend fun restaurerDansUneTransaction(ecrire: suspend (EcritureDeRestauration) -> T): T = + inTransaction { database -> ecrire(EcritureDeRestauration(database)) } + + /** What [restaurerDansUneTransaction] gives its block: reads and writes inside its transaction. */ + inner class EcritureDeRestauration internal constructor(private val database: NotesDatabase) { + suspend fun dossiersDuTelephone(): List = database.folderDao().listAll() + + suspend fun noteDuTelephone(id: String): NoteEntity? = database.noteDao().findById(id) + + suspend fun dossier(dossier: FolderEntity) = database.folderDao().insert(dossier) + + /** A note in clear has its links indexed, like any write; a sealed one's stay detached (D-009). */ + suspend fun note(note: NoteEntity) { + database.noteWriteDao().insert(note) + if (note.encryptedContent == null) { + val enClair = note.toDomain() + reindexLinks(database, enClair) + resolveIncoming(database, enClair) + } + } + } + /** * One imported note, written: a new id, the dates and flags the file carries (now, otherwise), the * links indexed like any write. Inside the caller's transaction. diff --git a/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/ContenuDeSauvegarde.kt b/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/ContenuDeSauvegarde.kt new file mode 100644 index 0000000..7897478 --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/ContenuDeSauvegarde.kt @@ -0,0 +1,357 @@ +package com.filestech.notes_tech.data.sauvegarde + +import android.util.Base64 +import android.util.JsonReader +import android.util.JsonToken +import android.util.JsonWriter +import java.io.Closeable +import java.io.IOException +import java.io.InputStream +import java.io.InputStreamReader +import java.io.OutputStream +import java.io.OutputStreamWriter + +/** + * What a `.ntbackup` file holds once decrypted (3.2.0, `docs/13-VERSION-3.2.md` "Format, version 1"): + * JSON, written and read **as a stream** — `android.util.JsonWriter` / `JsonReader`, never a tree in + * memory (R-8). The folders first, then the notes. + * + * A folder carries its vault as it is: a passphrase vault's columns, which only its phrase opens, on + * any phone; or, for a PIN vault saved while open, its folder key — the Keystore that seals it on this + * phone does not travel (D-030). A sealed note carries its blob untouched. + */ +internal object ContenuDeSauvegarde { + + const val FORMAT = "notes-tech-backup" + const val VERSION = 1 +} + +internal data class DossierSauvegarde( + val id: String, + val nom: String, + val parentId: String?, + val couleur: Int?, + val icone: String?, + val creeLe: Long, + val modifieLe: Long, + val coffre: CoffreSauvegarde?, +) + +internal sealed interface CoffreSauvegarde { + /** A passphrase vault's columns, copied as they are. */ + class APhrase(val sel: ByteArray, val cleEmballee: ByteArray, val iv: ByteArray, val verificateur: ByteArray) : + CoffreSauvegarde + + /** A PIN vault, saved open: its folder key. To be wiped once used. */ + class ACode(val cleDuDossier: ByteArray) : CoffreSauvegarde +} + +internal data class NoteSauvegardee( + val id: String, + val dossierId: String, + val titre: String, + val contenu: String, + /** The sealed blob of a vault note, untouched; `null` for a note in clear. */ + val scelle: ByteArray?, + val versionDeChiffrement: Int, + val etiquettes: List, + val epinglee: Boolean, + val favorite: Boolean, + val archivee: Boolean, + val misALaCorbeilleLe: Long?, + val couleur: Int?, + val creeeLe: Long, + val modifieeLe: Long, +) + +/** Writes the content of a backup into [sortie] — the encrypting stream — which [close] flushes, not closes. */ +internal class EcritureDuContenu(sortie: OutputStream, creeLe: Long) : Closeable { + + private val json = JsonWriter(OutputStreamWriter(sortie, Charsets.UTF_8)) + private var dansLesNotes = false + + init { + json.beginObject() + json.name("format").value(ContenuDeSauvegarde.FORMAT) + json.name("version").value(ContenuDeSauvegarde.VERSION.toLong()) + json.name("createdAt").value(creeLe) + json.name("folders").beginArray() + } + + fun dossier(d: DossierSauvegarde) { + check(!dansLesNotes) { "les dossiers avant les notes" } + json.beginObject() + json.name("id").value(d.id) + json.name("name").value(d.nom) + json.name("parentId").valeurOuNull(d.parentId) + json.name("color").valeurOuNull(d.couleur?.toLong()) + json.name("icon").valeurOuNull(d.icone) + json.name("createdAt").value(d.creeLe) + json.name("updatedAt").value(d.modifieLe) + json.name("vault") + when (val coffre = d.coffre) { + null -> json.nullValue() + is CoffreSauvegarde.APhrase -> { + json.beginObject() + json.name("mode").value(MODE_PHRASE) + json.name("salt").value(base64(coffre.sel)) + json.name("wrappedKey").value(base64(coffre.cleEmballee)) + json.name("iv").value(base64(coffre.iv)) + json.name("verifier").value(base64(coffre.verificateur)) + json.endObject() + } + is CoffreSauvegarde.ACode -> { + json.beginObject() + json.name("mode").value(MODE_CODE) + json.name("folderKey").value(base64(coffre.cleDuDossier)) + json.endObject() + } + } + json.endObject() + } + + fun note(n: NoteSauvegardee) { + if (!dansLesNotes) { + json.endArray() + json.name("notes").beginArray() + dansLesNotes = true + } + json.beginObject() + json.name("id").value(n.id) + json.name("folderId").value(n.dossierId) + json.name("title").value(n.titre) + json.name("content").value(n.contenu) + json.name("sealed").valeurOuNull(n.scelle?.let(::base64)) + json.name("encV").value(n.versionDeChiffrement.toLong()) + json.name("tags").beginArray() + n.etiquettes.forEach { json.value(it) } + json.endArray() + json.name("pinned").value(n.epinglee) + json.name("favorite").value(n.favorite) + json.name("archived").value(n.archivee) + json.name("trashedAt").valeurOuNull(n.misALaCorbeilleLe) + json.name("colorId").valeurOuNull(n.couleur?.toLong()) + json.name("createdAt").value(n.creeeLe) + json.name("updatedAt").value(n.modifieeLe) + json.endObject() + } + + /** Ends the JSON and flushes it; the caller closes the stream, which writes the GCM tag. */ + override fun close() { + if (!dansLesNotes) { + json.endArray() + json.name("notes").beginArray() + } + json.endArray() + json.endObject() + json.flush() + } + + private fun JsonWriter.valeurOuNull(valeur: String?): JsonWriter = + if (valeur == null) nullValue() else value(valeur) + + private fun JsonWriter.valeurOuNull(valeur: Long?): JsonWriter = if (valeur == null) nullValue() else value(valeur) +} + +/** + * Reads the content of a backup from [entree] — the decrypting stream — handing each folder, then each + * note, to the caller as it comes. + * + * 🔴 **Nothing read here is authenticated** until [lire] has returned: it drains [entree] to its very + * end, which is where the GCM tag is checked, and refuses any byte after the JSON. Stopping at the + * JSON's last brace would leave the tag unchecked — a file with its end changed would then pass. + * + * A malformed content — the usual face of a wrong secret, whose decryption is noise from the first + * byte — throws [ContenuIllisibleException] or the reader's own [IOException]; both mean "this secret + * does not open this file, or the file is damaged". + */ +internal object LectureDuContenu { + + class ContenuIllisibleException(raison: String) : IOException(raison) + + /** + * @param surLesDossiers called once, with every folder, before the first note: deciding where a + * note goes needs the whole tree. Folders are few; notes are streamed. + */ + suspend fun lire( + entree: InputStream, + surLesDossiers: suspend (List) -> Unit, + surUneNote: suspend (NoteSauvegardee) -> Unit, + ) { + val json = JsonReader(InputStreamReader(entree, Charsets.UTF_8)) + var format: String? = null + var version = -1L + var dossiersVus = false + json.beginObject() + while (json.hasNext()) { + when (json.nextName()) { + "format" -> format = json.nextString() + "version" -> version = json.nextLong() + "folders" -> { + exigerLEnTete(format, version) + val dossiers = ArrayList() + json.beginArray() + while (json.hasNext()) dossiers += lireUnDossier(json) + json.endArray() + surLesDossiers(dossiers) + dossiersVus = true + } + "notes" -> { + if (!dossiersVus) throw ContenuIllisibleException("notes avant les dossiers") + json.beginArray() + while (json.hasNext()) surUneNote(lireUneNote(json)) + json.endArray() + } + else -> json.skipValue() + } + } + json.endObject() + if (!dossiersVus) throw ContenuIllisibleException("aucun dossier") + // The JSON is over: whatever follows must be nothing — and reaching the end checks the tag. + if (json.peek() != JsonToken.END_DOCUMENT) throw ContenuIllisibleException("octets apres le contenu") + // END_DOCUMENT means the reader met the end of [entree]; read it once more all the same: the + // check that the tag was reached must not depend on how a JSON reader buffers. + if (entree.read() >= 0) throw ContenuIllisibleException("octets apres le contenu") + } + + private fun exigerLEnTete(format: String?, version: Long) { + if (format != ContenuDeSauvegarde.FORMAT || version != ContenuDeSauvegarde.VERSION.toLong()) { + throw ContenuIllisibleException("format $format, version $version") + } + } + + private fun lireUnDossier(json: JsonReader): DossierSauvegarde { + val champs = lireUnObjet(json, mapOf("vault" to { lireUnCoffre(json) })) + return DossierSauvegarde( + id = champs.texte("id"), + nom = champs.texte("name"), + parentId = champs.texteOuNull("parentId"), + couleur = champs.entierOuNull("color"), + icone = champs.texteOuNull("icon"), + creeLe = champs.long("createdAt"), + modifieLe = champs.long("updatedAt"), + coffre = champs["vault"] as CoffreSauvegarde?, + ) + } + + private fun lireUnCoffre(json: JsonReader): CoffreSauvegarde? { + if (json.peek() == JsonToken.NULL) { + json.nextNull() + return null + } + val champs = lireUnObjet(json, emptyMap()) + return when (champs.texte("mode")) { + MODE_PHRASE -> CoffreSauvegarde.APhrase( + sel = champs.octets("salt"), + cleEmballee = champs.octets("wrappedKey"), + iv = champs.octets("iv"), + verificateur = champs.octets("verifier"), + ) + MODE_CODE -> CoffreSauvegarde.ACode(champs.octets("folderKey")) + else -> throw ContenuIllisibleException("mode de coffre inconnu") + } + } + + private fun lireUneNote(json: JsonReader): NoteSauvegardee { + val etiquettes = ArrayList() + val champs = lireUnObjet( + json, + mapOf( + "tags" to { + json.beginArray() + while (json.hasNext()) etiquettes += json.nextString() + json.endArray() + etiquettes + }, + ), + ) + return NoteSauvegardee( + id = champs.texte("id"), + dossierId = champs.texte("folderId"), + titre = champs.texte("title"), + contenu = champs.texte("content"), + scelle = champs.texteOuNull("sealed")?.let(::deBase64), + versionDeChiffrement = champs.entier("encV"), + etiquettes = etiquettes, + epinglee = champs.booleen("pinned"), + favorite = champs.booleen("favorite"), + archivee = champs.booleen("archived"), + misALaCorbeilleLe = champs.longOuNull("trashedAt"), + couleur = champs.entierOuNull("colorId"), + creeeLe = champs.long("createdAt"), + modifieeLe = champs.long("updatedAt"), + ) + } + + /** + * One JSON object as a map of its plain values — strings, numbers as written, booleans, nulls — + * the fields of [lecteurs] read by their own reader, whatever value it returns (`null` included). + * An unknown field is skipped: a later version may add some. A field twice is refused. + */ + private fun lireUnObjet(json: JsonReader, lecteurs: Map Any?>): Map { + val champs = HashMap() + json.beginObject() + while (json.hasNext()) { + val nom = json.nextName() + if (nom in champs) throw ContenuIllisibleException("champ $nom en double") + val lecteur = lecteurs[nom] + champs[nom] = if (lecteur != null) { + lecteur() + } else { + when (json.peek()) { + JsonToken.STRING -> json.nextString() + JsonToken.NUMBER -> Nombre(json.nextString()) + JsonToken.BOOLEAN -> json.nextBoolean() + JsonToken.NULL -> json.nextNull().let { null } + else -> json.skipValue().let { null } + } + } + } + json.endObject() + return champs + } + + /** A number kept as written, read as the type its field needs — never through a `double`. */ + private class Nombre(val texte: String) + + private fun Map.texte(nom: String): String = + this[nom] as? String ?: throw ContenuIllisibleException("champ $nom manquant") + + private fun Map.texteOuNull(nom: String): String? = when (val v = this[nom]) { + null -> null + is String -> v + else -> throw ContenuIllisibleException("champ $nom") + } + + private fun Map.long(nom: String): Long = + longOuNull(nom) ?: throw ContenuIllisibleException("champ $nom manquant") + + private fun Map.longOuNull(nom: String): Long? = when (val v = this[nom]) { + null -> null + is Nombre -> v.texte.toLongOrNull() ?: throw ContenuIllisibleException("champ $nom") + else -> throw ContenuIllisibleException("champ $nom") + } + + private fun Map.entier(nom: String): Int = + entierOuNull(nom) ?: throw ContenuIllisibleException("champ $nom manquant") + + private fun Map.entierOuNull(nom: String): Int? = longOuNull(nom)?.let { + if (it in Int.MIN_VALUE..Int.MAX_VALUE) it.toInt() else throw ContenuIllisibleException("champ $nom") + } + + private fun Map.booleen(nom: String): Boolean = + this[nom] as? Boolean ?: throw ContenuIllisibleException("champ $nom manquant") + + private fun Map.octets(nom: String): ByteArray = deBase64(texte(nom)) + + private fun deBase64(texte: String): ByteArray = try { + Base64.decode(texte, Base64.NO_WRAP) + } catch (e: IllegalArgumentException) { + throw ContenuIllisibleException("base64 invalide").apply { initCause(e) } + } +} + +private fun base64(octets: ByteArray): String = Base64.encodeToString(octets, Base64.NO_WRAP) + +private const val MODE_PHRASE = "passphrase" +private const val MODE_CODE = "pin" diff --git a/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/PlanDeRestauration.kt b/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/PlanDeRestauration.kt new file mode 100644 index 0000000..75bfe45 --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/PlanDeRestauration.kt @@ -0,0 +1,216 @@ +package com.filestech.notes_tech.data.sauvegarde + +import com.filestech.notes_tech.data.repository.NotesRepository +import com.filestech.notes_tech.security.vault.VaultCrypto + +/** Why a note of a backup is not restored — said, counted, in the summary. */ +enum class RaisonDIgnorer { + INVALIDE, + DOSSIER_ABSENT, + SCELLEE_HORS_COFFRE, + EN_CLAIR_DANS_UN_COFFRE, + AUTRE_COFFRE, + CONFLIT_SCELLE, +} + +/** + * What a restore does with each folder and each note of a backup (3.2.0, `docs/13-VERSION-3.2.md` + * D-031) — decided in ONE place, for the trial pass that counts and the pass that writes: two passes + * deciding apart would be twins that diverge. + * + * Pure: the phone's side comes in as [DossierDuTelephone] and a lookup of its notes by id. + * + * - **Folders**: an id the phone has is reused **as the phone has it**; any other is created, parents + * first. A parent that is neither in the backup nor on the phone, a folder its own parent, a cycle, a + * tree deeper than [PROFONDEUR_MAX]: the folder is created without a parent (R-6). + * - **Each note is checked against the folder it really goes to** (R-4): the phone's when the id + * exists there, else the backup's. An ordinary folder takes notes in clear; a vault takes sealed + * notes, and only if it is the backup's vault — **the same key**, told by its verifier (R-5). A + * forged backup can thus neither slip a note in clear into a vault, nor a blob it cannot open. + * - **A note whose id the phone has** (R-3): the same → nothing to do; different and in clear → added + * beside it under a new id, nothing overwritten; different and sealed → ignored, a new id breaking + * its sealing (its AAD is its id). + */ +internal class PlanDeRestauration( + dossiersDeLaSauvegarde: List, + private val dossiersDuTelephone: Map, +) { + + /** + * A folder of the phone, as far as a restore needs it: its parent, whether it is a vault, and its + * vault's verifier. 🔴 A vault without a verifier is still a vault — one that takes no note at all: + * read as an ordinary folder, it would take notes in clear, written past the repository's sealing. + */ + class DossierDuTelephone( + val id: String, + val parentId: String?, + val estUnCoffre: Boolean, + val verificateur: ByteArray?, + ) + + /** A folder to create, its parent settled. */ + class DossierACreer(val dossier: DossierSauvegarde, val parentId: String?) + + sealed interface Decision { + data object Ajoutee : Decision + + /** In clear and different from the phone's note of the same id: added under a new id. */ + data object ACote : Decision + + data object DejaLa : Decision + + data class Ignoree(val raison: RaisonDIgnorer) : Decision + } + + /** The first folder of each id: a forged backup may repeat one, and one description must win everywhere. */ + private val parId = dossiersDeLaSauvegarde.distinctBy { it.id }.associateBy { it.id } + + /** The folders to create, parents before children. */ + val aCreer: List + + /** The backup's PIN vaults that will be created: each needs a new PIN, chosen at the restore. */ + val coffresACodeACreer: List + + val reutilises: Int = parId.keys.count { it in dossiersDuTelephone } + + init { + val nouveaux = parId.values.filter { it.id !in dossiersDuTelephone } + val parents = HashMap() + for (dossier in nouveaux) parents[dossier.id] = parentAdmis(dossier, parents) + aCreer = trierParentsDAbord(nouveaux.map { DossierACreer(it, parents[it.id]) }) + coffresACodeACreer = nouveaux.filter { it.coffre is CoffreSauvegarde.ACode } + } + + fun decider(note: NoteSauvegardee, existante: NoteDuTelephone?): Decision { + // What the app never writes: a title past the repository's limit would fail every later save. + if (note.titre.length > NotesRepository.TITLE_MAX_LENGTH) return Decision.Ignoree(RaisonDIgnorer.INVALIDE) + val destination = identiteDe(note.dossierId) ?: return Decision.Ignoree(RaisonDIgnorer.DOSSIER_ABSENT) + val scellee = note.scelle != null + val verifiee = when (destination) { + Identite.Ordinaire -> if (scellee) RaisonDIgnorer.SCELLEE_HORS_COFFRE else null + is Identite.Coffre -> when { + !scellee -> RaisonDIgnorer.EN_CLAIR_DANS_UN_COFFRE + !memeCleQueLaSauvegarde(note.dossierId, destination) -> RaisonDIgnorer.AUTRE_COFFRE + else -> null + } + } + if (verifiee != null) return Decision.Ignoree(verifiee) + return when { + existante == null -> Decision.Ajoutee + existante.estLaMemeQue(note) -> Decision.DejaLa + scellee -> Decision.Ignoree(RaisonDIgnorer.CONFLIT_SCELLE) + else -> Decision.ACote + } + } + + private sealed interface Identite { + data object Ordinaire : Identite + + /** [verificateur] `null`: a vault that cannot be checked, which nothing matches. */ + class Coffre(val verificateur: ByteArray?) : Identite + } + + /** Where a note of [dossierId] goes: the phone's folder of that id, else the backup's — or nowhere. */ + private fun identiteDe(dossierId: String): Identite? { + dossiersDuTelephone[dossierId]?.let { telephone -> + return if (telephone.estUnCoffre) Identite.Coffre(telephone.verificateur) else Identite.Ordinaire + } + val sauvegarde = parId[dossierId] ?: return null + return if (sauvegarde.coffre != null) Identite.Coffre(verificateurDe(sauvegarde)) else Identite.Ordinaire + } + + /** + * The destination is the backup's own vault: created from it, or the phone's vault of the same id + * with the same key. A sealed note whose folder the backup does not describe cannot be checked. + */ + private fun memeCleQueLaSauvegarde(dossierId: String, destination: Identite.Coffre): Boolean { + val sauvegarde = parId[dossierId] ?: return false + val attendu = verificateurDe(sauvegarde) ?: return false + val trouve = destination.verificateur ?: return false + return attendu.contentEquals(trouve) + } + + private fun verificateurDe(dossier: DossierSauvegarde): ByteArray? = when (val coffre = dossier.coffre) { + null -> null + is CoffreSauvegarde.APhrase -> coffre.verificateur + is CoffreSauvegarde.ACode -> VaultCrypto.verifierFor(coffre.cleDuDossier) + } + + /** [dossier]'s parent if it can be one: known, not itself, and not closing a cycle or going too deep. */ + private fun parentAdmis(dossier: DossierSauvegarde, parentsDejaAdmis: Map): String? { + val parent = dossier.parentId + val admis = parent != null && + parent != dossier.id && + (parent in dossiersDuTelephone || parent in parId) && + sansCycleNiExces(dossier.id, parent, parentsDejaAdmis) + return parent.takeIf { admis } + } + + /** Walking up from [parent], [id] is never met and the tree stays within [PROFONDEUR_MAX]. */ + private fun sansCycleNiExces(id: String, parent: String, parentsDejaAdmis: Map): Boolean { + var courant: String? = parent + var profondeur = 1 + while (courant != null) { + if (courant == id || profondeur > PROFONDEUR_MAX) return false + courant = when (courant) { + in dossiersDuTelephone -> dossiersDuTelephone.getValue(courant).parentId + // A backup folder not settled yet: its own parent, as written — the walk stays bounded. + in parentsDejaAdmis -> parentsDejaAdmis[courant] + else -> parId[courant]?.parentId + } + profondeur++ + } + return true + } + + private fun trierParentsDAbord(dossiers: List): List { + val restants = dossiers.associateByTo(LinkedHashMap()) { it.dossier.id } + val tries = ArrayList(dossiers.size) + while (restants.isNotEmpty()) { + // A parent already on the phone, already sorted, or none: ready. One at least each round — + // the parents admitted form no cycle. + val prets = restants.values.filter { it.parentId == null || it.parentId !in restants } + check(prets.isNotEmpty()) { "cycle parmi les dossiers a creer" } + prets.forEach { + tries += it + restants.remove(it.dossier.id) + } + } + return tries + } + + private companion object { + const val PROFONDEUR_MAX = 32 + } +} + +/** A note of the phone, as far as a restore compares it. */ +internal class NoteDuTelephone( + val dossierId: String, + val titre: String, + val contenu: String, + val scelle: ByteArray?, + val versionDeChiffrement: Int, + val etiquettes: List, + val epinglee: Boolean, + val favorite: Boolean, + val archivee: Boolean, + val misALaCorbeilleLe: Long?, + val couleur: Int?, + val creeeLe: Long, + val modifieeLe: Long, +) { + fun estLaMemeQue(n: NoteSauvegardee): Boolean = dossierId == n.dossierId && + titre == n.titre && + contenu == n.contenu && + scelle.contentEquals(n.scelle) && + versionDeChiffrement == n.versionDeChiffrement && + etiquettes == n.etiquettes && + epinglee == n.epinglee && + favorite == n.favorite && + archivee == n.archivee && + misALaCorbeilleLe == n.misALaCorbeilleLe && + couleur == n.couleur && + creeeLe == n.creeeLe && + modifieeLe == n.modifieeLe +} diff --git a/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/RestaurationDeSauvegarde.kt b/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/RestaurationDeSauvegarde.kt new file mode 100644 index 0000000..98b8fc4 --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/RestaurationDeSauvegarde.kt @@ -0,0 +1,269 @@ +package com.filestech.notes_tech.data.sauvegarde + +import android.content.Context +import android.net.Uri +import com.filestech.notes_tech.core.crypto.wipe +import com.filestech.notes_tech.data.local.entity.FolderEntity +import com.filestech.notes_tech.data.local.entity.NoteEntity +import com.filestech.notes_tech.data.local.mapper.TagCodec +import com.filestech.notes_tech.data.repository.NotesRepository +import com.filestech.notes_tech.data.sauvegarde.PlanDeRestauration.Decision +import com.filestech.notes_tech.data.sauvegarde.PlanDeRestauration.DossierDuTelephone +import com.filestech.notes_tech.security.sauvegarde.FichierDeSauvegarde +import com.filestech.notes_tech.security.sauvegarde.SecretDeSauvegarde +import com.filestech.notes_tech.security.vault.FolderVaultService +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext +import java.io.IOException +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The encrypted backup's restoring side (3.2.0, `docs/13-VERSION-3.2.md` D-030, D-031). It adds; it + * never overwrites. + * + * 🔴 **Two passes over the file, each decrypted and checked to its tag** — a stream's content is not + * authenticated until its end: + * 1. [essayer], writing nothing: everything checked and counted, for the summary — and the PIN vaults + * that will need a new PIN. + * 2. [restaurer], in ONE transaction, **rolled back** when the tag does not hold at the very end — what + * it wrote then is undone. The Keystore keys of the PIN vaults it creates are not in any transaction + * (R-11): created as their folder is, deleted if the transaction does not commit. + * + * Both passes decide through one [PlanDeRestauration]: the summary counts what the writing does. + */ +@Singleton +class RestaurationDeSauvegarde @Inject constructor( + @ApplicationContext private val context: Context, + private val notes: NotesRepository, + private val vaults: FolderVaultService, +) { + + /** A PIN vault of the backup that the restore creates: it needs a new PIN. */ + data class CoffreACode(val id: String, val nom: String) + + /** What a restore does — or, after [essayer], would do. */ + data class Resultat( + val dossiersCrees: Int, + val dossiersReutilises: Int, + val notesAjoutees: Int, + /** In clear and different from the phone's note of the same id: added under a new id. */ + val notesACote: Int, + val notesDejaLa: Int, + val notesIgnorees: Map, + /** Among the notes added, those that go back to the trash, with their date. */ + val notesALaCorbeille: Int, + val coffresACode: List, + ) + + class CodeManquantException(val dossierId: String) : + IllegalArgumentException("aucun code pour le coffre $dossierId") + + /** + * The trial pass. Throws an [IOException] when the file cannot be read with [secret]: a wrong + * secret and a damaged file look the same, both failing the tag or the content before it. + */ + suspend fun essayer(document: Uri, secret: String): Resultat = parcourir(document, secret, codes = null) + + /** + * The writing pass, [codes] the new PIN of each PIN vault [essayer] listed, by folder id. + * + * @throws CodeManquantException a PIN vault the restore creates has no PIN in [codes]. + */ + suspend fun restaurer(document: Uri, secret: String, codes: Map): Resultat = + parcourir(document, secret, codes) + + private suspend fun parcourir(document: Uri, secret: String, codes: Map?): Resultat = + withContext(Dispatchers.IO) { + val entree = context.contentResolver.openInputStream(document) + ?: throw IOException("sauvegarde impossible a ouvrir") + entree.use { brut -> + val borne = FichierDeSauvegarde.borne(brut) + // Before any derivation: a header version 1 does not write is refused here (R-10). + val (enTete, octetsDeLEnTete) = FichierDeSauvegarde.lireLEnTete(borne) + val octetsDuSecret = SecretDeSauvegarde.octets(secret) + val cle = try { + FichierDeSauvegarde.cle(octetsDuSecret, enTete) + } finally { + octetsDuSecret.wipe() + } + val passage = Passage(codes) + try { + FichierDeSauvegarde.ouvrir(borne, cle, enTete, octetsDeLEnTete).use { contenu -> + notes.restaurerDansUneTransaction { ecriture -> + LectureDuContenu.lire( + contenu, + surLesDossiers = { passage.surLesDossiers(ecriture, it) }, + surUneNote = { passage.surUneNote(ecriture, it) }, + ) + } + } + passage.resultat() + } catch (e: Throwable) { + // The transaction is rolled back: the keys its PIN vaults were sealed with go too. + withContext(NonCancellable) { passage.clesCreees.forEach { vaults.deletePinKey(it) } } + throw e + } finally { + cle.wipe() + passage.effacerLesCles() + } + } + } + + /** One pass over a file; [codes] `null` for the trial, which writes nothing. */ + private inner class Passage(private val codes: Map?) { + private lateinit var plan: PlanDeRestauration + private val clesDesCoffres = ArrayList() + val clesCreees = ArrayList() + + private var ajoutees = 0 + private var aCote = 0 + private var dejaLa = 0 + private var aLaCorbeille = 0 + private val ignorees = HashMap() + + suspend fun surLesDossiers( + ecriture: NotesRepository.EcritureDeRestauration, + dossiers: List, + ) { + dossiers.forEach { + (it.coffre as? CoffreSauvegarde.ACode)?.let { coffre -> + clesDesCoffres += + coffre.cleDuDossier + } + } + val telephone = ecriture.dossiersDuTelephone().associate { + it.id to + DossierDuTelephone( + it.id, + it.parentId, + estUnCoffre = it.vaultSalt != null, + verificateur = it.vaultVerifier, + ) + } + plan = PlanDeRestauration(dossiers, telephone) + val codes = codes ?: return + plan.coffresACodeACreer.forEach { if (it.id !in codes) throw CodeManquantException(it.id) } + for (aCreer in plan.aCreer) { + ecriture.dossier(versLEntite(aCreer, codes)) + } + } + + suspend fun surUneNote(ecriture: NotesRepository.EcritureDeRestauration, note: NoteSauvegardee) { + val existante = ecriture.noteDuTelephone(note.id)?.pourLaComparaison() + when (val decision = plan.decider(note, existante)) { + Decision.Ajoutee -> ajouter(ecriture, note, note.id).also { ajoutees++ } + // A new id: in clear only (the plan says so), whose sealing an id would not break. + Decision.ACote -> ajouter(ecriture, note, UUID.randomUUID().toString()).also { aCote++ } + Decision.DejaLa -> dejaLa++ + is Decision.Ignoree -> ignorees.merge(decision.raison, 1, Int::plus) + } + } + + private suspend fun ajouter( + ecriture: NotesRepository.EcritureDeRestauration, + note: NoteSauvegardee, + id: String, + ) { + if (note.misALaCorbeilleLe != null) aLaCorbeille++ + if (codes != null) ecriture.note(note.versLEntite(id)) + } + + private suspend fun versLEntite( + aCreer: PlanDeRestauration.DossierACreer, + codes: Map, + ): FolderEntity { + val d = aCreer.dossier + fun entite( + sel: ByteArray? = null, + cleEmballee: ByteArray? = null, + iv: ByteArray? = null, + verificateur: ByteArray? = null, + mode: String? = null, + blobDuCode: ByteArray? = null, + ivDuCode: ByteArray? = null, + ) = FolderEntity( + d.id, d.nom, aCreer.parentId, d.couleur, d.icone, d.creeLe, d.modifieLe, + sel, cleEmballee, iv, verificateur, mode, blobDuCode, ivDuCode, vaultAttempts = 0, + ) + return when (val coffre = d.coffre) { + null -> entite() + is CoffreSauvegarde.APhrase -> entite( + coffre.sel, + coffre.cleEmballee, + coffre.iv, + coffre.verificateur, + MODE_PHRASE, + ) + is CoffreSauvegarde.ACode -> { + val materiel = vaults.materielDUnCoffreACode(d.id, coffre.cleDuDossier, codes.getValue(d.id)) + clesCreees += d.id + entite( + sel = materiel.sel, + iv = materiel.iv, + verificateur = materiel.verificateur, + mode = MODE_CODE, + blobDuCode = materiel.blobDuCode, + ivDuCode = materiel.ivDuCode, + ) + } + } + } + + fun resultat() = Resultat( + dossiersCrees = plan.aCreer.size, + dossiersReutilises = plan.reutilises, + notesAjoutees = ajoutees, + notesACote = aCote, + notesDejaLa = dejaLa, + notesIgnorees = ignorees.toMap(), + notesALaCorbeille = aLaCorbeille, + coffresACode = plan.coffresACodeACreer.map { CoffreACode(it.id, it.nom) }, + ) + + /** The PIN vaults' folder keys read from the file: wiped once the pass is over, whatever its end. */ + fun effacerLesCles() = clesDesCoffres.forEach { it.wipe() } + } + + private fun NoteEntity.pourLaComparaison() = NoteDuTelephone( + dossierId = folderId, + titre = title, + contenu = content, + scelle = encryptedContent, + versionDeChiffrement = encVersion, + etiquettes = TagCodec.decode(tags), + epinglee = pinned, + favorite = favorite, + archivee = archived, + misALaCorbeilleLe = trashedAt, + couleur = colorId, + creeeLe = createdAt, + modifieeLe = updatedAt, + ) + + private fun NoteSauvegardee.versLEntite(id: String) = NoteEntity( + id = id, + title = titre, + content = contenu, + encryptedContent = scelle, + folderId = dossierId, + tags = TagCodec.encode(etiquettes), + pinned = epinglee, + favorite = favorite, + archived = archivee, + trashedAt = misALaCorbeilleLe, + createdAt = creeeLe, + updatedAt = modifieeLe, + encVersion = versionDeChiffrement, + colorId = couleur, + ) + + private companion object { + /** The `vault_mode` the vault service writes (`FolderDao.provisionPassphraseVault`, `provisionPinVault`). */ + const val MODE_PHRASE = "passphrase" + const val MODE_CODE = "pin" + } +} diff --git a/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/SauvegardeChiffree.kt b/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/SauvegardeChiffree.kt new file mode 100644 index 0000000..468d847 --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/SauvegardeChiffree.kt @@ -0,0 +1,286 @@ +package com.filestech.notes_tech.data.sauvegarde + +import android.content.Context +import android.net.Uri +import android.provider.DocumentsContract +import androidx.room.withTransaction +import com.filestech.notes_tech.core.crypto.wipe +import com.filestech.notes_tech.data.local.DatabaseProvider +import com.filestech.notes_tech.data.local.NotesDatabase +import com.filestech.notes_tech.data.local.entity.FolderEntity +import com.filestech.notes_tech.data.local.entity.NoteEntity +import com.filestech.notes_tech.data.local.mapper.TagCodec +import com.filestech.notes_tech.domain.model.VaultMode +import com.filestech.notes_tech.security.sauvegarde.FichierDeSauvegarde +import com.filestech.notes_tech.security.sauvegarde.SecretDeSauvegarde +import com.filestech.notes_tech.security.vault.VaultSessions +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.io.FilterOutputStream +import java.io.IOException +import java.io.OutputStream +import java.time.Clock +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.coroutineContext + +/** + * The encrypted backup's writing side (3.2.0, `docs/13-VERSION-3.2.md` D-029, D-030): every folder and + * every note, the trash included, into a `.ntbackup` document the user created where they wanted. + * + * - **One read transaction** for the whole content, notes read a page at a time and written as they + * come: a coherent picture, never all of the text in memory at once. + * - **Vaults travel unopened**: a passphrase vault's columns and its sealed blobs, as they are. A PIN + * vault only when open — its folder key enters the encrypted content, copied for the backup and + * wiped after (R-7); a closed one, and its notes, are left out, and [apercu] names it first. + * - 🔴 **Success is said only after the file is read back whole**, tag checked and content counted. + * On any failure the document is deleted when its provider allows it. + */ +@Singleton +class SauvegardeChiffree @Inject constructor( + @ApplicationContext private val context: Context, + private val databases: DatabaseProvider, + private val sessions: VaultSessions, + private val clock: Clock, +) { + + /** What a backup made now would hold — said before the user chooses a code. */ + data class Apercu( + val dossiers: Int, + val notes: Int, + /** Left out, with their notes: a PIN vault's key is sealed by this phone's Keystore. */ + val coffresACodeFermes: List, + /** Saved with their key: protected, in the file, by the backup's secret only. */ + val coffresACodeOuverts: List, + ) + + /** What was written, and read back. */ + data class Creation(val dossiers: Int, val notes: Int, val omises: Int) + + class SauvegardeTropGrosseException : IOException("sauvegarde au-dela de ${FichierDeSauvegarde.TAILLE_MAX} octets") + + class RelectureEchoueeException(cause: Throwable?) : IOException("la sauvegarde ecrite ne se relit pas", cause) + + suspend fun apercu(): Apercu = withContext(Dispatchers.IO) { + val database = databases.get() + database.withTransaction { + val dossiers = database.folderDao().listAll() + val (ouverts, fermes) = dossiers + .filter { it.mode() == VaultMode.PIN } + .partition { sessions.hasLiveSession(it.id) } + // The same rule as the writing's ([coffreDe]): what cannot travel is left out, here counted. + val omis = dossiers.filter { it.vaultSalt != null && !peutVoyager(it) }.map { it.id }.toSet() + var notes = 0 + parPages(database) { page -> notes += page.count { it.folderId !in omis } } + Apercu( + dossiers = dossiers.size - omis.size, + notes = notes, + coffresACodeFermes = fermes.map { it.name }, + coffresACodeOuverts = ouverts.map { it.name }, + ) + } + } + + /** + * Writes the backup into [document] — a NEW document (R-12) — under [secret], then reads it back. + * + * @throws SauvegardeTropGrosseException past [FichierDeSauvegarde.TAILLE_MAX]: nothing is kept. + * @throws RelectureEchoueeException the file written does not read back: nothing is kept. + */ + suspend fun creer(document: Uri, secret: String): Creation = withContext(Dispatchers.IO) { + val enTete = FichierDeSauvegarde.nouvelEnTete() + val octetsDuSecret = SecretDeSauvegarde.octets(secret) + val cle = try { + FichierDeSauvegarde.cle(octetsDuSecret, enTete) + } finally { + octetsDuSecret.wipe() + } + val clesDesCoffres = HashMap() + try { + val ecrite = ecrire(document, cle, enTete, clesDesCoffres) + relire(document, cle, ecrite) + ecrite + } catch (e: Throwable) { + supprimer(document) + throw e + } finally { + cle.wipe() + clesDesCoffres.values.forEach { it.wipe() } + } + } + + private suspend fun ecrire( + document: Uri, + cle: ByteArray, + enTete: FichierDeSauvegarde.EnTete, + clesDesCoffres: MutableMap, + ): Creation { + val database = databases.get() + val sortie = context.contentResolver.openOutputStream(document, "w") + ?: throw IOException("document de sauvegarde impossible a ouvrir") + var dossiers = 0 + var notes = 0 + var omises = 0 + bornee(sortie).use { fichier -> + FichierDeSauvegarde.ecrire(fichier, cle, enTete).use { chiffre -> + EcritureDuContenu(chiffre, clock.millis()).use { contenu -> + database.withTransaction { + val sauvegardes = HashSet() + for (dossier in database.folderDao().listAll()) { + val coffre = + coffreDe(dossier, clesDesCoffres) ?: if (dossier.vaultSalt != null) continue else null + contenu.dossier(dossier.versLaSauvegarde(coffre)) + sauvegardes += dossier.id + dossiers++ + } + parPages(database) { page -> + for (note in page) { + if (note.folderId in sauvegardes) { + contenu.note(note.versLaSauvegarde()) + notes++ + } else { + omises++ + } + } + } + } + } + } + } + return Creation(dossiers, notes, omises) + } + + /** + * A vault as it travels — or `null` for an ordinary folder, and for a vault that cannot travel: a + * PIN vault closed now, a vault whose columns make no known mode. The caller tells them apart. + */ + private fun coffreDe(dossier: FolderEntity, clesDesCoffres: MutableMap): CoffreSauvegarde? { + if (dossier.vaultSalt == null) return null + return when (dossier.mode()) { + VaultMode.PASSPHRASE -> phraseDe(dossier) + VaultMode.PIN -> sessions.sessionKey(dossier.id)?.let { cle -> + clesDesCoffres[dossier.id] = cle + CoffreSauvegarde.ACode(cle) + } + VaultMode.UNKNOWN -> null + } + } + + /** The file read back whole with the key it was written with: tag checked, folders and notes counted. */ + private suspend fun relire(document: Uri, cle: ByteArray, ecrite: Creation) { + val (dossiers, notes) = try { + val entree = context.contentResolver.openInputStream(document) + ?: throw IOException("sauvegarde ecrite impossible a rouvrir") + entree.use { + val borne = FichierDeSauvegarde.borne(it) + val (enTete, octetsDeLEnTete) = FichierDeSauvegarde.lireLEnTete(borne) + var dossiers = 0 + var notes = 0 + FichierDeSauvegarde.ouvrir(borne, cle, enTete, octetsDeLEnTete).use { contenu -> + LectureDuContenu.lire(contenu, surLesDossiers = { dossiers = it.size }, surUneNote = { notes++ }) + } + dossiers to notes + } + } catch (e: IOException) { + throw RelectureEchoueeException(e) + } + coroutineContext.ensureActive() + if (dossiers != ecrite.dossiers || notes != ecrite.notes) throw RelectureEchoueeException(null) + } + + private fun supprimer(document: Uri) { + try { + DocumentsContract.deleteDocument(context.contentResolver, document) + } catch (e: Exception) { + // Not every provider deletes; the screen says the backup failed either way. + Timber.w(e, "sauvegarde ratee non supprimee") + } + } + + /** [sortie], refused past [FichierDeSauvegarde.TAILLE_MAX] bytes written. */ + private fun bornee(sortie: OutputStream): OutputStream = object : FilterOutputStream(sortie) { + private var ecrits = 0L + + override fun write(b: Int) { + compter(1) + out.write(b) + } + + override fun write(b: ByteArray, off: Int, len: Int) { + compter(len) + out.write(b, off, len) + } + + private fun compter(n: Int) { + ecrits += n + if (ecrits > FichierDeSauvegarde.TAILLE_MAX) throw SauvegardeTropGrosseException() + } + } + + private suspend inline fun parPages(database: NotesDatabase, action: (List) -> Unit) { + var decalage = 0 + while (true) { + coroutineContext.ensureActive() + val page = database.sauvegardeDao().pageDeNotes(PAGE, decalage) + if (page.isEmpty()) return + action(page) + decalage += page.size + } + } + + private fun FolderEntity.mode(): VaultMode = VaultMode.fromMaterial(vaultKekWrapped, vaultPinBlob, vaultPinIv) + + /** A vault the backup can carry now — what [coffreDe] answers, without copying a key. */ + private fun peutVoyager(dossier: FolderEntity): Boolean = when (dossier.mode()) { + VaultMode.PASSPHRASE -> phraseDe(dossier) != null + VaultMode.PIN -> sessions.hasLiveSession(dossier.id) + VaultMode.UNKNOWN -> false + } + + /** + * A passphrase vault's columns, all of them — or `null`: a column missing is a vault no phrase + * opens anywhere, left out rather than the backup failed. + */ + private fun phraseDe(dossier: FolderEntity): CoffreSauvegarde.APhrase? { + val sel = dossier.vaultSalt ?: return null + val cle = dossier.vaultKekWrapped ?: return null + return dossier.vaultIv?.let { iv -> + dossier.vaultVerifier?.let { verificateur -> CoffreSauvegarde.APhrase(sel, cle, iv, verificateur) } + } + } + + private fun FolderEntity.versLaSauvegarde(coffre: CoffreSauvegarde?) = DossierSauvegarde( + id = id, + nom = name, + parentId = parentId, + couleur = color, + icone = icon, + creeLe = createdAt, + modifieLe = updatedAt, + coffre = coffre, + ) + + private fun NoteEntity.versLaSauvegarde() = NoteSauvegardee( + id = id, + dossierId = folderId, + titre = title, + contenu = content, + scelle = encryptedContent, + versionDeChiffrement = encVersion, + etiquettes = TagCodec.decode(tags), + epinglee = pinned, + favorite = favorite, + archivee = archived, + misALaCorbeilleLe = trashedAt, + couleur = colorId, + creeeLe = createdAt, + modifieeLe = updatedAt, + ) + + private companion object { + const val PAGE = 200 + } +} diff --git a/app/src/main/java/com/filestech/notes_tech/security/vault/FolderVaultService.kt b/app/src/main/java/com/filestech/notes_tech/security/vault/FolderVaultService.kt index 4d8f6d5..4ae8796 100644 --- a/app/src/main/java/com/filestech/notes_tech/security/vault/FolderVaultService.kt +++ b/app/src/main/java/com/filestech/notes_tech/security/vault/FolderVaultService.kt @@ -158,18 +158,7 @@ class FolderVaultService @Inject constructor( // ⚠️ After the derivation and inside this `try` — same reasons as in [createPassphraseVault]. val folderKey = VaultCrypto.newFolderKey() try { - val inner = VaultCrypto.seal(pinKey, nonce, folderKey, folderId.toByteArray(Charsets.UTF_8)) - // ⚠️ Wiped on every path, a failing Keystore included. `inner` is the folder key - // under a key derived from a 4-6 digit PIN, whose salt is in the database: only - // the Keystore layer stops an offline search of the PIN. Left in memory, it gave - // the folder key to whoever read the salt (found with the GPT-5.6 review, same day). - val sealed = try { - keystore.deleteKey(alias) - keystore.createKey(alias) - keystore.seal(alias, inner) - } finally { - inner.wipe() - } + val sealed = scellerSousUnCode(folderId, folderKey, pinKey, nonce) val provisioned = databases.get().folderDao().provisionPinVault( id = folderId, @@ -197,6 +186,70 @@ class FolderVaultService @Inject constructor( } } + /** + * The folder key under a key derived from a PIN, sealed again by a NEW Keystore key of this + * phone — the two inner layers of a PIN vault (see the class). One place for [createPinVault] and + * for the restore of a backup ([materielDUnCoffreACode]). + * + * ⚠️ `inner` is wiped on every path, a failing Keystore included: it is the folder key under a key + * derived from a 4-6 digit PIN, whose salt is in the database — only the Keystore layer stops an + * offline search of the PIN. Left in memory, it gave the folder key to whoever read the salt (found + * with the GPT-5.6 review, 2026-09-25). + */ + private suspend fun scellerSousUnCode( + folderId: String, + folderKey: ByteArray, + pinKey: ByteArray, + nonce: ByteArray, + ): SealedByKeystore { + val alias = VaultParams.pinKeystoreAlias(folderId) + val inner = VaultCrypto.seal(pinKey, nonce, folderKey, folderId.toByteArray(Charsets.UTF_8)) + return try { + keystore.deleteKey(alias) + keystore.createKey(alias) + keystore.seal(alias, inner) + } finally { + inner.wipe() + } + } + + /** A PIN vault's columns for a key that already exists — see [materielDUnCoffreACode]. */ + class MaterielDUnCoffreACode( + val sel: ByteArray, + val iv: ByteArray, + val verificateur: ByteArray, + val blobDuCode: ByteArray, + val ivDuCode: ByteArray, + ) + + /** + * The columns of a PIN vault holding [folderKey] under [pin] — the restore of a backup (3.2.0, + * D-030): a PIN vault comes back with a PIN chosen then, a new salt, a new nonce and a new Keystore + * key, its verifier computed from its key. Nothing is written in the database here. + * + * 🔴 **The Keystore key it creates is not in any transaction** (R-11): the caller deletes it + * ([deletePinKey]) if what it writes next fails. Only for a folder the phone does not have: never + * the key of a vault already there. + */ + suspend fun materielDUnCoffreACode(folderId: String, folderKey: ByteArray, pin: String): MaterielDUnCoffreACode { + requireValidPin(pin) + val salt = VaultCrypto.newSalt() + val nonce = VaultCrypto.newNonce() + val pinKey = VaultCrypto.withUtf8Bytes(pin) { VaultCrypto.derivePinKey(it, salt) } + try { + val sealed = scellerSousUnCode(folderId, folderKey, pinKey, nonce) + return MaterielDUnCoffreACode( + sel = salt, + iv = nonce, + verificateur = VaultCrypto.verifierFor(folderKey), + blobDuCode = sealed.ciphertext, + ivDuCode = sealed.nonce, + ) + } finally { + pinKey.wipe() + } + } + // ── Déverrouillage ─────────────────────────────────────────────────────────────────────────── /** diff --git a/app/src/test/java/com/filestech/notes_tech/data/sauvegarde/PlanDeRestaurationTest.kt b/app/src/test/java/com/filestech/notes_tech/data/sauvegarde/PlanDeRestaurationTest.kt new file mode 100644 index 0000000..f1c61a3 --- /dev/null +++ b/app/src/test/java/com/filestech/notes_tech/data/sauvegarde/PlanDeRestaurationTest.kt @@ -0,0 +1,154 @@ +package com.filestech.notes_tech.data.sauvegarde + +import com.filestech.notes_tech.data.sauvegarde.PlanDeRestauration.Decision +import com.filestech.notes_tech.data.sauvegarde.PlanDeRestauration.DossierDuTelephone +import com.filestech.notes_tech.data.sauvegarde.RaisonDIgnorer.AUTRE_COFFRE +import com.filestech.notes_tech.data.sauvegarde.RaisonDIgnorer.CONFLIT_SCELLE +import com.filestech.notes_tech.data.sauvegarde.RaisonDIgnorer.DOSSIER_ABSENT +import com.filestech.notes_tech.data.sauvegarde.RaisonDIgnorer.EN_CLAIR_DANS_UN_COFFRE +import com.filestech.notes_tech.data.sauvegarde.RaisonDIgnorer.SCELLEE_HORS_COFFRE +import com.filestech.notes_tech.security.vault.VaultCrypto +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** What a restore does with each folder and note (D-031): the rules a forged backup meets. */ +class PlanDeRestaurationTest { + + private val cleDuCoffre = ByteArray(32) { 3 } + private val autreCle = ByteArray(32) { 4 } + private val verificateur = VaultCrypto.verifierFor(cleDuCoffre) + + private fun dossier(id: String, parent: String? = null, coffre: CoffreSauvegarde? = null) = + DossierSauvegarde(id, "Dossier $id", parent, null, null, 1L, 2L, coffre) + + private fun phrase(verif: ByteArray) = CoffreSauvegarde.APhrase(ByteArray(16), ByteArray(48), ByteArray(12), verif) + + private fun telephone( + id: String, + parent: String? = null, + coffre: ByteArray? = null, + estUnCoffre: Boolean = coffre != null, + ) = id to DossierDuTelephone(id, parent, estUnCoffre, coffre) + + private fun note(id: String, dossier: String, scelle: ByteArray? = null, titre: String = "T") = NoteSauvegardee( + id = id, + dossierId = dossier, + titre = titre, + contenu = "C", + scelle = scelle, + versionDeChiffrement = 1, + etiquettes = listOf("a"), + epinglee = false, + favorite = false, + archivee = false, + misALaCorbeilleLe = null, + couleur = null, + creeeLe = 1L, + modifieeLe = 2L, + ) + + private fun surLeTelephone(n: NoteSauvegardee, titre: String = n.titre) = NoteDuTelephone( + n.dossierId, titre, n.contenu, n.scelle, n.versionDeChiffrement, n.etiquettes, n.epinglee, n.favorite, + n.archivee, n.misALaCorbeilleLe, n.couleur, n.creeeLe, n.modifieeLe, + ) + + @Test + fun `a folder the phone has is reused, others are created parents first`() { + val plan = PlanDeRestauration( + listOf( + dossier("enfant", "parent"), + dossier("inbox"), + dossier("parent", "inbox"), + dossier("orphelin", "absent"), + ), + mapOf(telephone("inbox")), + ) + + assertThat(plan.reutilises).isEqualTo(1) + assertThat(plan.aCreer.map { it.dossier.id to it.parentId }) + .containsExactly("parent" to "inbox", "orphelin" to null, "enfant" to "parent").inOrder() + } + + /** R-6: a folder its own parent, a cycle, a tree too deep — the folder goes to the top level. */ + @Test + fun `a parent that would close a cycle or go too deep is dropped`() { + val profond = (1..40).map { dossier("p$it", if (it == 1) null else "p${it - 1}") } + val plan = PlanDeRestauration( + listOf(dossier("soi", "soi"), dossier("a", "b"), dossier("b", "a")) + profond, + emptyMap(), + ) + val parents = plan.aCreer.associate { it.dossier.id to it.parentId } + + assertThat(parents["soi"]).isNull() + assertThat(listOf(parents["a"], parents["b"])).containsExactly(null, "a") + // The folder that would go past the bound starts a new tree; the ones under it follow it. + assertThat(parents["p34"]).isNull() + assertThat(parents["p40"]).isEqualTo("p39") + fun profondeur(id: String): Int = parents[id]?.let { 1 + profondeur(it) } ?: 1 + assertThat(profond.maxOf { profondeur(it.id) }).isAtMost(33) + } + + @Test + fun `a forged backup repeating a folder id is read by its first description`() { + val plan = PlanDeRestauration(listOf(dossier("x"), dossier("x", coffre = phrase(verificateur))), emptyMap()) + + assertThat(plan.aCreer).hasSize(1) + assertThat(plan.decider(note("n", "x"), null)).isEqualTo(Decision.Ajoutee) + } + + /** 🔴 R-4: each note against the folder it REALLY goes to. */ + @Test + fun `a note goes in clear into an ordinary folder and sealed into its own vault, nowhere else`() { + val plan = PlanDeRestauration( + listOf( + dossier("ordinaire"), + dossier("coffre", coffre = phrase(verificateur)), + dossier("autre", coffre = phrase(verificateur)), + dossier("nouveau", coffre = CoffreSauvegarde.ACode(cleDuCoffre)), + ), + mapOf( + telephone("ordinaire"), + telephone("coffre", coffre = verificateur), + telephone("autre", coffre = VaultCrypto.verifierFor(autreCle)), + ), + ) + val blob = ByteArray(40) + + assertThat(plan.decider(note("1", "ordinaire"), null)).isEqualTo(Decision.Ajoutee) + assertThat(plan.decider(note("2", "ordinaire", blob), null)).isEqualTo(Decision.Ignoree(SCELLEE_HORS_COFFRE)) + assertThat(plan.decider(note("3", "coffre", blob), null)).isEqualTo(Decision.Ajoutee) + assertThat(plan.decider(note("4", "coffre"), null)).isEqualTo(Decision.Ignoree(EN_CLAIR_DANS_UN_COFFRE)) + assertThat(plan.decider(note("5", "autre", blob), null)).isEqualTo(Decision.Ignoree(AUTRE_COFFRE)) + assertThat(plan.decider(note("6", "nouveau", blob), null)).isEqualTo(Decision.Ajoutee) + assertThat(plan.decider(note("7", "nulle-part"), null)).isEqualTo(Decision.Ignoree(DOSSIER_ABSENT)) + assertThat(plan.coffresACodeACreer.map { it.id }).containsExactly("nouveau") + } + + /** 🔴 A vault of the phone with no verifier is still a vault: it takes nothing, in clear least of all. */ + @Test + fun `a vault the phone cannot check takes no note at all`() { + val plan = PlanDeRestauration( + listOf(dossier("abime", coffre = phrase(verificateur))), + mapOf(telephone("abime", coffre = null, estUnCoffre = true)), + ) + + assertThat(plan.decider(note("1", "abime"), null)).isEqualTo(Decision.Ignoree(EN_CLAIR_DANS_UN_COFFRE)) + assertThat(plan.decider(note("2", "abime", ByteArray(40)), null)).isEqualTo(Decision.Ignoree(AUTRE_COFFRE)) + } + + /** R-3: nothing is overwritten. */ + @Test + fun `a note the phone has is left, added beside it, or ignored when sealed`() { + val plan = PlanDeRestauration( + listOf(dossier("ordinaire"), dossier("coffre", coffre = phrase(verificateur))), + mapOf(telephone("ordinaire"), telephone("coffre", coffre = verificateur)), + ) + val claire = note("1", "ordinaire") + val scellee = note("2", "coffre", ByteArray(40)) + + assertThat(plan.decider(claire, surLeTelephone(claire))).isEqualTo(Decision.DejaLa) + assertThat(plan.decider(claire, surLeTelephone(claire, titre = "Autre"))).isEqualTo(Decision.ACote) + assertThat(plan.decider(scellee, surLeTelephone(scellee, titre = "Autre"))) + .isEqualTo(Decision.Ignoree(CONFLIT_SCELLE)) + } +} From 27b3f1a87a4265f80028fe4a4213d39e0c6eb0c0 Mon Sep 17 00:00:00 2001 From: Patrice Haltaya Date: Thu, 29 Oct 2026 20:24:45 +0100 Subject: [PATCH 09/16] feat(backup): the Settings' encrypted backup, written before the picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The screens of the encrypted backup (D-029 to D-031 of docs/13-VERSION-3.2.md), in a section of their own after "Export and import", and what testing them on a S9 and an emulator turned up. - 🔴 Written BEFORE the system picker opens. The picker sends the app to the background, where every vault locks (NotesTechApplication, the main protection, left as it is). Written on the way back, the backup left out the open PIN vault the preview had just named: 294 bytes, "0 notes" (seen on a S9). SauvegardeChiffree.preparer now encrypts and reads the backup back into cache/sauvegarde/ while the vaults are as the preview saw them; deposer copies it into the chosen document and reads that back against it (same SHA-256). While the picker is open only that encrypted file waits: neither the secret nor a vault key is kept. It is deleted on every way out, and otherwise by the startup purge and the panic (ClairDuCache, the exports' definition). A process killed while the picker is open leaves no empty document behind. - Restore: the file is examined before any secret (size given by the provider, header, version): not a backup, too large or too new is refused at once. A wrong code is asked again in place, the file kept. - Every dialog bears the title of the row that opened it, waiting and failure included; the summary is in the future before, in the past after; closed and open PIN vaults are counted plurals; what a backup left out is said again at the end. The code shows as two lines of three groups, typed back case-, dash- and O/0-insensitive. - AppResultLauncher.launch returns false instead of letting ActivityNotFoundException crash the app when no documents app exists; the four pickers say so (both backup flows, the import, the voice model). - French texts say "passphrase", as the rest of the app does. - Privacy policy and terms (5 languages): the backup file, its cache copy, that panic mode does not reach it, and a forgotten backup code. Tests: SauvegardeChiffreeTest 4 -> 7 (a PIN vault locked between preparer and deposer keeps its notes: red when locked before, as the old code did; everything lost, a passphrase vault included, comes back; the examination); ClairDuCacheTest +1. On a S9: PIN vault closed (named, then said left out), open (1 note saved), deleted, restored with a new PIN and reopened with it; wrong code asked again. On an emulator: code and passphrase paths, rotation, Cancel, Back in both pickers, process killed during the picker, restore on fresh data, a note modified since (copy beside), truncated / newer / 65 MiB / ZIP files, no documents app. Co-Authored-By: Claude Opus 5.5 --- .../data/sauvegarde/SauvegardeChiffreeTest.kt | 82 +++- .../security/panic/ClairDuCacheTest.kt | 12 + .../sauvegarde/RestaurationDeSauvegarde.kt | 27 ++ .../data/sauvegarde/SauvegardeChiffree.kt | 121 ++++- .../notes_tech/security/panic/ClairDuCache.kt | 6 +- .../notes_tech/ui/common/AppResultLauncher.kt | 14 +- .../notes_tech/ui/settings/SauvegardeUi.kt | 420 ++++++++++++++++++ .../ui/settings/SauvegardeViewModel.kt | 210 +++++++++ .../notes_tech/ui/settings/SettingsScreen.kt | 12 +- .../notes_tech/ui/vault/VaultSheets.kt | 15 +- .../notes_tech/ui/voice/VoiceSetupScreen.kt | 9 +- app/src/main/res/raw-de/privacy.md | 8 +- app/src/main/res/raw-de/terms.md | 8 +- app/src/main/res/raw-es/privacy.md | 8 +- app/src/main/res/raw-es/terms.md | 8 +- app/src/main/res/raw-fr/privacy.md | 8 +- app/src/main/res/raw-fr/terms.md | 8 +- app/src/main/res/raw-it/privacy.md | 8 +- app/src/main/res/raw-it/terms.md | 8 +- app/src/main/res/raw/privacy.md | 8 +- app/src/main/res/raw/terms.md | 8 +- app/src/main/res/values-de/strings.xml | 89 ++++ app/src/main/res/values-es/strings.xml | 102 +++++ app/src/main/res/values-fr/strings.xml | 102 +++++ app/src/main/res/values-it/strings.xml | 102 +++++ app/src/main/res/values/strings.xml | 89 ++++ docs/13-VERSION-3.2.md | 28 +- 27 files changed, 1462 insertions(+), 58 deletions(-) create mode 100644 app/src/main/java/com/filestech/notes_tech/ui/settings/SauvegardeUi.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/ui/settings/SauvegardeViewModel.kt diff --git a/app/src/androidTest/java/com/filestech/notes_tech/data/sauvegarde/SauvegardeChiffreeTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/data/sauvegarde/SauvegardeChiffreeTest.kt index 06f27cc..dbbc707 100644 --- a/app/src/androidTest/java/com/filestech/notes_tech/data/sauvegarde/SauvegardeChiffreeTest.kt +++ b/app/src/androidTest/java/com/filestech/notes_tech/data/sauvegarde/SauvegardeChiffreeTest.kt @@ -14,6 +14,7 @@ import com.filestech.notes_tech.data.repository.FoldersRepository import com.filestech.notes_tech.data.repository.NotesRepository import com.filestech.notes_tech.security.kek.KekRepository import com.filestech.notes_tech.security.kek.WritableKekSource +import com.filestech.notes_tech.security.sauvegarde.FichierDeSauvegarde import com.filestech.notes_tech.security.vault.FolderVaultService import com.filestech.notes_tech.security.vault.KeystoreEnMemoire import com.filestech.notes_tech.security.vault.MonotonicClock @@ -103,6 +104,9 @@ class SauvegardeChiffreeTest { private val document: Uri get() = Uri.fromFile(fichier) + /** A backup made as the screen makes it: written first, then put into the document. */ + private suspend fun creer(): SauvegardeChiffree.Creation = sauvegarde.deposer(sauvegarde.preparer(CODE), document) + /** A PIN vault, open, holding one note sealed under its key. */ private suspend fun unCoffreACode(): Pair { val dossier = dossiers.create("Codes") @@ -121,7 +125,7 @@ class SauvegardeChiffreeTest { content = "pain", ) - val creee = sauvegarde.creer(document, CODE) + val creee = creer() assertThat(creee.omises).isEqualTo(0) // Lost: a note in clear, and the PIN vault with its note and its Keystore key. @@ -153,7 +157,7 @@ class SauvegardeChiffreeTest { @Test fun un_secret_faux_ou_une_fin_de_fichier_changee_ne_restaurent_rien(): Unit = runBlocking { val (coffre, _) = unCoffreACode() - sauvegarde.creer(document, CODE) + creer() sessions.lock(coffre) dossiers.delete(coffre) coffres.deletePinKey(coffre) @@ -175,7 +179,7 @@ class SauvegardeChiffreeTest { @Test fun restaurer_deux_fois_n_ajoute_rien_la_seconde(): Unit = runBlocking { notes.create(folderId = LegacyDatabaseFixture.Fixtures.FOLDER_WORK, title = "Courses", content = "pain") - sauvegarde.creer(document, CODE) + creer() val avant = notes.listAllAlive().size val fait = restauration.restaurer(document, CODE, emptyMap()) @@ -193,15 +197,85 @@ class SauvegardeChiffreeTest { sessions.lock(coffre) val apercu = sauvegarde.apercu() - val creee = sauvegarde.creer(document, CODE) + val creee = creer() assertThat(apercu.coffresACodeFermes).containsExactly("Codes") assertThat(creee.omises).isEqualTo(1) assertThat(restauration.essayer(document, CODE).coffresACode).isEmpty() } + /** + * 🔴 The system picker sends the app to the background, where every vault locks: the backup is + * written before it, and an open PIN vault locked in between keeps its notes in the file. Written + * after the picker, the backup of a S9 held none (2026-10-29). + */ + @Test + fun un_coffre_a_code_ferme_pendant_le_selecteur_garde_ses_notes_dans_la_sauvegarde(): Unit = runBlocking { + val (coffre, _) = unCoffreACode() + + val preparee = sauvegarde.preparer(CODE) + sessions.lockAll() // what the picker does to the app + val creee = sauvegarde.deposer(preparee, document) + + assertThat(creee.omises).isEqualTo(0) + assertThat(preparee.fichier.exists()).isFalse() + dossiers.delete(coffre) + coffres.deletePinKey(coffre) + val essai = restauration.essayer(document, CODE) + assertThat(essai.coffresACode.map { it.id }).containsExactly(coffre) + assertThat(essai.notesAjoutees).isEqualTo(1) + } + + /** + * Everything lost, as on a new phone: a passphrase vault and its sealed note, a subfolder and its + * note in the trash, a PIN vault — all back, the passphrase vault opening with its own passphrase. + */ + @Test + fun ce_qui_est_perdu_revient_coffre_a_passphrase_sous_dossier_et_corbeille_compris(): Unit = runBlocking { + val parent = dossiers.create("Projets") + val enfant = dossiers.create("2026", parentId = parent.id) + val jetee = notes.create(folderId = enfant.id, title = "Brouillon", content = "à jeter") + notes.moveToTrash(jetee.id) + val journal = dossiers.create("Journal") + coffres.createPassphraseVault(journal.id, PASSPHRASE) + val intime = notes.create(folderId = journal.id, title = "Lundi", content = "rien de neuf") + val (coffre, _) = unCoffreACode() + assertThat(creer().omises).isEqualTo(0) + + sessions.lockAll() + listOf(enfant.id, parent.id, journal.id, coffre).forEach { dossiers.delete(it) } + coffres.deletePinKey(coffre) + val fait = restauration.restaurer(document, CODE, mapOf(coffre to NOUVEAU_CODE)) + + assertThat(fait.dossiersCrees).isEqualTo(4) + assertThat(fait.notesIgnorees).isEmpty() + assertThat(fait.notesALaCorbeille).isEqualTo(1) + assertThat(dossiers.find(enfant.id)?.parentId).isEqualTo(parent.id) + assertThat(notes.find(jetee.id)?.isTrashed).isTrue() + coffres.unlockWithPassphrase(journal.id, PASSPHRASE) + assertThat(coffres.decrypt(requireNotNull(notes.find(intime.id))).content).isEqualTo("rien de neuf") + } + + /** Before any secret is asked: what is not a backup, or one of a newer version, is refused as such. */ + @Test + fun l_examen_refuse_sans_secret_ce_qui_n_est_pas_une_sauvegarde_lisible_ici(): Unit = runBlocking { + creer() + val vraie = fichier.readBytes() + suspend fun refus(octets: ByteArray): FichierDeSauvegarde.Refus? { + fichier.writeBytes(octets) + val echec = runCatching { restauration.examiner(document) }.exceptionOrNull() + return (echec as? FichierDeSauvegarde.SauvegardeRefuseeException)?.refus + } + + assertThat(refus(vraie)).isNull() + assertThat(refus(ByteArray(100) { 'x'.code.toByte() })).isEqualTo(FichierDeSauvegarde.Refus.PAS_UNE_SAUVEGARDE) + assertThat(refus(vraie.copyOf(10))).isEqualTo(FichierDeSauvegarde.Refus.PAS_UNE_SAUVEGARDE) + assertThat(refus(vraie.copyOf().also { it[9] = 2 })).isEqualTo(FichierDeSauvegarde.Refus.VERSION_INCONNUE) + } + private companion object { const val CODE = "K7QM-T2XB-0R1A-ZZ9P-HHJK-MN4V" + const val PASSPHRASE = "un jardin bleu sous la pluie" const val CODE_DU_COFFRE = "1234" const val NOUVEAU_CODE = "5678" const val AUTRE_CODE = "AUTR-ECOD-E000-0000-0000-0000" diff --git a/app/src/androidTest/java/com/filestech/notes_tech/security/panic/ClairDuCacheTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/security/panic/ClairDuCacheTest.kt index 06b69b3..8dd7263 100644 --- a/app/src/androidTest/java/com/filestech/notes_tech/security/panic/ClairDuCacheTest.kt +++ b/app/src/androidTest/java/com/filestech/notes_tech/security/panic/ClairDuCacheTest.kt @@ -3,6 +3,7 @@ package com.filestech.notes_tech.security.panic import android.content.Context import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry +import com.filestech.notes_tech.data.sauvegarde.SauvegardeChiffree import com.google.common.truth.Truth.assertThat import org.junit.After import org.junit.Before @@ -59,6 +60,17 @@ class ClairDuCacheTest { neutres.forEach { assertThat(it.exists()).isTrue() } } + /** 3.2.0: a backup left waiting for the picker's document by a process killed meanwhile. */ + @Test + fun a_backup_left_waiting_for_its_document_is_purged() { + val enAttente = File(SauvegardeChiffree.repertoireDAttente(context), "sauvegarde1.ntbackup") + enAttente.parentFile?.mkdirs() + enAttente.writeBytes(ByteArray(48)) + + assertThat(ClairDuCache.purger(context)).isEmpty() + assertThat(enAttente.exists()).isFalse() + } + @Test fun the_definition_names_the_share_plus_directory_by_its_own_name() { assertThat(ClairDuCache.estUnArtefactSensible(context, "share_plus")).isTrue() diff --git a/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/RestaurationDeSauvegarde.kt b/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/RestaurationDeSauvegarde.kt index 98b8fc4..b95bd92 100644 --- a/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/RestaurationDeSauvegarde.kt +++ b/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/RestaurationDeSauvegarde.kt @@ -2,6 +2,7 @@ package com.filestech.notes_tech.data.sauvegarde import android.content.Context import android.net.Uri +import android.provider.OpenableColumns import com.filestech.notes_tech.core.crypto.wipe import com.filestech.notes_tech.data.local.entity.FolderEntity import com.filestech.notes_tech.data.local.entity.NoteEntity @@ -62,6 +63,32 @@ class RestaurationDeSauvegarde @Inject constructor( class CodeManquantException(val dossierId: String) : IllegalArgumentException("aucun code pour le coffre $dossierId") + /** + * What can be refused before any secret is asked: a file too large by the size its provider gives, + * and a header that is not a backup's, or one of a newer version. Nothing is decrypted. + * + * @throws FichierDeSauvegarde.SauvegardeRefuseeException the file is refused, and why. + */ + suspend fun examiner(document: Uri) = withContext(Dispatchers.IO) { + val taille = context.contentResolver.query(document, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { + if (it.moveToFirst() && !it.isNull(0)) it.getLong(0) else null + } + // A size the provider does not give is not refused here: the reading counts what it reads. + if (taille != null && taille > FichierDeSauvegarde.TAILLE_MAX) { + throw FichierDeSauvegarde.SauvegardeRefuseeException(FichierDeSauvegarde.Refus.TROP_GROS) + } + val entree = context.contentResolver.openInputStream(document) + ?: throw IOException("sauvegarde impossible a ouvrir") + try { + entree.use { FichierDeSauvegarde.lireLEnTete(it) } + } catch (e: FichierDeSauvegarde.SauvegardeRefuseeException) { + // Shorter than a header: not a backup — no secret could be at fault yet. + if (e.refus != FichierDeSauvegarde.Refus.TRONQUE) throw e + throw FichierDeSauvegarde.SauvegardeRefuseeException(FichierDeSauvegarde.Refus.PAS_UNE_SAUVEGARDE) + } + Unit + } + /** * The trial pass. Throws an [IOException] when the file cannot be read with [secret]: a wrong * secret and a damaged file look the same, both failing the tag or the content before it. diff --git a/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/SauvegardeChiffree.kt b/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/SauvegardeChiffree.kt index 468d847..43d66f2 100644 --- a/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/SauvegardeChiffree.kt +++ b/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/SauvegardeChiffree.kt @@ -19,9 +19,14 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext import timber.log.Timber +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream import java.io.FilterOutputStream import java.io.IOException +import java.io.InputStream import java.io.OutputStream +import java.security.MessageDigest import java.time.Clock import javax.inject.Inject import javax.inject.Singleton @@ -36,8 +41,13 @@ import kotlin.coroutines.coroutineContext * - **Vaults travel unopened**: a passphrase vault's columns and its sealed blobs, as they are. A PIN * vault only when open — its folder key enters the encrypted content, copied for the backup and * wiped after (R-7); a closed one, and its notes, are left out, and [apercu] names it first. - * - 🔴 **Success is said only after the file is read back whole**, tag checked and content counted. - * On any failure the document is deleted when its provider allows it. + * - 🔴 **Written before the system picker opens** ([preparer]), into the app's cache, then copied into + * the document ([deposer]). The picker sends the app to the background, and every vault locks + * there (`NotesTechApplication`): written after it, the open PIN vault the preview had named was a + * closed one, left out with its notes — a backup of nothing (seen on a S9, 2026-10-29). + * - 🔴 **Success is said only after the file is read back whole**, tag checked and content counted, + * then the document read back against it. On any failure the document is deleted when its provider + * allows it. */ @Singleton class SauvegardeChiffree @Inject constructor( @@ -60,6 +70,12 @@ class SauvegardeChiffree @Inject constructor( /** What was written, and read back. */ data class Creation(val dossiers: Int, val notes: Int, val omises: Int) + /** + * A backup written and read back in [repertoireDAttente], waiting for the document the user picks. + * Encrypted: while the picker is open, neither the secret nor a vault key is held anywhere. + */ + class Preparee internal constructor(internal val fichier: File, val creation: Creation) + class SauvegardeTropGrosseException : IOException("sauvegarde au-dela de ${FichierDeSauvegarde.TAILLE_MAX} octets") class RelectureEchoueeException(cause: Throwable?) : IOException("la sauvegarde ecrite ne se relit pas", cause) @@ -85,12 +101,19 @@ class SauvegardeChiffree @Inject constructor( } /** - * Writes the backup into [document] — a NEW document (R-12) — under [secret], then reads it back. + * Writes the backup under [secret] into the app's cache, while the vaults are as the preview saw + * them, then reads it back whole. * * @throws SauvegardeTropGrosseException past [FichierDeSauvegarde.TAILLE_MAX]: nothing is kept. * @throws RelectureEchoueeException the file written does not read back: nothing is kept. */ - suspend fun creer(document: Uri, secret: String): Creation = withContext(Dispatchers.IO) { + suspend fun preparer(secret: String): Preparee = withContext(Dispatchers.IO) { + val repertoire = repertoireDAttente(context) + val pret = repertoire.isDirectory || repertoire.mkdirs() + if (!pret) { + throw IOException("attente de sauvegarde impossible a creer") + } + val fichier = File.createTempFile("sauvegarde", ".ntbackup", repertoire) val enTete = FichierDeSauvegarde.nouvelEnTete() val octetsDuSecret = SecretDeSauvegarde.octets(secret) val cle = try { @@ -100,11 +123,11 @@ class SauvegardeChiffree @Inject constructor( } val clesDesCoffres = HashMap() try { - val ecrite = ecrire(document, cle, enTete, clesDesCoffres) - relire(document, cle, ecrite) - ecrite + val ecrite = ecrire(FileOutputStream(fichier), cle, enTete, clesDesCoffres) + relire(cle, ecrite) { FileInputStream(fichier) } + Preparee(fichier, ecrite) } catch (e: Throwable) { - supprimer(document) + fichier.delete() throw e } finally { cle.wipe() @@ -112,19 +135,57 @@ class SauvegardeChiffree @Inject constructor( } } + /** + * Copies [preparee] into [document] — a NEW document (R-12) — then reads the document back against + * it: same SHA-256, so the document holds what [preparer] read back. The waiting file is gone + * after, whatever happens. + * + * @throws RelectureEchoueeException the document does not hold those bytes: it is deleted. + */ + suspend fun deposer(preparee: Preparee, document: Uri): Creation = withContext(Dispatchers.IO) { + try { + val sortie = context.contentResolver.openOutputStream(document, "w") + ?: throw IOException("document de sauvegarde impossible a ouvrir") + val ecrite = sortie.use { FileInputStream(preparee.fichier).use { entree -> empreinte(entree, it) } } + val relue = try { + val entree = context.contentResolver.openInputStream(document) + ?: throw IOException("sauvegarde ecrite impossible a rouvrir") + entree.use { empreinte(it, sortie = null) } + } catch (e: IOException) { + throw RelectureEchoueeException(e) + } + if (!MessageDigest.isEqual(ecrite, relue)) throw RelectureEchoueeException(null) + preparee.creation + } catch (e: Throwable) { + supprimer(document) + throw e + } finally { + abandonner(preparee) + } + } + + /** The waiting file, deleted: the user went back from the picker, or the screen is gone. */ + fun abandonner(preparee: Preparee) { + if (!preparee.fichier.delete() && preparee.fichier.exists()) Timber.w("sauvegarde en attente non supprimee") + } + + /** + * A document the picker created for a backup that is no longer there to fill it — the process + * died while the picker was open: deleted rather than left empty under a backup's name. + */ + suspend fun oublier(document: Uri) = withContext(Dispatchers.IO) { supprimer(document) } + private suspend fun ecrire( - document: Uri, + sortie: OutputStream, cle: ByteArray, enTete: FichierDeSauvegarde.EnTete, clesDesCoffres: MutableMap, ): Creation { - val database = databases.get() - val sortie = context.contentResolver.openOutputStream(document, "w") - ?: throw IOException("document de sauvegarde impossible a ouvrir") var dossiers = 0 var notes = 0 var omises = 0 bornee(sortie).use { fichier -> + val database = databases.get() FichierDeSauvegarde.ecrire(fichier, cle, enTete).use { chiffre -> EcritureDuContenu(chiffre, clock.millis()).use { contenu -> database.withTransaction { @@ -170,11 +231,9 @@ class SauvegardeChiffree @Inject constructor( } /** The file read back whole with the key it was written with: tag checked, folders and notes counted. */ - private suspend fun relire(document: Uri, cle: ByteArray, ecrite: Creation) { + private suspend fun relire(cle: ByteArray, ecrite: Creation, ouvrir: () -> InputStream) { val (dossiers, notes) = try { - val entree = context.contentResolver.openInputStream(document) - ?: throw IOException("sauvegarde ecrite impossible a rouvrir") - entree.use { + ouvrir().use { val borne = FichierDeSauvegarde.borne(it) val (enTete, octetsDeLEnTete) = FichierDeSauvegarde.lireLEnTete(borne) var dossiers = 0 @@ -191,6 +250,25 @@ class SauvegardeChiffree @Inject constructor( if (dossiers != ecrite.dossiers || notes != ecrite.notes) throw RelectureEchoueeException(null) } + /** + * The SHA-256 of [entree], read to its end — copied into [sortie] on the way, when there is one. + * Never past [FichierDeSauvegarde.TAILLE_MAX]: a document longer than any backup is not this one. + */ + private suspend fun empreinte(entree: InputStream, sortie: OutputStream?): ByteArray { + val condense = MessageDigest.getInstance("SHA-256") + val tampon = ByteArray(TAMPON) + var lus = 0L + while (true) { + coroutineContext.ensureActive() + val n = entree.read(tampon) + if (n < 0) return condense.digest() + lus += n + if (lus > FichierDeSauvegarde.TAILLE_MAX) throw IOException("au-dela de la taille d'une sauvegarde") + condense.update(tampon, 0, n) + sortie?.write(tampon, 0, n) + } + } + private fun supprimer(document: Uri) { try { DocumentsContract.deleteDocument(context.contentResolver, document) @@ -280,7 +358,14 @@ class SauvegardeChiffree @Inject constructor( modifieeLe = updatedAt, ) - private companion object { - const val PAGE = 200 + companion object { + private const val PAGE = 200 + private const val TAMPON = 64 * 1024 + + /** + * Where [preparer] writes, in the cache. Encrypted, but every note is in it: purged at startup + * and by the panic, by the same definition as the exports (`ClairDuCache`). + */ + fun repertoireDAttente(context: Context): File = File(context.cacheDir, "sauvegarde") } } diff --git a/app/src/main/java/com/filestech/notes_tech/security/panic/ClairDuCache.kt b/app/src/main/java/com/filestech/notes_tech/security/panic/ClairDuCache.kt index 674dab6..6c2058b 100644 --- a/app/src/main/java/com/filestech/notes_tech/security/panic/ClairDuCache.kt +++ b/app/src/main/java/com/filestech/notes_tech/security/panic/ClairDuCache.kt @@ -2,6 +2,7 @@ package com.filestech.notes_tech.security.panic import android.content.Context import com.filestech.notes_tech.data.export.NoteExporter +import com.filestech.notes_tech.data.sauvegarde.SauvegardeChiffree import com.filestech.notes_tech.data.voice.VoiceCapture import java.io.File @@ -28,7 +29,7 @@ import java.io.File * startup only. * * ⚠️ Only the **first level** is looked at by name: a directory is judged by its own name, never by - * what it holds. `share_plus` and the two directories of 3.x are named here for that reason. + * what it holds. `share_plus` and the directories of 3.x are named here for that reason. */ internal object ClairDuCache { @@ -44,8 +45,11 @@ internal object ClairDuCache { // // ⚠️⚠️ The two names are **asked of those who write these directories**, never copied: two // definitions, and the panic would watch a directory the export no longer uses. + // The backup waiting for its document (3.2.0) is encrypted, but carries every note: it goes + // with them — at startup, after a process killed while the picker was open, and in a panic. return n == NoteExporter.repertoireDExport(context).name.lowercase() || n == VoiceCapture.repertoireDeCapture(context).name.lowercase() || + n == SauvegardeChiffree.repertoireDAttente(context).name.lowercase() || n == COPIES_DE_SHARE_PLUS || n.endsWith(".zip") || n.endsWith(".md") || diff --git a/app/src/main/java/com/filestech/notes_tech/ui/common/AppResultLauncher.kt b/app/src/main/java/com/filestech/notes_tech/ui/common/AppResultLauncher.kt index ec1cd7c..6d0892a 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/common/AppResultLauncher.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/common/AppResultLauncher.kt @@ -1,5 +1,6 @@ package com.filestech.notes_tech.ui.common +import android.content.ActivityNotFoundException import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContract @@ -7,6 +8,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.staticCompositionLocalOf +import timber.log.Timber /** * Told when the app is about to open another activity for a result, so that the app lock does not @@ -28,9 +30,19 @@ class AppResultLauncher internal constructor( private val guard: ExternalActivityGuard, private val sparesLock: Boolean, ) { - fun launch(input: I) { + /** + * `false` when no app on the device can answer — a stripped device, its documents app disabled: + * the platform throws `ActivityNotFoundException` there, which crashed the app at every picker + * (3.2.0, found listing what the backup's picker can meet). The caller says so to the user. The + * pass the guard was given expires by itself, no stop having followed it (`PickerRelockPolicy`). + */ + fun launch(input: I): Boolean = try { if (sparesLock) guard.onExternalActivityLaunched() launcher.launch(input) + true + } catch (e: ActivityNotFoundException) { + Timber.w(e, "aucune activite pour ce resultat") + false } } diff --git a/app/src/main/java/com/filestech/notes_tech/ui/settings/SauvegardeUi.kt b/app/src/main/java/com/filestech/notes_tech/ui/settings/SauvegardeUi.kt new file mode 100644 index 0000000..67a30c4 --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/ui/settings/SauvegardeUi.kt @@ -0,0 +1,420 @@ +package com.filestech.notes_tech.ui.settings + +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.StringRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Backup +import androidx.compose.material.icons.outlined.Restore +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.filestech.notes_tech.R +import com.filestech.notes_tech.data.sauvegarde.RestaurationDeSauvegarde +import com.filestech.notes_tech.data.sauvegarde.SauvegardeChiffree +import com.filestech.notes_tech.security.sauvegarde.SecretDeSauvegarde +import com.filestech.notes_tech.security.vault.VaultParams +import com.filestech.notes_tech.ui.common.rememberAppResultLauncher +import com.filestech.notes_tech.ui.secure.SecureWindowGuard +import com.filestech.notes_tech.ui.vault.ChampDePhraseSecrete +import java.time.LocalDate + +/** + * The Settings' encrypted backup (3.2.0, D-029 to D-031): its two rows, and the dialogs of each step + * ([SauvegardeViewModel.Etape]). Both pickers go through the app's seam ([rememberAppResultLauncher]): + * without it, the app lock would ask for the PIN on the way back, the step lost. The vaults lock all + * the same when a picker opens: the backup is written before it ([SauvegardeViewModel.secretChoisi]). + */ +@Composable +internal fun LignesDeSauvegarde() { + val viewModel: SauvegardeViewModel = hiltViewModel() + val etape by viewModel.etape.collectAsStateWithLifecycle() + val creation = + rememberAppResultLauncher(ActivityResultContracts.CreateDocument(TYPE_DU_FICHIER), viewModel::documentCree) + val ouverture = rememberAppResultLauncher(ActivityResultContracts.OpenDocument(), viewModel::documentOuvert) + val auRepos = etape == SauvegardeViewModel.Etape.Repos + + Column { + ListItem( + headlineContent = { Text(stringResource(R.string.backup_create)) }, + supportingContent = { Text(stringResource(R.string.backup_create_subtitle)) }, + leadingContent = { Icon(Icons.Outlined.Backup, contentDescription = null) }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = Modifier.clickable(enabled = auRepos, onClick = viewModel::preparerLaCreation), + ) + HorizontalDivider() + ListItem( + headlineContent = { Text(stringResource(R.string.backup_restore)) }, + supportingContent = { Text(stringResource(R.string.backup_restore_subtitle)) }, + leadingContent = { Icon(Icons.Outlined.Restore, contentDescription = null) }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + // Any type: a `.ntbackup` has none of its own, most providers say `application/octet-stream`. + modifier = Modifier.clickable(enabled = auRepos) { + if (!ouverture.launch(arrayOf("*/*"))) viewModel.sansSelecteur() + }, + ) + } + + // Every dialog of a flow bears the title of the row that started it: "Create a backup" from the + // preview to the end, "Restore a backup" from the secret to the end — waiting and failure included. + when (val e = etape) { + SauvegardeViewModel.Etape.Repos, SauvegardeViewModel.Etape.EnAttenteDuDocument -> Unit + is SauvegardeViewModel.Etape.Occupe -> DialogueDAttente(e.titre) + is SauvegardeViewModel.Etape.AvantLaCreation -> DialogueDeCreation( + apercu = e.apercu, + code = e.code, + onEnregistrer = viewModel::secretChoisi, + onAnnuler = viewModel::fermer, + ) + SauvegardeViewModel.Etape.PreteADeposer -> LaunchedEffect(Unit) { + if (creation.launch("notes-tech-${LocalDate.now()}.ntbackup")) { + viewModel.selecteurOuvert() + } else { + viewModel.sansSelecteur() + } + } + is SauvegardeViewModel.Etape.Creee -> DialogueDeFin( + titre = R.string.backup_create, + message = texteDeLaCreation(e.creation), + onFermer = viewModel::fermer, + ) + is SauvegardeViewModel.Etape.SecretADemander -> DialogueDeSecret( + erreur = e.erreur, + onEssayer = viewModel::essayer, + onAnnuler = viewModel::fermer, + ) + is SauvegardeViewModel.Etape.Essayee -> DialogueDEssai( + e.resultat, + onRestaurer = viewModel::restaurer, + onAnnuler = viewModel::fermer, + ) + is SauvegardeViewModel.Etape.Restauree -> DialogueDeFin( + titre = R.string.backup_restore, + message = texteDuBilan(e.resultat, apres = true), + onFermer = viewModel::fermer, + ) + is SauvegardeViewModel.Etape.Echec -> DialogueDeFin(e.titre, stringResource(e.message), viewModel::fermer) + } +} + +/** Not dismissible: what runs cannot be left half done from here, and it ends on its own. */ +@Composable +private fun DialogueDAttente(@StringRes titre: Int) { + AlertDialog( + onDismissRequest = {}, + title = { Text(stringResource(titre)) }, + text = { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) { + CircularProgressIndicator(Modifier.size(24.dp)) + Text(stringResource(R.string.backup_working)) + } + }, + confirmButton = {}, + ) +} + +@Composable +private fun DialogueDeFin(@StringRes titre: Int, message: String, onFermer: () -> Unit) { + AlertDialog( + onDismissRequest = onFermer, + title = { Text(stringResource(titre)) }, + text = { Text(message) }, + confirmButton = { TextButton(onClick = onFermer) { Text(stringResource(R.string.common_ok)) } }, + ) +} + +/** + * Before a backup is made: what it holds, what it leaves out and why, then its secret — the code drawn + * for it, to write down and type back, or a phrase of the user's. + * + * 🔴 The window is secured while it is shown, whatever the setting: the code on screen opens the file. + * The fields are `remember`, not saved: a secret typed there must not end up in a saved state. + */ +@Composable +private fun DialogueDeCreation( + apercu: SauvegardeChiffree.Apercu, + code: String, + onEnregistrer: (String) -> Unit, + onAnnuler: () -> Unit, +) { + SecureWindowGuard(active = true) + var avecUnePhrase by remember { mutableStateOf(false) } + var recopie by remember { mutableStateOf("") } + var phrase by remember { mutableStateOf("") } + var confirmation by remember { mutableStateOf("") } + val refus = SecretDeSauvegarde.refusDeLaPhrase(phrase).takeIf { phrase.isNotEmpty() } + val secret = if (avecUnePhrase) { + phrase.takeIf { it.isNotEmpty() && refus == null && it == confirmation } + } else { + code.takeIf { SecretDeSauvegarde.codeCanonique(recopie) == SecretDeSauvegarde.codeCanonique(code) } + } + + AlertDialog( + onDismissRequest = onAnnuler, + title = { Text(stringResource(R.string.backup_create)) }, + text = { + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(pluralStringResource(R.plurals.backup_create_notes, apercu.notes, apercu.notes)) + val fermes = apercu.coffresACodeFermes + val ouverts = apercu.coffresACodeOuverts + if (fermes.isNotEmpty()) { + Text( + pluralStringResource( + R.plurals.backup_create_closed_pin, + fermes.size, + fermes.joinToString(", "), + ), + color = MaterialTheme.colorScheme.error, + ) + } + if (ouverts.isNotEmpty()) { + Text( + pluralStringResource( + R.plurals.backup_create_open_pin, + ouverts.size, + ouverts.joinToString(", "), + ), + ) + } + Text(stringResource(R.string.backup_create_panic), style = MaterialTheme.typography.bodySmall) + if (avecUnePhrase) { + ChampDePhraseSecrete( + valeur = phrase, + onValeurChange = { phrase = it }, + label = stringResource(R.string.backup_phrase), + actionClavier = ImeAction.Next, + actif = true, + ) + ChampDePhraseSecrete( + valeur = confirmation, + onValeurChange = { confirmation = it }, + label = stringResource(R.string.backup_phrase_confirm), + actionClavier = ImeAction.Done, + actif = true, + ) + val probleme = when { + refus == SecretDeSauvegarde.Refus.TROP_COURTE -> + stringResource( + R.string.backup_phrase_too_short, + SecretDeSauvegarde.LONGUEUR_MIN_DE_LA_PHRASE, + ) + refus == SecretDeSauvegarde.Refus.EVIDENTE -> stringResource(R.string.backup_phrase_obvious) + confirmation.isNotEmpty() && confirmation != phrase -> stringResource( + R.string.backup_phrase_mismatch, + ) + else -> null + } + probleme?.let { Text(it, color = MaterialTheme.colorScheme.error) } + TextButton(onClick = { avecUnePhrase = false }) { Text(stringResource(R.string.backup_use_code)) } + } else { + Text(stringResource(R.string.backup_code_intro)) + // Two lines of three groups: left to the text layout, a line broke inside a group. + Text( + code.split('-').chunked(GROUPES_PAR_LIGNE).joinToString("\n") { it.joinToString("-") }, + style = MaterialTheme.typography.titleLarge.copy(fontFamily = FontFamily.Monospace), + ) + ChampDePhraseSecrete( + valeur = recopie, + onValeurChange = { recopie = it }, + label = stringResource(R.string.backup_code_retype), + actionClavier = ImeAction.Done, + actif = true, + afficher = R.string.backup_code_show, + masquer = R.string.backup_code_hide, + ) + TextButton(onClick = { avecUnePhrase = true }) { Text(stringResource(R.string.backup_use_phrase)) } + } + } + }, + confirmButton = { + TextButton(onClick = { secret?.let(onEnregistrer) }, enabled = secret != null) { + Text(stringResource(R.string.backup_save)) + } + }, + dismissButton = { TextButton(onClick = onAnnuler) { Text(stringResource(R.string.common_cancel)) } }, + ) +} + +/** The chosen file's code or passphrase — asked again, field empty, after a wrong one ([erreur]). */ +@Composable +private fun DialogueDeSecret(@StringRes erreur: Int?, onEssayer: (String) -> Unit, onAnnuler: () -> Unit) { + var secret by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onAnnuler, + title = { Text(stringResource(R.string.backup_restore)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + ChampDePhraseSecrete( + valeur = secret, + onValeurChange = { secret = it }, + label = stringResource(R.string.backup_restore_secret), + actionClavier = ImeAction.Done, + actif = true, + afficher = R.string.backup_secret_show, + masquer = R.string.backup_secret_hide, + ) + erreur?.let { Text(stringResource(it), color = MaterialTheme.colorScheme.error) } + } + }, + confirmButton = { + TextButton(onClick = { onEssayer(secret) }, enabled = secret.isNotBlank()) { + Text(stringResource(R.string.backup_restore_check)) + } + }, + dismissButton = { TextButton(onClick = onAnnuler) { Text(stringResource(R.string.common_cancel)) } }, + ) +} + +/** + * The trial pass's answer: what the restore will add, what it leaves and why — and a new PIN, twice, + * for each PIN vault it brings back (D-030). + */ +@Composable +private fun DialogueDEssai( + resultat: RestaurationDeSauvegarde.Resultat, + onRestaurer: (Map) -> Unit, + onAnnuler: () -> Unit, +) { + val codes = remember { mutableStateMapOf() } + val confirmations = remember { mutableStateMapOf() } + val rienAAjouter = resultat.notesAjoutees + resultat.notesACote + resultat.dossiersCrees == 0 + val codesValides = resultat.coffresACode.all { coffre -> + val code = codes[coffre.id].orEmpty() + CODE_VALIDE.matches(code) && code == confirmations[coffre.id] + } + + AlertDialog( + onDismissRequest = onAnnuler, + title = { Text(stringResource(R.string.backup_restore)) }, + text = { + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(texteDuBilan(resultat, apres = false)) + if (!rienAAjouter) { + Text( + stringResource(R.string.backup_restore_deleted_since), + style = MaterialTheme.typography.bodySmall, + ) + } + resultat.coffresACode.forEach { coffre -> + Text( + stringResource(R.string.backup_restore_pin_vault, coffre.nom), + style = MaterialTheme.typography.titleSmall, + ) + ChampDePhraseSecrete( + valeur = codes[coffre.id].orEmpty(), + onValeurChange = { codes[coffre.id] = it.filter(Char::isDigit) }, + label = stringResource(R.string.backup_restore_pin), + actionClavier = ImeAction.Next, + actif = true, + typeDeClavier = KeyboardType.NumberPassword, + afficher = R.string.pin_show_tooltip, + masquer = R.string.pin_hide_tooltip, + ) + ChampDePhraseSecrete( + valeur = confirmations[coffre.id].orEmpty(), + onValeurChange = { confirmations[coffre.id] = it.filter(Char::isDigit) }, + label = stringResource(R.string.backup_restore_pin_confirm), + actionClavier = ImeAction.Done, + actif = true, + typeDeClavier = KeyboardType.NumberPassword, + afficher = R.string.pin_show_tooltip, + masquer = R.string.pin_hide_tooltip, + ) + } + if (resultat.coffresACode.isNotEmpty() && !codesValides) { + Text( + stringResource( + R.string.backup_restore_pin_rule, + VaultParams.PIN_MIN_LENGTH, + VaultParams.PIN_MAX_LENGTH, + ), + style = MaterialTheme.typography.bodySmall, + ) + } + } + }, + confirmButton = { + if (rienAAjouter) { + TextButton(onClick = onAnnuler) { Text(stringResource(R.string.common_ok)) } + } else { + TextButton(onClick = { onRestaurer(codes.toMap()) }, enabled = codesValides) { + Text(stringResource(R.string.backup_restore_do)) + } + } + }, + dismissButton = { + if (!rienAAjouter) TextButton(onClick = onAnnuler) { Text(stringResource(R.string.common_cancel)) } + }, + ) +} + +/** What the backup made holds — and what it left out, said after as it was before. */ +@Composable +private fun texteDeLaCreation(c: SauvegardeChiffree.Creation): String = buildList { + add(pluralStringResource(R.plurals.backup_created, c.notes, c.notes)) + if (c.omises > 0) add(pluralStringResource(R.plurals.backup_created_left_out, c.omises, c.omises)) +}.joinToString("\n") + +/** + * What a restore did ([apres] `true`) or would do: one line per kind of outcome there is — the notes + * and folders added said in the tense of the moment, the rest true before as after. + */ +@Composable +private fun texteDuBilan(r: RestaurationDeSauvegarde.Resultat, apres: Boolean): String = buildList { + val ajoutees = r.notesAjoutees + r.notesACote + val dossiers = r.dossiersCrees + val ignorees = r.notesIgnorees.values.sum() + when { + apres -> add(pluralStringResource(R.plurals.backup_restored, ajoutees, ajoutees)) + ajoutees + dossiers == 0 -> add(stringResource(R.string.backup_restore_nothing)) + else -> add(pluralStringResource(R.plurals.backup_restore_add_notes, ajoutees, ajoutees)) + } + if (dossiers > 0) { + val pluriel = if (apres) R.plurals.backup_restored_folders else R.plurals.backup_restore_add_folders + add(pluralStringResource(pluriel, dossiers, dossiers)) + } + val aLaCorbeille = r.notesALaCorbeille + if (aLaCorbeille > 0) add(pluralStringResource(R.plurals.backup_restore_trash, aLaCorbeille, aLaCorbeille)) + if (r.notesACote > 0) add(pluralStringResource(R.plurals.backup_restore_beside, r.notesACote, r.notesACote)) + if (r.notesDejaLa > 0) add(pluralStringResource(R.plurals.backup_restore_already, r.notesDejaLa, r.notesDejaLa)) + if (ignorees > 0) add(pluralStringResource(R.plurals.backup_restore_ignored, ignorees, ignorees)) +}.joinToString("\n") + +/** A vault's PIN, as the vaults take it — their own bounds, not a copy of them. */ +private val CODE_VALIDE = Regex("[0-9]{${VaultParams.PIN_MIN_LENGTH},${VaultParams.PIN_MAX_LENGTH}}") + +/** How many of the code's groups a line shows. */ +private const val GROUPES_PAR_LIGNE = 3 + +/** What the system picker is told the file is: it has no type of its own. */ +private const val TYPE_DU_FICHIER = "application/octet-stream" diff --git a/app/src/main/java/com/filestech/notes_tech/ui/settings/SauvegardeViewModel.kt b/app/src/main/java/com/filestech/notes_tech/ui/settings/SauvegardeViewModel.kt new file mode 100644 index 0000000..0bcf923 --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/ui/settings/SauvegardeViewModel.kt @@ -0,0 +1,210 @@ +package com.filestech.notes_tech.ui.settings + +import android.net.Uri +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.filestech.notes_tech.R +import com.filestech.notes_tech.data.sauvegarde.RestaurationDeSauvegarde +import com.filestech.notes_tech.data.sauvegarde.SauvegardeChiffree +import com.filestech.notes_tech.security.sauvegarde.FichierDeSauvegarde +import com.filestech.notes_tech.security.sauvegarde.FichierDeSauvegarde.Refus +import com.filestech.notes_tech.security.sauvegarde.SecretDeSauvegarde +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import timber.log.Timber +import java.io.IOException +import javax.inject.Inject + +/** + * The Settings' encrypted backup (3.2.0, `docs/13-VERSION-3.2.md` D-029 to D-031): making one, and + * restoring one, step by step — each step a state the screen shows. + * + * - Making one: the backup is written BEFORE the system picker opens ([SauvegardeChiffree.preparer]) — + * the picker sends the app to the background, where every vault locks. Only the encrypted file waits + * for the picker's answer; the secret is not kept. + * - Restoring one: the secret is held here between the trial and the restore, never in the state the + * screen saves — a rotation keeps it (this ViewModel survives it), a process death forgets it, and + * the user types it again, which is the safe side. + */ +@HiltViewModel +class SauvegardeViewModel @Inject constructor( + private val sauvegarde: SauvegardeChiffree, + private val restauration: RestaurationDeSauvegarde, +) : ViewModel() { + + sealed interface Etape { + data object Repos : Etape + + /** Work under way, in the flow titled [titre]: making a backup, or restoring one. */ + data class Occupe(@StringRes val titre: Int) : Etape + + /** Before a backup is made: what it will hold, and the code drawn for it. */ + data class AvantLaCreation(val apercu: SauvegardeChiffree.Apercu, val code: String) : Etape + + /** + * The backup is written and read back, waiting: the screen opens the system picker, then says + * so ([selecteurOuvert]) — the step it reacted to is gone before a rotation could show it again. + */ + data object PreteADeposer : Etape + + /** The system picker is open. */ + data object EnAttenteDuDocument : Etape + + data class Creee(val creation: SauvegardeChiffree.Creation) : Etape + + /** A file is chosen: its code or passphrase is asked — again, after a wrong one ([erreur]). */ + data class SecretADemander(@StringRes val erreur: Int? = null) : Etape + + /** The trial pass's answer: what the restore will do, and the PINs it needs. */ + data class Essayee(val resultat: RestaurationDeSauvegarde.Resultat) : Etape + + data class Restauree(val resultat: RestaurationDeSauvegarde.Resultat) : Etape + + data class Echec(@StringRes val titre: Int, @StringRes val message: Int) : Etape + } + + private val _etape = MutableStateFlow(Etape.Repos) + val etape: StateFlow = _etape.asStateFlow() + + private var secret: String? = null + private var document: Uri? = null + private var preparee: SauvegardeChiffree.Preparee? = null + + fun preparerLaCreation() = executer(CREATION, enLecture = false) { + Etape.AvantLaCreation(sauvegarde.apercu(), SecretDeSauvegarde.nouveauCode()) + } + + /** The secret is chosen: the backup is written now, while the vaults are as the preview saw them. */ + fun secretChoisi(choisi: String) = executer(CREATION, enLecture = false) { + preparee = sauvegarde.preparer(choisi) + Etape.PreteADeposer + } + + fun selecteurOuvert() { + if (_etape.value == Etape.PreteADeposer) _etape.value = Etape.EnAttenteDuDocument + } + + /** No app on the device can pick a file: the waiting backup goes, and the flow says why. */ + fun sansSelecteur() { + val titre = if (_etape.value == Etape.PreteADeposer) CREATION else RESTAURATION + fermer() + _etape.value = Etape.Echec(titre, R.string.common_no_picker) + } + + /** The system picker's answer — `null`: the user went back, and the waiting backup goes. */ + fun documentCree(cree: Uri?) { + val enAttente = preparee + preparee = null + when { + cree == null -> fermer(enAttente) + // The process died while the picker was open (the startup purge took the waiting file): the + // document it created stays empty — deleted, and the backup said not made. + enAttente == null -> executer(CREATION, enLecture = false) { + sauvegarde.oublier(cree) + Etape.Echec(CREATION, R.string.backup_error_generic) + } + else -> executer(CREATION, enLecture = false) { Etape.Creee(sauvegarde.deposer(enAttente, cree)) } + } + } + + /** The file is examined first: one that is not a backup, or too new, is refused before any secret. */ + fun documentOuvert(ouvert: Uri?) { + if (ouvert == null) return fermer() + document = ouvert + executer(RESTAURATION, enLecture = false) { + restauration.examiner(ouvert) + Etape.SecretADemander() + } + } + + /** A wrong secret — or a damaged file, they look the same — asks again, the file kept. */ + fun essayer(saisi: String) { + val ouvert = document ?: return fermer() + secret = saisi + executer(RESTAURATION, siLeSecretEstFaux = Etape.SecretADemander(R.string.backup_error_wrong_secret)) { + Etape.Essayee(restauration.essayer(ouvert, saisi)) + } + } + + /** [codes]: the new PIN of each PIN vault the trial listed, by folder id. */ + fun restaurer(codes: Map) { + val ouvert = document ?: return fermer() + val saisi = secret ?: return fermer() + executer(RESTAURATION, oublierApres = true) { Etape.Restauree(restauration.restaurer(ouvert, saisi, codes)) } + } + + fun fermer() = fermer(preparee) + + private fun fermer(enAttente: SauvegardeChiffree.Preparee?) { + enAttente?.let(sauvegarde::abandonner) + preparee = null + secret = null + document = null + _etape.value = Etape.Repos + } + + /** + * [titre]: the flow's, on the waiting dialog and on a failure. [enLecture]: a backup file is being + * read — a failure there means a wrong secret or a damaged file, which [siLeSecretEstFaux] answers + * when there is one. + */ + private fun executer( + @StringRes titre: Int, + oublierApres: Boolean = false, + enLecture: Boolean = true, + siLeSecretEstFaux: Etape? = null, + etape: suspend () -> Etape, + ) { + if (_etape.value is Etape.Occupe) return + _etape.value = Etape.Occupe(titre) + viewModelScope.launch { + _etape.value = try { + etape() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.w(e, "sauvegarde chiffree") + val message = messageDe(e, enLecture) + siLeSecretEstFaux?.takeIf { message == R.string.backup_error_wrong_secret } + ?: Etape.Echec(titre, message) + } finally { + if (oublierApres) { + secret = null + document = null + } + } + } + } + + override fun onCleared() { + preparee?.let(sauvegarde::abandonner) + preparee = null + secret = null + } + + private companion object { + /** The titles of the two flows: the rows the user tapped. */ + val CREATION = R.string.backup_create + val RESTAURATION = R.string.backup_restore + + @StringRes + fun messageDe(e: Exception, enLecture: Boolean): Int = when (e) { + is FichierDeSauvegarde.SauvegardeRefuseeException -> when (e.refus) { + Refus.PAS_UNE_SAUVEGARDE -> R.string.backup_error_not_a_backup + Refus.VERSION_INCONNUE, Refus.PARAMETRES_INCONNUS -> R.string.backup_error_newer + Refus.TROP_GROS -> R.string.backup_error_too_large + Refus.TRONQUE -> R.string.backup_error_wrong_secret + } + is SauvegardeChiffree.SauvegardeTropGrosseException -> R.string.backup_error_too_large + is SauvegardeChiffree.RelectureEchoueeException -> R.string.backup_error_readback + // Reading: a wrong secret and a damaged file look the same — the tag, or the content before it. + is IOException -> if (enLecture) R.string.backup_error_wrong_secret else R.string.backup_error_generic + else -> R.string.backup_error_generic + } + } +} diff --git a/app/src/main/java/com/filestech/notes_tech/ui/settings/SettingsScreen.kt b/app/src/main/java/com/filestech/notes_tech/ui/settings/SettingsScreen.kt index a9dfb07..0fe643b 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/settings/SettingsScreen.kt @@ -175,6 +175,7 @@ fun SettingsRoute(onBack: () -> Unit, onOpenAbout: () -> Unit, onOpenVoiceSetup: snackbarHost = { HoteDeMessages(snackbars) }, ligneDExport = { LigneDExport(snackbars) }, ligneDImport = { LigneDImport(snackbars) }, + lignesDeSauvegarde = { LignesDeSauvegarde() }, sectionDeVerrouillage = { AppLockSection(snackbars) }, ) } @@ -210,6 +211,8 @@ fun SettingsScreen( ligneDExport: @Composable () -> Unit = {}, /** A slot, like [ligneDExport]: the import has its own ViewModel and the system picker. */ ligneDImport: @Composable () -> Unit = {}, + /** A slot, like [ligneDExport]: the encrypted backup's two rows and their dialogs (D-029). */ + lignesDeSauvegarde: @Composable () -> Unit = {}, sectionDeVerrouillage: @Composable () -> Unit = {}, ) { var choixDeTheme by remember { mutableStateOf(false) } @@ -379,6 +382,9 @@ fun SettingsScreen( } } + TitreDeSection(stringResource(R.string.settings_section_backup)) + CarteFilesTech { lignesDeSauvegarde() } + TitreDeSection(stringResource(R.string.settings_panic)) // 🔴 **La seule carte cerclée de rouge de l'écran**, comme dans la référence // (`settings_screen.dart:130-140`). Le portage posait cette ligne à plat entre deux @@ -641,7 +647,11 @@ private fun LigneDImport(snackbars: SnackbarHostState) { if (state.busy) CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(20.dp)) }, colors = ListItemDefaults.colors(containerColor = Color.Transparent), - modifier = Modifier.clickable(enabled = !state.busy) { selecteur.launch(TYPES_IMPORTABLES) }, + modifier = Modifier.clickable(enabled = !state.busy) { + if (!selecteur.launch(TYPES_IMPORTABLES)) { + portee.launch { snackbars.showSnackbar(ressources.getString(R.string.common_no_picker)) } + } + }, ) } diff --git a/app/src/main/java/com/filestech/notes_tech/ui/vault/VaultSheets.kt b/app/src/main/java/com/filestech/notes_tech/ui/vault/VaultSheets.kt index 2c35ccf..d5cff75 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/vault/VaultSheets.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/vault/VaultSheets.kt @@ -1,6 +1,7 @@ package com.filestech.notes_tech.ui.vault import android.os.Build +import androidx.annotation.StringRes import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -558,12 +559,18 @@ internal fun FeuilleDePhraseSecrete( * confirmation ne révèle pas la phrase au-dessus. */ @Composable -private fun ChampDePhraseSecrete( +internal fun ChampDePhraseSecrete( valeur: String, onValeurChange: (String) -> Unit, label: String, actionClavier: ImeAction, actif: Boolean, + // `Password` or `NumberPassword`: either way a keyboard that does not learn what is typed. The + // encrypted backup (3.2.0) uses this field too, for its code, its phrase and a vault's new PIN — + // and names the eye after what the field holds. + typeDeClavier: KeyboardType = KeyboardType.Password, + @StringRes afficher: Int = R.string.passphrase_show_tooltip, + @StringRes masquer: Int = R.string.passphrase_hide_tooltip, ) { var visible by remember { mutableStateOf(false) } @@ -574,14 +581,12 @@ private fun ChampDePhraseSecrete( singleLine = true, enabled = actif, visualTransformation = if (visible) VisualTransformation.None else PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = actionClavier), + keyboardOptions = KeyboardOptions(keyboardType = typeDeClavier, imeAction = actionClavier), trailingIcon = { IconButton(onClick = { visible = !visible }) { Icon( imageVector = if (visible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, - contentDescription = stringResource( - if (visible) R.string.passphrase_hide_tooltip else R.string.passphrase_show_tooltip, - ), + contentDescription = stringResource(if (visible) masquer else afficher), ) } }, diff --git a/app/src/main/java/com/filestech/notes_tech/ui/voice/VoiceSetupScreen.kt b/app/src/main/java/com/filestech/notes_tech/ui/voice/VoiceSetupScreen.kt index 55be651..70d8f19 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/voice/VoiceSetupScreen.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/voice/VoiceSetupScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -52,6 +53,7 @@ import com.filestech.notes_tech.domain.voice.SttModelCatalogue import com.filestech.notes_tech.ui.common.ActionDeDialogue import com.filestech.notes_tech.ui.common.HoteDeMessages import com.filestech.notes_tech.ui.common.rememberAppResultLauncher +import kotlinx.coroutines.launch /** * L'installation du modèle de dictée. @@ -136,6 +138,8 @@ fun VoiceSetupRoute(onBack: () -> Unit) { } val messageLienCopie = stringResource(R.string.voice_setup_link_copied) + val messageSansSelecteur = stringResource(R.string.common_no_picker) + val portee = rememberCoroutineScope() val modeleInstalle = etat.installeAvecSucces val messageInstalle = modeleInstalle?.let { stringResource(R.string.voice_setup_install_ok, it) } @@ -170,7 +174,10 @@ fun VoiceSetupRoute(onBack: () -> Unit) { // plusieurs fournisseurs le servent en `application/octet-stream` quand ils ne le // servent pas comme inconnu. Filtrer ici rendrait le fichier **invisible** dans le // sélecteur, sans aucun message. - selecteur.launch(arrayOf("*/*")) + if (!selecteur.launch(arrayOf("*/*"))) { + modeleVise = null + portee.launch { snackbars.showSnackbar(messageSansSelecteur) } + } }, onDemanderLeRetrait = { aRetirer = it.id }, onConfirmerLeRetrait = { diff --git a/app/src/main/res/raw-de/privacy.md b/app/src/main/res/raw-de/privacy.md index 3a0df06..657d873 100644 --- a/app/src/main/res/raw-de/privacy.md +++ b/app/src/main/res/raw-de/privacy.md @@ -1,6 +1,6 @@ # Datenschutzerklärung — Notes Tech -_Zuletzt aktualisiert: 26. Oktober 2026_ +_Zuletzt aktualisiert: 29. Oktober 2026_ ## In einem Satz @@ -15,6 +15,7 @@ Notes Tech erhebt, überträgt und speichert keinerlei Daten auf entfernten Serv - **Tags und Farbe einer Tresornotiz**: Sie bleiben in der von SQLCipher verschlüsselten Datenbank, aber **außerhalb der eigenen Verschlüsselung des Tresors** — nur Titel und Inhalt fallen darunter. In den Listen zeigt eine Tresornotiz nie ihre Tags und ihre Farbe nur, solange dieser Tresor entsperrt ist. - **App-Sperre (optional)**: Die PIN selbst wird nie gespeichert. Aufbewahrt wird ein mit **Argon2id** daraus abgeleiteter Wert, der per **HMAC-SHA256** an einen Schlüssel des **Android Keystore** gebunden ist, der das Telefon nie verlässt — er kann also nirgendwo anders geprüft werden. Nach fünf falschen PINs wartet jeder neue Versuch länger (30 Sekunden, verdoppelt bis zu einer Stunde); falsche Versuche löschen nie etwas. **Entsperren per Fingerabdruck oder Gesicht** wird vollständig von Android übernommen: Notes Tech erhält keine biometrischen Daten, nur die Bestätigung, dass ein Keystore-Schlüssel verwendet werden durfte, und nur starke Biometrie (Klasse 3) wird akzeptiert. - **Rückverweise `[[Titel]]`**: lokaler invertierter Index, nie übertragen. +- **Verschlüsselte Sicherung (optional)**: eine `.ntbackup`-Datei, die Sie selbst erstellen, an dem Ort, den Sie über die Android-Dokumentauswahl wählen. Sie enthält Ihre Ordner und Notizen, Papierkorb inbegriffen, verschlüsselt mit **AES-256-GCM** unter einem Schlüssel, der mit **Argon2id** (m=64MB, t=3) aus einem von der App gezogenen Code, den Sie notieren, oder aus einer eigenen Passphrase abgeleitet wird — Notes Tech bewahrt keines von beiden auf. Sie wird zuerst, bereits verschlüsselt, im privaten Speicher der App geschrieben, dann an den gewählten Ort kopiert und aus dem Speicher der App gelöscht. Die Notizen eines Passphrase-Tresors bleiben in der Datei durch seine Passphrase versiegelt. Ein PIN-Tresor wird nur offen mitgenommen, mit seinem Schlüssel: In der Datei schützt ihn allein das Geheimnis der Sicherung; ein geschlossener wird ausgelassen. Die Wiederherstellung fügt hinzu, was fehlt, und überschreibt nichts. - **Diktiermodell (Whisper `.bin`)**: Sie beschaffen es selbst — die App zeigt den Dateinamen und seine Quelle an — und importieren es dann über die Android-Dokumentauswahl. Notes Tech hat keine Internetberechtigung und **bietet keine Möglichkeit, etwas herunterzuladen**. Sein SHA-256 wird beim Import und vor jedem Laden geprüft. - **Beim Diktat aufgenommenes Audio**: in eine temporäre Datei im privaten Speicher der App geschrieben — die Transkriptions-Engine liest eine Datei, anders geht es nicht — und **gelöscht, sobald die Transkription vorliegt**. Es wird außerdem beim Start der App und durch den Panikmodus gelöscht, sodass ein abruptes Beenden nichts zurücklässt. - **Einstellungen (Design, Sortierung, aktiviertes Diktat, automatische Tresorsperre, Optionen der App-Sperre)**: unverschlüsselt in den lokalen Einstellungen gespeichert (keine sensiblen Daten). @@ -33,7 +34,7 @@ Die aktiven Berechtigungen dienen ausschließlich der Funktion: - `RECORD_AUDIO` (Diktat: Das Audio wird in eine private temporäre Datei geschrieben und gelöscht, sobald die Transkription vorliegt). - `USE_BIOMETRIC` und `USE_FINGERPRINT` (optionales Entsperren der App-Sperre per Fingerabdruck oder Gesicht; von der AndroidX-Biometrie-Bibliothek deklariert — `USE_FINGERPRINT` ist der Name derselben Berechtigung unter Android 8 und älter). -Dies sind die **einzigen** angeforderten Berechtigungen. Die Auswahl der Modelldatei erfolgt über die Android-Dokumentauswahl, die keine benötigt. +Dies sind die **einzigen** angeforderten Berechtigungen. Die Auswahl einer Datei — das Diktiermodell, zu importierende Notizen, eine Sicherung — erfolgt über die Android-Dokumentauswahl, die keine benötigt. ### Panikmodus @@ -48,12 +49,15 @@ Das Menü **Einstellungen → Panikmodus** löscht in einem Durchgang: - das in der Sandbox installierte Whisper-Modell, - die Einstellungen (außer `db_encrypted_v1` und `secure_window_enabled`, die für einen konsistenten Neustart erhalten bleiben). +Der Panikmodus erreicht keine verschlüsselte Sicherung, die außerhalb der App gespeichert ist, wo auch immer Sie sie abgelegt haben: Löschen Sie sie selbst. + Das Löschen ist **nicht atomar**, und die Reihenfolge der Schritte ist darauf ausgelegt: Der Verschlüsselungsschlüssel wird **vor** den langen Löschvorgängen zerstört, dann die unverschlüsselten Dateien, dann der Rest. Ein abruptes Beenden zu jedem beliebigen Zeitpunkt hinterlässt daher den sichersten Zustand, der zu diesem Zeitpunkt erreichbar ist — schlimmstenfalls eine auf Rauschen reduzierte Datenbank. Der letzte Bildschirm nennt, was fehlgeschlagen ist, und **ob etwas Lesbares übrig bleiben kann**. ### Ihre Rechte Ihre Daten bleiben auf Ihrem Telefon, unter Ihrer alleinigen Kontrolle, und der Herausgeber hat keinerlei Zugriff darauf: Er verarbeitet also keine personenbezogenen Daten über Sie. Die CNIL, die französische Datenschutzbehörde, beschreibt diesen Fall als „einfache Software, die dem Nutzer zur Verfügung gestellt wird“, auf die die DSGVO nicht anwendbar ist (Empfehlung zu mobilen Apps, Abschnitt 3.3). Sie behalten die Kontrolle über Ihre Daten und können jederzeit: - Ihre Notizen als Markdown oder ZIP exportieren (`Einstellungen → Alle meine Notizen exportieren`), +- eine verschlüsselte Sicherung davon erstellen und wiederherstellen (`Einstellungen → Verschlüsselte Sicherung`), - eine Notiz endgültig löschen, aus dem Papierkorb oder mit langem Drücken in der Notizliste, - alle Daten über den Panikmodus löschen, - die App deinstallieren — Android löscht dann automatisch alle privaten Daten. diff --git a/app/src/main/res/raw-de/terms.md b/app/src/main/res/raw-de/terms.md index 902c4c9..946cb29 100644 --- a/app/src/main/res/raw-de/terms.md +++ b/app/src/main/res/raw-de/terms.md @@ -1,6 +1,6 @@ # Nutzungsbedingungen — Notes Tech -_Zuletzt aktualisiert: 26. Oktober 2026_ +_Zuletzt aktualisiert: 29. Oktober 2026_ ## Lizenz @@ -18,7 +18,7 @@ Die App wird von einer Privatperson **kostenlos**, privat und nicht kommerziell ## Panikmodus und Datenverlust -Der **Panikmodus** löscht Ihre Notizen, Ihren Verschlüsselungsschlüssel und Ihre Modelle endgültig und unwiderruflich. **Keine Wiederherstellung ist möglich** — das ist so gewollt. Exportieren Sie vor der Verwendung, was Sie behalten möchten, über `Einstellungen → Alle meine Notizen exportieren`. +Der **Panikmodus** löscht Ihre Notizen, Ihren Verschlüsselungsschlüssel und Ihre Modelle endgültig und unwiderruflich. **Keine Wiederherstellung ist möglich** — das ist so gewollt. Legen Sie vor der Verwendung, was Sie behalten möchten, in einer verschlüsselten Sicherung (`Einstellungen → Verschlüsselte Sicherung`) oder einem Export (`Einstellungen → Alle meine Notizen exportieren`) ab. Eine **in den Papierkorb verschobene** Notiz kann 30 Tage lang wiederhergestellt werden. Eine **endgültig gelöschte** Notiz — aus dem Papierkorb oder mit langem Drücken in der Notizliste, nach einer Bestätigung — geht nicht durch den Papierkorb und **kann nicht mehr wiederhergestellt werden**. @@ -26,6 +26,8 @@ Ebenso **macht eine vergessene Tresor-Passphrase dessen Notizen für immer unles Bei **PIN**-Tresoren **lösen 5 aufeinanderfolgende Fehlversuche ein automatisches Löschen aus** (Löschen des Keystore-Schlüssels). Dies entspricht dem Verhalten der üblichen Android-Displaysperre – erst ab Android 9: Davor kann Android diesen Schlüssel nicht an das Entsperren des Telefons binden, die App erstellt dort keinen PIN-Tresor, und ein zuvor erstellter PIN-Tresor hält diese Grenze von fünf Versuchen auf einem beschlagnahmten Telefon nicht ein. +Eine **verschlüsselte Sicherung** öffnet sich nur mit ihrem Code oder ihrer Passphrase, die Notes Tech nicht aufbewahrt: Ohne dieses Geheimnis bleibt die Datei **für immer unlesbar**. Der Panikmodus erreicht keine außerhalb der App gespeicherte Sicherung: Löschen Sie sie selbst. + ## Diktiermodell Die App ist kompatibel mit: @@ -68,7 +70,7 @@ Updates werden im offiziellen GitHub-Repository und auf F-Droid veröffentlicht. ## Haftung -Die App wird kostenlos von einer Privatperson bereitgestellt. Soweit gesetzlich zulässig, haftet der Herausgeber nicht für indirekte Schäden und nicht für Datenverluste, die auf die Nutzung bewusst unwiderruflicher Funktionen (Panikmodus, Löschen eines PIN-Tresors nach fünf falschen Codes, endgültiges Löschen, Deinstallation) oder auf eine vergessene Passphrase oder PIN zurückgehen (Apache-Lizenz 2.0, Abschnitt 8). Diese Beschränkung gilt weder bei grober Fahrlässigkeit oder Vorsatz noch für Schäden, deren Ausschluss das Gesetz verbietet. +Die App wird kostenlos von einer Privatperson bereitgestellt. Soweit gesetzlich zulässig, haftet der Herausgeber nicht für indirekte Schäden und nicht für Datenverluste, die auf die Nutzung bewusst unwiderruflicher Funktionen (Panikmodus, Löschen eines PIN-Tresors nach fünf falschen Codes, endgültiges Löschen, Deinstallation) oder auf eine vergessene Passphrase, PIN oder einen vergessenen Sicherungscode zurückgehen (Apache-Lizenz 2.0, Abschnitt 8). Diese Beschränkung gilt weder bei grober Fahrlässigkeit oder Vorsatz noch für Schäden, deren Ausschluss das Gesetz verbietet. ## Anwendbares Recht diff --git a/app/src/main/res/raw-es/privacy.md b/app/src/main/res/raw-es/privacy.md index 8206a4d..ad63ecf 100644 --- a/app/src/main/res/raw-es/privacy.md +++ b/app/src/main/res/raw-es/privacy.md @@ -1,6 +1,6 @@ # Política de privacidad — Notes Tech -_Última actualización: 26 de octubre de 2026_ +_Última actualización: 29 de octubre de 2026_ ## En una frase @@ -15,6 +15,7 @@ Notes Tech no recopila, no transmite ni almacena ningún dato en servidores remo - **Etiquetas y color de una nota de caja fuerte**: permanecen en la base de datos cifrada por SQLCipher, pero **fuera del cifrado propio de la caja fuerte**: solo el título y el contenido quedan bajo él. En las listas, una nota de caja fuerte nunca muestra sus etiquetas, y muestra su color solo mientras esa caja fuerte está desbloqueada. - **Bloqueo de la aplicación (opcional)**: el PIN nunca se guarda. Se conserva un valor derivado de él con **Argon2id** y vinculado mediante **HMAC-SHA256** a una clave del **Android Keystore** que nunca sale del teléfono, de modo que no puede comprobarse en ningún otro lugar. Tras cinco PIN incorrectos, cada nuevo intento espera más (30 segundos, que se duplican hasta una hora); los intentos fallidos nunca borran nada. El **desbloqueo con huella dactilar o rostro** lo gestiona íntegramente Android: Notes Tech no recibe ningún dato biométrico, solo la confirmación de que se ha permitido usar una clave del Keystore, y solo se acepta la biometría fuerte (clase 3). - **Retroenlaces `[[Título]]`**: índice invertido local, nunca transmitido. +- **Copia de seguridad cifrada (opcional)**: un archivo `.ntbackup` que usted mismo crea, en el lugar que elige mediante el selector de documentos de Android. Contiene sus carpetas y sus notas, papelera incluida, cifradas con **AES-256-GCM** con una clave derivada mediante **Argon2id** (m=64MB, t=3) de un código que genera la aplicación y que usted anota, o de una frase de contraseña propia; Notes Tech no guarda ni uno ni otra. Primero se escribe, ya cifrado, en el almacenamiento privado de la aplicación; luego se copia al lugar elegido y se borra de ese almacenamiento. Las notas de una caja fuerte con frase de contraseña siguen selladas por su frase en el archivo. Una caja fuerte con PIN solo viaja abierta, con su clave: en el archivo solo la protege el secreto de la copia; cerrada, queda fuera. La restauración añade lo que falta y no sobrescribe nada. - **Modelo de dictado por voz (Whisper `.bin`)**: lo obtiene usted mismo —la aplicación muestra el nombre del archivo y su origen— y luego lo importa mediante el selector de documentos de Android. Notes Tech no tiene permiso de acceso a Internet y **no ofrece ninguna forma de descargar nada**. Su SHA-256 se comprueba al importarlo y antes de cada carga. - **Audio captado durante el dictado**: se escribe en un archivo temporal del almacenamiento privado de la aplicación —el motor de transcripción lee un archivo, no hay otra manera— y **se borra en cuanto llega la transcripción**. También se borra al iniciar la aplicación y con el modo pánico, de modo que un cierre brusco no deja nada. - **Ajustes (tema, orden, dictado activado, bloqueo automático de las cajas fuertes, opciones del bloqueo de la aplicación)**: guardados sin cifrar en las preferencias locales (ningún dato sensible). @@ -33,7 +34,7 @@ Los permisos activos son estrictamente funcionales: - `RECORD_AUDIO` (dictado: el audio se escribe en un archivo temporal privado y se borra en cuanto llega la transcripción). - `USE_BIOMETRIC` y `USE_FINGERPRINT` (desbloqueo opcional del bloqueo de la aplicación con huella dactilar o rostro; declarados por la biblioteca biométrica de AndroidX —`USE_FINGERPRINT` es el nombre que tiene el mismo permiso en Android 8 y versiones anteriores). -Estos son los **únicos** permisos solicitados. La selección del archivo del modelo pasa por el selector de documentos de Android, que no requiere ninguno. +Estos son los **únicos** permisos solicitados. La selección de un archivo —el modelo de dictado, notas que importar, una copia de seguridad— pasa por el selector de documentos de Android, que no requiere ninguno. ### Modo pánico @@ -48,12 +49,15 @@ El menú **Ajustes → Modo pánico** borra de una vez: - el modelo Whisper instalado en el espacio aislado, - las preferencias (excepto `db_encrypted_v1` y `secure_window_enabled`, que se conservan para que el reinicio sea coherente). +El modo pánico no alcanza una copia de seguridad cifrada guardada fuera de la aplicación, dondequiera que la haya puesto: bórrela usted mismo. + El borrado **no es atómico**, y el orden de los pasos está pensado en función de ello: la clave de cifrado se destruye **antes** de los borrados largos, después los archivos sin cifrar y después el resto. Un cierre brusco en cualquier instante deja por tanto el estado más seguro alcanzable en ese instante: en el peor de los casos, una base de datos reducida a ruido. La pantalla final indica qué ha fallado y **si puede quedar algo legible**. ### Sus derechos Sus datos permanecen en su teléfono, bajo su exclusivo control, y el editor no tiene ningún medio de acceder a ellos: por tanto, no trata ningún dato personal suyo. Es el caso que la CNIL, la autoridad francesa de protección de datos, describe como un «simple software puesto a disposición del usuario», al que no se aplica el RGPD (recomendación sobre las aplicaciones móviles, apartado 3.3). Usted conserva el control de sus datos y en cualquier momento puede: - exportar sus notas en Markdown o ZIP (`Ajustes → Exportar todas mis notas`), +- hacer una copia de seguridad cifrada de ellas y restaurarla (`Ajustes → Copia de seguridad cifrada`), - eliminar una nota definitivamente, desde la papelera o con una pulsación larga en la lista de notas, - borrar todos los datos mediante el modo pánico, - desinstalar la aplicación: Android borrará automáticamente todos los datos privados. diff --git a/app/src/main/res/raw-es/terms.md b/app/src/main/res/raw-es/terms.md index 2ae30a4..eecc02c 100644 --- a/app/src/main/res/raw-es/terms.md +++ b/app/src/main/res/raw-es/terms.md @@ -1,6 +1,6 @@ # Condiciones de uso — Notes Tech -_Última actualización: 26 de octubre de 2026_ +_Última actualización: 29 de octubre de 2026_ ## Licencia @@ -18,7 +18,7 @@ Un particular pone la aplicación a disposición **de forma gratuita**, a títul ## Modo pánico y pérdida de datos -El **modo pánico** borra de forma definitiva e irreversible sus notas, su clave de cifrado y sus modelos. **No es posible ninguna recuperación**: es intencionado. Antes de usarlo, exporte lo que quiera conservar mediante `Ajustes → Exportar todas mis notas`. +El **modo pánico** borra de forma definitiva e irreversible sus notas, su clave de cifrado y sus modelos. **No es posible ninguna recuperación**: es intencionado. Antes de usarlo, guarde lo que quiera conservar en una copia de seguridad cifrada (`Ajustes → Copia de seguridad cifrada`) o en una exportación (`Ajustes → Exportar todas mis notas`). Una nota **movida a la papelera** puede restaurarse durante 30 días. Una nota **eliminada definitivamente** —desde la papelera, o con una pulsación larga en la lista de notas, tras una confirmación— no pasa por la papelera y **ya no puede recuperarse**. @@ -26,6 +26,8 @@ Del mismo modo, **olvidar la frase de contraseña de una caja fuerte hace que su En las cajas fuertes con **PIN**, **5 errores seguidos provocan un borrado automático** (eliminación de la clave del Keystore). Es el mismo comportamiento que el del bloqueo de pantalla habitual de Android, pero solo desde Android 9: antes, Android no puede vincular esta clave al desbloqueo del teléfono, la aplicación no crea allí cajas fuertes con PIN, y una caja fuerte con PIN creada antes no respeta ese límite de cinco intentos en un teléfono incautado. +Una **copia de seguridad cifrada** solo se abre con su código o su frase de contraseña, que Notes Tech no guarda: sin él, el archivo queda **ilegible para siempre**. El modo pánico no alcanza una copia guardada fuera de la aplicación: bórrela usted mismo. + ## Modelo de dictado por voz La aplicación es compatible con: @@ -68,7 +70,7 @@ Las actualizaciones se publican en el repositorio oficial de GitHub y en F-Droid ## Responsabilidad -La aplicación se ofrece gratuitamente, por un particular. En la medida en que la ley lo permita, el editor no es responsable de los daños indirectos, ni de las pérdidas de datos debidas al uso de funciones concebidas para ser irreversibles (modo pánico, borrado de una caja fuerte con PIN tras cinco códigos erróneos, eliminación definitiva, desinstalación) o a una frase de contraseña o un PIN olvidados (licencia Apache 2.0, sección 8). Esta limitación no se aplica en caso de culpa grave o dolo, ni a los daños que la ley prohíbe excluir. +La aplicación se ofrece gratuitamente, por un particular. En la medida en que la ley lo permita, el editor no es responsable de los daños indirectos, ni de las pérdidas de datos debidas al uso de funciones concebidas para ser irreversibles (modo pánico, borrado de una caja fuerte con PIN tras cinco códigos erróneos, eliminación definitiva, desinstalación) o a una frase de contraseña, un PIN o un código de copia olvidados (licencia Apache 2.0, sección 8). Esta limitación no se aplica en caso de culpa grave o dolo, ni a los daños que la ley prohíbe excluir. ## Legislación aplicable diff --git a/app/src/main/res/raw-fr/privacy.md b/app/src/main/res/raw-fr/privacy.md index b228a41..d0cac7a 100644 --- a/app/src/main/res/raw-fr/privacy.md +++ b/app/src/main/res/raw-fr/privacy.md @@ -1,6 +1,6 @@ # Politique de confidentialité — Notes Tech -_Dernière mise à jour : 26 octobre 2026_ +_Dernière mise à jour : 29 octobre 2026_ ## En une phrase @@ -15,6 +15,7 @@ Notes Tech ne collecte, ne transmet et ne stocke aucune donnée sur des serveurs - **Étiquettes et couleur d'une note de coffre** : elles restent dans la base chiffrée par SQLCipher, mais **hors du chiffrement propre au coffre** — seuls le titre et le contenu en relèvent. Dans les listes, une note de coffre n'affiche jamais ses étiquettes, et sa couleur seulement lorsque ce coffre est ouvert. - **Verrouillage de l'application (facultatif)** : le PIN lui-même n'est jamais conservé. Ce qui l'est, c'est une valeur dérivée par **Argon2id** et liée par **HMAC-SHA256** à une clé du **Keystore Android** qui ne quitte jamais le téléphone : elle ne peut donc être vérifiée nulle part ailleurs. Après cinq PIN erronés, chaque nouvel essai attend plus longtemps (30 secondes, doublées jusqu'à une heure) ; un essai raté n'efface jamais rien. Le **déverrouillage par empreinte ou par visage** est entièrement géré par Android : Notes Tech ne reçoit aucune donnée biométrique, seulement la confirmation qu'une clé du Keystore a été autorisée à servir, et seule la biométrie forte (classe 3) est acceptée. - **Backlinks `[[Titre]]`** : index inversé local, jamais transmis. +- **Sauvegarde chiffrée (facultative)** : un fichier `.ntbackup` que vous créez vous-même, à l'endroit que vous choisissez par le sélecteur de documents Android. Il contient vos dossiers et vos notes, corbeille comprise, chiffrés en **AES-256-GCM** sous une clé dérivée par **Argon2id** (m=64MB, t=3) d'un code tiré par l'application et que vous notez, ou d'une passphrase de votre choix — Notes Tech ne garde ni l'un ni l'autre. Il est d'abord écrit, déjà chiffré, dans le stockage privé de l'application, puis copié à l'endroit choisi et effacé du stockage de l'application. Les notes d'un coffre à passphrase restent scellées par sa passphrase dans le fichier. Un coffre à PIN ne voyage qu'ouvert, avec sa clé : dans le fichier, seul le secret de la sauvegarde le protège ; fermé, il est laissé de côté. La restauration ajoute ce qui manque et n'écrase rien. - **Modèle de dictée vocale (Whisper `.bin`)** : vous vous le procurez vous-même — l'application affiche le nom du fichier et sa source — puis vous l'importez par le sélecteur de documents Android. Notes Tech n'a pas la permission Internet et **n'expose aucun moyen de télécharger quoi que ce soit**. Son empreinte SHA-256 est vérifiée à l'import et avant chaque chargement. - **Audio capturé pendant la dictée** : écrit dans un fichier temporaire du stockage privé de l'application — le moteur de transcription lit un fichier, il ne peut pas en être autrement — puis **effacé dès la transcription obtenue**. Il l'est également au démarrage de l'application et par le mode panique, de sorte qu'un arrêt brutal ne laisse rien derrière lui. - **Préférences (thème, tri, dictée activée, auto-lock coffre, options du verrouillage)** : stockées en clair dans les préférences locales (pas de donnée sensible). @@ -33,7 +34,7 @@ Les permissions actives sont strictement utilitaires : - `RECORD_AUDIO` (dictée : l'audio est écrit dans un fichier temporaire privé, puis effacé dès la transcription obtenue). - `USE_BIOMETRIC` et `USE_FINGERPRINT` (déverrouillage facultatif de l'application par empreinte ou par visage ; déclarées par la bibliothèque biométrique AndroidX — `USE_FINGERPRINT` est le nom que porte la même permission sur Android 8 et antérieur). -Ce sont les **seules** permissions demandées. Le choix du fichier de modèle passe par le sélecteur de documents Android, qui n'en exige aucune. +Ce sont les **seules** permissions demandées. Le choix d'un fichier — le modèle de dictée, des notes à importer, une sauvegarde — passe par le sélecteur de documents Android, qui n'en exige aucune. ### Mode panique @@ -48,12 +49,15 @@ Le menu **Réglages → Mode panique** efface en bloc : - le modèle Whisper installé dans le sandbox, - les préférences (sauf `db_encrypted_v1` et `secure_window_enabled` conservées pour cohérence du redémarrage). +Le mode panique n'atteint pas une sauvegarde chiffrée enregistrée hors de l'application, où que vous l'ayez mise : supprimez-la vous-même. + L'effacement **n'est pas atomique**, et l'ordre des étapes est conçu pour cela : la clé de chiffrement est détruite **avant** les effacements longs, puis viennent les fichiers en clair, puis le reste. Un arrêt brutal à n'importe quel instant laisse donc l'état le plus sûr atteignable à cet instant — au pire une base réduite à du bruit. L'écran de fin indique ce qui a échoué, et **s'il peut subsister quelque chose de lisible**. ### Vos droits Vos données restent sur votre téléphone, sous votre seul contrôle, et l'éditeur n'a aucun moyen d'y accéder : il ne traite donc aucune donnée personnelle vous concernant. C'est le cas que la CNIL décrit comme un « simple logiciel mis à disposition de l'utilisateur », auquel le RGPD ne s'applique pas (recommandation sur les applications mobiles, § 3.3). Vous gardez la main sur vos données et pouvez à tout moment : - exporter vos notes au format Markdown ou ZIP (`Réglages → Exporter toutes mes notes`), +- en faire une sauvegarde chiffrée et la restaurer (`Réglages → Sauvegarde chiffrée`), - supprimer une note définitivement, depuis la corbeille ou par un appui long dans la liste des notes, - supprimer toutes les données via le mode panique, - désinstaller l'application — Android supprimera automatiquement toutes les données privées. diff --git a/app/src/main/res/raw-fr/terms.md b/app/src/main/res/raw-fr/terms.md index 6bc2f3d..ecccb30 100644 --- a/app/src/main/res/raw-fr/terms.md +++ b/app/src/main/res/raw-fr/terms.md @@ -1,6 +1,6 @@ # Conditions d'utilisation — Notes Tech -_Dernière mise à jour : 26 octobre 2026_ +_Dernière mise à jour : 29 octobre 2026_ ## Licence @@ -18,7 +18,7 @@ L'application est mise à disposition **gratuitement** par un particulier, à ti ## Mode panique et perte de données -Le **mode panique** efface définitivement et irréversiblement vos notes, votre clé de chiffrement et vos modèles. **Aucune récupération n'est possible** — c'est par conception. Avant de l'utiliser, exportez ce que vous voulez conserver via `Réglages → Exporter toutes mes notes`. +Le **mode panique** efface définitivement et irréversiblement vos notes, votre clé de chiffrement et vos modèles. **Aucune récupération n'est possible** — c'est par conception. Avant de l'utiliser, mettez ce que vous voulez conserver dans une sauvegarde chiffrée (`Réglages → Sauvegarde chiffrée`) ou un export (`Réglages → Exporter toutes mes notes`). Une note **mise à la corbeille** peut être restaurée pendant 30 jours. Une note **supprimée définitivement** — depuis la corbeille, ou par un appui long dans la liste des notes, après confirmation — ne passe pas par la corbeille et **ne peut plus être récupérée**. @@ -26,6 +26,8 @@ De même, **oublier la passphrase d'un coffre rend ses notes illisibles à jamai Pour les coffres en mode **PIN**, **5 échecs successifs déclenchent un auto-wipe** (suppression de la clé Keystore). Aligné sur le comportement standard d'un écran de verrouillage Android — à partir d'Android 9 seulement : avant, Android ne sait pas lier cette clé au déverrouillage du téléphone, l'application n'y crée aucun coffre PIN, et un coffre PIN créé auparavant ne tient pas cette limite de cinq essais sur un téléphone saisi. +Une **sauvegarde chiffrée** ne s'ouvre qu'avec son code ou sa passphrase, que Notes Tech ne garde pas : sans eux, le fichier reste **illisible à jamais**. Le mode panique n'atteint pas une sauvegarde enregistrée hors de l'application : supprimez-la vous-même. + ## Modèle de dictée vocale L'application est compatible avec : @@ -72,7 +74,7 @@ Les mises à jour sont publiées sur le dépôt GitHub officiel et sur F-Droid. ## Responsabilité -L'application est fournie gratuitement, par un particulier. Dans la mesure permise par la loi, l'éditeur n'est pas responsable des dommages indirects, ni des pertes de données dues à l'usage de fonctions conçues pour être irréversibles (mode panique, effacement d'un coffre PIN après cinq codes erronés, suppression définitive, désinstallation) ou à une passphrase ou un code PIN oublié (licence Apache 2.0, section 8). Cette limite ne s'applique ni en cas de faute lourde ou dolosive, ni aux dommages que la loi interdit d'exclure. +L'application est fournie gratuitement, par un particulier. Dans la mesure permise par la loi, l'éditeur n'est pas responsable des dommages indirects, ni des pertes de données dues à l'usage de fonctions conçues pour être irréversibles (mode panique, effacement d'un coffre PIN après cinq codes erronés, suppression définitive, désinstallation) ou à une passphrase, un code PIN ou un code de sauvegarde oublié (licence Apache 2.0, section 8). Cette limite ne s'applique ni en cas de faute lourde ou dolosive, ni aux dommages que la loi interdit d'exclure. ## Loi applicable diff --git a/app/src/main/res/raw-it/privacy.md b/app/src/main/res/raw-it/privacy.md index 360fc90..816f6bb 100644 --- a/app/src/main/res/raw-it/privacy.md +++ b/app/src/main/res/raw-it/privacy.md @@ -1,6 +1,6 @@ # Informativa sulla privacy — Notes Tech -_Ultimo aggiornamento: 26 ottobre 2026_ +_Ultimo aggiornamento: 29 ottobre 2026_ ## In una frase @@ -15,6 +15,7 @@ Notes Tech non raccoglie, non trasmette e non conserva alcun dato su server remo - **Tag e colore di una nota in cassaforte**: restano nel database cifrato da SQLCipher, ma **fuori dalla cifratura propria della cassaforte** — solo il titolo e il contenuto vi rientrano. Negli elenchi, una nota in cassaforte non mostra mai i suoi tag, e mostra il suo colore solo mentre la cassaforte è sbloccata. - **Blocco dell'app (facoltativo)**: il PIN non viene mai memorizzato. Viene conservato un valore derivato da esso con **Argon2id** e legato tramite **HMAC-SHA256** a una chiave dell'**Android Keystore** che non lascia mai il telefono, quindi non può essere verificato altrove. Dopo cinque PIN errati, ogni nuovo tentativo attende più a lungo (30 secondi, raddoppiando fino a un'ora); i tentativi errati non cancellano mai nulla. Lo **sblocco con impronta digitale o volto** è gestito interamente da Android: Notes Tech non riceve alcun dato biometrico, solo la conferma che l'uso di una chiave del Keystore è stato consentito, e viene accettata solo la biometria forte (classe 3). - **Collegamenti inversi `[[Titolo]]`**: indice invertito locale, mai trasmesso. +- **Backup cifrato (facoltativo)**: un file `.ntbackup` che crea lei stesso, nel punto che sceglie tramite il selettore di documenti di Android. Contiene le sue cartelle e le sue note, cestino compreso, cifrate con **AES-256-GCM** con una chiave derivata tramite **Argon2id** (m=64MB, t=3) da un codice estratto dall'app e che lei annota, o da una passphrase di sua scelta — Notes Tech non conserva né l'uno né l'altra. Viene prima scritto, già cifrato, nella memoria privata dell'app, poi copiato nel punto scelto e cancellato dalla memoria dell'app. Le note di una cassaforte con passphrase restano sigillate dalla sua passphrase nel file. Una cassaforte con PIN viaggia solo aperta, con la sua chiave: nel file la protegge soltanto il segreto del backup; chiusa, viene esclusa. Il ripristino aggiunge ciò che manca e non sovrascrive nulla. - **Modello di dettatura vocale (Whisper `.bin`)**: se lo procura lei stesso — l'app mostra il nome del file e la sua origine — e poi lo importa tramite il selettore di documenti di Android. Notes Tech non ha l'autorizzazione di accesso a Internet e **non offre alcun modo di scaricare nulla**. Il suo SHA-256 viene verificato all'importazione e prima di ogni caricamento. - **Audio acquisito durante la dettatura**: scritto in un file temporaneo nello spazio privato dell'app — il motore di trascrizione legge un file, non c'è altro modo — e **cancellato appena arriva la trascrizione**. Viene cancellato anche all'avvio dell'app e dalla modalità panico, così una chiusura improvvisa non lascia nulla. - **Impostazioni (tema, ordinamento, dettatura attivata, blocco automatico delle casseforti, opzioni del blocco dell'app)**: conservate in chiaro nelle preferenze locali (nessun dato sensibile). @@ -33,7 +34,7 @@ Le autorizzazioni attive sono strettamente funzionali: - `RECORD_AUDIO` (dettatura: l'audio viene scritto in un file temporaneo privato, poi cancellato appena arriva la trascrizione). - `USE_BIOMETRIC` e `USE_FINGERPRINT` (sblocco facoltativo del blocco dell'app con impronta digitale o volto; dichiarate dalla libreria biometrica di AndroidX — `USE_FINGERPRINT` è il nome della stessa autorizzazione su Android 8 e versioni precedenti). -Sono le **uniche** autorizzazioni richieste. La scelta del file del modello passa dal selettore di documenti di Android, che non ne richiede alcuna. +Sono le **uniche** autorizzazioni richieste. La scelta di un file — il modello di dettatura, note da importare, un backup — passa dal selettore di documenti di Android, che non ne richiede alcuna. ### Modalità panico @@ -48,12 +49,15 @@ Il menu **Impostazioni → Modalità panico** cancella in blocco: - il modello Whisper installato nella sandbox, - le preferenze (tranne `db_encrypted_v1` e `secure_window_enabled`, conservate per un riavvio coerente). +La modalità panico non raggiunge un backup cifrato salvato fuori dall'app, ovunque lo abbia messo: lo elimini lei stesso. + La cancellazione **non è atomica**, e l'ordine dei passaggi è pensato di conseguenza: la chiave di cifratura viene distrutta **prima** delle cancellazioni lunghe, poi i file in chiaro, poi il resto. Una chiusura improvvisa in qualsiasi istante lascia quindi lo stato più sicuro raggiungibile in quell'istante: nel peggiore dei casi, un database ridotto a rumore. La schermata finale indica che cosa non è riuscito e **se può restare qualcosa di leggibile**. ### I suoi diritti I suoi dati restano sul suo telefono, sotto il suo esclusivo controllo, e l'editore non ha alcun modo di accedervi: non tratta quindi alcun dato personale che la riguardi. È il caso che la CNIL, l'autorità francese per la protezione dei dati, descrive come un «semplice software messo a disposizione dell'utente», al quale il GDPR non si applica (raccomandazione sulle applicazioni mobili, paragrafo 3.3). Mantiene il controllo dei suoi dati e in qualsiasi momento può: - esportare le sue note in Markdown o ZIP (`Impostazioni → Esporta tutte le mie note`), +- farne un backup cifrato e ripristinarlo (`Impostazioni → Backup cifrato`), - eliminare definitivamente una nota, dal cestino o con una pressione prolungata nell'elenco delle note, - cancellare tutti i dati con la modalità panico, - disinstallare l'app: Android cancellerà automaticamente tutti i dati privati. diff --git a/app/src/main/res/raw-it/terms.md b/app/src/main/res/raw-it/terms.md index ce5ba10..a6a9105 100644 --- a/app/src/main/res/raw-it/terms.md +++ b/app/src/main/res/raw-it/terms.md @@ -1,6 +1,6 @@ # Condizioni d'uso — Notes Tech -_Ultimo aggiornamento: 26 ottobre 2026_ +_Ultimo aggiornamento: 29 ottobre 2026_ ## Licenza @@ -18,7 +18,7 @@ L'app è messa a disposizione **gratuitamente** da un privato, a titolo personal ## Modalità panico e perdita di dati -La **modalità panico** cancella in modo definitivo e irreversibile le sue note, la sua chiave di cifratura e i suoi modelli. **Nessun recupero è possibile**: è voluto. Prima di usarla, esporti ciò che vuole conservare con `Impostazioni → Esporta tutte le mie note`. +La **modalità panico** cancella in modo definitivo e irreversibile le sue note, la sua chiave di cifratura e i suoi modelli. **Nessun recupero è possibile**: è voluto. Prima di usarla, metta ciò che vuole conservare in un backup cifrato (`Impostazioni → Backup cifrato`) o in un'esportazione (`Impostazioni → Esporta tutte le mie note`). Una nota **spostata nel cestino** può essere ripristinata per 30 giorni. Una nota **eliminata definitivamente** — dal cestino, o con una pressione prolungata nell'elenco delle note, dopo una conferma — non passa dal cestino e **non può più essere recuperata**. @@ -26,6 +26,8 @@ Allo stesso modo, **dimenticare la passphrase di una cassaforte rende le sue not Per le casseforti con **PIN**, **5 errori consecutivi attivano una cancellazione automatica** (eliminazione della chiave del Keystore). È lo stesso comportamento del normale blocco schermo di Android, ma solo da Android 9: prima, Android non può legare questa chiave allo sblocco del telefono, l'app non vi crea casseforti con PIN, e una cassaforte con PIN creata in precedenza non rispetta questo limite di cinque tentativi su un telefono sequestrato. +Un **backup cifrato** si apre solo con il suo codice o la sua passphrase, che Notes Tech non conserva: senza di esso, il file resta **illeggibile per sempre**. La modalità panico non raggiunge un backup salvato fuori dall'app: lo elimini lei stesso. + ## Modello di dettatura vocale L'app è compatibile con: @@ -68,7 +70,7 @@ Gli aggiornamenti sono pubblicati sul repository GitHub ufficiale e su F-Droid. ## Responsabilità -L'app è fornita gratuitamente, da un privato. Nella misura consentita dalla legge, l'editore non è responsabile dei danni indiretti, né delle perdite di dati dovute all'uso di funzioni concepite per essere irreversibili (modalità panico, cancellazione di una cassaforte con PIN dopo cinque codici errati, eliminazione definitiva, disinstallazione) o a una passphrase o un PIN dimenticati (licenza Apache 2.0, sezione 8). Questo limite non si applica né in caso di colpa grave o dolo, né ai danni che la legge vieta di escludere. +L'app è fornita gratuitamente, da un privato. Nella misura consentita dalla legge, l'editore non è responsabile dei danni indiretti, né delle perdite di dati dovute all'uso di funzioni concepite per essere irreversibili (modalità panico, cancellazione di una cassaforte con PIN dopo cinque codici errati, eliminazione definitiva, disinstallazione) o a una passphrase, un PIN o un codice di backup dimenticati (licenza Apache 2.0, sezione 8). Questo limite non si applica né in caso di colpa grave o dolo, né ai danni che la legge vieta di escludere. ## Legge applicabile diff --git a/app/src/main/res/raw/privacy.md b/app/src/main/res/raw/privacy.md index 9f05026..9928df9 100644 --- a/app/src/main/res/raw/privacy.md +++ b/app/src/main/res/raw/privacy.md @@ -1,6 +1,6 @@ # Privacy policy — Notes Tech -_Last updated: 26 October 2026_ +_Last updated: 29 October 2026_ ## In one sentence @@ -15,6 +15,7 @@ Notes Tech does not collect, transmit or store any data on remote servers. Every - **Tags and colour of a vault note**: they stay in the database encrypted by SQLCipher, but **outside the vault's own encryption** — only the title and the content fall under it. In the lists, a vault note never shows its tags, and shows its colour only while that vault is open. - **App lock (optional)**: the PIN itself is never stored. What is kept is a value derived from it with **Argon2id** and bound by **HMAC-SHA256** to a key of the **Android Keystore** that never leaves the phone — so it cannot be checked anywhere else. After five wrong PINs, each new attempt waits longer (30 seconds, doubling up to one hour); wrong attempts never erase anything. **Fingerprint or face unlock** is handled entirely by Android: Notes Tech receives no biometric data, only the confirmation that a Keystore key was allowed to work, and only strong (Class 3) biometrics are accepted. - **Backlinks `[[Title]]`**: local inverted index, never transmitted. +- **Encrypted backup (optional)**: a `.ntbackup` file you create yourself, where you choose through the Android document picker. It holds your folders and notes, trash included, encrypted with **AES-256-GCM** under a key derived by **Argon2id** (m=64MB, t=3) from a code the app draws and you write down, or from a passphrase of your own — Notes Tech keeps neither. It is first written, already encrypted, in the app's private storage, then copied where you chose and erased from the app's storage. A passphrase vault's notes stay sealed by its passphrase in the file. A PIN vault travels only open, with its key: in the file, only the backup's secret protects it; a closed one is left out. Restoring adds what is missing and overwrites nothing. - **Voice dictation model (Whisper `.bin`)**: you obtain it yourself — the app shows the file name and its source — then import it through the Android document picker. Notes Tech has no Internet permission and **exposes no way to download anything**. Its SHA-256 is verified on import and before every load. - **Audio captured during dictation**: written to a temporary file in the app's private storage — the transcription engine reads a file, there is no way around it — then **wiped as soon as the transcription is returned**. It is also wiped on app start and by panic mode, so an abrupt shutdown leaves nothing behind. - **Settings (theme, sort, dictation enabled, vault auto-lock, app lock options)**: stored in clear in local preferences (no sensitive data). @@ -33,7 +34,7 @@ Active permissions are strictly utilitarian: - `RECORD_AUDIO` (dictation: audio is written to a private temporary file, then wiped as soon as the transcription is returned). - `USE_BIOMETRIC` and `USE_FINGERPRINT` (optional fingerprint or face unlock of the app lock; declared by the AndroidX biometric library — `USE_FINGERPRINT` is the name the same permission has on Android 8 and older). -These are the **only** permissions requested. Picking the model file goes through the Android document picker, which requires none. +These are the **only** permissions requested. Picking a file — the dictation model, notes to import, a backup — goes through the Android document picker, which requires none. ### Panic mode @@ -48,12 +49,15 @@ The **Settings → Panic mode** menu wipes in bulk: - the Whisper model installed in the sandbox, - the preferences (except `db_encrypted_v1` and `secure_window_enabled` kept for restart consistency). +Panic mode does not reach an encrypted backup saved outside the app, wherever you put it: delete it yourself. + The wipe is **not atomic**, and the step order is designed around that: the encryption key is destroyed **before** the long erasures, then the cleartext files, then the rest. An abrupt shutdown at any instant therefore leaves the safest state reachable at that instant — at worst a database reduced to noise. The final screen states what failed, and **whether anything readable may remain**. ### Your rights Your data stays on your phone, under your sole control, and the publisher has no way to access it, and therefore processes no personal data about you. This is what the CNIL, the French data protection authority, describes as "software simply made available to the user", to which the GDPR does not apply (recommendation on mobile apps, section 3.3). You remain in control of your data and may at any time: - export your notes in Markdown or ZIP (`Settings → Export all my notes`), +- make an encrypted backup of them and restore it (`Settings → Encrypted backup`), - delete a note permanently, from the trash or with a long press in the list of notes, - delete all data via panic mode, - uninstall the app — Android will automatically delete all private data. diff --git a/app/src/main/res/raw/terms.md b/app/src/main/res/raw/terms.md index 27088b3..644c00c 100644 --- a/app/src/main/res/raw/terms.md +++ b/app/src/main/res/raw/terms.md @@ -1,6 +1,6 @@ # Terms of use — Notes Tech -_Last updated: 26 October 2026_ +_Last updated: 29 October 2026_ ## License @@ -18,7 +18,7 @@ The app is made available **free of charge** by an individual, on a personal and ## Panic mode and data loss -The **panic mode** permanently and irreversibly wipes your notes, encryption key, and models. **No recovery is possible** — it is by design. Before using it, export what you want to keep via `Settings → Export all my notes`. +The **panic mode** permanently and irreversibly wipes your notes, encryption key, and models. **No recovery is possible** — it is by design. Before using it, put what you want to keep in an encrypted backup (`Settings → Encrypted backup`) or an export (`Settings → Export all my notes`). A note **moved to the trash** can be restored for 30 days. A note **deleted permanently** — from the trash, or with a long press in the list of notes, after a confirmation — does not go through the trash and **can no longer be recovered**. @@ -26,6 +26,8 @@ Likewise, **forgetting a vault passphrase makes its notes unreadable forever**: For **PIN** vaults, **5 successive failures trigger an auto-wipe** (Keystore key deletion). Aligned with the standard Android lock screen behaviour — from Android 9 only: before it, Android cannot tie that key to the phone being unlocked, the app creates no PIN vault there, and a PIN vault created earlier does not hold that five-attempt limit on a seized phone. +An **encrypted backup** opens only with its code or passphrase, which Notes Tech does not keep: without it, the file stays **unreadable forever**. Panic mode does not reach a backup saved outside the app: delete it yourself. + ## Voice dictation model The app is compatible with: @@ -71,7 +73,7 @@ Updates are published on the official GitHub repository and on F-Droid. The app ## Liability -The app is provided free of charge, by a private individual. To the extent permitted by law, the publisher is not liable for indirect damage, nor for data loss caused by using functions designed to be irreversible (panic mode, the erasure of a PIN vault after five wrong codes, permanent deletion, uninstalling) or by a forgotten passphrase or PIN (Apache 2.0 license, section 8). This limit applies neither to gross negligence or wilful misconduct, nor to damage the law forbids excluding. +The app is provided free of charge, by a private individual. To the extent permitted by law, the publisher is not liable for indirect damage, nor for data loss caused by using functions designed to be irreversible (panic mode, the erasure of a PIN vault after five wrong codes, permanent deletion, uninstalling) or by a forgotten passphrase, PIN or backup code (Apache 2.0 license, section 8). This limit applies neither to gross negligence or wilful misconduct, nor to damage the law forbids excluding. ## Governing law diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9d468f2..4169522 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -12,6 +12,7 @@ Abbrechen OK + Keine App auf diesem Gerät kann eine Datei auswählen. Schließen Löschen Entfernen @@ -320,6 +321,94 @@ %1$d Notiz kam in den Eingang: Ihr Ordner trägt den Namen eines Tresors. %1$d Notizen kamen in den Eingang: Ihr Ordner trägt den Namen eines Tresors. + Verschlüsselte Sicherung + Sicherung erstellen + Ihre Notizen und Ordner, Papierkorb inbegriffen, in einer einzigen verschlüsselten Datei. + Sicherung wiederherstellen + Fügt hinzu, was eine Sicherung enthält. Auf diesem Telefon wird nichts überschrieben. + Der Panikmodus erreicht diese Datei nicht: Bewahren Sie sie sicher auf und löschen Sie sie selbst, wenn Sie sie nicht mehr brauchen. + Notieren Sie diesen Code: Nur mit ihm lässt sich die Sicherung öffnen. Notes Tech bewahrt ihn nicht auf. + Code erneut eingeben + Eigene Passphrase verwenden + Generierten Code verwenden + Passphrase + Passphrase bestätigen + Mindestens %1$d Zeichen. + Zu leicht zu erraten: Wiederholungen, Folgen und gängige Passwörter werden abgelehnt. + Die beiden Passphrasen stimmen nicht überein. + Speichern + Wird ausgeführt… + Code oder Passphrase der Sicherung + Weiter + Aus dieser Sicherung ist nichts hinzuzufügen. + Seit der Sicherung von diesem Telefon gelöschte Notizen kommen ebenfalls zurück. + PIN-Tresor „%1$s“: Wählen Sie seine neue PIN + Neue PIN + Neue PIN bestätigen + Jede PIN: %1$d bis %2$d Ziffern, zweimal gleich eingegeben. + Wiederherstellen + Dieser Code oder diese Passphrase öffnet diese Sicherung nicht, oder die Datei ist beschädigt. Es wurde nichts wiederhergestellt. + Diese Datei ist keine Sicherung von Notes Tech. + Diese Sicherung stammt aus einer neueren Version von Notes Tech: Aktualisieren Sie die App, um sie wiederherzustellen. + Zu groß: Eine Sicherung umfasst höchstens 64 MB. + Die geschriebene Datei ließ sich nicht zurücklesen und wurde daher nicht behalten. Versuchen Sie einen anderen Speicherort. + Der Vorgang konnte nicht abgeschlossen werden. Versuchen Sie es erneut. + + Diese Sicherung wird %1$d Notiz enthalten, Papierkorb inbegriffen. + Diese Sicherung wird %1$d Notizen enthalten, Papierkorb inbegriffen. + + + Sicherung gespeichert: %1$d Notiz. + Sicherung gespeichert: %1$d Notizen. + + + %1$d Notiz wird hinzugefügt. + %1$d Notizen werden hinzugefügt. + + + %1$d Ordner wird erstellt. + %1$d Ordner werden erstellt. + + + Davon %1$d im Papierkorb, wie in der Sicherung. + Davon %1$d im Papierkorb, wie in der Sicherung. + + + %1$d Notiz weicht von ihrer Fassung auf diesem Telefon ab: Sie wird als Kopie daneben hinzugefügt. + %1$d Notizen weichen von ihrer Fassung auf diesem Telefon ab: Sie werden als Kopien daneben hinzugefügt. + + + %1$d Notiz ist bereits auf diesem Telefon und bleibt unverändert. + %1$d Notizen sind bereits auf diesem Telefon und bleiben unverändert. + + + %1$d Notiz wird ausgelassen: beschädigt oder unvereinbar mit den Tresoren dieses Telefons. + %1$d Notizen werden ausgelassen: beschädigt oder unvereinbar mit den Tresoren dieses Telefons. + + + Wiederhergestellt: %1$d Notiz hinzugefügt. + Wiederhergestellt: %1$d Notizen hinzugefügt. + + Code anzeigen + Code ausblenden + Code oder Passphrase anzeigen + Code oder Passphrase ausblenden + + Geschlossener PIN-Tresor, mit seinen Notizen ausgelassen: %1$s. Öffnen Sie ihn zuerst, um ihn einzuschließen. + Geschlossene PIN-Tresore, mit ihren Notizen ausgelassen: %1$s. Öffnen Sie sie zuerst, um sie einzuschließen. + + + Offener PIN-Tresor, mit seinem Schlüssel gesichert: %1$s. In der Datei schützt ihn das Geheimnis der Sicherung, nicht seine PIN. + Offene PIN-Tresore, mit ihren Schlüsseln gesichert: %1$s. In der Datei schützt sie das Geheimnis der Sicherung, nicht ihre PIN. + + + %1$d Notiz ausgelassen: Ihr Tresor konnte nicht mitgenommen werden (ein PIN-Tresor wird nur offen mitgenommen). + %1$d Notizen ausgelassen: Ihr Tresor konnte nicht mitgenommen werden (ein PIN-Tresor wird nur offen mitgenommen). + + + %1$d Ordner erstellt. + %1$d Ordner erstellt. + Export abgeschlossen: %1$d Notizen Export abgeschlossen: %1$d Notizen (%2$d in gesperrten Tresoren übersprungen) Export fehlgeschlagen: %1$s diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 2a7f22a..b76502d 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -13,6 +13,7 @@ Cancelar Aceptar + Ninguna aplicación de este dispositivo permite elegir un archivo. Cerrar Eliminar Quitar @@ -334,6 +335,107 @@ %1$d de notas fueron a la Bandeja de entrada: su carpeta lleva el nombre de una caja fuerte. %1$d notas fueron a la Bandeja de entrada: su carpeta lleva el nombre de una caja fuerte. + Copia de seguridad cifrada + Crear una copia de seguridad + Tus notas y carpetas, papelera incluida, en un solo archivo cifrado. + Restaurar una copia de seguridad + Añade lo que contiene una copia. No se sobrescribe nada en este teléfono. + El modo pánico no alcanza este archivo: guárdalo en un lugar seguro y bórralo tú cuando ya no lo necesites. + Anota este código: es la única forma de abrir la copia. Notes Tech no lo guarda. + Vuelve a escribir el código + Usar mi propia frase de contraseña + Usar un código generado + Frase de contraseña + Confirmar la frase de contraseña + Al menos %1$d caracteres. + Demasiado fácil de adivinar: se rechazan las repeticiones, las secuencias y las contraseñas comunes. + Las dos frases de contraseña no coinciden. + Guardar + En curso… + Código o frase de contraseña de la copia + Continuar + Nada que añadir desde esta copia. + También vuelven las notas borradas de este teléfono desde la copia. + Caja fuerte con PIN «%1$s»: elige su nuevo PIN + Nuevo PIN + Confirmar el nuevo PIN + Cada PIN: de %1$d a %2$d cifras, escrito dos veces igual. + Restaurar + Este código o esta frase de contraseña no abre esta copia, o el archivo está dañado. No se ha restaurado nada. + Este archivo no es una copia de seguridad de Notes Tech. + Esta copia viene de una versión más reciente de Notes Tech: actualiza la aplicación para restaurarla. + Demasiado grande: una copia ocupa como máximo 64 MB. + El archivo escrito no se puede releer, así que no se ha conservado. Prueba otra ubicación. + No se pudo completar la operación. Inténtalo de nuevo. + + Esta copia contendrá %1$d nota, papelera incluida. + Esta copia contendrá %1$d de notas, papelera incluida. + Esta copia contendrá %1$d notas, papelera incluida. + + + Copia guardada: %1$d nota. + Copia guardada: %1$d de notas. + Copia guardada: %1$d notas. + + + Se añadirá %1$d nota. + Se añadirán %1$d de notas. + Se añadirán %1$d notas. + + + Se creará %1$d carpeta. + Se crearán %1$d de carpetas. + Se crearán %1$d carpetas. + + + De ellas, %1$d en la papelera, como en la copia. + De ellas, %1$d en la papelera, como en la copia. + De ellas, %1$d en la papelera, como en la copia. + + + %1$d nota difiere de su versión en este teléfono: se añade como copia a su lado. + %1$d de notas difieren de su versión en este teléfono: se añaden como copias a su lado. + %1$d notas difieren de su versión en este teléfono: se añaden como copias a su lado. + + + %1$d nota ya está en este teléfono y se deja como está. + %1$d de notas ya están en este teléfono y se dejan como están. + %1$d notas ya están en este teléfono y se dejan como están. + + + %1$d nota queda fuera: dañada, o en conflicto con las cajas fuertes de este teléfono. + %1$d de notas quedan fuera: dañadas, o en conflicto con las cajas fuertes de este teléfono. + %1$d notas quedan fuera: dañadas, o en conflicto con las cajas fuertes de este teléfono. + + + Restauración hecha: %1$d nota añadida. + Restauración hecha: %1$d de notas añadidas. + Restauración hecha: %1$d notas añadidas. + + Mostrar el código + Ocultar el código + Mostrar el código o la frase de contraseña + Ocultar el código o la frase de contraseña + + Caja fuerte con PIN cerrada, excluida con sus notas: %1$s. Ábrela antes para incluirla. + Cajas fuertes con PIN cerradas, excluidas con sus notas: %1$s. Ábrelas antes para incluirlas. + Cajas fuertes con PIN cerradas, excluidas con sus notas: %1$s. Ábrelas antes para incluirlas. + + + Caja fuerte con PIN abierta, guardada con su clave: %1$s. En el archivo la protege el secreto de la copia, no su PIN. + Cajas fuertes con PIN abiertas, guardadas con sus claves: %1$s. En el archivo las protege el secreto de la copia, no su PIN. + Cajas fuertes con PIN abiertas, guardadas con sus claves: %1$s. En el archivo las protege el secreto de la copia, no su PIN. + + + %1$d nota excluida: su caja fuerte no podía viajar (una caja fuerte con PIN solo viaja abierta). + %1$d de notas excluidas: su caja fuerte no podía viajar (una caja fuerte con PIN solo viaja abierta). + %1$d notas excluidas: su caja fuerte no podía viajar (una caja fuerte con PIN solo viaja abierta). + + + %1$d carpeta creada. + %1$d de carpetas creadas. + %1$d carpetas creadas. + Exportación completada: %1$d notas Exportación completada: %1$d notas (%2$d omitidas en cajas fuertes bloqueadas) No se pudo exportar: %1$s diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index e1eb75e..51970bd 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -13,6 +13,7 @@ Annuler OK + Aucune application de cet appareil ne permet de choisir un fichier. Fermer Supprimer Retirer @@ -339,6 +340,107 @@ %1$d de notes sont allées dans la Boîte de réception : leur dossier porte le nom d\'un coffre. %1$d notes sont allées dans la Boîte de réception : leur dossier porte le nom d\'un coffre. + Sauvegarde chiffrée + Créer une sauvegarde + Vos notes et dossiers, corbeille comprise, dans un seul fichier chiffré. + Restaurer une sauvegarde + Rajoute ce que contient une sauvegarde. Rien n\'est écrasé sur ce téléphone. + Le mode panique n\'atteint pas ce fichier : gardez-le en lieu sûr, et supprimez-le vous-même quand il ne sert plus. + Notez ce code : c\'est le seul moyen d\'ouvrir la sauvegarde. Notes Tech ne le garde pas. + Retapez le code + Choisir ma propre passphrase + Utiliser un code généré + Passphrase + Confirmer la passphrase + Au moins %1$d caractères. + Trop facile à deviner : les répétitions, les suites et les mots de passe courants sont refusés. + Les deux passphrases ne correspondent pas. + Enregistrer + En cours… + Code ou passphrase de la sauvegarde + Continuer + Rien à ajouter depuis cette sauvegarde. + Les notes supprimées de ce téléphone depuis la sauvegarde reviennent aussi. + Coffre à PIN « %1$s » : choisissez son nouveau PIN + Nouveau PIN + Confirmer le nouveau PIN + Chaque PIN : de %1$d à %2$d chiffres, tapé deux fois à l\'identique. + Restaurer + Ce code ou cette passphrase n\'ouvre pas cette sauvegarde, ou le fichier est abîmé. Rien n\'a été restauré. + Ce fichier n\'est pas une sauvegarde de Notes Tech. + Cette sauvegarde vient d\'une version plus récente de Notes Tech : mettez l\'application à jour pour la restaurer. + Trop gros : une sauvegarde fait au plus 64 Mo. + Le fichier écrit ne se relit pas, il n\'a donc pas été gardé. Essayez un autre emplacement. + L\'opération n\'a pas pu aboutir. Réessayez. + + Cette sauvegarde contiendra %1$d note, corbeille comprise. + Cette sauvegarde contiendra %1$d de notes, corbeille comprise. + Cette sauvegarde contiendra %1$d notes, corbeille comprise. + + + Sauvegarde enregistrée : %1$d note. + Sauvegarde enregistrée : %1$d de notes. + Sauvegarde enregistrée : %1$d notes. + + + %1$d note sera ajoutée. + %1$d de notes seront ajoutées. + %1$d notes seront ajoutées. + + + %1$d dossier sera créé. + %1$d de dossiers seront créés. + %1$d dossiers seront créés. + + + Dont %1$d dans la corbeille, comme dans la sauvegarde. + Dont %1$d dans la corbeille, comme dans la sauvegarde. + Dont %1$d dans la corbeille, comme dans la sauvegarde. + + + %1$d note diffère de sa version sur ce téléphone : elle est ajoutée en copie à côté. + %1$d de notes diffèrent de leur version sur ce téléphone : elles sont ajoutées en copie à côté. + %1$d notes diffèrent de leur version sur ce téléphone : elles sont ajoutées en copie à côté. + + + %1$d note est déjà sur ce téléphone, laissée telle quelle. + %1$d de notes sont déjà sur ce téléphone, laissées telles quelles. + %1$d notes sont déjà sur ce téléphone, laissées telles quelles. + + + %1$d note est laissée de côté : abîmée, ou en désaccord avec les coffres de ce téléphone. + %1$d de notes sont laissées de côté : abîmées, ou en désaccord avec les coffres de ce téléphone. + %1$d notes sont laissées de côté : abîmées, ou en désaccord avec les coffres de ce téléphone. + + + Restauration faite : %1$d note ajoutée. + Restauration faite : %1$d de notes ajoutées. + Restauration faite : %1$d notes ajoutées. + + Afficher le code + Masquer le code + Afficher le code ou la passphrase + Masquer le code ou la passphrase + + Coffre à PIN fermé, laissé de côté avec ses notes : %1$s. Ouvrez-le d\'abord pour l\'inclure. + Coffres à PIN fermés, laissés de côté avec leurs notes : %1$s. Ouvrez-les d\'abord pour les inclure. + Coffres à PIN fermés, laissés de côté avec leurs notes : %1$s. Ouvrez-les d\'abord pour les inclure. + + + Coffre à PIN ouvert, sauvegardé avec sa clé : %1$s. Dans le fichier, c\'est le secret de la sauvegarde qui le protège, pas son PIN. + Coffres à PIN ouverts, sauvegardés avec leur clé : %1$s. Dans le fichier, c\'est le secret de la sauvegarde qui les protège, pas leur PIN. + Coffres à PIN ouverts, sauvegardés avec leur clé : %1$s. Dans le fichier, c\'est le secret de la sauvegarde qui les protège, pas leur PIN. + + + %1$d note laissée de côté : son coffre ne pouvait pas voyager (un coffre à PIN ne voyage qu\'ouvert). + %1$d de notes laissées de côté : leur coffre ne pouvait pas voyager (un coffre à PIN ne voyage qu\'ouvert). + %1$d notes laissées de côté : leur coffre ne pouvait pas voyager (un coffre à PIN ne voyage qu\'ouvert). + + + %1$d dossier créé. + %1$d de dossiers créés. + %1$d dossiers créés. + Export terminé : %1$d notes Export terminé : %1$d notes (%2$d ignorées dans des coffres verrouillés) L\'export a échoué : %1$s diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 1440fa0..b38c51b 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -13,6 +13,7 @@ Annulla OK + Nessuna app su questo dispositivo permette di scegliere un file. Chiudi Elimina Rimuovi @@ -334,6 +335,107 @@ %1$d di note sono finite in In arrivo: la loro cartella ha il nome di una cassaforte. %1$d note sono finite in In arrivo: la loro cartella ha il nome di una cassaforte. + Backup cifrato + Crea un backup + Le tue note e cartelle, cestino compreso, in un unico file cifrato. + Ripristina un backup + Aggiunge ciò che un backup contiene. Su questo telefono non viene sovrascritto nulla. + La modalità panico non raggiunge questo file: conservalo in un luogo sicuro ed eliminalo tu quando non ti serve più. + Annota questo codice: è l\'unico modo per aprire il backup. Notes Tech non lo conserva. + Digita di nuovo il codice + Usa una mia passphrase + Usa un codice generato + Passphrase + Conferma la passphrase + Almeno %1$d caratteri. + Troppo facile da indovinare: ripetizioni, sequenze e password comuni sono rifiutate. + Le due passphrase non coincidono. + Salva + In corso… + Codice o passphrase del backup + Continua + Niente da aggiungere da questo backup. + Tornano anche le note eliminate da questo telefono dopo il backup. + Cassaforte con PIN «%1$s»: scegli il suo nuovo PIN + Nuovo PIN + Conferma il nuovo PIN + Ogni PIN: da %1$d a %2$d cifre, digitato due volte uguale. + Ripristina + Questo codice o questa passphrase non apre questo backup, oppure il file è danneggiato. Non è stato ripristinato nulla. + Questo file non è un backup di Notes Tech. + Questo backup viene da una versione più recente di Notes Tech: aggiorna l\'app per ripristinarlo. + Troppo grande: un backup contiene al massimo 64 MB. + Il file scritto non si rilegge, quindi non è stato conservato. Prova un altro percorso. + Non è stato possibile completare l\'operazione. Riprova. + + Questo backup conterrà %1$d nota, cestino compreso. + Questo backup conterrà %1$d di note, cestino compreso. + Questo backup conterrà %1$d note, cestino compreso. + + + Backup salvato: %1$d nota. + Backup salvato: %1$d di note. + Backup salvato: %1$d note. + + + Verrà aggiunta %1$d nota. + Verranno aggiunte %1$d di note. + Verranno aggiunte %1$d note. + + + Verrà creata %1$d cartella. + Verranno create %1$d di cartelle. + Verranno create %1$d cartelle. + + + Di cui %1$d nel cestino, come nel backup. + Di cui %1$d nel cestino, come nel backup. + Di cui %1$d nel cestino, come nel backup. + + + %1$d nota è diversa dalla sua versione su questo telefono: viene aggiunta come copia accanto. + %1$d di note sono diverse dalla loro versione su questo telefono: vengono aggiunte come copie accanto. + %1$d note sono diverse dalla loro versione su questo telefono: vengono aggiunte come copie accanto. + + + %1$d nota è già su questo telefono e resta com\'è. + %1$d di note sono già su questo telefono e restano come sono. + %1$d note sono già su questo telefono e restano come sono. + + + %1$d nota viene esclusa: danneggiata, o in contrasto con le casseforti di questo telefono. + %1$d di note vengono escluse: danneggiate, o in contrasto con le casseforti di questo telefono. + %1$d note vengono escluse: danneggiate, o in contrasto con le casseforti di questo telefono. + + + Ripristino fatto: %1$d nota aggiunta. + Ripristino fatto: %1$d di note aggiunte. + Ripristino fatto: %1$d note aggiunte. + + Mostra il codice + Nascondi il codice + Mostra il codice o la passphrase + Nascondi il codice o la passphrase + + Cassaforte con PIN chiusa, esclusa con le sue note: %1$s. Aprila prima per includerla. + Casseforti con PIN chiuse, escluse con le loro note: %1$s. Aprile prima per includerle. + Casseforti con PIN chiuse, escluse con le loro note: %1$s. Aprile prima per includerle. + + + Cassaforte con PIN aperta, salvata con la sua chiave: %1$s. Nel file la protegge il segreto del backup, non il suo PIN. + Casseforti con PIN aperte, salvate con le loro chiavi: %1$s. Nel file le protegge il segreto del backup, non il loro PIN. + Casseforti con PIN aperte, salvate con le loro chiavi: %1$s. Nel file le protegge il segreto del backup, non il loro PIN. + + + %1$d nota esclusa: la sua cassaforte non poteva viaggiare (una cassaforte con PIN viaggia solo aperta). + %1$d di note escluse: la loro cassaforte non poteva viaggiare (una cassaforte con PIN viaggia solo aperta). + %1$d note escluse: la loro cassaforte non poteva viaggiare (una cassaforte con PIN viaggia solo aperta). + + + %1$d cartella creata. + %1$d di cartelle create. + %1$d cartelle create. + Esportazione completata: %1$d note Esportazione completata: %1$d note (%2$d saltate in casseforti bloccate) Esportazione non riuscita: %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e3f32f1..216d2e5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,6 +16,7 @@ Cancel OK + No app on this device can pick a file. Close Delete Remove @@ -395,6 +396,94 @@ %1$d note went to the Inbox: its folder bears the name of a vault. %1$d notes went to the Inbox: their folder bears the name of a vault. + Encrypted backup + Create a backup + Your notes and folders, trash included, in one encrypted file. + Restore a backup + Adds back what a backup holds. Nothing on this phone is overwritten. + Panic mode does not reach this file: keep it somewhere safe, and delete it yourself when you no longer need it. + Write down this code: it is the only way to open the backup. Notes Tech does not keep it. + Type the code again + Use a passphrase of my own + Use a generated code + Passphrase + Confirm passphrase + At least %1$d characters. + Too easy to guess: repetitions, sequences and common passwords are refused. + The two passphrases do not match. + Save + Working… + The backup\'s code or passphrase + Continue + Nothing to add from this backup. + Notes deleted from this phone since the backup come back too. + PIN vault “%1$s”: choose its new PIN + New PIN + Confirm the new PIN + Each PIN: %1$d to %2$d digits, typed twice the same. + Restore + This code or passphrase does not open this backup, or the file is damaged. Nothing was restored. + This file is not a Notes Tech backup. + This backup comes from a newer version of Notes Tech: update the app to restore it. + Too large: a backup holds at most 64 MB. + The file written could not be read back, so it was not kept. Try another location. + The operation could not be completed. Try again. + + This backup will hold %1$d note, trash included. + This backup will hold %1$d notes, trash included. + + + Backup saved: %1$d note. + Backup saved: %1$d notes. + + + %1$d note will be added. + %1$d notes will be added. + + + %1$d folder will be created. + %1$d folders will be created. + + + Of these, %1$d is in the trash, as in the backup. + Of these, %1$d are in the trash, as in the backup. + + + %1$d note differs from its version on this phone: it is added as a copy beside it. + %1$d notes differ from their version on this phone: they are added as copies beside them. + + + %1$d note is already on this phone, left as it is. + %1$d notes are already on this phone, left as they are. + + + %1$d note is left out: damaged, or at odds with this phone\'s vaults. + %1$d notes are left out: damaged, or at odds with this phone\'s vaults. + + + Restored: %1$d note added. + Restored: %1$d notes added. + + Show the code + Hide the code + Show the code or passphrase + Hide the code or passphrase + + Closed PIN vault, left out with its notes: %1$s. Open it first to include it. + Closed PIN vaults, left out with their notes: %1$s. Open them first to include them. + + + Open PIN vault, saved with its key: %1$s. In the file, the backup\'s secret protects it, not its PIN. + Open PIN vaults, saved with their keys: %1$s. In the file, the backup\'s secret protects them, not their PIN. + + + %1$d note left out: its vault could not travel (a PIN vault only travels open). + %1$d notes left out: their vault could not travel (a PIN vault only travels open). + + + %1$d folder created. + %1$d folders created. + Export complete: %1$d notes Export complete: %1$d notes (%2$d skipped in locked vaults) Export failed: %1$s diff --git a/docs/13-VERSION-3.2.md b/docs/13-VERSION-3.2.md index 61db986..6fb89e3 100644 --- a/docs/13-VERSION-3.2.md +++ b/docs/13-VERSION-3.2.md @@ -47,8 +47,19 @@ Argon2) en flux. - **Écrit où l'utilisateur veut** par `ACTION_CREATE_DOCUMENT` — toujours un **nouveau** document, jamais par-dessus un ancien (R-12). Aucun fichier intermédiaire en clair. +- 🔴 **Écrite AVANT d'ouvrir le sélecteur** (corrigé le 2026-10-29, mesuré sur le S9) : le sélecteur + envoie l'appli en arrière-plan, où **tous les coffres se referment** (`NotesTechApplication`, la + protection principale — on n'y touche pas). Écrite au retour, la sauvegarde laissait de côté le coffre à + code que l'aperçu venait d'annoncer ouvert : un fichier de 294 octets, « 0 note ». Désormais la + sauvegarde est chiffrée et relue dans `cache/sauvegarde/` pendant que les coffres sont tels que + l'aperçu les a vus, puis **recopiée** dans le document choisi et relue contre elle (même SHA-256). + Pendant le sélecteur, seul ce fichier **chiffré** attend : ni le secret ni une clé de coffre ne sont + gardés. Il est effacé dans tous les cas (Retour, échec, écran quitté), et sinon par la purge du + démarrage et par le mode panique (`ClairDuCache`, même définition que les exports). Si l'appli est + tuée pendant le sélecteur, le document vide qu'il a créé est supprimé et l'écran dit l'échec. - **Le succès n'est annoncé qu'après relecture** : le fichier écrit est rouvert, déchiffré en entier et - son étiquette vérifiée. En cas d'échec, le document créé est supprimé si le fournisseur le permet. + son étiquette vérifiée. En cas d'échec, le document créé est supprimé si le fournisseur le permet. Ce + qui a été laissé de côté (coffre à code fermé) est redit dans le message final. - **Lecture cohérente** : dossiers et notes lus dans **une seule transaction** de lecture ; les clés des coffres à code ouverts sont **copiées** avant d'écrire (une copie propre à la sauvegarde, que le verrouillage automatique n'efface pas), puis effacées (R-7). @@ -138,7 +149,20 @@ octets taille champ transaction ; si elle échoue, elles sont supprimées. Jamais une clé d'un coffre déjà présent. - Les liens `[[…]]` des notes en clair sont réindexés dans la transaction (D-009) ; ceux des notes scellées restent détachés, comme à l'ouverture de la base. -- **Mauvaise phrase et fichier abîmé** se présentent pareil (l'étiquette ne passe pas). +- **Mauvaise phrase et fichier abîmé** se présentent pareil (l'étiquette ne passe pas) : le code est + **redemandé sur place**, le fichier gardé, comme au déverrouillage d'un coffre. +- **Examen avant tout secret**, dès le fichier choisi : taille annoncée par le fournisseur au-delà de + 64 Mio, en-tête qui n'est pas celui d'une sauvegarde (ou plus court qu'un en-tête), version future — + refusés avec leur raison, sans faire taper de code pour rien. + +### Écrans (2026-10-29) + +- Chaque dialogue porte le titre de la ligne qui l'a ouvert : « Créer une sauvegarde » de l'aperçu au + message final, « Restaurer une sauvegarde » du code au bilan — attente et échec compris. Boutons + constants : Annuler / action, OK pour finir. Bilan au futur avant, au passé après. +- Aucun sélecteur de documents sur l'appareil : `AppResultLauncher.launch` rend `false` au lieu de + laisser `ActivityNotFoundException` faire planter l'appli, et chacun des quatre sélecteurs le dit + (sauvegarde ×2 dans leur dialogue, import et modèle vocal par un bandeau). ### Effacement From d7ba5eefc549d823ff6152e434d3f90d4ac687e8 Mon Sep 17 00:00:00 2001 From: Patrice Haltaya Date: Thu, 29 Oct 2026 20:52:10 +0100 Subject: [PATCH 10/16] fix(backup): what the GPT-5.6 review of the backup code found 15 findings, each checked in the code before any change: 12 fixed, one refuted (sessionKey already returns a copy taken under its lock), one set aside (the whole app reads a vault as salt != null, the backup too), one answered in the KDoc (a process death forgets the restore flow). Creating - A PIN vault the preview said open and that locked before the writing (the user left the app to note the code) stops the backup: nothing is written, the screen names the vault. Seen on a S9. - Notes are read in lots of at most 200 notes and 1 Mi characters, through ids and weights first (SauvegardeDao.poidsDesNotes): a page of 200 imported notes of up to 5 MiB each could exceed the heap. The preview counts notes from that light query, no longer from their text. - The waiting file is deleted on every exit: the derivation failing, or the deposit cancelled before its IO block ever ran. Restoring - The writing pass is held to the trial's bytes (SHA-256 of the raw file, compared inside the transaction): another valid backup in their place commits nothing and says so. - A failed pass deletes only the Keystore keys whose vault is NOT in the database, one by one: a cancellation can arrive after the commit, and deleting those keys would have locked the restored vaults for good. A key is listed before it is created, so a sealing that throws after the creation leaves nothing behind. - The root of the JSON takes each field once: a second "folders" would have the trial and the writing decide on two trees. - JsonReader's IllegalStateException and NumberFormatException become an unreadable content: noise from a wrong secret begins with "[" or a digit about once in twenty, and the screen said "failed" instead of asking for the code again. Never around the database callbacks. - A PIN vault's key must be 32 bytes (passphrase vaults keep the lengths the phone has: a vault migrated from 2.x does not have 3.x's). - The examination before the secret needs the tag after the header. - Secured window on the restore dialogs, as on the creation's. Tests: SauvegardeChiffreeTest 7 -> 9 (vault locked before the writing; file changed between the passes), LectureDuContenuTest 2 -> 4 (another shape of JSON, a root field twice). The four new refusals sabotaged at once: exactly those four tests red, the nine others green. Co-Authored-By: Claude Opus 5.5 --- .../data/sauvegarde/LectureDuContenuTest.kt | 35 +++++ .../data/sauvegarde/SauvegardeChiffreeTest.kt | 55 ++++++- .../data/local/dao/SauvegardeDao.kt | 22 ++- .../data/sauvegarde/ContenuDeSauvegarde.kt | 70 ++++++--- .../sauvegarde/RestaurationDeSauvegarde.kt | 143 ++++++++++++------ .../data/sauvegarde/SauvegardeChiffree.kt | 122 +++++++++------ .../notes_tech/ui/settings/SauvegardeUi.kt | 15 +- .../ui/settings/SauvegardeViewModel.kt | 48 +++--- app/src/main/res/values-de/strings.xml | 2 + app/src/main/res/values-es/strings.xml | 2 + app/src/main/res/values-fr/strings.xml | 2 + app/src/main/res/values-it/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 13 files changed, 383 insertions(+), 137 deletions(-) diff --git a/app/src/androidTest/java/com/filestech/notes_tech/data/sauvegarde/LectureDuContenuTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/data/sauvegarde/LectureDuContenuTest.kt index 0adffcb..d9b8f38 100644 --- a/app/src/androidTest/java/com/filestech/notes_tech/data/sauvegarde/LectureDuContenuTest.kt +++ b/app/src/androidTest/java/com/filestech/notes_tech/data/sauvegarde/LectureDuContenuTest.kt @@ -60,4 +60,39 @@ class LectureDuContenuTest { assertThat(echec).isNotNull() assertThat(echec is ContenuIllisibleException || echec is java.io.IOException).isTrue() } + + /** + * Noise from a wrong secret that happens to begin like JSON of another shape — `[`, a digit — is + * an unreadable content like any other, an [java.io.IOException]: the screen asks for the code + * again. `JsonReader` threw `IllegalStateException` for some of them (GPT-5.6 review). + */ + @Test + fun un_debut_d_une_autre_forme_est_un_contenu_illisible(): Unit = runBlocking { + for (debut in listOf("[1,2]", "42", "\"x\"", "{\"version\":\"x\"}")) { + val echec = runCatching { + LectureDuContenu.lire(ByteArrayInputStream(debut.toByteArray()), surLesDossiers = {}, surUneNote = {}) + }.exceptionOrNull() + assertThat(echec).isInstanceOf(java.io.IOException::class.java) + } + } + + /** A second `folders` would have the trial and the writing pass decide on two trees: refused. */ + @Test + fun un_champ_de_la_racine_en_double_le_fait_refuser(): Unit = runBlocking { + val texte = contenu().decodeToString() + val dossiers = texte.substring(texte.indexOf("\"folders\""), texte.indexOf("\"notes\"")) + val double = texte.replace("\"notes\"", dossiers + "\"notes\"") + var appels = 0 + + val echec = runCatching { + LectureDuContenu.lire( + ByteArrayInputStream(double.toByteArray()), + surLesDossiers = { appels++ }, + surUneNote = {}, + ) + }.exceptionOrNull() + + assertThat(echec).isInstanceOf(ContenuIllisibleException::class.java) + assertThat(appels).isEqualTo(1) + } } diff --git a/app/src/androidTest/java/com/filestech/notes_tech/data/sauvegarde/SauvegardeChiffreeTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/data/sauvegarde/SauvegardeChiffreeTest.kt index dbbc707..de8d0dd 100644 --- a/app/src/androidTest/java/com/filestech/notes_tech/data/sauvegarde/SauvegardeChiffreeTest.kt +++ b/app/src/androidTest/java/com/filestech/notes_tech/data/sauvegarde/SauvegardeChiffreeTest.kt @@ -104,8 +104,13 @@ class SauvegardeChiffreeTest { private val document: Uri get() = Uri.fromFile(fichier) - /** A backup made as the screen makes it: written first, then put into the document. */ - private suspend fun creer(): SauvegardeChiffree.Creation = sauvegarde.deposer(sauvegarde.preparer(CODE), document) + /** A backup made as the screen makes it: previewed, written, then put into the document. */ + private suspend fun creer(): SauvegardeChiffree.Creation = + sauvegarde.deposer(sauvegarde.preparer(CODE, sauvegarde.apercu().idsDesCoffresACodeOuverts), document) + + /** A restore made as the screen makes it: the trial, then the writing held to the trial's bytes. */ + private suspend fun restaurer(codes: Map): RestaurationDeSauvegarde.Resultat = + restauration.restaurer(document, CODE, codes, restauration.essayer(document, CODE)) /** A PIN vault, open, holding one note sealed under its key. */ private suspend fun unCoffreACode(): Pair { @@ -139,7 +144,7 @@ class SauvegardeChiffreeTest { assertThat(essai.notesAjoutees).isEqualTo(2) assertThat(notes.find(enClair.id)).isNull() - val fait = restauration.restaurer(document, CODE, mapOf(coffre to NOUVEAU_CODE)) + val fait = restauration.restaurer(document, CODE, mapOf(coffre to NOUVEAU_CODE), essai) assertThat(fait.notesAjoutees).isEqualTo(2) assertThat(fait.notesIgnorees).isEmpty() @@ -162,12 +167,13 @@ class SauvegardeChiffreeTest { dossiers.delete(coffre) coffres.deletePinKey(coffre) val avant = notes.listAllAlive().map { it.id }.toSet() + val essai = restauration.essayer(document, CODE) - val faux = runCatching { restauration.restaurer(document, AUTRE_CODE, mapOf(coffre to NOUVEAU_CODE)) } + val faux = runCatching { restauration.restaurer(document, AUTRE_CODE, mapOf(coffre to NOUVEAU_CODE), essai) } // The last byte is the tag's: every note parses, every row is written, and the end refuses it. val octets = fichier.readBytes() fichier.writeBytes(octets.copyOf().also { it[it.lastIndex] = (it[it.lastIndex] + 1).toByte() }) - val abime = runCatching { restauration.restaurer(document, CODE, mapOf(coffre to NOUVEAU_CODE)) } + val abime = runCatching { restauration.restaurer(document, CODE, mapOf(coffre to NOUVEAU_CODE), essai) } assertThat(faux.exceptionOrNull()).isInstanceOf(IOException::class.java) assertThat(abime.exceptionOrNull()).isInstanceOf(IOException::class.java) @@ -182,7 +188,7 @@ class SauvegardeChiffreeTest { creer() val avant = notes.listAllAlive().size - val fait = restauration.restaurer(document, CODE, emptyMap()) + val fait = restaurer(emptyMap()) assertThat(fait.notesAjoutees).isEqualTo(0) assertThat(fait.notesACote).isEqualTo(0) @@ -213,7 +219,7 @@ class SauvegardeChiffreeTest { fun un_coffre_a_code_ferme_pendant_le_selecteur_garde_ses_notes_dans_la_sauvegarde(): Unit = runBlocking { val (coffre, _) = unCoffreACode() - val preparee = sauvegarde.preparer(CODE) + val preparee = sauvegarde.preparer(CODE, sauvegarde.apercu().idsDesCoffresACodeOuverts) sessions.lockAll() // what the picker does to the app val creee = sauvegarde.deposer(preparee, document) @@ -245,7 +251,7 @@ class SauvegardeChiffreeTest { sessions.lockAll() listOf(enfant.id, parent.id, journal.id, coffre).forEach { dossiers.delete(it) } coffres.deletePinKey(coffre) - val fait = restauration.restaurer(document, CODE, mapOf(coffre to NOUVEAU_CODE)) + val fait = restaurer(mapOf(coffre to NOUVEAU_CODE)) assertThat(fait.dossiersCrees).isEqualTo(4) assertThat(fait.notesIgnorees).isEmpty() @@ -256,6 +262,39 @@ class SauvegardeChiffreeTest { assertThat(coffres.decrypt(requireNotNull(notes.find(intime.id))).content).isEqualTo("rien de neuf") } + /** + * The other side of the picker: a PIN vault the preview said open, locked BEFORE the writing (the + * user left the app to note the code) — nothing written, the vault named, no file left waiting. + */ + @Test + fun un_coffre_annonce_ouvert_puis_referme_avant_l_ecriture_arrete_la_sauvegarde(): Unit = runBlocking { + unCoffreACode() + val apercu = sauvegarde.apercu() + sessions.lockAll() + + val refus = runCatching { sauvegarde.preparer(CODE, apercu.idsDesCoffresACodeOuverts) }.exceptionOrNull() + + assertThat((refus as? SauvegardeChiffree.CoffreRefermeException)?.nom).isEqualTo("Codes") + assertThat(SauvegardeChiffree.repertoireDAttente(context).listFiles().orEmpty()).isEmpty() + } + + /** The writing pass is held to the trial's bytes: another valid backup in their place writes nothing. */ + @Test + fun un_fichier_change_entre_l_essai_et_l_ecriture_ne_restaure_rien(): Unit = runBlocking { + val premiere = notes.create(folderId = LegacyDatabaseFixture.Fixtures.FOLDER_WORK, title = "Un", content = "1") + creer() + val essai = restauration.essayer(document, CODE) + notes.create(folderId = LegacyDatabaseFixture.Fixtures.FOLDER_WORK, title = "Deux", content = "2") + creer() // another valid backup, same code, in the file the trial read + notes.deletePermanently(premiere.id) + val avant = notes.listAllAlive().map { it.id }.toSet() + + val refus = runCatching { restauration.restaurer(document, CODE, emptyMap(), essai) }.exceptionOrNull() + + assertThat(refus).isInstanceOf(RestaurationDeSauvegarde.FichierChangeException::class.java) + assertThat(notes.listAllAlive().map { it.id }.toSet()).isEqualTo(avant) + } + /** Before any secret is asked: what is not a backup, or one of a newer version, is refused as such. */ @Test fun l_examen_refuse_sans_secret_ce_qui_n_est_pas_une_sauvegarde_lisible_ici(): Unit = runBlocking { diff --git a/app/src/main/java/com/filestech/notes_tech/data/local/dao/SauvegardeDao.kt b/app/src/main/java/com/filestech/notes_tech/data/local/dao/SauvegardeDao.kt index 39320ab..407716b 100644 --- a/app/src/main/java/com/filestech/notes_tech/data/local/dao/SauvegardeDao.kt +++ b/app/src/main/java/com/filestech/notes_tech/data/local/dao/SauvegardeDao.kt @@ -5,12 +5,26 @@ import androidx.room.Query import com.filestech.notes_tech.data.local.entity.NoteEntity /** - * What the encrypted backup (3.2.0) reads, and nothing else does: every note, the trash included, a - * page at a time in a stable order — read inside one transaction, written to the file as it comes. + * What the encrypted backup (3.2.0) reads, and nothing else does: every note, the trash included, in a + * stable order — read inside one transaction, written to the file as it comes. */ @Dao interface SauvegardeDao { - @Query("SELECT * FROM notes ORDER BY id LIMIT :limite OFFSET :decalage") - suspend fun pageDeNotes(limite: Int, decalage: Int): List + /** + * Each note's id, folder and weight — characters of text, bytes of a sealed blob — and nothing of + * its text: enough to count the notes and to cut them in lots ([notes]) that the heap can hold. + */ + @Query( + "SELECT id, folder_id AS dossierId, " + + "length(title) + length(content) + length(tags) + ifnull(length(encrypted_content), 0) AS poids " + + "FROM notes ORDER BY id", + ) + suspend fun poidsDesNotes(): List + + /** The notes of [ids], in the order of [poidsDesNotes]. */ + @Query("SELECT * FROM notes WHERE id IN (:ids) ORDER BY id") + suspend fun notes(ids: List): List } + +data class PoidsDUneNote(val id: String, val dossierId: String, val poids: Long) diff --git a/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/ContenuDeSauvegarde.kt b/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/ContenuDeSauvegarde.kt index 7897478..0f11afc 100644 --- a/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/ContenuDeSauvegarde.kt +++ b/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/ContenuDeSauvegarde.kt @@ -4,6 +4,7 @@ import android.util.Base64 import android.util.JsonReader import android.util.JsonToken import android.util.JsonWriter +import com.filestech.notes_tech.security.vault.VaultParams import java.io.Closeable import java.io.IOException import java.io.InputStream @@ -182,38 +183,66 @@ internal object LectureDuContenu { var format: String? = null var version = -1L var dossiersVus = false - json.beginObject() - while (json.hasNext()) { - when (json.nextName()) { - "format" -> format = json.nextString() - "version" -> version = json.nextLong() + val vus = HashSet() + illisible { json.beginObject() } + while (illisible { json.hasNext() }) { + val nom = illisible { json.nextName() } + // Once each, as every object's fields: a second `folders` would have the trial and the + // writing pass decide on two different trees (GPT-5.6 review, 2026-10-29). + if (!vus.add(nom)) throw ContenuIllisibleException("champ $nom en double") + when (nom) { + "format" -> format = illisible { json.nextString() } + "version" -> version = illisible { json.nextLong() } "folders" -> { exigerLEnTete(format, version) - val dossiers = ArrayList() - json.beginArray() - while (json.hasNext()) dossiers += lireUnDossier(json) - json.endArray() + val dossiers = illisible { + ArrayList().apply { + json.beginArray() + while (json.hasNext()) add(lireUnDossier(json)) + json.endArray() + } + } surLesDossiers(dossiers) dossiersVus = true } "notes" -> { if (!dossiersVus) throw ContenuIllisibleException("notes avant les dossiers") - json.beginArray() - while (json.hasNext()) surUneNote(lireUneNote(json)) - json.endArray() + illisible { json.beginArray() } + while (illisible { json.hasNext() }) surUneNote(illisible { lireUneNote(json) }) + illisible { json.endArray() } } - else -> json.skipValue() + else -> illisible { json.skipValue() } } } - json.endObject() + illisible { json.endObject() } if (!dossiersVus) throw ContenuIllisibleException("aucun dossier") - // The JSON is over: whatever follows must be nothing — and reaching the end checks the tag. - if (json.peek() != JsonToken.END_DOCUMENT) throw ContenuIllisibleException("octets apres le contenu") + exigerLaFin(json, entree) + } + + /** The JSON is over: whatever follows must be nothing — and reaching the end checks the tag. */ + private fun exigerLaFin(json: JsonReader, entree: InputStream) { + val apres = illisible { json.peek() } + if (apres != JsonToken.END_DOCUMENT) throw ContenuIllisibleException("octets apres le contenu") // END_DOCUMENT means the reader met the end of [entree]; read it once more all the same: the // check that the tag was reached must not depend on how a JSON reader buffers. if (entree.read() >= 0) throw ContenuIllisibleException("octets apres le contenu") } + /** + * [lecture] of the JSON, its runtime refusals made what they are here: an unreadable content. A + * wrong secret decrypts to noise, which begins with `[`, `"` or a digit about once in twenty: + * `JsonReader` then throws `IllegalStateException` or `NumberFormatException`, not an + * [IOException], and the screen said "failed" instead of asking for the code again (GPT-5.6 + * review, 2026-10-29). Never around the callbacks: what the database throws stays what it is. + */ + private inline fun illisible(lecture: () -> T): T = try { + lecture() + } catch (e: IllegalStateException) { + throw ContenuIllisibleException("json").apply { initCause(e) } + } catch (e: NumberFormatException) { + throw ContenuIllisibleException("json").apply { initCause(e) } + } + private fun exigerLEnTete(format: String?, version: Long) { if (format != ContenuDeSauvegarde.FORMAT || version != ContenuDeSauvegarde.VERSION.toLong()) { throw ContenuIllisibleException("format $format, version $version") @@ -247,7 +276,14 @@ internal object LectureDuContenu { iv = champs.octets("iv"), verificateur = champs.octets("verifier"), ) - MODE_CODE -> CoffreSauvegarde.ACode(champs.octets("folderKey")) + // The raw folder key, this format's own: any other length would make a vault that no PIN + // ever opens. (A passphrase vault's columns are copied as the phone has them — a vault + // migrated from 2.x does not have 3.x's lengths.) + MODE_CODE -> CoffreSauvegarde.ACode( + champs.octets("folderKey").also { + if (it.size != VaultParams.FOLDER_KEY_BYTES) throw ContenuIllisibleException("cle de coffre") + }, + ) else -> throw ContenuIllisibleException("mode de coffre inconnu") } } diff --git a/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/RestaurationDeSauvegarde.kt b/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/RestaurationDeSauvegarde.kt index b95bd92..50ed277 100644 --- a/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/RestaurationDeSauvegarde.kt +++ b/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/RestaurationDeSauvegarde.kt @@ -13,11 +13,15 @@ import com.filestech.notes_tech.data.sauvegarde.PlanDeRestauration.DossierDuTele import com.filestech.notes_tech.security.sauvegarde.FichierDeSauvegarde import com.filestech.notes_tech.security.sauvegarde.SecretDeSauvegarde import com.filestech.notes_tech.security.vault.FolderVaultService +import com.filestech.notes_tech.security.vault.VaultParams import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext +import timber.log.Timber import java.io.IOException +import java.security.DigestInputStream +import java.security.MessageDigest import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -58,8 +62,16 @@ class RestaurationDeSauvegarde @Inject constructor( /** Among the notes added, those that go back to the trash, with their date. */ val notesALaCorbeille: Int, val coffresACode: List, + /** SHA-256 of the file's bytes as this pass read them: what [restaurer] holds the writing to. */ + val empreinte: String, ) + /** + * The file the writing pass read is not the one the trial read: replaced in between, or served + * differently by its provider. Rolled back — the user approved a summary of another file. + */ + class FichierChangeException : IOException("le fichier a change depuis l'essai") + class CodeManquantException(val dossierId: String) : IllegalArgumentException("aucun code pour le coffre $dossierId") @@ -79,12 +91,25 @@ class RestaurationDeSauvegarde @Inject constructor( } val entree = context.contentResolver.openInputStream(document) ?: throw IOException("sauvegarde impossible a ouvrir") - try { - entree.use { FichierDeSauvegarde.lireLEnTete(it) } - } catch (e: FichierDeSauvegarde.SauvegardeRefuseeException) { - // Shorter than a header: not a backup — no secret could be at fault yet. - if (e.refus != FichierDeSauvegarde.Refus.TRONQUE) throw e - throw FichierDeSauvegarde.SauvegardeRefuseeException(FichierDeSauvegarde.Refus.PAS_UNE_SAUVEGARDE) + val pasUneSauvegarde = + FichierDeSauvegarde.SauvegardeRefuseeException(FichierDeSauvegarde.Refus.PAS_UNE_SAUVEGARDE) + entree.use { + try { + FichierDeSauvegarde.lireLEnTete(it) + } catch (e: FichierDeSauvegarde.SauvegardeRefuseeException) { + // Shorter than a header: not a backup — no secret could be at fault yet. + if (e.refus != FichierDeSauvegarde.Refus.TRONQUE) throw e + throw pasUneSauvegarde + } + // And at least the tag after it: a header alone would cost the user a derivation for nothing. + // Read, not skipped: `skip` may skip less than it could on some providers' streams. + val etiquette = ByteArray(VaultParams.TAG_BYTES) + var lus = 0 + while (lus < etiquette.size) { + val n = it.read(etiquette, lus, etiquette.size - lus) + if (n < 0) throw pasUneSauvegarde + lus += n + } } Unit } @@ -93,52 +118,80 @@ class RestaurationDeSauvegarde @Inject constructor( * The trial pass. Throws an [IOException] when the file cannot be read with [secret]: a wrong * secret and a damaged file look the same, both failing the tag or the content before it. */ - suspend fun essayer(document: Uri, secret: String): Resultat = parcourir(document, secret, codes = null) + suspend fun essayer(document: Uri, secret: String): Resultat = + parcourir(document, secret, codes = null, essai = null) /** - * The writing pass, [codes] the new PIN of each PIN vault [essayer] listed, by folder id. + * The writing pass, [codes] the new PIN of each PIN vault [essai] listed, by folder id — on the + * very bytes [essai] read ([Resultat.empreinte]). * * @throws CodeManquantException a PIN vault the restore creates has no PIN in [codes]. + * @throws FichierChangeException the file is not the one [essai] read: nothing is written. */ - suspend fun restaurer(document: Uri, secret: String, codes: Map): Resultat = - parcourir(document, secret, codes) + suspend fun restaurer(document: Uri, secret: String, codes: Map, essai: Resultat): Resultat = + parcourir(document, secret, codes, essai.empreinte) - private suspend fun parcourir(document: Uri, secret: String, codes: Map?): Resultat = - withContext(Dispatchers.IO) { - val entree = context.contentResolver.openInputStream(document) - ?: throw IOException("sauvegarde impossible a ouvrir") - entree.use { brut -> - val borne = FichierDeSauvegarde.borne(brut) - // Before any derivation: a header version 1 does not write is refused here (R-10). - val (enTete, octetsDeLEnTete) = FichierDeSauvegarde.lireLEnTete(borne) - val octetsDuSecret = SecretDeSauvegarde.octets(secret) - val cle = try { - FichierDeSauvegarde.cle(octetsDuSecret, enTete) - } finally { - octetsDuSecret.wipe() - } - val passage = Passage(codes) - try { - FichierDeSauvegarde.ouvrir(borne, cle, enTete, octetsDeLEnTete).use { contenu -> - notes.restaurerDansUneTransaction { ecriture -> - LectureDuContenu.lire( - contenu, - surLesDossiers = { passage.surLesDossiers(ecriture, it) }, - surUneNote = { passage.surUneNote(ecriture, it) }, - ) + private suspend fun parcourir( + document: Uri, + secret: String, + codes: Map?, + essai: String?, + ): Resultat = withContext(Dispatchers.IO) { + val entree = context.contentResolver.openInputStream(document) + ?: throw IOException("sauvegarde impossible a ouvrir") + val condense = MessageDigest.getInstance("SHA-256") + DigestInputStream(entree, condense).use { brut -> + val borne = FichierDeSauvegarde.borne(brut) + // Before any derivation: a header version 1 does not write is refused here (R-10). + val (enTete, octetsDeLEnTete) = FichierDeSauvegarde.lireLEnTete(borne) + val octetsDuSecret = SecretDeSauvegarde.octets(secret) + val cle = try { + FichierDeSauvegarde.cle(octetsDuSecret, enTete) + } finally { + octetsDuSecret.wipe() + } + val passage = Passage(codes) + try { + val empreinte = FichierDeSauvegarde.ouvrir(borne, cle, enTete, octetsDeLEnTete).use { contenu -> + notes.restaurerDansUneTransaction { ecriture -> + LectureDuContenu.lire( + contenu, + surLesDossiers = { passage.surLesDossiers(ecriture, it) }, + surUneNote = { passage.surUneNote(ecriture, it) }, + ) + // The file is read to its end here: the digest covers every byte. Still in + // the transaction, so a file that is not the trial's commits nothing. + condense.digest().joinToString("") { "%02x".format(it) }.also { + if (essai != null && it != essai) throw FichierChangeException() } } - passage.resultat() - } catch (e: Throwable) { - // The transaction is rolled back: the keys its PIN vaults were sealed with go too. - withContext(NonCancellable) { passage.clesCreees.forEach { vaults.deletePinKey(it) } } - throw e - } finally { - cle.wipe() - passage.effacerLesCles() } + passage.resultat(empreinte) + } catch (e: Throwable) { + withContext(NonCancellable) { oublierLesClesSansCoffre(passage.clesCreees) } + throw e + } finally { + cle.wipe() + passage.effacerLesCles() } } + } + + /** + * The Keystore keys created for the PIN vaults of a pass that failed — deleted, each on its own, but + * only where the vault is NOT in the database. The failure says nothing of the transaction: a + * cancellation can arrive after its commit, and deleting those keys then would lock the restored + * vaults for good (GPT-5.6 review, 2026-10-29). A key that does not exist deletes as nothing. + */ + private suspend fun oublierLesClesSansCoffre(cles: List) { + for (id in cles) { + try { + if (!vaults.isVault(id)) vaults.deletePinKey(id) + } catch (e: Exception) { + Timber.w(e, "cle d'un coffre restaure non supprimee") + } + } + } /** One pass over a file; [codes] `null` for the trial, which writes nothing. */ private inner class Passage(private val codes: Map?) { @@ -226,8 +279,11 @@ class RestaurationDeSauvegarde @Inject constructor( MODE_PHRASE, ) is CoffreSauvegarde.ACode -> { - val materiel = vaults.materielDUnCoffreACode(d.id, coffre.cleDuDossier, codes.getValue(d.id)) + // Listed BEFORE its key exists: a sealing that throws after the key's creation + // still leaves it to the cleanup. A folder created here is new: its alias is no + // live vault's (GPT-5.6 review, 2026-10-29). clesCreees += d.id + val materiel = vaults.materielDUnCoffreACode(d.id, coffre.cleDuDossier, codes.getValue(d.id)) entite( sel = materiel.sel, iv = materiel.iv, @@ -240,7 +296,7 @@ class RestaurationDeSauvegarde @Inject constructor( } } - fun resultat() = Resultat( + fun resultat(empreinte: String) = Resultat( dossiersCrees = plan.aCreer.size, dossiersReutilises = plan.reutilises, notesAjoutees = ajoutees, @@ -249,6 +305,7 @@ class RestaurationDeSauvegarde @Inject constructor( notesIgnorees = ignorees.toMap(), notesALaCorbeille = aLaCorbeille, coffresACode = plan.coffresACodeACreer.map { CoffreACode(it.id, it.nom) }, + empreinte = empreinte, ) /** The PIN vaults' folder keys read from the file: wiped once the pass is over, whatever its end. */ diff --git a/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/SauvegardeChiffree.kt b/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/SauvegardeChiffree.kt index 43d66f2..73b013d 100644 --- a/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/SauvegardeChiffree.kt +++ b/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/SauvegardeChiffree.kt @@ -65,6 +65,8 @@ class SauvegardeChiffree @Inject constructor( val coffresACodeFermes: List, /** Saved with their key: protected, in the file, by the backup's secret only. */ val coffresACodeOuverts: List, + /** Their ids: what [preparer] holds the writing to ([CoffreRefermeException]). */ + val idsDesCoffresACodeOuverts: Set, ) /** What was written, and read back. */ @@ -80,6 +82,13 @@ class SauvegardeChiffree @Inject constructor( class RelectureEchoueeException(cause: Throwable?) : IOException("la sauvegarde ecrite ne se relit pas", cause) + /** + * A PIN vault the preview said open has locked since — the user left the app to note the code, + * and every vault locks in the background. Written anyway, the backup would not be the one + * announced: nothing is written, and the screen names the vault. + */ + class CoffreRefermeException(val nom: String) : IllegalStateException("coffre a code referme depuis l'apercu") + suspend fun apercu(): Apercu = withContext(Dispatchers.IO) { val database = databases.get() database.withTransaction { @@ -89,48 +98,54 @@ class SauvegardeChiffree @Inject constructor( .partition { sessions.hasLiveSession(it.id) } // The same rule as the writing's ([coffreDe]): what cannot travel is left out, here counted. val omis = dossiers.filter { it.vaultSalt != null && !peutVoyager(it) }.map { it.id }.toSet() - var notes = 0 - parPages(database) { page -> notes += page.count { it.folderId !in omis } } Apercu( dossiers = dossiers.size - omis.size, - notes = notes, + // Counted from each note's folder alone: no title, no content read to count them. + notes = database.sauvegardeDao().poidsDesNotes().count { it.dossierId !in omis }, coffresACodeFermes = fermes.map { it.name }, coffresACodeOuverts = ouverts.map { it.name }, + idsDesCoffresACodeOuverts = ouverts.map { it.id }.toSet(), ) } } /** * Writes the backup under [secret] into the app's cache, while the vaults are as the preview saw - * them, then reads it back whole. + * them — [coffresOuverts], the PIN vaults it said open — then reads it back whole. A [Preparee] + * lost to a cancellation on the way back is purged at the next startup. * + * @throws CoffreRefermeException one of [coffresOuverts] has locked since: nothing is kept. * @throws SauvegardeTropGrosseException past [FichierDeSauvegarde.TAILLE_MAX]: nothing is kept. * @throws RelectureEchoueeException the file written does not read back: nothing is kept. */ - suspend fun preparer(secret: String): Preparee = withContext(Dispatchers.IO) { + suspend fun preparer(secret: String, coffresOuverts: Set): Preparee = withContext(Dispatchers.IO) { val repertoire = repertoireDAttente(context) val pret = repertoire.isDirectory || repertoire.mkdirs() if (!pret) { throw IOException("attente de sauvegarde impossible a creer") } val fichier = File.createTempFile("sauvegarde", ".ntbackup", repertoire) - val enTete = FichierDeSauvegarde.nouvelEnTete() - val octetsDuSecret = SecretDeSauvegarde.octets(secret) - val cle = try { - FichierDeSauvegarde.cle(octetsDuSecret, enTete) - } finally { - octetsDuSecret.wipe() - } + // From here every exit deletes the file and wipes what was derived — the derivation itself + // (64 MiB) included, which is the step most likely to fail. + var cle: ByteArray? = null val clesDesCoffres = HashMap() try { - val ecrite = ecrire(FileOutputStream(fichier), cle, enTete, clesDesCoffres) - relire(cle, ecrite) { FileInputStream(fichier) } + val enTete = FichierDeSauvegarde.nouvelEnTete() + val octetsDuSecret = SecretDeSauvegarde.octets(secret) + val derivee = try { + FichierDeSauvegarde.cle(octetsDuSecret, enTete) + } finally { + octetsDuSecret.wipe() + } + cle = derivee + val ecrite = ecrire(FileOutputStream(fichier), derivee, enTete, clesDesCoffres, coffresOuverts) + relire(derivee, ecrite) { FileInputStream(fichier) } Preparee(fichier, ecrite) } catch (e: Throwable) { fichier.delete() throw e } finally { - cle.wipe() + cle?.wipe() clesDesCoffres.values.forEach { it.wipe() } } } @@ -142,26 +157,30 @@ class SauvegardeChiffree @Inject constructor( * * @throws RelectureEchoueeException the document does not hold those bytes: it is deleted. */ - suspend fun deposer(preparee: Preparee, document: Uri): Creation = withContext(Dispatchers.IO) { - try { - val sortie = context.contentResolver.openOutputStream(document, "w") - ?: throw IOException("document de sauvegarde impossible a ouvrir") - val ecrite = sortie.use { FileInputStream(preparee.fichier).use { entree -> empreinte(entree, it) } } - val relue = try { - val entree = context.contentResolver.openInputStream(document) - ?: throw IOException("sauvegarde ecrite impossible a rouvrir") - entree.use { empreinte(it, sortie = null) } - } catch (e: IOException) { - throw RelectureEchoueeException(e) + suspend fun deposer(preparee: Preparee, document: Uri): Creation = try { + withContext(Dispatchers.IO) { + try { + val sortie = context.contentResolver.openOutputStream(document, "w") + ?: throw IOException("document de sauvegarde impossible a ouvrir") + val ecrite = sortie.use { FileInputStream(preparee.fichier).use { entree -> empreinte(entree, it) } } + val relue = try { + val entree = context.contentResolver.openInputStream(document) + ?: throw IOException("sauvegarde ecrite impossible a rouvrir") + entree.use { empreinte(it, sortie = null) } + } catch (e: IOException) { + throw RelectureEchoueeException(e) + } + if (!MessageDigest.isEqual(ecrite, relue)) throw RelectureEchoueeException(null) + preparee.creation + } catch (e: Throwable) { + supprimer(document) + throw e } - if (!MessageDigest.isEqual(ecrite, relue)) throw RelectureEchoueeException(null) - preparee.creation - } catch (e: Throwable) { - supprimer(document) - throw e - } finally { - abandonner(preparee) } + } finally { + // Outside the dispatcher's block: a call cancelled before that block ever ran still lets the + // waiting file go (GPT-5.6 review, 2026-10-29). + abandonner(preparee) } /** The waiting file, deleted: the user went back from the picker, or the screen is gone. */ @@ -180,6 +199,7 @@ class SauvegardeChiffree @Inject constructor( cle: ByteArray, enTete: FichierDeSauvegarde.EnTete, clesDesCoffres: MutableMap, + coffresOuverts: Set, ): Creation { var dossiers = 0 var notes = 0 @@ -191,13 +211,16 @@ class SauvegardeChiffree @Inject constructor( database.withTransaction { val sauvegardes = HashSet() for (dossier in database.folderDao().listAll()) { - val coffre = - coffreDe(dossier, clesDesCoffres) ?: if (dossier.vaultSalt != null) continue else null + val coffre = coffreDe(dossier, clesDesCoffres) + if (coffre == null && dossier.vaultSalt != null) { + if (dossier.id in coffresOuverts) throw CoffreRefermeException(dossier.name) + continue + } contenu.dossier(dossier.versLaSauvegarde(coffre)) sauvegardes += dossier.id dossiers++ } - parPages(database) { page -> + parLots(database) { page -> for (note in page) { if (note.folderId in sauvegardes) { contenu.note(note.versLaSauvegarde()) @@ -298,14 +321,24 @@ class SauvegardeChiffree @Inject constructor( } } - private suspend inline fun parPages(database: NotesDatabase, action: (List) -> Unit) { - var decalage = 0 - while (true) { + /** + * Every note, in lots of at most [LOT_MAX] notes and [POIDS_MAX] characters — a note heavier than + * that alone in its lot. Counted by notes alone, a lot of imported notes (up to 5 MiB each) could + * hold more text than the heap (GPT-5.6 review, 2026-10-29). In the caller's read transaction. + */ + private suspend inline fun parLots(database: NotesDatabase, action: (List) -> Unit) { + val poids = database.sauvegardeDao().poidsDesNotes() + var debut = 0 + while (debut < poids.size) { coroutineContext.ensureActive() - val page = database.sauvegardeDao().pageDeNotes(PAGE, decalage) - if (page.isEmpty()) return - action(page) - decalage += page.size + var fin = debut + 1 + var cumul = poids[debut].poids + while (fin < poids.size && fin - debut < LOT_MAX && cumul + poids[fin].poids <= POIDS_MAX) { + cumul += poids[fin].poids + fin++ + } + action(database.sauvegardeDao().notes(poids.subList(debut, fin).map { it.id })) + debut = fin } } @@ -359,7 +392,8 @@ class SauvegardeChiffree @Inject constructor( ) companion object { - private const val PAGE = 200 + private const val LOT_MAX = 200 + private const val POIDS_MAX = 1L shl 20 private const val TAMPON = 64 * 1024 /** diff --git a/app/src/main/java/com/filestech/notes_tech/ui/settings/SauvegardeUi.kt b/app/src/main/java/com/filestech/notes_tech/ui/settings/SauvegardeUi.kt index 67a30c4..486d7fb 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/settings/SauvegardeUi.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/settings/SauvegardeUi.kt @@ -123,7 +123,11 @@ internal fun LignesDeSauvegarde() { message = texteDuBilan(e.resultat, apres = true), onFermer = viewModel::fermer, ) - is SauvegardeViewModel.Etape.Echec -> DialogueDeFin(e.titre, stringResource(e.message), viewModel::fermer) + is SauvegardeViewModel.Etape.Echec -> DialogueDeFin( + titre = e.titre, + message = e.argument?.let { stringResource(e.message, it) } ?: stringResource(e.message), + onFermer = viewModel::fermer, + ) } } @@ -265,9 +269,13 @@ private fun DialogueDeCreation( ) } -/** The chosen file's code or passphrase — asked again, field empty, after a wrong one ([erreur]). */ +/** + * The chosen file's code or passphrase — asked again, field empty, after a wrong one ([erreur]). + * Secured window, as at the creation: the field can show what it holds (GPT-5.6 review, 2026-10-29). + */ @Composable private fun DialogueDeSecret(@StringRes erreur: Int?, onEssayer: (String) -> Unit, onAnnuler: () -> Unit) { + SecureWindowGuard(active = true) var secret by remember { mutableStateOf("") } AlertDialog( onDismissRequest = onAnnuler, @@ -297,7 +305,7 @@ private fun DialogueDeSecret(@StringRes erreur: Int?, onEssayer: (String) -> Uni /** * The trial pass's answer: what the restore will add, what it leaves and why — and a new PIN, twice, - * for each PIN vault it brings back (D-030). + * for each PIN vault it brings back (D-030). Secured window: those fields can show the new PINs. */ @Composable private fun DialogueDEssai( @@ -305,6 +313,7 @@ private fun DialogueDEssai( onRestaurer: (Map) -> Unit, onAnnuler: () -> Unit, ) { + SecureWindowGuard(active = true) val codes = remember { mutableStateMapOf() } val confirmations = remember { mutableStateMapOf() } val rienAAjouter = resultat.notesAjoutees + resultat.notesACote + resultat.dossiersCrees == 0 diff --git a/app/src/main/java/com/filestech/notes_tech/ui/settings/SauvegardeViewModel.kt b/app/src/main/java/com/filestech/notes_tech/ui/settings/SauvegardeViewModel.kt index 0bcf923..3c427d0 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/settings/SauvegardeViewModel.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/settings/SauvegardeViewModel.kt @@ -28,8 +28,8 @@ import javax.inject.Inject * the picker sends the app to the background, where every vault locks. Only the encrypted file waits * for the picker's answer; the secret is not kept. * - Restoring one: the secret is held here between the trial and the restore, never in the state the - * screen saves — a rotation keeps it (this ViewModel survives it), a process death forgets it, and - * the user types it again, which is the safe side. + * screen saves — a rotation keeps it (this ViewModel survives it). A process death forgets the whole + * flow, the chosen file included: the user starts it again, which is the safe side. */ @HiltViewModel class SauvegardeViewModel @Inject constructor( @@ -65,7 +65,8 @@ class SauvegardeViewModel @Inject constructor( data class Restauree(val resultat: RestaurationDeSauvegarde.Resultat) : Etape - data class Echec(@StringRes val titre: Int, @StringRes val message: Int) : Etape + /** [argument]: what [message] names, when it names something — a vault locked since the preview. */ + data class Echec(@StringRes val titre: Int, @StringRes val message: Int, val argument: String? = null) : Etape } private val _etape = MutableStateFlow(Etape.Repos) @@ -79,10 +80,13 @@ class SauvegardeViewModel @Inject constructor( Etape.AvantLaCreation(sauvegarde.apercu(), SecretDeSauvegarde.nouveauCode()) } - /** The secret is chosen: the backup is written now, while the vaults are as the preview saw them. */ - fun secretChoisi(choisi: String) = executer(CREATION, enLecture = false) { - preparee = sauvegarde.preparer(choisi) - Etape.PreteADeposer + /** The secret is chosen: the backup is written now, held to the vaults the preview said open. */ + fun secretChoisi(choisi: String) { + val ouverts = (_etape.value as? Etape.AvantLaCreation)?.apercu?.idsDesCoffresACodeOuverts ?: return fermer() + executer(CREATION, enLecture = false) { + preparee = sauvegarde.preparer(choisi, ouverts) + Etape.PreteADeposer + } } fun selecteurOuvert() { @@ -98,17 +102,21 @@ class SauvegardeViewModel @Inject constructor( /** The system picker's answer — `null`: the user went back, and the waiting backup goes. */ fun documentCree(cree: Uri?) { - val enAttente = preparee - preparee = null when { - cree == null -> fermer(enAttente) + cree == null -> fermer() // The process died while the picker was open (the startup purge took the waiting file): the // document it created stays empty — deleted, and the backup said not made. - enAttente == null -> executer(CREATION, enLecture = false) { + preparee == null -> executer(CREATION, enLecture = false) { sauvegarde.oublier(cree) Etape.Echec(CREATION, R.string.backup_error_generic) } - else -> executer(CREATION, enLecture = false) { Etape.Creee(sauvegarde.deposer(enAttente, cree)) } + // Handed over inside the step: cancelled before it ran, the waiting file is still this + // ViewModel's, and [onCleared] lets it go (GPT-5.6 review, 2026-10-29). + else -> executer(CREATION, enLecture = false) { + val enAttente = checkNotNull(preparee) + preparee = null + Etape.Creee(sauvegarde.deposer(enAttente, cree)) + } } } @@ -135,13 +143,15 @@ class SauvegardeViewModel @Inject constructor( fun restaurer(codes: Map) { val ouvert = document ?: return fermer() val saisi = secret ?: return fermer() - executer(RESTAURATION, oublierApres = true) { Etape.Restauree(restauration.restaurer(ouvert, saisi, codes)) } + // The writing pass is held to the bytes this trial read. + val essai = (_etape.value as? Etape.Essayee)?.resultat ?: return fermer() + executer(RESTAURATION, oublierApres = true) { + Etape.Restauree(restauration.restaurer(ouvert, saisi, codes, essai)) + } } - fun fermer() = fermer(preparee) - - private fun fermer(enAttente: SauvegardeChiffree.Preparee?) { - enAttente?.let(sauvegarde::abandonner) + fun fermer() { + preparee?.let(sauvegarde::abandonner) preparee = null secret = null document = null @@ -171,7 +181,7 @@ class SauvegardeViewModel @Inject constructor( Timber.w(e, "sauvegarde chiffree") val message = messageDe(e, enLecture) siLeSecretEstFaux?.takeIf { message == R.string.backup_error_wrong_secret } - ?: Etape.Echec(titre, message) + ?: Etape.Echec(titre, message, (e as? SauvegardeChiffree.CoffreRefermeException)?.nom) } finally { if (oublierApres) { secret = null @@ -200,6 +210,8 @@ class SauvegardeViewModel @Inject constructor( Refus.TROP_GROS -> R.string.backup_error_too_large Refus.TRONQUE -> R.string.backup_error_wrong_secret } + is SauvegardeChiffree.CoffreRefermeException -> R.string.backup_error_vault_closed + is RestaurationDeSauvegarde.FichierChangeException -> R.string.backup_error_changed is SauvegardeChiffree.SauvegardeTropGrosseException -> R.string.backup_error_too_large is SauvegardeChiffree.RelectureEchoueeException -> R.string.backup_error_readback // Reading: a wrong secret and a damaged file look the same — the tag, or the content before it. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 4169522..42be09e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -353,6 +353,8 @@ Zu groß: Eine Sicherung umfasst höchstens 64 MB. Die geschriebene Datei ließ sich nicht zurücklesen und wurde daher nicht behalten. Versuchen Sie einen anderen Speicherort. Der Vorgang konnte nicht abgeschlossen werden. Versuchen Sie es erneut. + Der PIN-Tresor „%1$s“ hat sich inzwischen geschlossen: Es wurde nichts gespeichert. Öffnen Sie ihn erneut und beginnen Sie von vorn. + Die Datei hat sich seit der Prüfung geändert: Es wurde nichts wiederhergestellt. Wählen Sie sie erneut. Diese Sicherung wird %1$d Notiz enthalten, Papierkorb inbegriffen. Diese Sicherung wird %1$d Notizen enthalten, Papierkorb inbegriffen. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index b76502d..890d6fb 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -367,6 +367,8 @@ Demasiado grande: una copia ocupa como máximo 64 MB. El archivo escrito no se puede releer, así que no se ha conservado. Prueba otra ubicación. No se pudo completar la operación. Inténtalo de nuevo. + La caja fuerte con PIN «%1$s» se ha cerrado mientras tanto: no se ha guardado nada. Vuelve a abrirla y empieza de nuevo. + El archivo ha cambiado desde la comprobación: no se ha restaurado nada. Vuelve a elegirlo. Esta copia contendrá %1$d nota, papelera incluida. Esta copia contendrá %1$d de notas, papelera incluida. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 51970bd..cf36877 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -372,6 +372,8 @@ Trop gros : une sauvegarde fait au plus 64 Mo. Le fichier écrit ne se relit pas, il n\'a donc pas été gardé. Essayez un autre emplacement. L\'opération n\'a pas pu aboutir. Réessayez. + Le coffre à PIN « %1$s » s\'est refermé entre-temps : rien n\'a été enregistré. Rouvrez-le, puis recommencez. + Le fichier a changé depuis sa vérification : rien n\'a été restauré. Choisissez-le de nouveau. Cette sauvegarde contiendra %1$d note, corbeille comprise. Cette sauvegarde contiendra %1$d de notes, corbeille comprise. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index b38c51b..6122d5b 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -367,6 +367,8 @@ Troppo grande: un backup contiene al massimo 64 MB. Il file scritto non si rilegge, quindi non è stato conservato. Prova un altro percorso. Non è stato possibile completare l\'operazione. Riprova. + La cassaforte con PIN «%1$s» si è chiusa nel frattempo: non è stato salvato nulla. Riaprila, poi ricomincia. + Il file è cambiato dopo la verifica: non è stato ripristinato nulla. Sceglilo di nuovo. Questo backup conterrà %1$d nota, cestino compreso. Questo backup conterrà %1$d di note, cestino compreso. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 216d2e5..48c21fb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -428,6 +428,8 @@ Too large: a backup holds at most 64 MB. The file written could not be read back, so it was not kept. Try another location. The operation could not be completed. Try again. + The PIN vault “%1$s” locked in the meantime: nothing was saved. Open it again, then start over. + The file changed since it was checked: nothing was restored. Choose it again. This backup will hold %1$d note, trash included. This backup will hold %1$d notes, trash included. From 5bebff2cf12b6358d975eae29e3b8da5dc4021a8 Mon Sep 17 00:00:00 2001 From: Patrice Haltaya Date: Fri, 30 Oct 2026 11:17:43 +0100 Subject: [PATCH 11/16] fix(editor): write the body in Android's own text field A long note was unusable to write in. Compose draws every line of a text each time it records it (ui-text 1.12.1: TextAndroidCanvas reports a clip of infinite height), and a text field records at every keystroke and every blink of its cursor. Measured on a Galaxy S9 (Android 10), release build, time per frame while typing (median): 2 000 characters 30 ms, 100 000 950 ms, 200 000 3.35 s, the keyboard up after 9 s. Compose's TextFieldState field did no better (1.2 s at 100 000). The body is now an EditText (ChampDuCorps): 23 ms per frame at 2 000 as at 200 000 characters. The note's state keeps its TextFieldValue; the field reports text and selection to it and applies its changes (a link, a dictation, the search's cursor) as the smallest replacement. Values the field sent are recognised by identity when they come back late, so a late recomposition cannot undo a keystroke. What an EditText opens and Compose's field did not is closed: - the selection toolbar keeps cut, copy, paste and select all: no Share, no Autofill, no other app's PROCESS_TEXT action (on Android 10 and below every one is listed, "Translate" among them); - Copy and Cut go through SaisieDeCoffre's clipboard (protected for a vault note), and a Cut removes its text only once written and only if nothing changed meanwhile; a refused copy is said (note_editor_copy_failed outside a vault); - a vault note asks the keyboard not to learn, and its selection cannot be dragged to another app; a drop and a paste are plain text; - no classifier, no autofill, no content capture (Android 11+), no spell checker, nothing in the saved state, no fullscreen keyboard. The keyboard reads a window of 5 000 characters around the cursor, never the note: the Samsung Keyboard asks for the extracted text after every keystroke, and at 450 000 characters the 900 KB answer failed the Binder and stalled each keystroke 2 s (S9). A selection longer than the window is handed over by none of its reads. The keyboard leaves with the field (the tick, the app bar's arrow, a note opened from a link): an EditText taken out of the window left it open over the list. Reviewed by GPT-5.6: 8 findings, 5 fixed (a long selection in the extracted text and in takeSnapshot, a slow Cut and an edit during it, an echo seen twice), 1 refuted (search keys are compared by identity), 1 kept as it is safe (a Cut cancelled with the screen stays a Copy), 1 answered in the KDoc (content capture's view flag is Android 11+; a vault note's window is FLAG_SECURE before). Tests: ReglesDuChampDuCorpsTest 9 (JVM), ChampDuCorpsTest 13, the editor's screen tests moved to the native field, and the keyboard closing end to end. Every new defence sabotaged: its test red. Co-Authored-By: Claude Opus 5.5 --- .../ui/editor/ApercuDeBoutEnBoutTest.kt | 39 +- .../ui/editor/ChampDuCorpsDesTests.kt | 18 + .../notes_tech/ui/editor/ChampDuCorpsTest.kt | 409 ++++++++++ .../notes_tech/ui/editor/EditeurTest.kt | 28 +- .../ui/editor/FermetureDuCoffreTest.kt | 10 +- .../notes_tech/ui/editor/ChampDuCorps.kt | 712 ++++++++++++++++++ .../notes_tech/ui/editor/NoteEditorScreen.kt | 35 +- .../ui/editor/NoteEditorViewModel.kt | 13 +- .../ui/editor/ReglesDuChampDuCorps.kt | 69 ++ .../notes_tech/ui/secure/SaisieDeCoffre.kt | 51 +- app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../ui/editor/ReglesDuChampDuCorpsTest.kt | 117 +++ 16 files changed, 1460 insertions(+), 46 deletions(-) create mode 100644 app/src/androidTest/java/com/filestech/notes_tech/ui/editor/ChampDuCorpsDesTests.kt create mode 100644 app/src/androidTest/java/com/filestech/notes_tech/ui/editor/ChampDuCorpsTest.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/ui/editor/ChampDuCorps.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/ui/editor/ReglesDuChampDuCorps.kt create mode 100644 app/src/test/java/com/filestech/notes_tech/ui/editor/ReglesDuChampDuCorpsTest.kt diff --git a/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/ApercuDeBoutEnBoutTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/ApercuDeBoutEnBoutTest.kt index 304afc8..84ba564 100644 --- a/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/ApercuDeBoutEnBoutTest.kt +++ b/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/ApercuDeBoutEnBoutTest.kt @@ -7,14 +7,15 @@ import androidx.compose.ui.test.filter import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule import androidx.compose.ui.test.onChildren import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performTextInput import androidx.test.core.app.ActivityScenario import androidx.test.espresso.Espresso import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -156,7 +157,7 @@ class ApercuDeBoutEnBoutTest { // The note opens to be read (3.1.0): "Edit" first, which puts the cursor in the body. compose.onNodeWithText(context.getString(R.string.note_editor_mode_edit)).performClick() - compose.onNode(hasSetTextAction() and hasText(context.getString(R.string.note_editor_content))).performClick() + compose.onNodeWithTag(TAG_DU_CHAMP_DU_CORPS).performClick() assumeTrue("No soft keyboard showed on this device", attendreLeClavier(affiche = true)) compose.waitUntilDoesNotExist(lienFantome, TIMEOUT_MILLIS) @@ -181,8 +182,9 @@ class ApercuDeBoutEnBoutTest { attendreLaLecture() compose.onNodeWithText(context.getString(R.string.note_editor_mode_edit)).performClick() - compose.onNode(hasSetTextAction() and hasText(context.getString(R.string.note_editor_content))) - .performTextInput(AJOUT) + // The body is Android's own EditText (3.2.0): typed in as a keyboard does. + compose.waitUntilAtLeastOneExists(hasTestTag(TAG_DU_CHAMP_DU_CORPS), TIMEOUT_MILLIS) + checkNotNull(scenario).onActivity { checkNotNull(champDuCorpsDe(it)).taper(AJOUT) } compose.onNodeWithContentDescription(context.getString(R.string.note_editor_tooltip_done)).performClick() val depart = SystemClock.uptimeMillis() @@ -194,8 +196,30 @@ class ApercuDeBoutEnBoutTest { assertThat(enregistre).isTrue() // And it is the reading that shows it: no field left to write in. compose.waitUntilAtLeastOneExists(hasText(AJOUT, substring = true), TIMEOUT_MILLIS) - compose.onNode(hasSetTextAction() and hasText(context.getString(R.string.note_editor_content))) - .assertDoesNotExist() + compose.onNodeWithTag(TAG_DU_CHAMP_DU_CORPS).assertDoesNotExist() + } + + /** + * 🔴 **The keyboard leaves with the body field** (3.2.0). The body is an EditText, and one taken out + * of the window left the keyboard open — over the reading after the tick, over the list after the + * arrow (measured on the emulator, 2026-10-30). "Edit" opens it; the tick must close it. + */ + @Test + fun the_keyboard_leaves_with_the_body_field() { + scenario = ActivityScenario.launch(MainActivity::class.java) + compose.waitUntilAtLeastOneExists(hasText(titreSource), TIMEOUT_MILLIS) + compose.onNodeWithText(titreSource).performClick() + attendreLaLecture() + + compose.onNodeWithText(context.getString(R.string.note_editor_mode_edit)).performClick() + // A touch, as a finger: the instrumentation is not in touch mode, where a keyboard asked for by + // the app alone may not show. + compose.onNodeWithTag(TAG_DU_CHAMP_DU_CORPS).performClick() + assumeTrue("No soft keyboard showed on this device", attendreLeClavier(affiche = true)) + compose.onNodeWithContentDescription(context.getString(R.string.note_editor_tooltip_done)).performClick() + compose.waitUntilDoesNotExist(hasTestTag(TAG_DU_CHAMP_DU_CORPS), TIMEOUT_MILLIS) + + assertThat(attendreLeClavier(affiche = false)).isTrue() } /** Whether the system reaches [affiche] — a soft keyboard shown or not — within [TIMEOUT_MILLIS]. */ @@ -218,8 +242,7 @@ class ApercuDeBoutEnBoutTest { /** The note is shown to be read: its rendering is there, and no field to write in. */ private fun attendreLaLecture() { compose.waitUntilAtLeastOneExists(hasText(texteAffiche), TIMEOUT_MILLIS) - compose.onNode(hasSetTextAction() and hasText(context.getString(R.string.note_editor_content))) - .assertDoesNotExist() + compose.onNodeWithTag(TAG_DU_CHAMP_DU_CORPS).assertDoesNotExist() } private fun toucherLeLien() { diff --git a/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/ChampDuCorpsDesTests.kt b/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/ChampDuCorpsDesTests.kt new file mode 100644 index 0000000..7e8de1e --- /dev/null +++ b/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/ChampDuCorpsDesTests.kt @@ -0,0 +1,18 @@ +package com.filestech.notes_tech.ui.editor + +import android.app.Activity +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import androidx.core.view.descendants + +/** + * The note body's `EditText` (3.2.0, [ChampDuCorps]) in [activite]'s window, or `null` while there is + * none. Compose's semantics do not reach into it: the screen tests read it here. Main thread. + */ +internal fun champDuCorpsDe(activite: Activity): ChampNatifDuCorps? = + (activite.window.decorView as ViewGroup).descendants.filterIsInstance().firstOrNull() + +/** Types [texte] where the cursor is, as a keyboard does: through the field's input connection. Main thread. */ +internal fun ChampNatifDuCorps.taper(texte: String) { + checkNotNull(onCreateInputConnection(EditorInfo())).commitText(texte, 1) +} diff --git a/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/ChampDuCorpsTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/ChampDuCorpsTest.kt new file mode 100644 index 0000000..a82c260 --- /dev/null +++ b/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/ChampDuCorpsTest.kt @@ -0,0 +1,409 @@ +package com.filestech.notes_tech.ui.editor + +import android.content.Intent +import android.os.Build +import android.os.Parcelable +import android.text.style.BackgroundColorSpan +import android.util.SparseArray +import android.view.ActionMode +import android.view.Menu +import android.view.MenuInflater +import android.view.View +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.ExtractedTextRequest +import android.view.textclassifier.TextClassifier +import android.widget.PopupMenu +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.core.view.inputmethod.EditorInfoCompat +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.filestech.notes_tech.domain.recherche.RechercheDansLeTexte.Plage +import com.filestech.notes_tech.ui.secure.LocalCopieDeSaisie +import com.filestech.notes_tech.ui.secure.LocalSaisieProtegee +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CompletableDeferred +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * **The note's body field, Android's own `EditText` (3.2.0, [ChampDuCorps])** — what it must keep + * from the Compose field it replaces, and what an `EditText` opens that must stay closed. + * + * Each case answers a defect the field would have without the line it protects; the comment of the + * field's KDoc says which. + */ +@RunWith(AndroidJUnit4::class) +class ChampDuCorpsTest { + + @get:Rule + val regle = createAndroidComposeRule() + + private var etat by mutableStateOf(TextFieldValue()) + private var protegee by mutableStateOf(false) + private var surlignage by mutableStateOf(Surlignage.AUCUN) + private val recues = mutableListOf() + private val copiees = mutableListOf() + private var copieAcceptee = true + + /** When set, the clipboard's write waits for it: a slow protected write. */ + private var attente: CompletableDeferred? = null + + /** `true`: the state lags — what the field reports is recorded, and given back only when the test says. */ + private var etatEnRetard = false + + private fun poser(texte: String, selection: TextRange = TextRange(texte.length)) { + etat = TextFieldValue(texte, selection) + regle.setContent { + MaterialTheme { + CompositionLocalProvider( + LocalSaisieProtegee provides protegee, + LocalCopieDeSaisie provides { texte -> + copiees += texte + attente?.await() + copieAcceptee + }, + LocalSurlignage provides surlignage, + ) { + ChampDuCorps( + valeur = etat, + onValeurChange = { + recues += it + if (!etatEnRetard) etat = it + }, + focaliser = false, + onFocalise = {}, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + regle.waitForIdle() + } + + private fun champ(): ChampNatifDuCorps = regle.runOnUiThread { checkNotNull(champDuCorpsDe(regle.activity)) } + + private fun surLeChamp(action: ChampNatifDuCorps.() -> T): T = regle.runOnUiThread { champ().action() } + + // ------------------------------------------------------------------ the state, both ways + + /** + * What is typed reaches the state ONCE, with its cursor. A second, selection-only report of the + * same change would make the state's "value before" equal to the new one, and a refused Cut of the + * title could no longer be undone (`NoteEditorViewModel.copieRefusee`). + */ + @Test + fun what_is_typed_reaches_the_state_once_with_its_cursor() { + poser("Bonjour") + + surLeChamp { taper(" le monde") } + regle.waitForIdle() + + assertThat(etat).isEqualTo(TextFieldValue("Bonjour le monde", TextRange(16))) + assertThat(recues.count { it.text == "Bonjour le monde" }).isEqualTo(1) + } + + /** + * 🔴 The state reaches the screen a frame or more after the field reported it: a recomposition + * still carrying the value of the keystroke BEFORE must not undo the last one. + */ + @Test + fun a_late_echo_of_the_state_does_not_undo_a_keystroke() { + poser("x") + etatEnRetard = true + surLeChamp { + taper("a") + taper("b") + } + + // The screen recomposes with the value of "a" — the very object the field sent —, "b" not in yet. + regle.runOnIdle { etat = recues.first { it.text == "xa" } } + regle.waitForIdle() + assertThat(surLeChamp { text.toString() }).isEqualTo("xab") + // And again: a search's answer arrives, the field is updated — the same value still there + // (GPT-5.6 review: the echo was dropped at its first sight, and its second undid "b"). + regle.runOnIdle { surlignage = surlignageDe("xab", Plage(0, 1)) } + regle.waitForIdle() + assertThat(surLeChamp { text.toString() }).isEqualTo("xab") + + // Then with "b": the field and the state agree. + regle.runOnIdle { etat = recues.last() } + regle.waitForIdle() + assertThat(surLeChamp { text.toString() }).isEqualTo("xab") + assertThat(etat.text).isEqualTo("xab") + } + + /** + * A change from the state — a link inserted — is applied as the smallest replacement: what lies + * outside it stays as it was. Replacing the whole text would lay out a long note again, lose the + * scroll position and the keyboard's composition; a span far from the change shows it did not. + */ + @Test + fun a_change_from_the_state_replaces_only_what_changed() { + poser("Voir pour la suite. " + "Fin.".repeat(50), TextRange(5)) + val temoin = Any() + surLeChamp { text.setSpan(temoin, text.length - 4, text.length, 0) } + + regle.runOnIdle { etat = InsertionDeTexte.dansLaSelection(etat, "[[Notes]]") } + regle.waitForIdle() + + assertThat(surLeChamp { text.toString() }).startsWith("Voir [[Notes]] pour la suite.") + assertThat(surLeChamp { selectionStart to selectionEnd }).isEqualTo(14 to 14) + assertThat(surLeChamp { text.getSpanStart(temoin) }).isAtLeast(0) + } + + // ------------------------------------------------------------------ clipboard + + /** Copy goes through the note's clipboard (`SaisieDeCoffre`), never the `EditText`'s own plain clip. */ + @Test + fun copy_goes_through_the_note_s_clipboard() { + poser("un deux trois", TextRange(3, 7)) + + surLeChamp { onTextContextMenuItem(android.R.id.copy) } + regle.waitForIdle() + + assertThat(copiees).containsExactly("deux") + assertThat(etat.text).isEqualTo("un deux trois") + } + + /** A Cut removes the text once it is written — and only then. */ + @Test + fun a_cut_removes_the_text_once_written() { + poser("un deux trois", TextRange(3, 7)) + + surLeChamp { onTextContextMenuItem(android.R.id.cut) } + regle.waitForIdle() + + assertThat(copiees).containsExactly("deux") + assertThat(etat.text).isEqualTo("un trois") + } + + /** + * 🔴 A refused Cut loses nothing: Compose's field removed the selection BEFORE writing it, and a + * refused write lost it until the state undid it. This one writes first. + */ + @Test + fun a_refused_cut_loses_nothing() { + copieAcceptee = false + poser("un deux trois", TextRange(3, 7)) + + surLeChamp { onTextContextMenuItem(android.R.id.cut) } + regle.waitForIdle() + + assertThat(copiees).containsExactly("deux") + assertThat(surLeChamp { text.toString() }).isEqualTo("un deux trois") + assertThat(etat.text).isEqualTo("un deux trois") + } + + /** + * 🔴 A Cut whose text changed while it was being written removes nothing: the same characters at + * the same place may be ones typed since (GPT-5.6 review, 2026-10-30). Left in place, it was a Copy. + */ + @Test + fun a_cut_removes_nothing_if_the_text_changed_meanwhile() { + val ecriture = CompletableDeferred() + attente = ecriture + poser("aX", TextRange(0, 1)) + + surLeChamp { onTextContextMenuItem(android.R.id.cut) } + // While the clipboard is being written, "aY" typed at the start. + surLeChamp { + setSelection(0) + taper("aY") + } + regle.runOnIdle { ecriture.complete(Unit) } + regle.waitForIdle() + + assertThat(copiees).containsExactly("a") + assertThat(surLeChamp { text.toString() }).isEqualTo("aYaX") + } + + // ------------------------------------------------------------------ the keyboard + + /** A vault note's keyboard is asked not to learn (audit 2026-09-26, F4); another note's is not. */ + @Test + fun a_vault_note_asks_the_keyboard_not_to_learn() { + poser("secret") + val drapeau = EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING + fun options() = surLeChamp { EditorInfo().also { onCreateInputConnection(it) }.imeOptions } + + assertThat(options() and drapeau).isEqualTo(0) + + regle.runOnIdle { protegee = true } + regle.waitForIdle() + + assertThat(options() and drapeau).isEqualTo(drapeau) + } + + /** + * 🔴 The keyboard reads a window around the cursor, never the note: the Samsung Keyboard asks for + * the whole extracted text after every keystroke, and a 450 000-character note failed the Binder + * and stalled each keystroke 2 s (S9, 2026-10-30). The offsets stay right. + */ + @Test + fun the_keyboard_reads_a_window_around_the_cursor() { + val texte = (0 until 3_000).joinToString("") { "ligne $it\n" } + val curseur = texte.length / 2 + poser(texte, TextRange(curseur)) + + val extrait = surLeChamp { + checkNotNull(onCreateInputConnection(EditorInfo())).getExtractedText(ExtractedTextRequest(), 0) + } + + assertThat(extrait!!.text.length).isAtMost(2 * FENETRE) + assertThat(extrait.startOffset).isEqualTo(curseur - FENETRE) + assertThat(extrait.selectionStart).isEqualTo(FENETRE) + assertThat(extrait.text.toString()).isEqualTo(texte.substring(curseur - FENETRE, curseur + FENETRE)) + val avant = + surLeChamp { checkNotNull(onCreateInputConnection(EditorInfo())).getTextBeforeCursor(Int.MAX_VALUE, 0) } + assertThat(avant!!.length).isEqualTo(FENETRE) + } + + /** + * A selection longer than the window is handed to the keyboard by none of its reads — "Select all" + * on a long vault note, then the keyboard asking for the extracted text, gave it the whole note + * (GPT-5.6 review, 2026-10-30). + */ + @Test + fun a_long_selection_is_not_handed_to_the_keyboard() { + val texte = "a".repeat(3 * FENETRE) + poser(texte, TextRange(0, texte.length)) + + surLeChamp { + val connexion = checkNotNull(onCreateInputConnection(EditorInfo())) + assertThat(connexion.getSelectedText(0)).isNull() + assertThat(connexion.getExtractedText(ExtractedTextRequest(), 0)).isNull() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) assertThat(connexion.takeSnapshot()).isNull() + } + } + + // ------------------------------------------------------------------ closed doors + + /** + * 🔴 The toolbar keeps its editing entries only: no Share, no Autofill, and no other app's + * `PROCESS_TEXT` action — the "Translate" that sent the text online (3.1.1) — in either toolbar. + */ + @Test + fun the_toolbars_offer_no_other_app_and_no_share() { + poser("un deux trois") + + val restantes = surLeChamp { + listOf(customSelectionActionModeCallback, customInsertionActionModeCallback).map { rappel -> + val menu = PopupMenu(context, this).menu + for (id in listOf(android.R.id.cut, android.R.id.copy, android.R.id.paste, android.R.id.shareText)) { + menu.add(Menu.NONE, id, Menu.NONE, "x") + } + menu.add(Menu.NONE, android.R.id.autofill, Menu.NONE, "x") + menu.add(Menu.NONE, Menu.NONE, Menu.NONE, "Translate").intent = Intent(Intent.ACTION_PROCESS_TEXT) + rappel.onCreateActionMode(ModeFactice, menu) + rappel.onPrepareActionMode(ModeFactice, menu) + (0 until menu.size()).map { menu.getItem(it).itemId } + } + } + + restantes.forEach { ids -> + assertThat(ids).containsExactly(android.R.id.cut, android.R.id.copy, android.R.id.paste) + } + } + + /** + * Not offered to autofill, never written to the saved state, no spell checker, no classifier, no + * fullscreen keyboard — see the field's KDoc for why each. + */ + @Test + fun what_an_edit_text_offers_by_default_stays_closed() { + poser("secret") + + surLeChamp { + assertThat(importantForAutofill).isEqualTo(View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS) + // Nothing of the text in the saved state — what Compose's AndroidView saves for its view —, + // and not even with an id given to it some day. + assertThat(isSaveEnabled).isFalse() + val sauvegarde = SparseArray() + saveHierarchyState(sauvegarde) + assertThat(sauvegarde.size()).isEqualTo(0) + assertThat(isSuggestionsEnabled).isFalse() + assertThat(textClassifier).isSameInstanceAs(TextClassifier.NO_OP) + assertThat(imeOptions and EditorInfo.IME_FLAG_NO_EXTRACT_UI).isNotEqualTo(0) + assertThat(id).isEqualTo(View.NO_ID) + } + } + + // ------------------------------------------------------------------ the search + + /** The search's occurrences, highlighted; the current one in its own colour; gone with the bar. */ + @Test + fun the_search_highlights_its_occurrences_and_clears_them() { + val texte = "pomme, poire, pomme" + poser(texte) + val plages = listOf(Plage(0, 5), Plage(14, 19)) + val resultat = ResultatDeRecherche( + source = SourceDeRecherche.Ecriture("", texte), + mots = listOf("pomme"), + occurrences = plages.map { Occurrence(CleDuCorps, -1, it) }, + tronque = false, + ) + + regle.runOnIdle { surlignage = Surlignage(resultat, resultat.occurrences[1]) } + regle.waitForIdle() + val fonds = surLeChamp { + text.getSpans(0, text.length, BackgroundColorSpan::class.java).map { + text.getSpanStart(it) to + it.backgroundColor + } + } + + assertThat(fonds.map { it.first }).containsExactly(0, 14) + val tertiaire = regle.runOnIdle { couleurTertiaire } + assertThat(fonds.single { it.first == 14 }.second).isEqualTo(tertiaire) + + regle.runOnIdle { surlignage = Surlignage.AUCUN } + regle.waitForIdle() + + assertThat(surLeChamp { text.getSpans(0, text.length, BackgroundColorSpan::class.java) }).isEmpty() + } + + /** The search's answer for [plage] in [texte], none of them the current one. */ + private fun surlignageDe(texte: String, plage: Plage): Surlignage { + val resultat = ResultatDeRecherche( + source = SourceDeRecherche.Ecriture("", texte), + mots = listOf(texte.substring(plage.debut, plage.fin)), + occurrences = listOf(Occurrence(CleDuCorps, -1, plage)), + tronque = false, + ) + return Surlignage(resultat, courante = null) + } + + private val couleurTertiaire: Int get() = androidx.compose.material3.lightColorScheme().tertiary.toArgb() + + /** An action mode that does nothing: the toolbar's callbacks only read the menu they are given. */ + private object ModeFactice : ActionMode() { + override fun setTitle(title: CharSequence?) = Unit + override fun setTitle(resId: Int) = Unit + override fun setSubtitle(subtitle: CharSequence?) = Unit + override fun setSubtitle(resId: Int) = Unit + override fun setCustomView(view: View?) = Unit + override fun invalidate() = Unit + override fun finish() = Unit + override fun getMenu(): Menu? = null + override fun getTitle(): CharSequence? = null + override fun getSubtitle(): CharSequence? = null + override fun getCustomView(): View? = null + override fun getMenuInflater(): MenuInflater? = null + } + + private companion object { + /** The field's window, `ChampNatifDuCorps.FENETRE_DU_CLAVIER`. */ + const val FENETRE = 5_000 + } +} diff --git a/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/EditeurTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/EditeurTest.kt index b7bf799..e52b971 100644 --- a/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/EditeurTest.kt +++ b/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/EditeurTest.kt @@ -18,18 +18,19 @@ import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.hasAnyDescendant import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.hasScrollAction import androidx.compose.ui.test.hasScrollToIndexAction import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.isHeading import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo @@ -204,6 +205,12 @@ class EditeurTest { poser(noteChargee()) assertThat(regle.champsDeSaisieSansNom()).isEmpty() + // The body is an EditText since 3.2.0, which the sweep — Compose's semantics — does not see: + // its name is read where TalkBack reads it, note filled. + val nom = regle.runOnUiThread { + checkNotNull(champDuCorpsDe(regle.activity)).createAccessibilityNodeInfo().hintText + } + assertThat(nom?.toString()).isEqualTo(texte(R.string.note_editor_content)) } /** Le balayage §71, sur l'état qui porte le plus d'actionnables. */ @@ -331,9 +338,12 @@ class EditeurTest { regle.onNodeWithContentDescription(texte(R.string.common_loading)).assertIsDisplayed() assertThat(regle.onAllNodes(hasSetTextAction()).fetchSemanticsNodes()).isEmpty() + champDuCorps().assertDoesNotExist() poser(noteChargee()) - assertThat(regle.onAllNodes(hasSetTextAction()).fetchSemanticsNodes()).hasSize(2) + // The title, a Compose field; the body, Android's own (3.2.0). + assertThat(regle.onAllNodes(hasSetTextAction()).fetchSemanticsNodes()).hasSize(1) + champDuCorps().assertExists() } /** @@ -688,8 +698,10 @@ class EditeurTest { regle.onNodeWithText(texte(R.string.note_editor_mode_edit)).performClick() regle.waitForIdle() assertThat(basculements).containsExactly(true, false).inOrder() - // "Edit" means "write": the cursor is in the body. - champDuCorps().assertIsFocused() + // "Edit" means "write": the cursor is in the body — its own EditText since 3.2.0. + regle.waitUntil(DELAI_DU_FOCUS_MS) { + regle.runOnUiThread { champDuCorpsDe(regle.activity)?.hasFocus() } == true + } } /** @@ -818,8 +830,7 @@ class EditeurTest { assertThat(hauteurDuCorps).isAtLeast(with(regle.density) { HAUTEUR_MINIMALE_DU_CORPS.roundToPx() }) // What no longer fits, the links panel, is reached by scrolling the editor to its end — the // column holding the body, not the panel's own scroll, which `performScrollTo` would pick. - val corps = hasSetTextAction() and hasText(texte(R.string.note_editor_content)) - regle.onNode(hasScrollAction() and hasAnyDescendant(corps)) + regle.onNode(hasScrollAction() and hasAnyDescendant(hasTestTag(TAG_DU_CHAMP_DU_CORPS))) .performSemanticsAction(SemanticsActions.ScrollBy) { defiler -> defiler(0f, DEFILEMENT_JUSQU_AU_BOUT) } regle.onNodeWithText(TITRE_MENTION).performScrollTo().assertIsDisplayed() } @@ -845,7 +856,8 @@ class EditeurTest { private fun champDuTitre() = regle.onNode(hasSetTextAction() and hasText(texte(R.string.note_editor_title))) - private fun champDuCorps() = regle.onNode(hasSetTextAction() and hasText(texte(R.string.note_editor_content))) + /** The body field (3.2.0): Android's own EditText, in a Compose node carrying this tag. */ + private fun champDuCorps() = regle.onNodeWithTag(TAG_DU_CHAMP_DU_CORPS) /** The preview is read off the main thread: wait for [texteAttendu] to be composed. */ private fun attendre(texteAttendu: String) { @@ -921,6 +933,8 @@ class EditeurTest { ) private companion object { + const val DELAI_DU_FOCUS_MS = 5_000L + val INDICATEUR_D_ACTIVITE = SemanticsMatcher.keyIsDefined(SemanticsProperties.ProgressBarRangeInfo) /** Les quatre actions de barre d'une note ouverte, dans l'ordre où elles apparaissent. */ diff --git a/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/FermetureDuCoffreTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/FermetureDuCoffreTest.kt index c4e1d3c..ea94960 100644 --- a/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/FermetureDuCoffreTest.kt +++ b/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/FermetureDuCoffreTest.kt @@ -163,7 +163,7 @@ class FermetureDuCoffreTest { withContext(Dispatchers.Main) { editeur.onContentChange(TextFieldValue("abc PIN def", selection = TextRange(4, 7))) // selected editeur.onContentChange(TextFieldValue("abc def", selection = TextRange(4))) // what Cut leaves - editeur.copieProtegeeRefusee("PIN") + editeur.copieRefusee("PIN") } val etat = editeur.state.value @@ -181,7 +181,7 @@ class FermetureDuCoffreTest { withContext(Dispatchers.Main) { editeur.onContentChange(TextFieldValue("abc PIN def", selection = TextRange(4, 7))) - editeur.copieProtegeeRefusee("PIN") + editeur.copieRefusee("PIN") } assertThat(editeur.state.value.content.text).isEqualTo("abc PIN def") @@ -198,7 +198,7 @@ class FermetureDuCoffreTest { withContext(Dispatchers.Main) { editeur.onTitleChange(TextFieldValue(titre, selection = TextRange(0, 5))) // "Bank " selected editeur.onTitleChange(TextFieldValue(titre.removePrefix("Bank "), selection = TextRange(0))) - editeur.copieProtegeeRefusee("Bank ") + editeur.copieRefusee("Bank ") } assertThat(editeur.state.value.title).isEqualTo(titre) @@ -219,7 +219,7 @@ class FermetureDuCoffreTest { editeur.onContentChange(TextFieldValue("PIN and PIN", selection = TextRange(0, 3))) // "PIN" selected editeur.onContentChange(TextFieldValue(" and PIN", selection = TextRange(0))) // deleted by hand editeur.onContentChange(TextFieldValue(" and PIN", selection = TextRange(5, 8))) // the other "PIN" - editeur.copieProtegeeRefusee("PIN") // copied, refused + editeur.copieRefusee("PIN") // copied, refused } assertThat(editeur.state.value.content.text).isEqualTo(" and PIN") @@ -236,7 +236,7 @@ class FermetureDuCoffreTest { editeur.onTitleChange(TextFieldValue("PIN and PIN", selection = TextRange(0, 3))) editeur.onTitleChange(TextFieldValue(" and PIN", selection = TextRange(0))) editeur.onTitleChange(TextFieldValue(" and PIN", selection = TextRange(5, 8))) - editeur.copieProtegeeRefusee("PIN") + editeur.copieRefusee("PIN") } assertThat(editeur.state.value.title).isEqualTo(" and PIN") diff --git a/app/src/main/java/com/filestech/notes_tech/ui/editor/ChampDuCorps.kt b/app/src/main/java/com/filestech/notes_tech/ui/editor/ChampDuCorps.kt new file mode 100644 index 0000000..4572583 --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/ui/editor/ChampDuCorps.kt @@ -0,0 +1,712 @@ +package com.filestech.notes_tech.ui.editor + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.graphics.drawable.GradientDrawable +import android.os.Build +import android.text.Editable +import android.text.InputType +import android.text.Spanned +import android.text.TextWatcher +import android.text.style.BackgroundColorSpan +import android.text.style.ForegroundColorSpan +import android.util.TypedValue +import android.view.ActionMode +import android.view.DragEvent +import android.view.Gravity +import android.view.Menu +import android.view.MenuItem +import android.view.MotionEvent +import android.view.View +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.ExtractedText +import android.view.inputmethod.ExtractedTextRequest +import android.view.inputmethod.InputConnection +import android.view.inputmethod.InputConnectionWrapper +import android.view.inputmethod.InputMethodManager +import android.view.inputmethod.SurroundingText +import android.view.inputmethod.TextSnapshot +import android.view.textclassifier.TextClassifier +import android.widget.EditText +import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.AccessibilityDelegateCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat +import androidx.core.view.children +import androidx.core.view.doOnAttach +import androidx.core.view.inputmethod.EditorInfoCompat +import androidx.core.widget.TextViewCompat +import com.filestech.notes_tech.R +import com.filestech.notes_tech.domain.recherche.RechercheDansLeTexte.Plage +import com.filestech.notes_tech.ui.secure.LocalCopieDeSaisie +import com.filestech.notes_tech.ui.secure.LocalSaisieProtegee +import com.filestech.notes_tech.ui.secure.copierDepuisUnChamp +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +/** The body field's tag, for the screen's tests. */ +internal const val TAG_DU_CHAMP_DU_CORPS = "champ-du-corps" + +/** + * 🔴 **The note's body while writing: Android's own `EditText`, not a Compose text field (3.2.0).** + * + * Compose draws every line of a text, on screen or not: its `TextAndroidCanvas.getClipBounds` answers + * a clip of infinite height (bytecode of ui-text 1.12.1, read on 2026-10-30), so that a text recorded + * once scrolls without being drawn again. A text field is drawn again at every keystroke and every + * blink of its cursor — a long note, line by line, each time. Measured on the S9 (Android 10), release + * build, ten letters typed, time per frame (median): 20 000 characters 9 ms, 50 000 150 ms, + * 100 000 950 ms, 200 000 3.35 s — the keyboard came up after 9 s. Compose's newer `TextFieldState` + * field did no better (1.2 s at 100 000). An `EditText` lays out again only the paragraphs a change + * touches and draws only the lines on screen: 13 ms at 100 000 characters, 15 ms at 200 000. + * + * The note's state keeps its [TextFieldValue]: what the field shows is synchronised with it both + * ways — see [ChampNatifDuCorps.appliquer] — so the links, the dictation, the search and the save are + * unchanged. + * + * ⚠️ What a vault note's fields get from `SaisieDeCoffre` reaches Compose's fields only; this field + * applies the same protections itself, and closes what an `EditText` opens that Compose's field did + * not — see [ChampNatifDuCorps]. + * + * @param focaliser `true` to put the focus and the keyboard in the field — "Edit" means "write"; + * [onFocalise] is called once done. + */ +@Composable +internal fun ChampDuCorps( + valeur: TextFieldValue, + onValeurChange: (TextFieldValue) -> Unit, + focaliser: Boolean, + onFocalise: () -> Unit, + modifier: Modifier = Modifier, +) { + val protegee = LocalSaisieProtegee.current + val natif = LocalClipboard.current + val copieDeSaisie = LocalCopieDeSaisie.current + // Outside `SaisieDeCoffre` — the screen's tests — a plain clip, as there. + val copieNative: suspend (String) -> Boolean = remember(natif) { + { texte -> copierDepuisUnChamp(texte, protegee = false, natif = natif, deposer = {}, surEchec = {}) } + } + val copie by rememberUpdatedState(copieDeSaisie ?: copieNative) + val portee = rememberCoroutineScope() + val surlignage = LocalSurlignage.current + val plages = surlignage.plagesDe(CleDuCorps) + val courante = surlignage.couranteDans(CleDuCorps) + val schema = MaterialTheme.colorScheme + val style = MaterialTheme.typography.bodyLarge + val libelle = stringResource(R.string.note_editor_content) + val indication = stringResource(R.string.note_editor_content_hint) + var focalise by remember { mutableStateOf(false) } + val vue = remember { mutableStateOf(null) } + val auFocus by rememberUpdatedState(onFocalise) + + LaunchedEffect(focaliser) { + if (!focaliser) return@LaunchedEffect + snapshotFlow { vue.value }.filterNotNull().first().focaliser() + auFocus() + } + + // Material 3's filled field, as the title's: the label sits small above the text once the field + // holds the focus or a text; empty and idle, it is the field's own hint, and the placeholder takes + // its place once the field is focused. + val flottant = focalise || valeur.text.isNotEmpty() + // The tag on the whole field, its label included — as Compose's field, whose label is inside it. + Column(modifier.testTag(TAG_DU_CHAMP_DU_CORPS)) { + if (flottant) { + Text( + text = libelle, + style = MaterialTheme.typography.bodySmall, + color = if (focalise) schema.primary else schema.onSurfaceVariant, + // The field itself carries this name for TalkBack: read once, not twice. + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp).clearAndSetSemantics {}, + ) + } + AndroidView( + factory = { contexte -> ChampNatifDuCorps(contexte).also { vue.value = it } }, + update = { champ -> + champ.onValeur = onValeurChange + champ.onFocus = { focalise = it } + champ.ecrire = { texte, fini -> portee.launch { fini(copie(texte)) } } + champ.protegee = protegee + champ.apparence(Apparence.de(style, schema, champ.context, enHaut = !flottant)) + champ.indications(indication = if (focalise) indication else libelle, nom = libelle) + champ.appliquer(valeur) + champ.surligner(plages, courante, CouleursDeSurlignage.de(schema)) + }, + onRelease = { champ -> + vue.value = null + champ.relacher() + }, + modifier = Modifier.fillMaxWidth().weight(1f), + ) + } +} + +/** What the field looks like: the title's style and colours, from the theme. */ +internal data class Apparence( + val tailleSp: Float, + val interligneSp: Float, + val espacementEm: Float, + val texte: Int, + val indication: Int, + val selection: Int, + val curseur: Int, + val margeDuHautPx: Int, + val margeDeCotePx: Int, + val margeDuBasPx: Int, +) { + companion object { + fun de(style: TextStyle, schema: ColorScheme, contexte: Context, enHaut: Boolean): Apparence { + val taille = if (style.fontSize.isSp) style.fontSize.value else TAILLE_PAR_DEFAUT_SP + val interligne = if (style.lineHeight.isSp) style.lineHeight.value else taille * INTERLIGNE_PAR_DEFAUT + val espacement = if (style.letterSpacing.isSp) style.letterSpacing.value / taille else 0f + fun px(dp: Float) = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, contexte.resources.displayMetrics).toInt() + return Apparence( + tailleSp = taille, + interligneSp = interligne, + espacementEm = espacement, + texte = schema.onSurface.toArgb(), + indication = schema.onSurfaceVariant.toArgb(), + // Compose's selection: the primary colour at 40 %. + selection = schema.primary.copy(alpha = ALPHA_DE_LA_SELECTION).toArgb(), + curseur = schema.primary.toArgb(), + // The label above takes the place of this margin once it shows. + margeDuHautPx = if (enHaut) px(MARGE_DU_HAUT_DP) else 0, + margeDeCotePx = px(MARGE_DE_COTE_DP), + margeDuBasPx = px(MARGE_DU_BAS_DP), + ) + } + + private const val TAILLE_PAR_DEFAUT_SP = 16f + private const val INTERLIGNE_PAR_DEFAUT = 1.5f + private const val ALPHA_DE_LA_SELECTION = 0.4f + private const val MARGE_DU_HAUT_DP = 16f + private const val MARGE_DE_COTE_DP = 16f + private const val MARGE_DU_BAS_DP = 8f + } +} + +/** The search's two highlights — those of [stylesDeSurlignage] — as colours a span takes. */ +internal data class CouleursDeSurlignage(val fond: Int, val encre: Int, val fondCourante: Int, val encreCourante: Int) { + companion object { + fun de(schema: ColorScheme) = CouleursDeSurlignage( + fond = schema.tertiaryContainer.toArgb(), + encre = schema.onTertiaryContainer.toArgb(), + fondCourante = schema.tertiary.toArgb(), + encreCourante = schema.onTertiary.toArgb(), + ) + } +} + +/** + * The `EditText` of [ChampDuCorps]. + * + * ## Closed here, open in any `EditText` + * + * - 🔴 **Copy and Cut go through [ecrire]** — the clipboard of `SaisieDeCoffre`: marked sensitive, + * cleared, purged by the panic for a vault note (audit 2026-09-26, F1). The text is written FIRST, + * and a Cut removes it only once written: a refused copy loses nothing. + * - 🔴 **The toolbar keeps editing entries only** ([MenuDuChampDuCorps]): no Share, no Autofill, no + * other app's `PROCESS_TEXT` — on Android 10 and below, every app answering it is listed, and + * "Translate" sent the text online (3.1.1). The classifier is [TextClassifier.NO_OP] here too. + * - 🔴 **A vault note's keyboard is asked not to learn** (F4): `IME_FLAG_NO_PERSONALIZED_LEARNING`, + * as `SaisieDeCoffre` asks it for Compose's fields. + * - 🔴 **A vault note's selection cannot be dragged to another app**: dragging is copying, with none + * of [ecrire]'s protections. A drop INTO the field arrives as plain text, as a paste does. + * - 🔴 **The text is never written to the saved state**: an `EditText` saves its text with the + * activity's state — a vault note's plaintext in a Bundle. No id, `isSaveEnabled = false`. + * - **Not offered to autofill, nor to content capture.** The view's content capture flag exists from + * Android 11; on Android 10, a vault note's window carries FLAG_SECURE, which keeps capture off. + * - **No spell checker**: Android's checks the whole text, word by word, through a service — a + * 200 000-character note sent piece by piece, and the decrypted text of a vault note with it. + * - **No fullscreen keyboard** in landscape: its extracted text would copy the whole note. + * - 🔴 **The keyboard reads a window around the cursor**, not the note: see [ConnexionBornee]. + */ +@SuppressLint("ViewConstructor") +internal class ChampNatifDuCorps(context: Context) : EditText(context) { + + var onValeur: (TextFieldValue) -> Unit = {} + var onFocus: (Boolean) -> Unit = {} + + /** Writes a copied text to the clipboard, then says whether it was written. */ + var ecrire: (texte: String, fini: (Boolean) -> Unit) -> Unit = { _, fini -> fini(false) } + + /** A vault note: see the class. */ + var protegee: Boolean = false + set(valeur) { + if (field == valeur) return + field = valeur + // The keyboard reads the flag when its connection opens: open it again. + if (hasFocus()) clavier()?.restartInput(this) + } + + /** + * Set once the constructor is done. `TextView`'s own constructor calls overridden methods — + * [onSelectionChanged] among them — before this class's fields exist. + */ + private var pret = false + + /** What the state holds, as last sent by the field or applied from the state. */ + private var connue = TextFieldValue() + + /** + * Values sent and not seen back yet. 🔴 The state reaches the screen a frame or more later, and a + * recomposition can still carry a value this field sent BEFORE the last keystroke: applied, it + * would undo that keystroke. A value seen here, compared by identity — the state keeps the very + * object it was given —, is an echo, never a change. + */ + private val envoyees = ArrayDeque() + private var enApplication = false + private var enModification = false + private var dernierToucher = -1 + private var menu: ActionMode? = null + private var apparence: Apparence? = null + private var indication: String? = null + private var nom: String = "" + private val spans = ArrayList() + private var plagesSurlignees: List? = null + private var couranteSurlignee: Plage? = null + private var couleursSurlignees: CouleursDeSurlignage? = null + + init { + inputType = + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE or InputType.TYPE_TEXT_FLAG_AUTO_CORRECT + imeOptions = EditorInfo.IME_FLAG_NO_FULLSCREEN or EditorInfo.IME_FLAG_NO_EXTRACT_UI + gravity = Gravity.TOP or Gravity.START + background = null + includeFontPadding = false + isVerticalScrollBarEnabled = true + // An EditText always "freezes" its text (`getFreezesText` is true whatever is set): what keeps it + // out of the saved state is no id and this. + isSaveEnabled = false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + importantForAutofill = View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS + setTextClassifier(TextClassifier.NO_OP) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + importantForContentCapture = View.IMPORTANT_FOR_CONTENT_CAPTURE_NO_EXCLUDE_DESCENDANTS + } + customSelectionActionModeCallback = FiltreDuMenu() + customInsertionActionModeCallback = FiltreDuMenu() + addTextChangedListener(Surveillance()) + setOnFocusChangeListener { _, focus -> onFocus(focus) } + // A press the listener consumes is not the platform's: no drag starts (see [glisserRetenu]). + setOnLongClickListener { glisserRetenu(protegee, selectionStart, selectionEnd, dernierToucher) } + ViewCompat.setAccessibilityDelegate( + this, + object : AccessibilityDelegateCompat() { + override fun onInitializeAccessibilityNodeInfo(hote: View, info: AccessibilityNodeInfoCompat) { + super.onInitializeAccessibilityNodeInfo(hote, info) + // The field's name, whatever it holds: the label shown above is hidden from TalkBack. + info.hintText = nom + } + }, + ) + pret = true + } + + // ------------------------------------------------------------------ the state, both ways + + /** + * Shows [valeur], the state's. An echo of what this field sent is ignored (see [envoyees]); a + * change from elsewhere — a link, a dictation, the search's cursor — is applied as the smallest + * replacement ([DifferenceDeTexte]), the cursor brought into view. + */ + fun appliquer(valeur: TextFieldValue) { + if (valeur === connue) { + envoyees.clear() + return + } + val echo = envoyees.indexOfFirst { it === valeur } + if (echo >= 0) { + // Older ones are past; this one stays: another recomposition may carry it again before the + // latest is in (GPT-5.6 review, 2026-10-30 — dropped at once, its second sight undid a keystroke). + repeat(echo) { envoyees.removeFirst() } + return + } + envoyees.clear() + val editable = text + var texteChange = false + var selectionChange = false + enApplication = true + try { + DifferenceDeTexte.entre(editable, valeur.text)?.let { d -> + editable.replace(d.debut, d.finAvant, valeur.text, d.debut, d.finApres) + texteChange = true + } + val longueur = editable.length + val debut = valeur.selection.start.coerceIn(0, longueur) + val fin = valeur.selection.end.coerceIn(0, longueur) + if (selectionStart != debut || selectionEnd != fin) { + setSelection(debut, fin) + selectionChange = true + } + } finally { + enApplication = false + } + // The very object: the next recomposition carries it again, and must find it known. + connue = valeur + // A text changed under the keyboard: its composition and its idea of the text are stale. + if (texteChange && hasFocus()) clavier()?.restartInput(this) + if (texteChange || selectionChange) amenerLeCurseur() + } + + private fun envoyer(valeur: TextFieldValue) { + connue = valeur + envoyees.addLast(valeur) + while (envoyees.size > ENVOYEES_MAX) envoyees.removeFirst() + onValeur(valeur) + } + + private fun selectionCourante() = TextRange(selectionStart.coerceAtLeast(0), selectionEnd.coerceAtLeast(0)) + + /** Changes of the text so far, from the field or from the state: what a Cut checks before removing. */ + private var version = 0L + + /** The text and the selection, once a change is over. */ + private inner class Surveillance : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { + enModification = true + version++ + } + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit + + override fun afterTextChanged(s: Editable) { + enModification = false + if (!enApplication) envoyer(TextFieldValue(s.toString(), selectionCourante())) + } + } + + /** + * A selection that moved alone — a tap, a handle dragged. One that moves with the text is sent by + * [Surveillance]: sending it here too would leave the state's "value before" equal to the new one. + */ + override fun onSelectionChanged(selStart: Int, selEnd: Int) { + super.onSelectionChanged(selStart, selEnd) + if (!pret || enApplication || enModification) return + val selection = selectionCourante() + if (selection != connue.selection) envoyer(connue.copy(selection = selection)) + } + + // ------------------------------------------------------------------ focus and keyboard + + /** + * The focus, the keyboard, and the cursor in view. The keyboard through the window's insets + * controller: `showSoftInput` right after `requestFocus` is refused while the keyboard does not + * serve this field yet — seen in the instrumented tests —, the controller waits for it. + */ + fun focaliser() { + doOnAttach { + requestFocus() + val fenetre = context.activite()?.window + if (fenetre != null) { + WindowCompat.getInsetsController(fenetre, this).show(WindowInsetsCompat.Type.ime()) + } else { + post { clavier()?.showSoftInput(this, 0) } + } + amenerLeCurseur() + } + } + + private tailrec fun Context.activite(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.activite() + else -> null + } + + private fun amenerLeCurseur() { + post { if (layout != null) bringPointIntoView(selectionEnd.coerceAtLeast(0)) } + } + + private fun clavier() = context.getSystemService(InputMethodManager::class.java) + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + // The keyboard came up and took the bottom of the field: the cursor stays in view. + if (h < oldh && hasFocus()) amenerLeCurseur() + } + + /** + * 🔴 **The keyboard leaves with the field** — called when it leaves the composition. Compose's field + * closed the keyboard at the end of its input session; an `EditText` taken out of the window leaves + * it open, and the reading — or the list of notes — came back under it (measured on the emulator, + * 2026-10-30, the tick and the app bar's arrow). + * + * Always, not on a focus change: the tick takes the focus away while the field is still there. The + * field never leaves for another text field — the search bar and the link sheet open with it on + * screen —, only for the reading, another screen, a note opened from a link, a rotation: no + * keyboard is wanted after any of them. Through the window: once the field is gone, no view is the + * keyboard's, and `hideSoftInputFromWindow` was refused for that (instrumented test, 2026-10-30). + */ + fun relacher() { + val fenetre = context.activite()?.window ?: return + WindowCompat.getInsetsController(fenetre, fenetre.decorView).hide(WindowInsetsCompat.Type.ime()) + } + + override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? { + val connexion = super.onCreateInputConnection(outAttrs) ?: return null + if (protegee) outAttrs.imeOptions = outAttrs.imeOptions or EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING + return ConnexionBornee(connexion) + } + + /** + * 🔴 **What the keyboard reads of the note: a window around the cursor, never the whole text.** + * + * Measured on the S9 (Samsung Keyboard, 2026-10-30): after EVERY keystroke the keyboard asks for + * the extracted text, and a `TextView` answers with all of it — 900 KB for a note of 450 000 + * characters. Over the 1 MB a Binder transaction carries with everything else in flight, the answer + * failed (`TransactionTooLargeException`), and the keyboard waited 2 s for it, at each keystroke. + * Below, it still copied the note to the keyboard's process a keystroke at a time — a vault + * note's plaintext with it, where the keyboard needs a few words around the cursor. + * + * The window keeps the offsets right ([ExtractedText.startOffset]): the keyboard knows where the + * cursor is in the note. No monitoring: the keyboard asks again, as it does after every keystroke. + * With no fullscreen keyboard (see the class), nothing shows the extracted text. + */ + private inner class ConnexionBornee(cible: InputConnection) : InputConnectionWrapper(cible, false) { + + /** `null` for a selection longer than the window: the window holds the selection whole. */ + override fun getExtractedText(request: ExtractedTextRequest?, flags: Int): ExtractedText? { + if (selectionLongue()) return null + val editable = text + val selDebut = selectionStart.coerceIn(0, editable.length) + val selFin = selectionEnd.coerceIn(0, editable.length) + val debut = (minOf(selDebut, selFin) - FENETRE_DU_CLAVIER).coerceAtLeast(0) + val fin = (maxOf(selDebut, selFin) + FENETRE_DU_CLAVIER).coerceAtMost(editable.length) + return ExtractedText().apply { + this.text = editable.substring(debut, fin) + startOffset = debut + partialStartOffset = -1 + partialEndOffset = -1 + selectionStart = selDebut - debut + selectionEnd = selFin - debut + } + } + + override fun getTextBeforeCursor(n: Int, flags: Int): CharSequence? = + super.getTextBeforeCursor(n.coerceAtMost(FENETRE_DU_CLAVIER), flags) + + override fun getTextAfterCursor(n: Int, flags: Int): CharSequence? = + super.getTextAfterCursor(n.coerceAtMost(FENETRE_DU_CLAVIER), flags) + + /** A selection longer than the window is not handed over: `null`, as an editor that cannot. */ + override fun getSelectedText(flags: Int): CharSequence? = + if (selectionLongue()) null else super.getSelectedText(flags) + + @RequiresApi(Build.VERSION_CODES.S) + override fun getSurroundingText(beforeLength: Int, afterLength: Int, flags: Int): SurroundingText? = + if (selectionLongue()) { + null + } else { + super.getSurroundingText( + beforeLength.coerceAtMost(FENETRE_DU_CLAVIER), + afterLength.coerceAtMost(FENETRE_DU_CLAVIER), + flags, + ) + } + + /** + * Android 13's single read of the keyboard's state. The platform bounds the text around the + * selection, not the selection itself — included whole. + */ + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + override fun takeSnapshot(): TextSnapshot? = if (selectionLongue()) null else super.takeSnapshot() + + private fun selectionLongue() = kotlin.math.abs(selectionEnd - selectionStart) > FENETRE_DU_CLAVIER + } + + /** No spell checker: see the class. The keyboard's own suggestions are untouched. */ + override fun isSuggestionsEnabled(): Boolean = false + + // ------------------------------------------------------------------ clipboard, menu, drag + + override fun onTextContextMenuItem(id: Int): Boolean = when (id) { + android.R.id.copy -> copier(couper = false) + android.R.id.cut -> copier(couper = true) + // A note is plain text: a styled clip would show styles the note does not keep. + android.R.id.paste -> super.onTextContextMenuItem(android.R.id.pasteAsPlainText) + else -> super.onTextContextMenuItem(id) + } + + private fun copier(couper: Boolean): Boolean { + val debut = minOf(selectionStart, selectionEnd).coerceAtLeast(0) + val fin = maxOf(selectionStart, selectionEnd).coerceAtMost(text.length) + if (debut >= fin) return true + val texte = text.substring(debut, fin) + val avant = version + ecrire(texte) { ecrit -> + // Removed only if written, and only if the text did not change meanwhile: the same characters + // at the same place may be ones typed since (GPT-5.6 review, 2026-10-30). Left in place, the + // Cut was a Copy — nothing lost. Likewise if the field leaves the screen during the write: its + // scope is cancelled, and nothing is removed. + if (ecrit && couper && version == avant) text.delete(debut, fin) + } + menu?.finish() + return true + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent): Boolean { + if (event.actionMasked == MotionEvent.ACTION_DOWN) dernierToucher = getOffsetForPosition(event.x, event.y) + return super.onTouchEvent(event) + } + + /** + * A drop from another app arrives as plain text. A drag within this field — it carries the + * field's own drag state — keeps the platform's move. + */ + override fun onDragEvent(event: DragEvent): Boolean { + if (event.action != DragEvent.ACTION_DROP || event.localState != null) return super.onDragEvent(event) + val donnees = event.clipData ?: return false + val texte = buildString { + for (i in 0 until donnees.itemCount) append(donnees.getItemAt(i).coerceToText(context)) + } + val position = getOffsetForPosition(event.x, event.y) + if (texte.isEmpty() || position < 0) return false + text.insert(position, texte) + setSelection(position + texte.length) + return true + } + + /** Keeps the toolbar's editing entries only — see [MenuDuChampDuCorps]. */ + private inner class FiltreDuMenu : ActionMode.Callback { + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + this@ChampNatifDuCorps.menu = mode + return true + } + + // Called after the platform filled the menu — its `PROCESS_TEXT` entries come in after + // `onCreateActionMode`. + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { + val retirees = menu.children + .filterNot { MenuDuChampDuCorps.garder(it.itemId, intention = it.intent != null) } + .toList() + retirees.forEach { menu.removeItem(it.itemId) } + return true + } + + override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean = false + + override fun onDestroyActionMode(mode: ActionMode) { + if (this@ChampNatifDuCorps.menu === mode) this@ChampNatifDuCorps.menu = null + } + } + + // ------------------------------------------------------------------ look + + fun apparence(nouvelle: Apparence) { + val avant = apparence + if (avant == nouvelle) return + apparence = nouvelle + // Each size change lays out the whole text again: only what changed. + if (avant?.tailleSp != nouvelle.tailleSp) setTextSize(TypedValue.COMPLEX_UNIT_SP, nouvelle.tailleSp) + if (avant?.interligneSp != nouvelle.interligneSp) { + val px = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_SP, + nouvelle.interligneSp, + resources.displayMetrics, + ) + TextViewCompat.setLineHeight(this, px.toInt()) + } + if (avant?.espacementEm != nouvelle.espacementEm) letterSpacing = nouvelle.espacementEm + setPadding(nouvelle.margeDeCotePx, nouvelle.margeDuHautPx, nouvelle.margeDeCotePx, nouvelle.margeDuBasPx) + setTextColor(nouvelle.texte) + setHintTextColor(nouvelle.indication) + highlightColor = nouvelle.selection + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && + avant?.curseur != nouvelle.curseur + ) { + teindreLeCurseur(nouvelle.curseur) + } + } + + /** The cursor and the selection handles in the theme's colour — Android 10 on; before, the platform's. */ + @RequiresApi(Build.VERSION_CODES.Q) + private fun teindreLeCurseur(couleur: Int) { + val largeur = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + LARGEUR_DU_CURSEUR_DP, + resources.displayMetrics, + ) + textCursorDrawable = GradientDrawable().apply { + setColor(couleur) + setSize(largeur.toInt().coerceAtLeast(1), 0) + } + textSelectHandle?.mutate()?.let { setTextSelectHandle(it.apply { setTint(couleur) }) } + textSelectHandleLeft?.mutate()?.let { setTextSelectHandleLeft(it.apply { setTint(couleur) }) } + textSelectHandleRight?.mutate()?.let { setTextSelectHandleRight(it.apply { setTint(couleur) }) } + } + + fun indications(indication: String, nom: String) { + this.nom = nom + if (this.indication != indication) { + this.indication = indication + hint = indication + } + } + + /** + * The search's occurrences in the field ([plages]), [courante] more strongly — the spans of the + * previous answer removed first. Spans change the drawing only, not the text nor its layout. + */ + fun surligner(plages: List, courante: Plage?, couleurs: CouleursDeSurlignage) { + if (plages === plagesSurlignees && courante == couranteSurlignee && couleurs == couleursSurlignees) return + plagesSurlignees = plages + couranteSurlignee = courante + couleursSurlignees = couleurs + val editable = text + spans.forEach(editable::removeSpan) + spans.clear() + plages.forEach { plage -> + if (plage.debut < plage.fin && plage.fin <= editable.length) { + val actuelle = plage == courante + val fond = BackgroundColorSpan(if (actuelle) couleurs.fondCourante else couleurs.fond) + val encre = ForegroundColorSpan(if (actuelle) couleurs.encreCourante else couleurs.encre) + editable.setSpan(fond, plage.debut, plage.fin, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + editable.setSpan(encre, plage.debut, plage.fin, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + spans += fond + spans += encre + } + } + } + + private companion object { + /** Echoes kept: far more than the keystrokes one frame can hold. */ + const val ENVOYEES_MAX = 64 + const val LARGEUR_DU_CURSEUR_DP = 2f + + /** Characters on each side of the cursor the keyboard may read: 20 KB at most. */ + const val FENETRE_DU_CLAVIER = 5_000 + } +} diff --git a/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorScreen.kt b/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorScreen.kt index d2302ec..340fb15 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorScreen.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorScreen.kt @@ -79,8 +79,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.Layout @@ -351,7 +349,7 @@ fun NoteEditorRoute(onBack: () -> Unit, onOpenNote: (String) -> Unit, onInstalle SaisieDeCoffre( actif = state.isVaultNote, deposer = viewModel::deposerDansLePressePapiers, - surEchec = viewModel::copieProtegeeRefusee, + surEchec = viewModel::copieRefusee, ) { FeuilleDAutocompletion( suggestions = suggestions, @@ -403,7 +401,7 @@ fun NoteEditorRoute(onBack: () -> Unit, onOpenNote: (String) -> Unit, onInstalle SaisieDeCoffre( actif = state.isVaultNote, deposer = viewModel::deposerDansLePressePapiers, - surEchec = viewModel::copieProtegeeRefusee, + surEchec = viewModel::copieRefusee, ) { NoteEditorScreen( state = state, @@ -520,14 +518,8 @@ fun NoteEditorScreen( val focus = LocalFocusManager.current // Leaving the reading puts the cursor in the body, and with it the keyboard: "Edit" means "write". // Only after that button — a note created blank opens to be written, as before, without forcing it. - val focusDuCorps = remember { FocusRequester() } + // Taken by the body field once it is there (see `ChampDuCorps`). var focaliserLeCorps by remember { mutableStateOf(false) } - LaunchedEffect(lecture, focaliserLeCorps) { - if (!lecture && focaliserLeCorps) { - focaliserLeCorps = false - focusDuCorps.requestFocus() - } - } val noteOuverte = state.note != null && state.lockedVault == null && state.loadError == null && !state.loading // Above the Edit / Preview branch, not inside it: the branch leaving the composition took the @@ -826,18 +818,15 @@ fun NoteEditorScreen( if (lecture) { FicheDeLecture(state, rendu, defilementDeLApercu, onLienDeLApercu) } else { - TextField( - value = state.content, - onValueChange = onContenuChange, - // ⚠️ `note_editor_content` était traduite des deux côtés et lue **nulle - // part** — le publié en fait le `labelText` de ce champ exactement. Même - // discriminant que §79, appliqué au même écran le même jour. - label = { Text(stringResource(R.string.note_editor_content)) }, - placeholder = { Text(stringResource(R.string.note_editor_content_hint)) }, - textStyle = MaterialTheme.typography.bodyLarge, - visualTransformation = surlignageDuChamp(CleDuCorps), - colors = champSansDecor(), - modifier = Modifier.fillMaxSize().focusRequester(focusDuCorps), + // 🔴 Android's own text field, not Compose's: a long note was unusable to + // write in — see `ChampDuCorps`. Its label is `note_editor_content`, as + // the published app's `labelText`. + ChampDuCorps( + valeur = state.content, + onValeurChange = onContenuChange, + focaliser = focaliserLeCorps, + onFocalise = { focaliserLeCorps = false }, + modifier = Modifier.fillMaxSize(), ) } } diff --git a/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorViewModel.kt b/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorViewModel.kt index d48358c..5aa8509 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorViewModel.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorViewModel.kt @@ -636,8 +636,11 @@ class NoteEditorViewModel @Inject constructor( * current one. Every change reaches here, a mere selection change included, for both fields: a * text deleted by hand, then another selection copied, leaves a previous value whose selection * is the copied one — nothing is put back. A Copy changed nothing and is only said. + * + * The body field (3.2.0, `ChampDuCorps`) removes a Cut only once written: its refusals — a vault + * note's, or a plain clip too long for the clipboard — reach here with nothing to undo. */ - fun copieProtegeeRefusee(texte: String) { + fun copieRefusee(texte: String) { val etat = _state.value val avant = contenuPrecedent val titreAvant = titrePrecedent @@ -653,7 +656,13 @@ class NoteEditorViewModel @Inject constructor( } if (!_action.value.enCours) { _action.value = ActionDEditeur( - erreur = R.string.error_clipboard_secure_unavailable, + // The body field (3.2.0) writes a plain clip outside a vault, and reports a refusal too: + // a selection too long for one Binder transaction. + erreur = if (etat.isVaultNote) { + R.string.error_clipboard_secure_unavailable + } else { + R.string.note_editor_copy_failed + }, origine = ActionDEditeur.OrigineDErreur.COPIE, ) } diff --git a/app/src/main/java/com/filestech/notes_tech/ui/editor/ReglesDuChampDuCorps.kt b/app/src/main/java/com/filestech/notes_tech/ui/editor/ReglesDuChampDuCorps.kt new file mode 100644 index 0000000..e89f9c9 --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/ui/editor/ReglesDuChampDuCorps.kt @@ -0,0 +1,69 @@ +package com.filestech.notes_tech.ui.editor + +/** + * The smallest replacement turning one text into another: `avant[debut until finAvant]` becomes + * `apres[debut until finApres]`, the rest is common to both. + * + * What the body field applies when the note's state changes under it — a link inserted, a dictation, + * a Cut of the title undone — instead of the whole text, which would cost the field its scroll + * position, the keyboard's composition, and a layout of the whole note (see [ChampDuCorps]). + */ +internal data class DifferenceDeTexte(val debut: Int, val finAvant: Int, val finApres: Int) { + + companion object { + + /** + * `null` when [avant] and [apres] are the same text. The common prefix and suffix never + * overlap, and never split a surrogate pair: a replacement starting between the two halves of + * an emoji would leave a lone half in the field for as long as the replacement takes. + */ + fun entre(avant: CharSequence, apres: CharSequence): DifferenceDeTexte? { + val commun = minOf(avant.length, apres.length) + var debut = 0 + while (debut < commun && avant[debut] == apres[debut]) debut++ + if (debut == avant.length && debut == apres.length) return null + var fin = 0 + while (fin < commun - debut && avant[avant.length - 1 - fin] == apres[apres.length - 1 - fin]) fin++ + if (debut > 0 && Character.isHighSurrogate(avant[debut - 1])) debut-- + if (fin > 0 && Character.isLowSurrogate(avant[avant.length - fin])) fin-- + return DifferenceDeTexte(debut, avant.length - fin, apres.length - fin) + } + } +} + +/** + * The entries the body field's selection toolbar keeps: editing ones only. + * + * 🔴 An `EditText` adds what the Compose field it replaces never showed: Share, Autofill, the + * classifier's actions, and one entry per app answering `PROCESS_TEXT` — the very "Translate" that + * sent a vault note's text to a search engine online (3.1.1, `SansClassificationDeTexte`). Removing + * the `PROCESS_TEXT` queries hides those apps from Android 11 on only: on Android 10 and below every + * app is visible. A phone maker's own additions are unknown in advance, so this is a list of what is + * KEPT, not of what is removed. + */ +internal object MenuDuChampDuCorps { + + private val GARDEES = setOf( + android.R.id.cut, + android.R.id.copy, + android.R.id.paste, + android.R.id.pasteAsPlainText, + android.R.id.selectAll, + android.R.id.replaceText, + ) + + /** Whether the entry [id] stays; never one carrying an [intention] — another app's action. */ + fun garder(id: Int, intention: Boolean): Boolean = !intention && id in GARDEES +} + +/** + * Whether the body field holds back a long press: one inside a vault note's selection, where the + * platform would start dragging the selected text — to another app too, as a clip none of the + * clipboard's protections reach. Held back, the press starts nothing; the toolbar stays. + * + * @param toucher the offset under the finger, `-1` when unknown. + */ +internal fun glisserRetenu(protegee: Boolean, selectionDebut: Int, selectionFin: Int, toucher: Int): Boolean = + protegee && + selectionDebut != selectionFin && + toucher in minOf(selectionDebut, selectionFin) until maxOf(selectionDebut, selectionFin) diff --git a/app/src/main/java/com/filestech/notes_tech/ui/secure/SaisieDeCoffre.kt b/app/src/main/java/com/filestech/notes_tech/ui/secure/SaisieDeCoffre.kt index 797f9c1..5bab988 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/secure/SaisieDeCoffre.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/secure/SaisieDeCoffre.kt @@ -1,12 +1,15 @@ package com.filestech.notes_tech.ui.secure +import android.content.ClipData import android.content.ClipboardManager import android.view.inputmethod.EditorInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.platform.AndroidClipboard import androidx.compose.ui.platform.ClipEntry @@ -58,7 +61,14 @@ fun SaisieDeCoffre( val pressePapiers = remember(natif) { PressePapiersDeCoffre(natif, { estActif }, { deposerCourant(it) }, { surEchecCourant(it) }) } - CompositionLocalProvider(LocalClipboard provides pressePapiers) { + val copie: suspend (String) -> Boolean = remember(natif) { + { texte -> copierDepuisUnChamp(texte, estActif, natif, { deposerCourant(it) }, { surEchecCourant(it) }) } + } + CompositionLocalProvider( + LocalClipboard provides pressePapiers, + LocalSaisieProtegee provides actif, + LocalCopieDeSaisie provides copie, + ) { InterceptPlatformTextInput( interceptor = { requete, suivant -> val transmise = if (!estActif) { @@ -78,6 +88,45 @@ fun SaisieDeCoffre( } } +/** + * Whether the NATIVE text fields under [SaisieDeCoffre] must protect what they hold (3.2.0): the + * interceptor and the clipboard above reach Compose's fields only, and the note's body is an + * `EditText` — see `ChampDuCorps`, which applies the same two protections itself. + */ +internal val LocalSaisieProtegee = compositionLocalOf { false } + +/** + * How a native text field under [SaisieDeCoffre] writes its Copy and Cut — see [copierDepuisUnChamp]. + * `null` outside it. + */ +internal val LocalCopieDeSaisie = staticCompositionLocalOf<(suspend (String) -> Boolean)?> { null } + +/** + * Writes [texte], copied from a field, to the clipboard: through [deposer] — the menu's protections — + * when [protegee], as a plain clip otherwise. `true` once written; `false` when refused, after + * [surEchec] was told, so that a Cut removes nothing. + * + * A plain clip can be refused too: a long selection exceeds what one Binder transaction carries, about + * 1 MB — 500 000 characters, which a note can hold — and the clipboard throws. + * ⚠️ Never a fallback to a plain clip when [protegee]: that is the leak [SaisieDeCoffre] closes. + */ +internal suspend fun copierDepuisUnChamp( + texte: String, + protegee: Boolean, + natif: Clipboard, + deposer: suspend (String) -> Unit, + surEchec: (String) -> Unit, +): Boolean = try { + if (protegee) deposer(texte) else natif.setClipEntry(ClipEntry(ClipData.newPlainText(null, texte))) + true +} catch (e: CancellationException) { + throw e +} catch (e: Exception) { + Timber.w(e, "copie depuis un champ refusee") + surEchec(texte) + false +} + /** * The clipboard of a vault note's fields: a text written goes through [deposer]; the rest is native. * diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 42be09e..d7fd32c 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -168,6 +168,7 @@ Bearbeiten Noch nichts für die Vorschau. Nichts zu kopieren: Diese Notiz ist leer + Kopieren nicht möglich: Die Zwischenablage hat diesen Text abgelehnt, vermutlich ist er zu lang. Tresorordner: Die Notiz wird verschlüsselt In einen anderen Ordner verschieben In einen anderen Ordner verschieben diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 890d6fb..fb52edb 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -176,6 +176,7 @@ Editar Todavía no hay nada que previsualizar. Nada que copiar: esta nota está vacía + No se pudo copiar: el portapapeles rechazó este texto, probablemente demasiado largo. Carpeta de caja fuerte: la nota se cifrará Mover a otra carpeta Mover a otra carpeta diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index cf36877..1c3e398 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -181,6 +181,7 @@ Éditer Rien à afficher pour l\'instant. Rien à copier : cette note est vide + Copie impossible : le presse-papiers a refusé ce texte, sans doute trop long. Dossier coffre : la note sera chiffrée Déplacer vers un autre dossier Déplacer vers un autre dossier diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 6122d5b..7b4cafc 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -176,6 +176,7 @@ Modifica Ancora niente da mostrare in anteprima. Niente da copiare: questa nota è vuota + Copia non riuscita: gli appunti hanno rifiutato questo testo, probabilmente troppo lungo. Cartella cassaforte: la nota verrà cifrata Sposta in un\'altra cartella Sposta in un\'altra cartella diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 48c21fb..6ea751c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -201,6 +201,7 @@ partage plus. --> Nothing to copy: this note is empty + Couldn\'t copy: the clipboard refused this text, probably too long. Nueva carpeta @@ -313,7 +313,7 @@ Importar notas Archivos Markdown o de texto, o un ZIP con ellos, como una exportación de Notes Tech. Importación - La importación se detuvo en un límite (5.000 archivos, o 100 MB leídos): el resto no se importó. + La importación se detuvo en un límite (5.000 archivos, 100 MB leídos, o 30 millones de caracteres de notas): el resto no se importó. No se importó ninguna nota. No se pudo importar, no se importó nada: %1$s @@ -332,18 +332,18 @@ %1$d carpetas creadas. - %1$d nota fue a la Bandeja de entrada: su carpeta lleva el nombre de una caja fuerte. - %1$d de notas fueron a la Bandeja de entrada: su carpeta lleva el nombre de una caja fuerte. - %1$d notas fueron a la Bandeja de entrada: su carpeta lleva el nombre de una caja fuerte. + %1$d nota fue a la bandeja de entrada: su carpeta lleva el nombre de una caja fuerte. + %1$d de notas fueron a la bandeja de entrada: su carpeta lleva el nombre de una caja fuerte. + %1$d notas fueron a la bandeja de entrada: su carpeta lleva el nombre de una caja fuerte. Copia de seguridad cifrada Crear una copia de seguridad - Tus notas y carpetas, papelera incluida, en un solo archivo cifrado. + Sus notas y carpetas, papelera incluida, en un solo archivo cifrado. Restaurar una copia de seguridad Añade lo que contiene una copia. No se sobrescribe nada en este teléfono. - El modo pánico no alcanza este archivo: guárdalo en un lugar seguro y bórralo tú cuando ya no lo necesites. - Anota este código: es la única forma de abrir la copia. Notes Tech no lo guarda. - Vuelve a escribir el código + El modo pánico no alcanza este archivo: guárdelo en un lugar seguro y bórrelo usted mismo cuando ya no lo necesite. + Anote este código: es la única forma de abrir la copia. Notes Tech no lo guarda. + Vuelva a escribir el código Usar mi propia frase de contraseña Usar un código generado Frase de contraseña @@ -356,20 +356,20 @@ Código o frase de contraseña de la copia Continuar Nada que añadir desde esta copia. - También vuelven las notas borradas de este teléfono desde la copia. - Caja fuerte con PIN «%1$s»: elige su nuevo PIN + También vuelven las notas eliminadas de este teléfono desde la copia. + Caja fuerte con PIN «%1$s»: elija su nuevo PIN Nuevo PIN Confirmar el nuevo PIN Cada PIN: de %1$d a %2$d cifras, escrito dos veces igual. Restaurar Este código o esta frase de contraseña no abre esta copia, o el archivo está dañado. No se ha restaurado nada. Este archivo no es una copia de seguridad de Notes Tech. - Esta copia viene de una versión más reciente de Notes Tech: actualiza la aplicación para restaurarla. + Esta copia viene de una versión más reciente de Notes Tech: actualice la aplicación para restaurarla. Demasiado grande: una copia ocupa como máximo 64 MB. - El archivo escrito no se puede releer, así que no se ha conservado. Prueba otra ubicación. - No se pudo completar la operación. Inténtalo de nuevo. - La caja fuerte con PIN «%1$s» se ha cerrado mientras tanto: no se ha guardado nada. Vuelve a abrirla y empieza de nuevo. - El archivo ha cambiado desde la comprobación: no se ha restaurado nada. Vuelve a elegirlo. + El archivo escrito no se puede releer, así que no se ha conservado. Pruebe otra ubicación. + No se pudo completar la operación. Inténtelo de nuevo. + La caja fuerte con PIN «%1$s» se ha cerrado mientras tanto: no se ha guardado nada. Vuelva a abrirla y empiece de nuevo. + El archivo ha cambiado desde la comprobación: no se ha restaurado nada. Vuelva a elegirlo. Esta copia contendrá %1$d nota, papelera incluida. Esta copia contendrá %1$d de notas, papelera incluida. @@ -420,9 +420,9 @@ Mostrar el código o la frase de contraseña Ocultar el código o la frase de contraseña - Caja fuerte con PIN cerrada, excluida con sus notas: %1$s. Ábrela antes para incluirla. - Cajas fuertes con PIN cerradas, excluidas con sus notas: %1$s. Ábrelas antes para incluirlas. - Cajas fuertes con PIN cerradas, excluidas con sus notas: %1$s. Ábrelas antes para incluirlas. + Caja fuerte con PIN cerrada, excluida con sus notas: %1$s. Ábrala antes para incluirla. + Cajas fuertes con PIN cerradas, excluidas con sus notas: %1$s. Ábralas antes para incluirlas. + Cajas fuertes con PIN cerradas, excluidas con sus notas: %1$s. Ábralas antes para incluirlas. Caja fuerte con PIN abierta, guardada con su clave: %1$s. En el archivo la protege el secreto de la copia, no su PIN. @@ -684,9 +684,9 @@ Compartido con Notes Tech No se pudo guardar lo compartido. No se añadió nada. - %1$d nota añadida a la Bandeja de entrada. - %1$d de notas añadidas a la Bandeja de entrada. - %1$d notas añadidas a la Bandeja de entrada. + %1$d nota añadida a la bandeja de entrada. + %1$d de notas añadidas a la bandeja de entrada. + %1$d notas añadidas a la bandeja de entrada. %1$d elemento compartido no se pudo añadir: no es texto, o es demasiado grande. @@ -694,11 +694,11 @@ %1$d elementos compartidos no se pudieron añadir: no son texto, demasiado grandes o demasiados. ¿Añadir a Notes Tech? - «%1$s» se añadirá a la Bandeja de entrada. + «%1$s» se añadirá a la bandeja de entrada. Añadir - %1$d nota se añadirá a la Bandeja de entrada. - %1$d de notas se añadirán a la Bandeja de entrada. - %1$d notas se añadirán a la Bandeja de entrada. + %1$d nota se añadirá a la bandeja de entrada. + %1$d de notas se añadirán a la bandeja de entrada. + %1$d notas se añadirán a la bandeja de entrada. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 1c3e398..9a3acfb 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -318,7 +318,7 @@ Importer des notes Fichiers Markdown ou texte, ou une archive ZIP de ces fichiers, comme un export de Notes Tech. Import - L\'import s\'est arrêté à une limite (5 000 fichiers, ou 100 Mo lus) : la suite n\'a pas été importée. + L\'import s\'est arrêté à une limite (5 000 fichiers, 100 Mo lus, ou 30 millions de caractères de notes) : la suite n\'a pas été importée. Aucune note n\'a été importée. L\'import a échoué, rien n\'a été importé : %1$s diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 7b4cafc..a1ad594 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -313,7 +313,7 @@ Importa note File Markdown o di testo, o un archivio ZIP di questi file, come un\'esportazione di Notes Tech. Importazione - L\'importazione si è fermata a un limite (5.000 file, o 100 MB letti): il resto non è stato importato. + L\'importazione si è fermata a un limite (5.000 file, 100 MB letti, o 30 milioni di caratteri di note): il resto non è stato importato. Nessuna nota è stata importata. Importazione non riuscita, nulla è stato importato: %1$s @@ -332,18 +332,18 @@ %1$d cartelle create. - %1$d nota è finita in In arrivo: la sua cartella ha il nome di una cassaforte. - %1$d di note sono finite in In arrivo: la loro cartella ha il nome di una cassaforte. - %1$d note sono finite in In arrivo: la loro cartella ha il nome di una cassaforte. + %1$d nota è finita nella cartella In arrivo: la sua cartella ha il nome di una cassaforte. + %1$d di note sono finite nella cartella In arrivo: la loro cartella ha il nome di una cassaforte. + %1$d note sono finite nella cartella In arrivo: la loro cartella ha il nome di una cassaforte. Backup cifrato Crea un backup - Le tue note e cartelle, cestino compreso, in un unico file cifrato. + Le sue note e cartelle, cestino compreso, in un unico file cifrato. Ripristina un backup Aggiunge ciò che un backup contiene. Su questo telefono non viene sovrascritto nulla. - La modalità panico non raggiunge questo file: conservalo in un luogo sicuro ed eliminalo tu quando non ti serve più. - Annota questo codice: è l\'unico modo per aprire il backup. Notes Tech non lo conserva. - Digita di nuovo il codice + La modalità panico non raggiunge questo file: lo conservi in un luogo sicuro e lo elimini lei stesso quando non le serve più. + Annoti questo codice: è l\'unico modo per aprire il backup. Notes Tech non lo conserva. + Digiti di nuovo il codice Usa una mia passphrase Usa un codice generato Passphrase @@ -357,19 +357,19 @@ Continua Niente da aggiungere da questo backup. Tornano anche le note eliminate da questo telefono dopo il backup. - Cassaforte con PIN «%1$s»: scegli il suo nuovo PIN + Cassaforte con PIN «%1$s»: scelga il suo nuovo PIN Nuovo PIN Conferma il nuovo PIN Ogni PIN: da %1$d a %2$d cifre, digitato due volte uguale. Ripristina - Questo codice o questa passphrase non apre questo backup, oppure il file è danneggiato. Non è stato ripristinato nulla. + Questo codice o questa passphrase non aprono questo backup, oppure il file è danneggiato. Non è stato ripristinato nulla. Questo file non è un backup di Notes Tech. - Questo backup viene da una versione più recente di Notes Tech: aggiorna l\'app per ripristinarlo. + Questo backup viene da una versione più recente di Notes Tech: aggiorni l\'app per ripristinarlo. Troppo grande: un backup contiene al massimo 64 MB. - Il file scritto non si rilegge, quindi non è stato conservato. Prova un altro percorso. - Non è stato possibile completare l\'operazione. Riprova. - La cassaforte con PIN «%1$s» si è chiusa nel frattempo: non è stato salvato nulla. Riaprila, poi ricomincia. - Il file è cambiato dopo la verifica: non è stato ripristinato nulla. Sceglilo di nuovo. + Il file scritto non si rilegge, quindi non è stato conservato. Provi un altro percorso. + Non è stato possibile completare l\'operazione. Riprovi. + La cassaforte con PIN «%1$s» si è chiusa nel frattempo: non è stato salvato nulla. La riapra, poi ricominci. + Il file è cambiato dopo la verifica: non è stato ripristinato nulla. Lo scelga di nuovo. Questo backup conterrà %1$d nota, cestino compreso. Questo backup conterrà %1$d di note, cestino compreso. @@ -420,9 +420,9 @@ Mostra il codice o la passphrase Nascondi il codice o la passphrase - Cassaforte con PIN chiusa, esclusa con le sue note: %1$s. Aprila prima per includerla. - Casseforti con PIN chiuse, escluse con le loro note: %1$s. Aprile prima per includerle. - Casseforti con PIN chiuse, escluse con le loro note: %1$s. Aprile prima per includerle. + Cassaforte con PIN chiusa, esclusa con le sue note: %1$s. La apra prima per includerla. + Casseforti con PIN chiuse, escluse con le loro note: %1$s. Le apra prima per includerle. + Casseforti con PIN chiuse, escluse con le loro note: %1$s. Le apra prima per includerle. Cassaforte con PIN aperta, salvata con la sua chiave: %1$s. Nel file la protegge il segreto del backup, non il suo PIN. @@ -684,9 +684,9 @@ Condiviso con Notes Tech Ciò che è stato condiviso non è stato salvato. Non è stato aggiunto nulla. - %1$d nota aggiunta alla Posta in arrivo. - %1$d di note aggiunte alla Posta in arrivo. - %1$d note aggiunte alla Posta in arrivo. + %1$d nota aggiunta alla cartella In arrivo. + %1$d di note aggiunte alla cartella In arrivo. + %1$d note aggiunte alla cartella In arrivo. %1$d elemento condiviso non è stato aggiunto: non è testo, o è troppo grande. @@ -694,11 +694,11 @@ %1$d elementi condivisi non sono stati aggiunti: non testo, troppo grandi o troppi. Aggiungere a Notes Tech? - «%1$s» sarà aggiunta alla Posta in arrivo. + «%1$s» sarà aggiunta alla cartella In arrivo. Aggiungi - %1$d nota sarà aggiunta alla Posta in arrivo. - %1$d di note saranno aggiunte alla Posta in arrivo. - %1$d note saranno aggiunte alla Posta in arrivo. + %1$d nota sarà aggiunta alla cartella In arrivo. + %1$d di note saranno aggiunte alla cartella In arrivo. + %1$d note saranno aggiunte alla cartella In arrivo. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6ea751c..16e399e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -378,7 +378,7 @@ Import notes Markdown or text files, or a ZIP of them, such as a Notes Tech export. Import - The import stopped at a limit (5,000 files, or 100 MB read): what came after was not imported. + The import stopped at a limit (5,000 files, 100 MB read, or 30 million characters of notes): what came after was not imported. No note was imported. Import failed, nothing was imported: %1$s diff --git a/app/src/test/java/com/filestech/notes_tech/domain/importation/LectureDUnImportTest.kt b/app/src/test/java/com/filestech/notes_tech/domain/importation/LectureDUnImportTest.kt index ac00529..c4a78fa 100644 --- a/app/src/test/java/com/filestech/notes_tech/domain/importation/LectureDUnImportTest.kt +++ b/app/src/test/java/com/filestech/notes_tech/domain/importation/LectureDUnImportTest.kt @@ -100,6 +100,45 @@ class LectureDUnImportTest { assertThat(lu.ignores).isEqualTo(1) } + /** + * 🔴 The text kept until the one writing transaction is bounded: past it, the import stops and keeps + * what it had (audit 3 axes of 3.2.0, P1 — 100 MiB of notes held at once could exceed the heap). + */ + @Test + fun `past the text it may keep, the import stops and keeps what it had`() { + val lecture = LectureDUnImport(zone, texteRetenuMax = 10) + for (nom in listOf("Un.md", "Deux.md", "Trois.md")) { + lecture.ajouter(nom, ByteArrayInputStream("abcd".toByteArray())) + } + + val lu = lecture.resultat() + + // 4 + 4 fit in 10; the third would make 12. + assertThat(lu.notes.map { it.note.titre }).containsExactly("Un", "Deux").inOrder() + assertThat(lu.arrete).isTrue() + } + + /** + * A folder's name from an archive loses its control and bidirectional formatting characters — an + * override can make a name read as another in the folders' drawer — and is cut at its bound + * (audit 3 axes of 3.2.0, P2). + */ + @Test + fun `a folder's name from an archive is cleaned and bounded`() { + val lu = lire( + "a.zip" to zip( + "\u202Efdp.exe\u0007/Une.md" to "x".toByteArray(), + "${"d".repeat(300)}/Deux.md" to "x".toByteArray(), + "\u202E\u200F/Trois.md" to "x".toByteArray(), + ), + ) + + assertThat(lu.notes.map { it.dossier }) + .containsExactly("fdp.exe", "d".repeat(LectureDUnImport.NOM_DE_DOSSIER_MAX), null) + .inOrder() + assertThat(LectureDUnImport.nomDeDossier("a".repeat(99) + "😀")).isEqualTo("a".repeat(99)) + } + @Test fun `an entry too long is left out, and the next one is read`() { val lu = lire( diff --git a/app/src/test/java/com/filestech/notes_tech/ui/editor/RechercheDansLaNoteTest.kt b/app/src/test/java/com/filestech/notes_tech/ui/editor/RechercheDansLaNoteTest.kt index f640cd9..b6a5c77 100644 --- a/app/src/test/java/com/filestech/notes_tech/ui/editor/RechercheDansLaNoteTest.kt +++ b/app/src/test/java/com/filestech/notes_tech/ui/editor/RechercheDansLaNoteTest.kt @@ -70,4 +70,22 @@ class RechercheDansLaNoteTest { assertThat(juste.occurrences).hasSize(5) assertThat(juste.tronque).isFalse() } + + /** + * 🔴 A vault note's query is not saved: it is a word of its plaintext, and the activity's saved state + * must hold none (audit 3 axes of 3.2.0, S1). Another note's is, as before. + */ + @Test + @DisplayName("the bar's query is saved, a vault note's excepted") + fun saisieSauvegardee() { + val etat = EtatDeRechercheDansLaNote(ouverte = true, saisie = "salaire", index = 2, deplacer = false) + val portee = androidx.compose.runtime.saveable.SaverScope { true } + fun sauvee() = with(EtatDeRechercheDansLaNote.Saver) { portee.save(etat) } + + assertThat(sauvee()).isEqualTo(listOf(true, "salaire", 2)) + + etat.saisieSauvegardee = false + + assertThat(sauvee()).isEqualTo(listOf(true, "", 2)) + } } diff --git a/app/src/test/java/com/filestech/notes_tech/ui/settings/CodeEpeleTest.kt b/app/src/test/java/com/filestech/notes_tech/ui/settings/CodeEpeleTest.kt new file mode 100644 index 0000000..75cdfa6 --- /dev/null +++ b/app/src/test/java/com/filestech/notes_tech/ui/settings/CodeEpeleTest.kt @@ -0,0 +1,13 @@ +package com.filestech.notes_tech.ui.settings + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** The backup code as TalkBack reads it: spelled (audit 3 axes of 3.2.0, U2 — it was read as words). */ +class CodeEpeleTest { + + @Test + fun `the code is spelled sign by sign, a pause between groups`() { + assertThat(codeEpele("K7QM-T2XB")).isEqualTo("K, 7, Q, M. T, 2, X, B") + } +} diff --git a/docs/13-VERSION-3.2.md b/docs/13-VERSION-3.2.md index d8559ef..c743d17 100644 --- a/docs/13-VERSION-3.2.md +++ b/docs/13-VERSION-3.2.md @@ -89,7 +89,9 @@ octets taille champ - **Taille** : 48 + 16 octets au moins ; **64 Mio au plus**, comptés sur ce qui est **réellement lu** (un flux qui s'arrête au 64 Mio + 1ᵉ octet), la taille annoncée par le fournisseur n'étant qu'un premier refus (R-9). 64 Mio de notes, c'est des dizaines de milliers de pages ; la création refuse au-delà et le - dit. + dit. **Limite assumée** (audit 3 axes, P3) : passé 64 Mio, aucune sauvegarde n'est possible — ni découpe ni + sauvegarde partielle ; à revoir si un utilisateur y arrive (l'import retient au plus 30 millions de + caractères par passe, mais plusieurs imports s'additionnent). - **Contenu** : JSON UTF-8 écrit et lu **en flux** (`android.util.JsonWriter` / `JsonReader`), jamais d'arbre en mémoire (R-8). Les dossiers d'abord, puis les notes : ``` From e64347b1f3b676a3fb9175fb72623ef1dc6fb708 Mon Sep 17 00:00:00 2001 From: Patrice Haltaya Date: Fri, 30 Oct 2026 12:49:33 +0100 Subject: [PATCH 16/16] =?UTF-8?q?release:=203.2.0=20(503)=20=E2=80=94=20ch?= =?UTF-8?q?angelogs,=20store=20listing,=20legal,=20READMEs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - version.properties: 3.2.0, base 503 (5031/5032/5033 per ABI, 5030 universal). - Changelogs 5031-5033 in the five store languages, under F-Droid's 500 characters: encrypted backup, Markdown/ZIP import, find in note, incoming share, long notes fluid. - Store descriptions: the new features in the Editing block. - Privacy policy (five languages, dated 30 October 2026): what happens to imported and shared notes — read once on the phone, ordinary notes never in a vault, nothing kept beside them, 500 000 characters at most per note. - README (EN, FR): what's new in 3.2.0, the features list, the APK name. SECURITY (EN, FR): the 3.2.0 section — backup format and restore, import and share bounds, the body field's protections. Co-Authored-By: Claude Opus 5.5 --- README.fr.md | 32 +++++++++++++++++-- README.md | 27 ++++++++++++++-- SECURITY.fr.md | 31 +++++++++++++++++- SECURITY.md | 30 ++++++++++++++++- app/src/main/res/raw-de/privacy.md | 3 +- app/src/main/res/raw-es/privacy.md | 3 +- app/src/main/res/raw-fr/privacy.md | 3 +- app/src/main/res/raw-it/privacy.md | 3 +- app/src/main/res/raw/privacy.md | 3 +- .../android/de-DE/changelogs/5031.txt | 1 + .../android/de-DE/changelogs/5032.txt | 1 + .../android/de-DE/changelogs/5033.txt | 1 + .../android/de-DE/full_description.txt | 4 ++- .../android/en-US/changelogs/5031.txt | 1 + .../android/en-US/changelogs/5032.txt | 1 + .../android/en-US/changelogs/5033.txt | 1 + .../android/en-US/full_description.txt | 4 ++- .../android/es-ES/changelogs/5031.txt | 1 + .../android/es-ES/changelogs/5032.txt | 1 + .../android/es-ES/changelogs/5033.txt | 1 + .../android/es-ES/full_description.txt | 4 ++- .../android/fr-FR/changelogs/5031.txt | 1 + .../android/fr-FR/changelogs/5032.txt | 1 + .../android/fr-FR/changelogs/5033.txt | 1 + .../android/fr-FR/full_description.txt | 4 ++- .../android/it-IT/changelogs/5031.txt | 1 + .../android/it-IT/changelogs/5032.txt | 1 + .../android/it-IT/changelogs/5033.txt | 1 + .../android/it-IT/full_description.txt | 4 ++- version.properties | 4 +-- 30 files changed, 156 insertions(+), 18 deletions(-) create mode 100644 fastlane/metadata/android/de-DE/changelogs/5031.txt create mode 100644 fastlane/metadata/android/de-DE/changelogs/5032.txt create mode 100644 fastlane/metadata/android/de-DE/changelogs/5033.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/5031.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/5032.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/5033.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/5031.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/5032.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/5033.txt create mode 100644 fastlane/metadata/android/fr-FR/changelogs/5031.txt create mode 100644 fastlane/metadata/android/fr-FR/changelogs/5032.txt create mode 100644 fastlane/metadata/android/fr-FR/changelogs/5033.txt create mode 100644 fastlane/metadata/android/it-IT/changelogs/5031.txt create mode 100644 fastlane/metadata/android/it-IT/changelogs/5032.txt create mode 100644 fastlane/metadata/android/it-IT/changelogs/5033.txt diff --git a/README.fr.md b/README.fr.md index 073ac6e..766b52a 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ 🇬🇧 [English version](README.md) -**v3.1.1 — octobre 2026** · [Politique de confidentialité](PRIVACY.md) · [Conditions d'utilisation](TERMS.md) · [Sécurité](SECURITY.fr.md) +**v3.2.0 — octobre 2026** · [Politique de confidentialité](PRIVACY.md) · [Conditions d'utilisation](TERMS.md) · [Sécurité](SECURITY.fr.md) Application de prise de notes Markdown chiffrée pour Android, écrite en **Kotlin** avec Jetpack Compose. **100 % locale, sans permission Internet.** Interface en **anglais, français, allemand, @@ -21,6 +21,26 @@ vérifier.** --- +## Nouveautés de la 3.2.0 + +- **Sauvegarde chiffrée** (Réglages → Sauvegarde chiffrée) : vos notes et dossiers, corbeille comprise, + dans un seul fichier `.ntbackup`, chiffré (Argon2id, AES-256-GCM) par un code généré ou une + passphrase de votre choix. Un coffre à passphrase voyage tel quel, toujours scellé par sa propre + passphrase ; un coffre à code ouvert voyage dans le chiffrement de la sauvegarde et reçoit un nouveau + code à la restauration. Restaurer ajoute et n'écrase jamais : tout le fichier est vérifié d'abord, + puis écrit en une seule transaction. +- **Import** de fichiers Markdown ou texte, ou d'une archive ZIP de ces fichiers — un export de Notes + Tech, un coffre Obsidian : dossiers conservés, frontmatter lu, jamais dans un coffre. 500 000 + caractères au plus par note. +- **Rechercher dans la note** : occurrences surlignées, « 3 / 12 », précédente et suivante, en lecture + comme en écriture ; une note ouverte depuis un résultat de recherche s'ouvre avec sa recherche. +- **Partager vers Notes Tech** depuis une autre appli : un texte devient une note dans la Boîte de + réception, des fichiers texte sont importés — après le verrou de l'appli, s'il est activé. +- **Notes longues** : le corps s'écrit désormais dans le champ de texte d'Android, qui ne dessine que + les lignes à l'écran — sur un Galaxy S9, d'environ 1 s par frappe à 100 000 caractères à environ + 20 ms. Le clavier ne lit que le texte autour du curseur, le défilement glisse sur sa lancée, et la + lecture d'une très longue note ne fige plus l'appli. + ## Nouveautés de la 3.1.1 - **Corrigé** : en 3.1.0, un appui long dans une note en cours d'édition fermait l'appli ; impossible de @@ -132,6 +152,14 @@ avec sa propre clé. Une copie installée depuis F-Droid reçoit la 3.0.0 par F- ### Recherche - Recherche plein texte **FTS5** instantanée (analyseur `unicode61`, accents ignorés). Le texte supprimé est aussi effacé de l'index (`secure-delete`). +- **Rechercher dans la note** : les occurrences d'une recherche surlignées dans la note à l'écran, l'une + après l'autre. + +### Sauvegarde, import et partage +- **Sauvegarde chiffrée** dans un fichier `.ntbackup` que vous gardez, restaurée sur ce téléphone ou un + autre. +- **Import** de fichiers Markdown ou texte et d'archives ZIP ; **partage** de textes ou de fichiers depuis + d'autres applis. ### Dictée Whisper - **whisper.cpp 1.8.3**, compilé dans l'application à partir de ses sources et exécuté sur l'appareil. @@ -163,7 +191,7 @@ avec sa propre clé. Une copie installée depuis F-Droid reçoit la 3.0.0 par F- ## Installation 1. **APK publié** — depuis les [Releases GitHub](https://github.com/gitubpatrice/notes_tech/releases), - l'APK universel (`notes-tech-universel-3.1.1.apk`, tous téléphones), ou le fichier plus léger de + l'APK universel (`notes-tech-universel-3.2.0.apk`, tous téléphones), ou le fichier plus léger de votre appareil (`arm64-v8a` convient à presque tous les téléphones depuis 2016). Vérifiez l'empreinte SHA-256 publiée dans les notes de version. 2. **F-Droid** — [f-droid.org/packages/com.filestech.notes_tech](https://f-droid.org/packages/com.filestech.notes_tech/), construite et signée par F-Droid. diff --git a/README.md b/README.md index 766c264..d64026a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 🇫🇷 [Version française](README.fr.md) -**v3.1.1 — October 2026** · [Privacy policy](PRIVACY.md) · [Terms of use](TERMS.md) · [Security](SECURITY.md) +**v3.2.0 — October 2026** · [Privacy policy](PRIVACY.md) · [Terms of use](TERMS.md) · [Security](SECURITY.md) Encrypted Markdown note-taking app for Android, written in **Kotlin** with Jetpack Compose. **100% local, no Internet permission.** Interface in **English, French, German, Italian and @@ -20,6 +20,24 @@ technically unable to send anything, and you can check that in its manifest.** --- +## What's new in 3.2.0 + +- **Encrypted backup** (Settings → Encrypted backup): your notes and folders, trash included, in one + `.ntbackup` file, encrypted (Argon2id, AES-256-GCM) by a generated code or a passphrase you choose. + A passphrase vault travels as it is, still sealed by its own passphrase; an open PIN vault travels + inside the backup's encryption and gets a new PIN when restored. Restoring adds and never + overwrites: the whole file is checked first, then written in one transaction. +- **Import** Markdown or text files, or a ZIP of them — a Notes Tech export, an Obsidian vault: + folders kept, frontmatter read, never into a vault. At most 500,000 characters per note. +- **Find in note**: matches highlighted, "3 / 12", previous and next, while reading and writing; a + note opened from a search result opens with its search. +- **Share to Notes Tech** from another app: a text becomes a note in the Inbox, text files are + imported — after the app lock, when it is on. +- **Long notes**: the body is now written in Android's own text field, which draws only the lines on + screen — on a Galaxy S9, from about 1 s per keystroke at 100,000 characters to about 20 ms. The + keyboard reads only the text around the cursor, scrolling flings, and reading a very long note no + longer freezes. + ## What's new in 3.1.1 - **Fixed**: in 3.1.0, a long press inside a note being edited closed the app, so text could not be @@ -127,6 +145,11 @@ A copy installed from F-Droid receives the 3.0.0 through F-Droid. ### Search - Instant **FTS5** full-text search (`unicode61` tokenizer, diacritics folded). Deleted text is erased from the index too (`secure-delete`). +- **Find in note**: the matches of a search highlighted in the note on screen, one after the other. + +### Backup, import and sharing +- **Encrypted backup** to a `.ntbackup` file you keep, restored on this phone or another one. +- **Import** of Markdown or text files and ZIP archives; **share** text or files from other apps. ### Whisper voice dictation - **whisper.cpp 1.8.3**, compiled into the app from source and run on the device. @@ -157,7 +180,7 @@ A copy installed from F-Droid receives the 3.0.0 through F-Droid. ## Installation 1. **Published APK** — from [GitHub Releases](https://github.com/gitubpatrice/notes_tech/releases), - the universal APK (`notes-tech-universel-3.1.1.apk`, any phone), or the lighter one for your device + the universal APK (`notes-tech-universel-3.2.0.apk`, any phone), or the lighter one for your device (`arm64-v8a` fits almost every phone since 2016). Check the SHA-256 published in the release notes. 2. **F-Droid** — [f-droid.org/packages/com.filestech.notes_tech](https://f-droid.org/packages/com.filestech.notes_tech/), built and signed by F-Droid. diff --git a/SECURITY.fr.md b/SECURITY.fr.md index c92af1b..b8dcf6d 100644 --- a/SECURITY.fr.md +++ b/SECURITY.fr.md @@ -2,10 +2,39 @@ *English version: [SECURITY.md](SECURITY.md)* -**Version actuelle : v3.1.1 — octobre 2026.** Notes Tech 3.0 est une réécriture en Kotlin. Le journal +**Version actuelle : v3.2.0 — octobre 2026.** Notes Tech 3.0 est une réécriture en Kotlin. Le journal de sécurité des versions Flutter 1.x et 2.x est conservé tel quel, dans [`SECURITY.fr.md` au tag v2.0.9](https://github.com/gitubpatrice/notes_tech/blob/v2.0.9/SECURITY.fr.md). +## v3.2.0 — sauvegarde chiffrée, import, partage entrant, le champ du corps (2026-10-30) + +- **Sauvegarde chiffrée** (`.ntbackup` v1) : un en-tête de 48 octets authentifié comme données associées + (version du format, paramètres Argon2id — t = 3, m = 64 Mio, p = 1, refusés avant toute dérivation + s'ils diffèrent —, sel, nonce), puis AES-256-GCM en flux ; le contenu est lu jusqu'à la fin de son + flux, donc l'étiquette est toujours vérifiée. Bornée à 64 Mio sur ce qui est réellement lu. Le fichier + est écrit dans le cache de l'appli AVANT l'ouverture du sélecteur — tout coffre se verrouille quand + l'appli passe en arrière-plan —, puis copié et relu, comparé par SHA-256 ; la copie du cache est + effacée à chaque sortie, à chaque démarrage et par le mode panique. Restaurer, c'est une passe d'essai + puis une passe d'écriture dans une seule transaction Room, liée aux octets de l'essai par SHA-256 ; les + clés Keystore qu'une passe échouée a créées pour des coffres à code ne sont supprimées que si leur + coffre n'est pas en base. Le mode panique n'atteint pas une sauvegarde rangée hors de l'appli, et + l'écran le dit. Deux relectures externes (GPT-5.6), de la conception et du code ; chaque constat traité. +- **Import et partage entrant** : UTF-8 strict, 500 000 caractères au plus par note ; 5 000 fichiers et + 100 Mio décompressés par import (une bombe ZIP l'arrête), 20 fichiers et 16 Mio par partage, lus un + fichier à la fois ; jamais dans un coffre. L'activité de partage est exportée mais sans interface : + elle borne ce qu'elle reçoit et le confie en mémoire à l'appli. `MainActivity` jette toujours tous + les extras, et la note n'est créée qu'après le verrou de l'appli. +- **Le corps d'une note est un `EditText`** — Compose dessine toutes les lignes d'un texte à chaque + image, et une longue note devenait impossible à écrire. Ce qu'un `EditText` ouvre, refermé : la barre + de sélection garde couper, copier, coller et tout sélectionner (ni Partager, ni Saisie automatique, ni + appli `PROCESS_TEXT` — Android 10 et avant les listent toutes) ; Copier et Couper passent par le + presse-papiers protégé d'une note de coffre, et une coupe n'efface son texte qu'une fois copié ; ni + saisie automatique, ni capture de contenu (Android 11 et suivants ; la fenêtre d'une note de coffre est + en `FLAG_SECURE`), ni correcteur orthographique, rien dans l'état sauvegardé. Une note de coffre + demande au clavier de ne pas apprendre, et sa sélection ne peut pas être glissée vers une autre appli. + Le clavier lit 5 000 caractères autour du curseur, jamais la note entière — une longue sélection, rien. + Relu par GPT-5.6 ; chaque nouvelle défense vue échouer une fois retirée. + ## v3.1.1 — l'appui long qui fermait l'éditeur (2026-10-29) - **La 3.1.0 se fermait à chaque appui long dans l'éditeur** : impossible de coller, de copier ou de diff --git a/SECURITY.md b/SECURITY.md index 2eb1c55..addee73 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,10 +2,38 @@ *Version française : [SECURITY.fr.md](SECURITY.fr.md)* -**Current version: v3.1.1 — October 2026.** Notes Tech 3.0 is a rewrite in Kotlin. The security log +**Current version: v3.2.0 — October 2026.** Notes Tech 3.0 is a rewrite in Kotlin. The security log of the Flutter versions 1.x and 2.x is kept as it was, in [`SECURITY.md` at tag v2.0.9](https://github.com/gitubpatrice/notes_tech/blob/v2.0.9/SECURITY.md). +## v3.2.0 — encrypted backup, import, incoming share, the body field (2026-10-30) + +- **Encrypted backup** (`.ntbackup` v1): a 48-byte header authenticated as associated data (format + version, Argon2id parameters — t = 3, m = 64 MiB, p = 1, refused before any derivation if different —, + salt, nonce), then AES-256-GCM in streaming mode; the content is read to the end of its stream, so the + tag is always checked. Bounded to 64 MiB on what is really read. The file is written in the app's + cache BEFORE the system picker opens — every vault locks when the app goes to the background —, then + copied and read back, compared by SHA-256; the cache copy is deleted on every way out, at each start + and by the panic mode. Restoring is a trial pass, then a writing pass in one Room transaction, bound + to the trial's bytes by SHA-256; the Keystore keys a failed pass created for PIN vaults are deleted + only when their vault is not in the database. The panic mode cannot reach a backup saved outside the + app, and the screen says so. Two external reviews (GPT-5.6), of the design and of the code; every + finding handled. +- **Import and incoming share**: strict UTF-8, at most 500,000 characters per note; 5,000 files and + 100 MiB inflated per import (a ZIP bomb stops it), 20 files and 16 MiB per share, read one file at a + time; never into a vault. The share activity is exported but has no interface: it bounds what it + receives and hands it in memory to the app. `MainActivity` still drops every extra, and the note is + created only after the app lock. +- **The note's body is an `EditText`** — Compose draws every line of a text at each frame, and a long + note could not be written in. What an `EditText` opens, closed: the selection toolbar keeps cut, copy, + paste and select all (no Share, no Autofill, no `PROCESS_TEXT` app — Android 10 and below list them + all); Copy and Cut go through a vault note's protected clipboard, and a Cut removes its text only + once written; no autofill, no content capture (Android 11 and later; a vault note's window is + `FLAG_SECURE`), no spell checker, nothing in the saved state. A vault note asks the keyboard not to + learn, and its selection cannot be dragged to another app. The keyboard reads 5,000 characters + around the cursor, never the whole note — a long selection, nothing. Reviewed by GPT-5.6; every new + defence seen failing when removed. + ## v3.1.1 — the long press that closed the editor (2026-10-29) - **3.1.0 closed on every long press in the editor**, so no note could be pasted into, copied from or diff --git a/app/src/main/res/raw-de/privacy.md b/app/src/main/res/raw-de/privacy.md index 657d873..2c70c8e 100644 --- a/app/src/main/res/raw-de/privacy.md +++ b/app/src/main/res/raw-de/privacy.md @@ -1,6 +1,6 @@ # Datenschutzerklärung — Notes Tech -_Zuletzt aktualisiert: 29. Oktober 2026_ +_Zuletzt aktualisiert: 30. Oktober 2026_ ## In einem Satz @@ -16,6 +16,7 @@ Notes Tech erhebt, überträgt und speichert keinerlei Daten auf entfernten Serv - **App-Sperre (optional)**: Die PIN selbst wird nie gespeichert. Aufbewahrt wird ein mit **Argon2id** daraus abgeleiteter Wert, der per **HMAC-SHA256** an einen Schlüssel des **Android Keystore** gebunden ist, der das Telefon nie verlässt — er kann also nirgendwo anders geprüft werden. Nach fünf falschen PINs wartet jeder neue Versuch länger (30 Sekunden, verdoppelt bis zu einer Stunde); falsche Versuche löschen nie etwas. **Entsperren per Fingerabdruck oder Gesicht** wird vollständig von Android übernommen: Notes Tech erhält keine biometrischen Daten, nur die Bestätigung, dass ein Keystore-Schlüssel verwendet werden durfte, und nur starke Biometrie (Klasse 3) wird akzeptiert. - **Rückverweise `[[Titel]]`**: lokaler invertierter Index, nie übertragen. - **Verschlüsselte Sicherung (optional)**: eine `.ntbackup`-Datei, die Sie selbst erstellen, an dem Ort, den Sie über die Android-Dokumentauswahl wählen. Sie enthält Ihre Ordner und Notizen, Papierkorb inbegriffen, verschlüsselt mit **AES-256-GCM** unter einem Schlüssel, der mit **Argon2id** (m=64MB, t=3) aus einem von der App gezogenen Code, den Sie notieren, oder aus einer eigenen Passphrase abgeleitet wird — Notes Tech bewahrt keines von beiden auf. Sie wird zuerst, bereits verschlüsselt, im privaten Speicher der App geschrieben, dann an den gewählten Ort kopiert und aus dem Speicher der App gelöscht. Die Notizen eines Passphrase-Tresors bleiben in der Datei durch seine Passphrase versiegelt. Ein PIN-Tresor wird nur offen mitgenommen, mit seinem Schlüssel: In der Datei schützt ihn allein das Geheimnis der Sicherung; ein geschlossener wird ausgelassen. Die Wiederherstellung fügt hinzu, was fehlt, und überschreibt nichts. +- **Importierte und geteilte Notizen**: Markdown- oder Textdateien – oder ein ZIP davon –, die Sie in der Dokumentauswahl von Android wählen, und Texte oder Dateien, die Sie aus einer anderen App an Notes Tech teilen, werden zu gewöhnlichen Notizen in Ihren Ordnern, nie in einem Tresor. Sie werden einmal gelesen, auf dem Telefon; die Originale bleiben unverändert, und neben den erstellten Notizen wird nichts aufbewahrt. Höchstens 500.000 Zeichen pro Notiz. - **Diktiermodell (Whisper `.bin`)**: Sie beschaffen es selbst — die App zeigt den Dateinamen und seine Quelle an — und importieren es dann über die Android-Dokumentauswahl. Notes Tech hat keine Internetberechtigung und **bietet keine Möglichkeit, etwas herunterzuladen**. Sein SHA-256 wird beim Import und vor jedem Laden geprüft. - **Beim Diktat aufgenommenes Audio**: in eine temporäre Datei im privaten Speicher der App geschrieben — die Transkriptions-Engine liest eine Datei, anders geht es nicht — und **gelöscht, sobald die Transkription vorliegt**. Es wird außerdem beim Start der App und durch den Panikmodus gelöscht, sodass ein abruptes Beenden nichts zurücklässt. - **Einstellungen (Design, Sortierung, aktiviertes Diktat, automatische Tresorsperre, Optionen der App-Sperre)**: unverschlüsselt in den lokalen Einstellungen gespeichert (keine sensiblen Daten). diff --git a/app/src/main/res/raw-es/privacy.md b/app/src/main/res/raw-es/privacy.md index ad63ecf..f49dcfa 100644 --- a/app/src/main/res/raw-es/privacy.md +++ b/app/src/main/res/raw-es/privacy.md @@ -1,6 +1,6 @@ # Política de privacidad — Notes Tech -_Última actualización: 29 de octubre de 2026_ +_Última actualización: 30 de octubre de 2026_ ## En una frase @@ -16,6 +16,7 @@ Notes Tech no recopila, no transmite ni almacena ningún dato en servidores remo - **Bloqueo de la aplicación (opcional)**: el PIN nunca se guarda. Se conserva un valor derivado de él con **Argon2id** y vinculado mediante **HMAC-SHA256** a una clave del **Android Keystore** que nunca sale del teléfono, de modo que no puede comprobarse en ningún otro lugar. Tras cinco PIN incorrectos, cada nuevo intento espera más (30 segundos, que se duplican hasta una hora); los intentos fallidos nunca borran nada. El **desbloqueo con huella dactilar o rostro** lo gestiona íntegramente Android: Notes Tech no recibe ningún dato biométrico, solo la confirmación de que se ha permitido usar una clave del Keystore, y solo se acepta la biometría fuerte (clase 3). - **Retroenlaces `[[Título]]`**: índice invertido local, nunca transmitido. - **Copia de seguridad cifrada (opcional)**: un archivo `.ntbackup` que usted mismo crea, en el lugar que elige mediante el selector de documentos de Android. Contiene sus carpetas y sus notas, papelera incluida, cifradas con **AES-256-GCM** con una clave derivada mediante **Argon2id** (m=64MB, t=3) de un código que genera la aplicación y que usted anota, o de una frase de contraseña propia; Notes Tech no guarda ni uno ni otra. Primero se escribe, ya cifrado, en el almacenamiento privado de la aplicación; luego se copia al lugar elegido y se borra de ese almacenamiento. Las notas de una caja fuerte con frase de contraseña siguen selladas por su frase en el archivo. Una caja fuerte con PIN solo viaja abierta, con su clave: en el archivo solo la protege el secreto de la copia; cerrada, queda fuera. La restauración añade lo que falta y no sobrescribe nada. +- **Notas importadas y compartidas**: los archivos Markdown o de texto —o un archivo ZIP que los contenga— que usted elige en el selector de documentos de Android, y los textos o archivos que comparte con Notes Tech desde otra app, se convierten en notas normales en sus carpetas, nunca en una caja fuerte. Se leen una sola vez, en el teléfono; los originales no se modifican y no se conserva nada aparte de las notas creadas. Como máximo 500.000 caracteres por nota. - **Modelo de dictado por voz (Whisper `.bin`)**: lo obtiene usted mismo —la aplicación muestra el nombre del archivo y su origen— y luego lo importa mediante el selector de documentos de Android. Notes Tech no tiene permiso de acceso a Internet y **no ofrece ninguna forma de descargar nada**. Su SHA-256 se comprueba al importarlo y antes de cada carga. - **Audio captado durante el dictado**: se escribe en un archivo temporal del almacenamiento privado de la aplicación —el motor de transcripción lee un archivo, no hay otra manera— y **se borra en cuanto llega la transcripción**. También se borra al iniciar la aplicación y con el modo pánico, de modo que un cierre brusco no deja nada. - **Ajustes (tema, orden, dictado activado, bloqueo automático de las cajas fuertes, opciones del bloqueo de la aplicación)**: guardados sin cifrar en las preferencias locales (ningún dato sensible). diff --git a/app/src/main/res/raw-fr/privacy.md b/app/src/main/res/raw-fr/privacy.md index d0cac7a..a146e12 100644 --- a/app/src/main/res/raw-fr/privacy.md +++ b/app/src/main/res/raw-fr/privacy.md @@ -1,6 +1,6 @@ # Politique de confidentialité — Notes Tech -_Dernière mise à jour : 29 octobre 2026_ +_Dernière mise à jour : 30 octobre 2026_ ## En une phrase @@ -16,6 +16,7 @@ Notes Tech ne collecte, ne transmet et ne stocke aucune donnée sur des serveurs - **Verrouillage de l'application (facultatif)** : le PIN lui-même n'est jamais conservé. Ce qui l'est, c'est une valeur dérivée par **Argon2id** et liée par **HMAC-SHA256** à une clé du **Keystore Android** qui ne quitte jamais le téléphone : elle ne peut donc être vérifiée nulle part ailleurs. Après cinq PIN erronés, chaque nouvel essai attend plus longtemps (30 secondes, doublées jusqu'à une heure) ; un essai raté n'efface jamais rien. Le **déverrouillage par empreinte ou par visage** est entièrement géré par Android : Notes Tech ne reçoit aucune donnée biométrique, seulement la confirmation qu'une clé du Keystore a été autorisée à servir, et seule la biométrie forte (classe 3) est acceptée. - **Backlinks `[[Titre]]`** : index inversé local, jamais transmis. - **Sauvegarde chiffrée (facultative)** : un fichier `.ntbackup` que vous créez vous-même, à l'endroit que vous choisissez par le sélecteur de documents Android. Il contient vos dossiers et vos notes, corbeille comprise, chiffrés en **AES-256-GCM** sous une clé dérivée par **Argon2id** (m=64MB, t=3) d'un code tiré par l'application et que vous notez, ou d'une passphrase de votre choix — Notes Tech ne garde ni l'un ni l'autre. Il est d'abord écrit, déjà chiffré, dans le stockage privé de l'application, puis copié à l'endroit choisi et effacé du stockage de l'application. Les notes d'un coffre à passphrase restent scellées par sa passphrase dans le fichier. Un coffre à PIN ne voyage qu'ouvert, avec sa clé : dans le fichier, seul le secret de la sauvegarde le protège ; fermé, il est laissé de côté. La restauration ajoute ce qui manque et n'écrase rien. +- **Notes importées et partagées** : les fichiers Markdown ou texte — ou une archive ZIP de ces fichiers — que vous choisissez dans le sélecteur de documents d'Android, et les textes ou fichiers que vous partagez vers Notes Tech depuis une autre appli, deviennent des notes ordinaires dans vos dossiers, jamais dans un coffre. Ils sont lus une fois, sur le téléphone ; les originaux ne sont pas modifiés et rien n'est gardé à côté des notes créées. 500 000 caractères au plus par note. - **Modèle de dictée vocale (Whisper `.bin`)** : vous vous le procurez vous-même — l'application affiche le nom du fichier et sa source — puis vous l'importez par le sélecteur de documents Android. Notes Tech n'a pas la permission Internet et **n'expose aucun moyen de télécharger quoi que ce soit**. Son empreinte SHA-256 est vérifiée à l'import et avant chaque chargement. - **Audio capturé pendant la dictée** : écrit dans un fichier temporaire du stockage privé de l'application — le moteur de transcription lit un fichier, il ne peut pas en être autrement — puis **effacé dès la transcription obtenue**. Il l'est également au démarrage de l'application et par le mode panique, de sorte qu'un arrêt brutal ne laisse rien derrière lui. - **Préférences (thème, tri, dictée activée, auto-lock coffre, options du verrouillage)** : stockées en clair dans les préférences locales (pas de donnée sensible). diff --git a/app/src/main/res/raw-it/privacy.md b/app/src/main/res/raw-it/privacy.md index 816f6bb..0b068d5 100644 --- a/app/src/main/res/raw-it/privacy.md +++ b/app/src/main/res/raw-it/privacy.md @@ -1,6 +1,6 @@ # Informativa sulla privacy — Notes Tech -_Ultimo aggiornamento: 29 ottobre 2026_ +_Ultimo aggiornamento: 30 ottobre 2026_ ## In una frase @@ -16,6 +16,7 @@ Notes Tech non raccoglie, non trasmette e non conserva alcun dato su server remo - **Blocco dell'app (facoltativo)**: il PIN non viene mai memorizzato. Viene conservato un valore derivato da esso con **Argon2id** e legato tramite **HMAC-SHA256** a una chiave dell'**Android Keystore** che non lascia mai il telefono, quindi non può essere verificato altrove. Dopo cinque PIN errati, ogni nuovo tentativo attende più a lungo (30 secondi, raddoppiando fino a un'ora); i tentativi errati non cancellano mai nulla. Lo **sblocco con impronta digitale o volto** è gestito interamente da Android: Notes Tech non riceve alcun dato biometrico, solo la conferma che l'uso di una chiave del Keystore è stato consentito, e viene accettata solo la biometria forte (classe 3). - **Collegamenti inversi `[[Titolo]]`**: indice invertito locale, mai trasmesso. - **Backup cifrato (facoltativo)**: un file `.ntbackup` che crea lei stesso, nel punto che sceglie tramite il selettore di documenti di Android. Contiene le sue cartelle e le sue note, cestino compreso, cifrate con **AES-256-GCM** con una chiave derivata tramite **Argon2id** (m=64MB, t=3) da un codice estratto dall'app e che lei annota, o da una passphrase di sua scelta — Notes Tech non conserva né l'uno né l'altra. Viene prima scritto, già cifrato, nella memoria privata dell'app, poi copiato nel punto scelto e cancellato dalla memoria dell'app. Le note di una cassaforte con passphrase restano sigillate dalla sua passphrase nel file. Una cassaforte con PIN viaggia solo aperta, con la sua chiave: nel file la protegge soltanto il segreto del backup; chiusa, viene esclusa. Il ripristino aggiunge ciò che manca e non sovrascrive nulla. +- **Note importate e condivise**: i file Markdown o di testo — o un archivio ZIP che li contiene — che lei sceglie nel selettore di documenti di Android, e i testi o i file che condivide con Notes Tech da un'altra app, diventano note ordinarie nelle sue cartelle, mai in una cassaforte. Vengono letti una volta, sul telefono; gli originali non vengono modificati e nulla viene conservato oltre alle note create. Al massimo 500.000 caratteri per nota. - **Modello di dettatura vocale (Whisper `.bin`)**: se lo procura lei stesso — l'app mostra il nome del file e la sua origine — e poi lo importa tramite il selettore di documenti di Android. Notes Tech non ha l'autorizzazione di accesso a Internet e **non offre alcun modo di scaricare nulla**. Il suo SHA-256 viene verificato all'importazione e prima di ogni caricamento. - **Audio acquisito durante la dettatura**: scritto in un file temporaneo nello spazio privato dell'app — il motore di trascrizione legge un file, non c'è altro modo — e **cancellato appena arriva la trascrizione**. Viene cancellato anche all'avvio dell'app e dalla modalità panico, così una chiusura improvvisa non lascia nulla. - **Impostazioni (tema, ordinamento, dettatura attivata, blocco automatico delle casseforti, opzioni del blocco dell'app)**: conservate in chiaro nelle preferenze locali (nessun dato sensibile). diff --git a/app/src/main/res/raw/privacy.md b/app/src/main/res/raw/privacy.md index 9928df9..fda13ea 100644 --- a/app/src/main/res/raw/privacy.md +++ b/app/src/main/res/raw/privacy.md @@ -1,6 +1,6 @@ # Privacy policy — Notes Tech -_Last updated: 29 October 2026_ +_Last updated: 30 October 2026_ ## In one sentence @@ -16,6 +16,7 @@ Notes Tech does not collect, transmit or store any data on remote servers. Every - **App lock (optional)**: the PIN itself is never stored. What is kept is a value derived from it with **Argon2id** and bound by **HMAC-SHA256** to a key of the **Android Keystore** that never leaves the phone — so it cannot be checked anywhere else. After five wrong PINs, each new attempt waits longer (30 seconds, doubling up to one hour); wrong attempts never erase anything. **Fingerprint or face unlock** is handled entirely by Android: Notes Tech receives no biometric data, only the confirmation that a Keystore key was allowed to work, and only strong (Class 3) biometrics are accepted. - **Backlinks `[[Title]]`**: local inverted index, never transmitted. - **Encrypted backup (optional)**: a `.ntbackup` file you create yourself, where you choose through the Android document picker. It holds your folders and notes, trash included, encrypted with **AES-256-GCM** under a key derived by **Argon2id** (m=64MB, t=3) from a code the app draws and you write down, or from a passphrase of your own — Notes Tech keeps neither. It is first written, already encrypted, in the app's private storage, then copied where you chose and erased from the app's storage. A passphrase vault's notes stay sealed by its passphrase in the file. A PIN vault travels only open, with its key: in the file, only the backup's secret protects it; a closed one is left out. Restoring adds what is missing and overwrites nothing. +- **Imported and shared notes**: Markdown or text files — or a ZIP of them — you choose in the Android document picker, and text or files you share to Notes Tech from another app, become ordinary notes in your folders, never in a vault. They are read once, on the phone; the originals are left untouched and nothing is kept beside the notes created. At most 500,000 characters per note. - **Voice dictation model (Whisper `.bin`)**: you obtain it yourself — the app shows the file name and its source — then import it through the Android document picker. Notes Tech has no Internet permission and **exposes no way to download anything**. Its SHA-256 is verified on import and before every load. - **Audio captured during dictation**: written to a temporary file in the app's private storage — the transcription engine reads a file, there is no way around it — then **wiped as soon as the transcription is returned**. It is also wiped on app start and by panic mode, so an abrupt shutdown leaves nothing behind. - **Settings (theme, sort, dictation enabled, vault auto-lock, app lock options)**: stored in clear in local preferences (no sensitive data). diff --git a/fastlane/metadata/android/de-DE/changelogs/5031.txt b/fastlane/metadata/android/de-DE/changelogs/5031.txt new file mode 100644 index 0000000..e2940c8 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/5031.txt @@ -0,0 +1 @@ +Neu: verschlüsselte Sicherung (Einstellungen) – Ihre Notizen, Ordner und Tresore in einer Datei, geschützt durch einen Code oder eine Passphrase, wiederhergestellt, ohne etwas zu überschreiben. Import von Markdown- oder Textdateien und ZIP-Archiven, auch eines Notes-Tech-Exports. In der Notiz suchen, Treffer hervorgehoben. Texte oder Dateien aus anderen Apps an Notes Tech teilen. Lange Notizen scrollen und tippen sich jetzt flüssig. diff --git a/fastlane/metadata/android/de-DE/changelogs/5032.txt b/fastlane/metadata/android/de-DE/changelogs/5032.txt new file mode 100644 index 0000000..e2940c8 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/5032.txt @@ -0,0 +1 @@ +Neu: verschlüsselte Sicherung (Einstellungen) – Ihre Notizen, Ordner und Tresore in einer Datei, geschützt durch einen Code oder eine Passphrase, wiederhergestellt, ohne etwas zu überschreiben. Import von Markdown- oder Textdateien und ZIP-Archiven, auch eines Notes-Tech-Exports. In der Notiz suchen, Treffer hervorgehoben. Texte oder Dateien aus anderen Apps an Notes Tech teilen. Lange Notizen scrollen und tippen sich jetzt flüssig. diff --git a/fastlane/metadata/android/de-DE/changelogs/5033.txt b/fastlane/metadata/android/de-DE/changelogs/5033.txt new file mode 100644 index 0000000..e2940c8 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/5033.txt @@ -0,0 +1 @@ +Neu: verschlüsselte Sicherung (Einstellungen) – Ihre Notizen, Ordner und Tresore in einer Datei, geschützt durch einen Code oder eine Passphrase, wiederhergestellt, ohne etwas zu überschreiben. Import von Markdown- oder Textdateien und ZIP-Archiven, auch eines Notes-Tech-Exports. In der Notiz suchen, Treffer hervorgehoben. Texte oder Dateien aus anderen Apps an Notes Tech teilen. Lange Notizen scrollen und tippen sich jetzt flüssig. diff --git a/fastlane/metadata/android/de-DE/full_description.txt b/fastlane/metadata/android/de-DE/full_description.txt index f70f079..170b968 100644 --- a/fastlane/metadata/android/de-DE/full_description.txt +++ b/fastlane/metadata/android/de-DE/full_description.txt @@ -23,8 +23,10 @@ Notes Tech: Markdown-Notizen, 100 % lokal und im Ruhezustand verschlüsselt. In • Langes Drücken auf eine Notiz: bearbeiten, in den Papierkorb mit Rückgängig oder endgültig löschen • Eine Pastellfarbe pro Notiz in den Listen (bei einer Tresornotiz nur bei entsperrtem Tresor) • Rückverweise [[Titel]] mit Autovervollständigung und Bereich „Notizen, die hierher verlinken“ -• Sofortige FTS5-Suche, einstellbare Sortierung +• Sofortige FTS5-Suche, Suche in der Notiz mit hervorgehobenen Treffern, einstellbare Sortierung • Markdown-Export, eine Notiz oder als ZIP (YAML-Frontmatter, kompatibel mit Obsidian/Logseq) +• Import von Markdown-, Text- und ZIP-Dateien; Texte oder Dateien aus anderen Apps teilen +• Verschlüsselte Sicherung Ihrer Notizen und Tresore, wiederhergestellt, ohne etwas zu überschreiben Fünf Sprachen • Englisch, Französisch, Deutsch, Italienisch, Spanisch — in den Einstellungen wählbar diff --git a/fastlane/metadata/android/en-US/changelogs/5031.txt b/fastlane/metadata/android/en-US/changelogs/5031.txt new file mode 100644 index 0000000..e66f77a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/5031.txt @@ -0,0 +1 @@ +New: encrypted backup (Settings) — your notes, folders and vaults in one file, protected by a code or a passphrase, restored without overwriting anything. Import of Markdown or text files and ZIP archives, a Notes Tech export included. Find in note, matches highlighted. Share text or files to Notes Tech from other apps. Long notes now scroll and type smoothly. diff --git a/fastlane/metadata/android/en-US/changelogs/5032.txt b/fastlane/metadata/android/en-US/changelogs/5032.txt new file mode 100644 index 0000000..e66f77a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/5032.txt @@ -0,0 +1 @@ +New: encrypted backup (Settings) — your notes, folders and vaults in one file, protected by a code or a passphrase, restored without overwriting anything. Import of Markdown or text files and ZIP archives, a Notes Tech export included. Find in note, matches highlighted. Share text or files to Notes Tech from other apps. Long notes now scroll and type smoothly. diff --git a/fastlane/metadata/android/en-US/changelogs/5033.txt b/fastlane/metadata/android/en-US/changelogs/5033.txt new file mode 100644 index 0000000..e66f77a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/5033.txt @@ -0,0 +1 @@ +New: encrypted backup (Settings) — your notes, folders and vaults in one file, protected by a code or a passphrase, restored without overwriting anything. Import of Markdown or text files and ZIP archives, a Notes Tech export included. Find in note, matches highlighted. Share text or files to Notes Tech from other apps. Long notes now scroll and type smoothly. diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 5948a2f..94097e9 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -23,8 +23,10 @@ Notes Tech: 100% local Markdown note-taking, encrypted at rest. Written in Kotli • Long press on a note: edit, move to trash with Undo, or delete permanently • A pastel colour per note in the lists (a vault note's only while its vault is open) • [[Title]] backlinks with autocomplete + "notes linking here" panel -• Instant FTS5 search, configurable sort +• Instant FTS5 search, find in note with highlighted matches, configurable sort • Markdown export, one note or a ZIP (YAML frontmatter, Obsidian/Logseq compatible) +• Import of Markdown, text and ZIP files; share text or files from other apps +• Encrypted backup of your notes and vaults, restored without overwriting anything Five languages • English, French, German, Italian, Spanish — chosen in Settings diff --git a/fastlane/metadata/android/es-ES/changelogs/5031.txt b/fastlane/metadata/android/es-ES/changelogs/5031.txt new file mode 100644 index 0000000..25689b4 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/5031.txt @@ -0,0 +1 @@ +Novedades: copia de seguridad cifrada (Ajustes): notas, carpetas y cajas fuertes en un solo archivo, protegido por un código o una frase de contraseña, restaurado sin sobrescribir nada. Importación de archivos Markdown o de texto y de archivos ZIP, incluida una exportación de Notes Tech. Buscar en la nota, coincidencias resaltadas. Compartir textos o archivos con Notes Tech desde otras apps. Las notas largas ahora se desplazan y se escriben con fluidez. diff --git a/fastlane/metadata/android/es-ES/changelogs/5032.txt b/fastlane/metadata/android/es-ES/changelogs/5032.txt new file mode 100644 index 0000000..25689b4 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/5032.txt @@ -0,0 +1 @@ +Novedades: copia de seguridad cifrada (Ajustes): notas, carpetas y cajas fuertes en un solo archivo, protegido por un código o una frase de contraseña, restaurado sin sobrescribir nada. Importación de archivos Markdown o de texto y de archivos ZIP, incluida una exportación de Notes Tech. Buscar en la nota, coincidencias resaltadas. Compartir textos o archivos con Notes Tech desde otras apps. Las notas largas ahora se desplazan y se escriben con fluidez. diff --git a/fastlane/metadata/android/es-ES/changelogs/5033.txt b/fastlane/metadata/android/es-ES/changelogs/5033.txt new file mode 100644 index 0000000..25689b4 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/5033.txt @@ -0,0 +1 @@ +Novedades: copia de seguridad cifrada (Ajustes): notas, carpetas y cajas fuertes en un solo archivo, protegido por un código o una frase de contraseña, restaurado sin sobrescribir nada. Importación de archivos Markdown o de texto y de archivos ZIP, incluida una exportación de Notes Tech. Buscar en la nota, coincidencias resaltadas. Compartir textos o archivos con Notes Tech desde otras apps. Las notas largas ahora se desplazan y se escriben con fluidez. diff --git a/fastlane/metadata/android/es-ES/full_description.txt b/fastlane/metadata/android/es-ES/full_description.txt index bf5d420..f0cdbcd 100644 --- a/fastlane/metadata/android/es-ES/full_description.txt +++ b/fastlane/metadata/android/es-ES/full_description.txt @@ -23,8 +23,10 @@ Notes Tech: notas Markdown 100 % locales, cifradas en reposo. Escrita en Kotlin. • Pulsación larga en una nota: editarla, moverla a la papelera con Deshacer o eliminarla definitivamente • Un color pastel por nota en las listas (el de una nota de caja fuerte, solo desbloqueada) • Retroenlaces [[Título]] con autocompletado y panel «notas que enlazan aquí» -• Búsqueda FTS5 instantánea, orden configurable +• Búsqueda FTS5 instantánea, búsqueda en la nota con coincidencias resaltadas, orden configurable • Exportación Markdown, de una nota o en ZIP (frontmatter YAML compatible con Obsidian/Logseq) +• Importación de archivos Markdown, de texto y ZIP; compartir textos o archivos desde otras apps +• Copia de seguridad cifrada de las notas y cajas fuertes, restaurada sin sobrescribir nada Cinco idiomas • Inglés, francés, alemán, italiano, español — a elegir en Ajustes diff --git a/fastlane/metadata/android/fr-FR/changelogs/5031.txt b/fastlane/metadata/android/fr-FR/changelogs/5031.txt new file mode 100644 index 0000000..00c30df --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/5031.txt @@ -0,0 +1 @@ +Nouveau : sauvegarde chiffrée (Réglages) — vos notes, dossiers et coffres dans un seul fichier, protégé par un code ou une passphrase, restauré sans rien écraser. Import de fichiers Markdown ou texte et d'archives ZIP, export de Notes Tech compris. Rechercher dans la note, occurrences surlignées. Partage de textes ou de fichiers vers Notes Tech depuis d'autres applis. Les longues notes défilent et se tapent enfin avec fluidité. diff --git a/fastlane/metadata/android/fr-FR/changelogs/5032.txt b/fastlane/metadata/android/fr-FR/changelogs/5032.txt new file mode 100644 index 0000000..00c30df --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/5032.txt @@ -0,0 +1 @@ +Nouveau : sauvegarde chiffrée (Réglages) — vos notes, dossiers et coffres dans un seul fichier, protégé par un code ou une passphrase, restauré sans rien écraser. Import de fichiers Markdown ou texte et d'archives ZIP, export de Notes Tech compris. Rechercher dans la note, occurrences surlignées. Partage de textes ou de fichiers vers Notes Tech depuis d'autres applis. Les longues notes défilent et se tapent enfin avec fluidité. diff --git a/fastlane/metadata/android/fr-FR/changelogs/5033.txt b/fastlane/metadata/android/fr-FR/changelogs/5033.txt new file mode 100644 index 0000000..00c30df --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/5033.txt @@ -0,0 +1 @@ +Nouveau : sauvegarde chiffrée (Réglages) — vos notes, dossiers et coffres dans un seul fichier, protégé par un code ou une passphrase, restauré sans rien écraser. Import de fichiers Markdown ou texte et d'archives ZIP, export de Notes Tech compris. Rechercher dans la note, occurrences surlignées. Partage de textes ou de fichiers vers Notes Tech depuis d'autres applis. Les longues notes défilent et se tapent enfin avec fluidité. diff --git a/fastlane/metadata/android/fr-FR/full_description.txt b/fastlane/metadata/android/fr-FR/full_description.txt index 69ae968..b268854 100644 --- a/fastlane/metadata/android/fr-FR/full_description.txt +++ b/fastlane/metadata/android/fr-FR/full_description.txt @@ -23,8 +23,10 @@ Notes Tech : prise de notes Markdown 100 % locale, chiffrée au repos. Écrite e • Appui long sur une note : éditer, mettre à la corbeille avec Annuler, ou supprimer définitivement • Une couleur pastel par note dans les listes (celle d'une note de coffre, seulement coffre ouvert) • Rétroliens [[Titre]] avec autocomplétion + panneau « notes qui mentionnent celle-ci » -• Recherche FTS5 instantanée, tri réglable +• Recherche FTS5 instantanée, recherche dans la note avec occurrences surlignées, tri réglable • Export Markdown, d'une note ou en ZIP (en-tête YAML compatible Obsidian/Logseq) +• Import de fichiers Markdown, texte et ZIP ; partage de textes ou de fichiers depuis d'autres applis +• Sauvegarde chiffrée de vos notes et coffres, restaurée sans rien écraser Cinq langues • Anglais, français, allemand, italien, espagnol — au choix dans les Réglages diff --git a/fastlane/metadata/android/it-IT/changelogs/5031.txt b/fastlane/metadata/android/it-IT/changelogs/5031.txt new file mode 100644 index 0000000..a94adbc --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/5031.txt @@ -0,0 +1 @@ +Novità: backup cifrato (Impostazioni) – note, cartelle e casseforti in un unico file, protetto da un codice o da una passphrase, ripristinato senza sovrascrivere nulla. Importazione di file Markdown o di testo e di archivi ZIP, anche un'esportazione di Notes Tech. Cerca nella nota, occorrenze evidenziate. Condivisione di testi o file con Notes Tech da altre app. Le note lunghe ora scorrono e si scrivono in modo fluido. diff --git a/fastlane/metadata/android/it-IT/changelogs/5032.txt b/fastlane/metadata/android/it-IT/changelogs/5032.txt new file mode 100644 index 0000000..a94adbc --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/5032.txt @@ -0,0 +1 @@ +Novità: backup cifrato (Impostazioni) – note, cartelle e casseforti in un unico file, protetto da un codice o da una passphrase, ripristinato senza sovrascrivere nulla. Importazione di file Markdown o di testo e di archivi ZIP, anche un'esportazione di Notes Tech. Cerca nella nota, occorrenze evidenziate. Condivisione di testi o file con Notes Tech da altre app. Le note lunghe ora scorrono e si scrivono in modo fluido. diff --git a/fastlane/metadata/android/it-IT/changelogs/5033.txt b/fastlane/metadata/android/it-IT/changelogs/5033.txt new file mode 100644 index 0000000..a94adbc --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/5033.txt @@ -0,0 +1 @@ +Novità: backup cifrato (Impostazioni) – note, cartelle e casseforti in un unico file, protetto da un codice o da una passphrase, ripristinato senza sovrascrivere nulla. Importazione di file Markdown o di testo e di archivi ZIP, anche un'esportazione di Notes Tech. Cerca nella nota, occorrenze evidenziate. Condivisione di testi o file con Notes Tech da altre app. Le note lunghe ora scorrono e si scrivono in modo fluido. diff --git a/fastlane/metadata/android/it-IT/full_description.txt b/fastlane/metadata/android/it-IT/full_description.txt index a5d21f1..02c0101 100644 --- a/fastlane/metadata/android/it-IT/full_description.txt +++ b/fastlane/metadata/android/it-IT/full_description.txt @@ -23,8 +23,10 @@ Notes Tech: note Markdown 100% locali, cifrate a riposo. Scritta in Kotlin. • Pressione prolungata su una nota: modificarla, spostarla nel cestino con Annulla o eliminarla definitivamente • Un colore pastello per nota negli elenchi (per una nota in cassaforte, solo a cassaforte sbloccata) • Collegamenti inversi [[Titolo]] con completamento automatico e pannello «note che rimandano qui» -• Ricerca FTS5 istantanea, ordinamento regolabile +• Ricerca FTS5 istantanea, ricerca nella nota con occorrenze evidenziate, ordinamento regolabile • Esportazione Markdown, di una nota o in ZIP (frontmatter YAML compatibile con Obsidian/Logseq) +• Importazione di file Markdown, di testo e ZIP; condivisione di testi o file da altre app +• Backup cifrato di note e casseforti, ripristinato senza sovrascrivere nulla Cinque lingue • Inglese, francese, tedesco, italiano, spagnolo — a scelta nelle Impostazioni diff --git a/version.properties b/version.properties index 7c7105a..00e5467 100644 --- a/version.properties +++ b/version.properties @@ -21,5 +21,5 @@ # 500 is a clean step above the whole 2.0.x line, and leaves 408..499 to any Flutter bug-fix # release shipped during the transition. ⚠️ Before publishing, compare with the LAST published # Flutter tag, not with this comment: the base must stay above it. -versionCode=502 -versionName=3.1.1 +versionCode=503 +versionName=3.2.0