diff --git a/README.fr.md b/README.fr.md index 073ac6e8..766b52a2 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 766c2640..d64026ae 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 c92af1bd..b8dcf6d8 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 2eb1c552..addee73d 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/androidTest/java/com/filestech/notes_tech/data/export/NoteExporteurTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/data/export/NoteExporteurTest.kt index 8f467115..2ca7e959 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/repository/NotesRepositoryTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/data/repository/NotesRepositoryTest.kt index 1d0bd2cf..f230cf6a 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/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 00000000..d9b8f38c --- /dev/null +++ b/app/src/androidTest/java/com/filestech/notes_tech/data/sauvegarde/LectureDuContenuTest.kt @@ -0,0 +1,98 @@ +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() + } + + /** + * 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 new file mode 100644 index 00000000..de8d0dd3 --- /dev/null +++ b/app/src/androidTest/java/com/filestech/notes_tech/data/sauvegarde/SauvegardeChiffreeTest.kt @@ -0,0 +1,322 @@ +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.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 +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 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 { + 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 = creer() + 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), essai) + + 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() + creer() + sessions.lock(coffre) + 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), 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), essai) } + + 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") + creer() + val avant = notes.listAllAlive().size + + val fait = restaurer(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 = 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, sauvegarde.apercu().idsDesCoffresACodeOuverts) + 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 = restaurer(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") + } + + /** + * 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 { + 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 06b69b34..8dd72633 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/androidTest/java/com/filestech/notes_tech/security/vault/FolderVaultServiceTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/security/vault/FolderVaultServiceTest.kt index fe095d93..bcc24e65 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 00000000..a543345e --- /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/androidTest/java/com/filestech/notes_tech/ui/editor/ApercuDeBoutEnBoutTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/ApercuDeBoutEnBoutTest.kt index 304afc81..84ba5648 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 00000000..7e8de1ee --- /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 00000000..d911bc5d --- /dev/null +++ b/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/ChampDuCorpsTest.kt @@ -0,0 +1,471 @@ +package com.filestech.notes_tech.ui.editor + +import android.content.Intent +import android.os.Build +import android.os.Parcelable +import android.os.SystemClock +import android.text.style.BackgroundColorSpan +import android.util.SparseArray +import android.view.ActionMode +import android.view.Menu +import android.view.MenuInflater +import android.view.MotionEvent +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 value the state already gave back, seen once more after the next keystroke — something else + * recomposed the field while the state lagged — is not a change: it emptied the queue when it was + * acknowledged, and undid the keystroke (audit 3 axes of 3.2.0, E1). + */ + @Test + fun an_acknowledged_value_seen_again_does_not_undo_the_next_keystroke() { + poser("x") + surLeChamp { taper("a") } + regle.waitForIdle() + val reconnue = etat + + etatEnRetard = true + surLeChamp { taper("b") } + regle.runOnIdle { surlignage = surlignageDe("xab", Plage(0, 1)) } + regle.waitForIdle() + + assertThat(etat).isSameInstanceAs(reconnue) + assertThat(surLeChamp { text.toString() }).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() + } + } + + // ------------------------------------------------------------------ scrolling + + /** + * 🔴 A fling keeps the text going once the finger is lifted: an EditText scrolls by the finger's + * distance only, and a long note "rame" (Patrice, S24 and S9, 2026-10-30). Discriminating: without + * the fling, the text moves by the drag and stops. + */ + @Test + fun a_fling_keeps_the_text_going() { + poser((0 until 4_000).joinToString("\n") { "Ligne $it" }) + val avant = surLeChamp { + scrollTo(0, layout.height + totalPaddingTop + totalPaddingBottom - height) + scrollY + } + + val trajet = 600 + surLeChamp { + val x = width / 2f + val depart = height / 4f + val instant = SystemClock.uptimeMillis() + fun evenement(action: Int, dt: Long, y: Float) = + MotionEvent.obtain(instant, instant + dt, action, x, y, 0).also { onTouchEvent(it) }.recycle() + evenement(MotionEvent.ACTION_DOWN, 0, depart) + for (pas in 1..6) evenement(MotionEvent.ACTION_MOVE, pas * 10L, depart + pas * trajet / 6f) + evenement(MotionEvent.ACTION_UP, 60, depart + trajet) + } + val apresLeDoigt = surLeChamp { scrollY } + SystemClock.sleep(DUREE_DE_L_ELAN_MS) + regle.waitForIdle() + val apres = surLeChamp { scrollY } + + // The drag itself, then far more: the text kept going. + assertThat(avant - apresLeDoigt).isAtLeast(trajet / 2) + assertThat(avant - apres).isGreaterThan(3 * trajet) + } + + // ------------------------------------------------------------------ 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 + + /** Long enough for a fling of a few thousand pixels per second to travel. */ + const val DUREE_DE_L_ELAN_MS = 1_500L + } +} 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 62c7b5eb..1c2ed16e 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 @@ -1,5 +1,6 @@ package com.filestech.notes_tech.ui.editor +import android.os.SystemClock import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.height @@ -18,18 +19,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 @@ -38,6 +40,8 @@ import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeUp import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -181,6 +185,7 @@ class EditeurTest { lectureCourante.value = true }, onLienDeLApercu = { liensDeLApercu += it }, + rechercheALOuverture = null, ) } } @@ -203,6 +208,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. */ @@ -330,9 +341,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() } /** @@ -687,8 +701,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 + } } /** @@ -801,6 +817,26 @@ class EditeurTest { champDuTitre().assertIsDisplayed() } + /** + * 🔴 **A long body scrolls under the finger, all the way** (3.2.0). The editor's own column took + * every vertical drag on the native field: the field received ACTION_CANCEL 40 to 90 px into each + * gesture (logged on the S24, 2026-10-30), the text stopped mid-gesture and never flung — + * "ça rame, ça bloque même". Through Compose's pointer input, as a finger: a test handing the + * events to the field itself could not see it. + */ + @Test + fun a_long_body_scrolls_under_the_finger_all_the_way() { + poser(noteChargee(corps = (0 until LIGNES_D_UNE_TRES_LONGUE_NOTE).joinToString("\n") { "Line $it" })) + val hauteur = champDuCorps().fetchSemanticsNode().size.height + + champDuCorps().performTouchInput { swipeUp(startY = bottom - 2f, endY = top + 2f, durationMillis = 200) } + SystemClock.sleep(DUREE_D_UN_ELAN_MS) + regle.waitForIdle() + + val defilement = regle.runOnUiThread { checkNotNull(champDuCorpsDe(regle.activity)).scrollY } + assertThat(defilement).isGreaterThan(hauteur / 2) + } + /** * 🔴 **In a short window, the body keeps room to type and the editor scrolls** (Gemini review, * 2026-09-25). With 2.0.9's layout alone, the header took all the height the keyboard left in @@ -817,8 +853,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() } @@ -844,7 +879,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) { @@ -920,6 +956,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. */ @@ -939,6 +977,9 @@ class EditeurTest { const val HORODATAGE = 1_700_000_000_000L const val PARAGRAPHES_D_UNE_LONGUE_NOTE = 3_000 const val LIGNES_D_UNE_TRES_LONGUE_NOTE = 5_000 + + /** Long enough for a fling to run its course. */ + const val DUREE_D_UN_ELAN_MS = 1_500L const val PARAGRAPHE_DU_MILIEU = "Paragraph 1500" const val ATTENTE_DE_L_APERCU_MS = 10_000L 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 c4e1d3c0..ea94960c 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/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 00000000..50da5d44 --- /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 00000000..6a104290 --- /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/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 00000000..12a30418 --- /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/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 46663b52..fda4bbb1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -72,6 +72,32 @@ + + + + + + + + + + + + + + Abbrechen OK + Keine App auf diesem Gerät kann eine Datei auswählen. Schließen Löschen Entfernen @@ -167,10 +168,20 @@ 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 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 @@ -288,6 +299,119 @@ 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, 100 MB gelesen oder 30 Millionen Zeichen an Notizen): 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. + + 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. + 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. + + + 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 @@ -527,4 +651,22 @@ Der Schlüssel wurde gefunden, konnte aber nicht verwendet werden. Versuchen Sie es erneut; wenn das Problem weiter besteht, melden Sie es – Ihre Notizen werden nicht verändert. Erneut versuchen + + Mit Notes Tech geteilt + Das Geteilte konnte nicht gespeichert werden. Es wurde nichts hinzugefügt. + + %1$d Notiz zum Eingang hinzugefügt. + %1$d Notizen zum Eingang hinzugefügt. + + + %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 Eingang hinzugefügt. + Hinzufügen + + %1$d Notiz wird zum Eingang hinzugefügt. + %1$d Notizen werden zum Eingang hinzugefügt. + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 1f3bfe5d..f4b510e0 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 @@ -79,7 +80,7 @@ Activar la caja fuerte Bloquear esta carpeta con una frase de contraseña o un PIN Bloquear ahora - Vuelve a bloquear la caja fuerte descifrada + Vuelva a bloquear la caja fuerte descifrada Nueva carpeta @@ -175,10 +176,20 @@ 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 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 @@ -298,6 +309,136 @@ 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, 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 + + %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. + + Copia de seguridad cifrada + Crear una copia de seguridad + 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á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 + 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 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: 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. 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. + 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. Á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. + 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 @@ -539,4 +680,25 @@ 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 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 e77ccdc4..9a3acfb6 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 @@ -180,10 +181,20 @@ É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 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 @@ -303,6 +314,136 @@ 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, 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 + + %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. + + 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. + 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. + 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 @@ -572,4 +713,25 @@ 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 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 184c4f36..a1ad5948 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 @@ -175,10 +176,20 @@ 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 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 @@ -298,6 +309,136 @@ 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, 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 + + %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 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 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: 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 + 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»: 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 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: aggiorni l\'app per ripristinarlo. + Troppo grande: un backup contiene al massimo 64 MB. + 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. + 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. 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. + 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 @@ -539,4 +680,25 @@ 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 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. + %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 cartella In arrivo. + Aggiungi + + %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 343d826a..16e399e2 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 @@ -200,6 +201,7 @@ partage plus. --> Nothing to copy: this note is empty + Couldn\'t copy: the clipboard refused this text, probably too long. 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 @@ -363,6 +374,119 @@ 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, 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 + + %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. + + 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. + 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. + + + 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 @@ -708,4 +832,22 @@ 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 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/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 79e9398c..b60848e3 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/data/sauvegarde/PlanDeRestaurationTest.kt b/app/src/test/java/com/filestech/notes_tech/data/sauvegarde/PlanDeRestaurationTest.kt new file mode 100644 index 00000000..f1c61a3c --- /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)) + } +} 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 00000000..0ee8133d --- /dev/null +++ b/app/src/test/java/com/filestech/notes_tech/domain/importation/LecteurMarkdownTest.kt @@ -0,0 +1,180 @@ +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) + } + + /** + * 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") + 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/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 00000000..c4a78fa8 --- /dev/null +++ b/app/src/test/java/com/filestech/notes_tech/domain/importation/LectureDUnImportTest.kt @@ -0,0 +1,236 @@ +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) + } + + /** + * 🔴 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( + "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 note brought in is at most [LongueurDUneNote.CARACTERES_MAX] characters (2026-10-30): read in + * full — within the bytes allowed — but one character longer, a file is counted, not kept. + */ + @Test + fun `a file one character longer than a note is counted, one at the bound kept`() { + val lu = lire( + "Long.md" to "a".repeat(LongueurDUneNote.CARACTERES_MAX + 1).toByteArray(), + "Juste.md" to "a".repeat(LongueurDUneNote.CARACTERES_MAX).toByteArray(), + ) + + assertThat(lu.notes.map { it.note.titre }).containsExactly("Juste") + 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/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 00000000..6f11faf4 --- /dev/null +++ b/app/src/test/java/com/filestech/notes_tech/domain/importation/LectureDUnPartageTest.kt @@ -0,0 +1,106 @@ +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, 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.lireUnTexte(" ", null)).isEqualTo(PartageRecu(emptyList(), 1)) + val tropLong = "a".repeat(LectureDUnPartage.TEXTE_MAX + 1) + assertThat(LectureDUnPartage.lireUnTexte(tropLong, null)).isEqualTo(PartageRecu(emptyList(), 1)) + val pile = "a".repeat(LectureDUnPartage.TEXTE_MAX) + assertThat(LectureDUnPartage.lireUnTexte(pile, null).notes.single().contenu).hasLength(pile.length) + } + + @Test + 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") + 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 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 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(30) + assertThat(ouverts).hasSize(LectureDUnPartage.FICHIERS_MAX) + } + + /** M2 of the 3.2.0 review: full files spend the budget; the ones after are not even opened. */ + @Test + fun `once the budget is spent, nothing more is read`() { + val pleins = LectureDUnPartage.BUDGET / LectureDUnPartage.FICHIER_MAX + val reste = LectureDUnPartage.BUDGET - pleins * LectureDUnPartage.FICHIER_MAX + val ouverts = mutableListOf() + val bornes = mutableListOf() + val recu = LectureDUnPartage.lireDesFichiers(pleins + 3, 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", texteDe(maxOctets)) + } + // Full files while they fit, then one of what is left, then nothing. + assertThat(reste).isGreaterThan(0) + assertThat(bornes).isEqualTo(List(pleins) { LectureDUnPartage.FICHIER_MAX } + reste) + assertThat(ouverts).isEqualTo((0..pleins).toList()) + assertThat(recu.notes).hasSize(pleins + 1) + assertThat(recu.ignores).isEqualTo(2) + } + + /** + * 🔴 A note brought in is at most [LongueurDUneNote.CARACTERES_MAX] characters (2026-10-30): a file + * within the bytes read but one character longer is counted, not kept. + */ + @Test + fun `a file one character longer than a note is counted, one at the bound kept`() { + val recu = lire( + FichierPartage("long.txt", "a".repeat(LongueurDUneNote.CARACTERES_MAX + 1).toByteArray()), + FichierPartage("juste.txt", "a".repeat(LongueurDUneNote.CARACTERES_MAX).toByteArray()), + ) + assertThat(recu.notes.map { it.titre }).containsExactly("juste") + assertThat(recu.ignores).isEqualTo(1) + } + + /** [octets] bytes of valid UTF-8 in characters of 3 bytes each: as many bytes, a third as many characters. */ + private fun texteDe(octets: Int) = ("€".repeat(octets / 3) + "a".repeat(octets % 3)).toByteArray() + + 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/domain/recherche/RechercheDansLeTexteTest.kt b/app/src/test/java/com/filestech/notes_tech/domain/recherche/RechercheDansLeTexteTest.kt new file mode 100644 index 00000000..0f4e92a3 --- /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/security/sauvegarde/FichierDeSauvegardeTest.kt b/app/src/test/java/com/filestech/notes_tech/security/sauvegarde/FichierDeSauvegardeTest.kt new file mode 100644 index 00000000..b95e64f3 --- /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 00000000..377cb667 --- /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/app/src/test/java/com/filestech/notes_tech/ui/editor/AplatissementDeLApercuTest.kt b/app/src/test/java/com/filestech/notes_tech/ui/editor/AplatissementDeLApercuTest.kt index 30ad4b9c..1820efe3 100644 --- a/app/src/test/java/com/filestech/notes_tech/ui/editor/AplatissementDeLApercuTest.kt +++ b/app/src/test/java/com/filestech/notes_tech/ui/editor/AplatissementDeLApercuTest.kt @@ -53,4 +53,57 @@ class AplatissementDeLApercuTest { fun `nothing to read is no item`() { assertThat(aplatir(emptyList())).isEmpty() } + + /** + * 🔴 A note shown as written was ONE item holding all of it: a 5 MiB note froze the app on + * opening (2026-10-29). Its pieces are items, numbered, and none exceeds the maximum. + */ + @Test + fun `a note shown as written is one item per piece, none longer than the maximum`() { + val ligne = "x".repeat(60) + val texte = (1..200).joinToString("\n") { "$it $ligne" } + + val elements = aplatir(listOf(PreviewBlock.AsWritten(texte))) + + assertThat(elements.size).isGreaterThan(1) + assertThat(elements.map { it.partie }).isEqualTo(elements.indices.toList()) + elements.forEach { element -> + val morceau = (element.bloc as PreviewBlock.AsWritten).text + assertThat(morceau.length).isAtMost(MORCEAU_TEL_QUEL_MAX) + } + // Cut at line breaks: the pieces, joined by the breaks they dropped, are the note. + assertThat(elements.joinToString("\n") { (it.bloc as PreviewBlock.AsWritten).text }).isEqualTo(texte) + } + + @Test + fun `a short note shown as written stays one item`() { + val bloc = PreviewBlock.AsWritten("[a](") + assertThat(aplatir(listOf(bloc))).containsExactly(ElementDApercu(bloc, 0)) + } + + /** One line longer than a piece — no break to cut at — is cut at the maximum, whole characters. */ + @Test + fun `a line longer than a piece is cut at the maximum, never inside a surrogate pair`() { + val ligne = "a".repeat(9) + "😀".repeat(10) + + val morceaux = morceauxTelQuel(ligne, max = 10) + + assertThat(morceaux.joinToString("")).isEqualTo(ligne) + morceaux.forEach { morceau -> + assertThat(morceau.length).isAtMost(10) + assertThat(Character.isLowSurrogate(morceau.first())).isFalse() + assertThat(Character.isHighSurrogate(morceau.last())).isFalse() + } + } + + /** A break in the first half of a piece would make pieces tiny: there, the cut is at the maximum. */ + @Test + fun `a break early in a piece is not a place to cut`() { + val texte = "ab\n" + "c".repeat(20) + + val morceaux = morceauxTelQuel(texte, max = 10) + + assertThat(morceaux.first()).hasLength(10) + assertThat(morceaux.joinToString("")).isEqualTo(texte) + } } 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 00000000..b6a5c772 --- /dev/null +++ b/app/src/test/java/com/filestech/notes_tech/ui/editor/RechercheDansLaNoteTest.kt @@ -0,0 +1,91 @@ +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() + } + + /** + * 🔴 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/editor/ReglesDuChampDuCorpsTest.kt b/app/src/test/java/com/filestech/notes_tech/ui/editor/ReglesDuChampDuCorpsTest.kt new file mode 100644 index 00000000..5f16a86b --- /dev/null +++ b/app/src/test/java/com/filestech/notes_tech/ui/editor/ReglesDuChampDuCorpsTest.kt @@ -0,0 +1,117 @@ +package com.filestech.notes_tech.ui.editor + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +/** + * The replacement the body field applies when the state changes under it. What these tests protect: + * that applying it gives exactly the new text — a wrong bound would corrupt the note without anything + * showing — and that it never splits a surrogate pair. + */ +class ReglesDuChampDuCorpsTest { + + /** [avant] with the difference applied, as the field's `Editable.replace` does. */ + private fun appliquer(avant: String, apres: String): String { + val d = DifferenceDeTexte.entre(avant, apres) ?: return avant + return avant.substring(0, d.debut) + apres.substring(d.debut, d.finApres) + avant.substring(d.finAvant) + } + + @Test + @DisplayName("the same text: nothing to apply") + fun memeTexte() { + assertThat(DifferenceDeTexte.entre("abc", "abc")).isNull() + assertThat(DifferenceDeTexte.entre("", "")).isNull() + } + + @Test + @DisplayName("an insertion in the middle replaces nothing around it") + fun insertion() { + val d = DifferenceDeTexte.entre("Voir la suite", "Voir [[Notes]] la suite") + + assertThat(d).isEqualTo(DifferenceDeTexte(debut = 5, finAvant = 5, finApres = 14)) + } + + @Test + @DisplayName("a deletion, at the start, in the middle, at the end") + fun suppressions() { + assertThat(DifferenceDeTexte.entre("xabc", "abc")).isEqualTo(DifferenceDeTexte(0, 1, 0)) + assertThat(DifferenceDeTexte.entre("abxc", "abc")).isEqualTo(DifferenceDeTexte(2, 3, 2)) + assertThat(DifferenceDeTexte.entre("abcx", "abc")).isEqualTo(DifferenceDeTexte(3, 4, 3)) + } + + /** Prefix and suffix may claim the same characters ("aa" → "aaa"): they must not overlap. */ + @Test + @DisplayName("repeated characters: the prefix and the suffix never overlap") + fun repetitions() { + for ((avant, apres) in listOf("aa" to "aaa", "aaa" to "aa", "abab" to "ab", "" to "a", "a" to "")) { + val d = DifferenceDeTexte.entre(avant, apres)!! + assertThat(d.debut).isAtMost(d.finAvant) + assertThat(d.debut).isAtMost(d.finApres) + assertThat(appliquer(avant, apres)).isEqualTo(apres) + } + } + + /** Two emoji sharing their high surrogate: the common prefix ends between the two halves. */ + @Test + @DisplayName("a surrogate pair is replaced whole, never split") + fun pairesDeSubstitution() { + val avant = "a😀b" + val apres = "a😁b" + + val d = DifferenceDeTexte.entre(avant, apres)!! + + assertThat(d).isEqualTo(DifferenceDeTexte(debut = 1, finAvant = 3, finApres = 3)) + assertThat(appliquer(avant, apres)).isEqualTo(apres) + } + + /** The suffix starting on a low surrogate whose high one differs: an emoji changed for another. */ + @Test + @DisplayName("a suffix starting inside a surrogate pair gives the pair back to the replacement") + fun suffixeDansUnePaire() { + // U+1F600 and U+1FA00: different high surrogates, the same low one. + val avant = "😀" + val apres = "🨀" + + val d = DifferenceDeTexte.entre(avant, apres)!! + + assertThat(d).isEqualTo(DifferenceDeTexte(debut = 0, finAvant = 2, finApres = 2)) + } + + @Test + @DisplayName("on texts of every shape, applying the difference gives the new text") + fun toujoursLeNouveauTexte() { + val textes = listOf("", "a", "ab", "ba", "aba", "Bonjour le monde", "Bonjour monde", "x😀y", "\n\n") + for (avant in textes) { + for (apres in textes) assertThat(appliquer(avant, apres)).isEqualTo(apres) + } + } + + @Test + @DisplayName("the toolbar keeps the editing entries, and nothing carrying another app's action") + fun menu() { + for (id in listOf(android.R.id.cut, android.R.id.copy, android.R.id.paste, android.R.id.selectAll)) { + assertThat(MenuDuChampDuCorps.garder(id, intention = false)).isTrue() + assertThat(MenuDuChampDuCorps.garder(id, intention = true)).isFalse() + } + assertThat(MenuDuChampDuCorps.garder(android.R.id.shareText, intention = false)).isFalse() + assertThat(MenuDuChampDuCorps.garder(android.R.id.autofill, intention = false)).isFalse() + assertThat(MenuDuChampDuCorps.garder(android.R.id.textAssist, intention = false)).isFalse() + // A phone maker's own entry, unknown here. + assertThat(MenuDuChampDuCorps.garder(0x7f0a0042, intention = false)).isFalse() + } + + @Test + @DisplayName("a long press inside a vault note's selection is held back — and nowhere else") + fun glisser() { + // Inside the selection [6, 25), whichever way it was made. + assertThat(glisserRetenu(protegee = true, selectionDebut = 6, selectionFin = 25, toucher = 14)).isTrue() + assertThat(glisserRetenu(protegee = true, selectionDebut = 25, selectionFin = 6, toucher = 6)).isTrue() + // An ordinary note, a press outside or at the end, no selection, an unknown touch. + assertThat(glisserRetenu(protegee = false, selectionDebut = 6, selectionFin = 25, toucher = 14)).isFalse() + assertThat(glisserRetenu(protegee = true, selectionDebut = 6, selectionFin = 25, toucher = 25)).isFalse() + assertThat(glisserRetenu(protegee = true, selectionDebut = 6, selectionFin = 25, toucher = 3)).isFalse() + assertThat(glisserRetenu(protegee = true, selectionDebut = 9, selectionFin = 9, toucher = 9)).isFalse() + assertThat(glisserRetenu(protegee = true, selectionDebut = 6, selectionFin = 25, toucher = -1)).isFalse() + } +} 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 00000000..75cdfa6b --- /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/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 00000000..83618bc9 --- /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) + } +} diff --git a/docs/13-VERSION-3.2.md b/docs/13-VERSION-3.2.md new file mode 100644 index 00000000..c743d17a --- /dev/null +++ b/docs/13-VERSION-3.2.md @@ -0,0 +1,319 @@ +# 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. +- **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é, 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 à + 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. +- **É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. 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). +- **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. **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 : + ``` + { "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) : 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 + +- 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, **500 000 caractères par note** (5 Mio par fichier jusqu'au 2026-10-30, voir + D-034), 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. + +**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 + +- 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). + +**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 + 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 (500 000 caractères + par note, texte ou fichier — D-034 ; 1 Mio de texte et 5 Mio par fichier jusqu'au 2026-10-30) 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. Notes longues + +### D-034 — Le corps s'écrit dans le champ d'Android, la lecture va par morceaux, 500 000 caractères à l'entrée + +Constaté le 2026-10-29 en vérifiant qu'une note de 5 Mio (la borne de l'import) tenait : la base la tient +(la `CursorWindow` de SQLCipher 4.16 part de 16 Kio et grandit), mais **l'appli la montrait figée** et +l'**éditeur était inutilisable bien avant**. Mesuré sur le S9 (Android 10), build release : + +| Note | Lecture | Écriture (champ Compose) : temps par image, médiane | +|---|---|---| +| 20 000 car. | immédiate | 9 ms | +| 50 000 | immédiate | 150 ms | +| 100 000 | immédiate | 950 ms | +| 200 000 | immédiate | 3,35 s — clavier après 9 s | +| > 500 000 | **figée** (un seul `Text`, tas plein) | — | + +Cause, lue dans le bytecode de Compose ui-text 1.12.1 : `TextAndroidCanvas.getClipBounds` répond une +hauteur infinie, donc **Compose dessine toutes les lignes d'un texte** à chaque nouvel enregistrement ; un +champ en écriture est redessiné à chaque frappe et à chaque clignotement du curseur. Le champ +`TextFieldState` de Compose ne fait pas mieux (1,2 s à 100 000, mesuré). + +**Décidé (Patrice, 2026-10-30 : « dans la 3.2.0 », « que tout soit fluide »)** : +- **Le corps en écriture est l'`EditText` d'Android** (`ui/editor/ChampDuCorps.kt`) : il ne remet en page + que les paragraphes touchés et ne dessine que les lignes visibles — 23 ms par image à 2 000 comme à + 200 000 caractères sur le S9, l'ancien champ faisant 30 ms à 2 000. L'état reste le `TextFieldValue` du + ViewModel (liens, dictée, recherche, enregistrement inchangés) ; le champ lui renvoie texte et sélection + et applique ses changements par le plus petit remplacement. Ce qu'un `EditText` ouvre et que le champ + Compose n'ouvrait pas est fermé : barre de sélection restreinte (ni Partager, ni Saisie automatique, ni + les actions `PROCESS_TEXT` d'autres applis), classifieur `NO_OP`, ni saisie automatique ni capture de + contenu, pas de correcteur orthographique, rien dans l'état sauvegardé, pas de clavier plein écran, + pas de glisser hors d'une note de coffre, dépôt et collage en texte brut ; Copier et Couper passent par + le presse-papiers de `SaisieDeCoffre` et une coupe n'efface qu'une fois copiée. +- **Le clavier ne lit qu'une fenêtre de 5 000 caractères autour du curseur** : le clavier Samsung redemande + le texte extrait après CHAQUE frappe, et un `TextView` le donne entier — 900 Ko à 450 000 caractères, + refusé par le Binder, 2 s d'attente à chaque frappe (mesuré sur le S9). Après correction : 450 000 + caractères se tapent comme une note courte. +- **Le clavier part avec le champ** (la coche, la flèche, un lien ouvert) : un `EditText` retiré de la + fenêtre le laissait ouvert sur la liste (mesuré). +- **La lecture d'une note non analysée (> 500 000 caractères) va par morceaux** de 2 000 caractères, un + élément paresseux chacun (`morceauxTelQuel`), au lieu d'un seul texte. +- **Import, fichier partagé, texte partagé : 500 000 caractères au plus** (`LongueurDUneNote`), la borne de + l'aperçu — toute note importée est montrée dessinée. Patrice avait d'abord choisi 50 000 sur les mesures + de l'ancien éditeur, puis proposé de monter « si c'est fluide » : mesuré fluide à 450 000 sur le S9. Une + note tapée ou collée dans l'appli n'a pas de borne. + +**Défilement en écriture (2026-10-30, essais de Patrice sur S24 et S9 : « ça rame, ça bloque même »)** : +deux défauts, chaque image pourtant dessinée en moins de 12 ms. +- Un `EditText` défile de la seule distance du doigt, sans élan : ajouté (`VelocityTracker` + `Scroller`), arrêté + par un toucher ou une frappe. +- **La colonne de l'éditeur volait le geste** : journalisé sur le S24, le champ recevait ACTION_CANCEL 40 à + 90 px après chaque appui — le texte s'arrêtait net, l'élan ne partait jamais. Le champ garde désormais le geste + dès que son texte peut défiler (`requestDisallowInterceptTouchEvent`). Mesuré, 8 gestes sur `note-450k` : S24 + 24 → 862 images, 5 ms (médiane), 10 ms au pire, clavier ouvert ou non ; S9 540 images, 21 ms au pire — l'à-coup + d'environ 1 s vu sur le S9 clavier ouvert, d'abord pris pour une limite du clavier Samsung, venait de ce geste + coupé et a disparu. Un test instrumenté qui donnait les événements au champ lui-même ne pouvait pas le voir : + `EditeurTest.a_long_body_scrolls_under_the_finger_all_the_way` passe par l'entrée de Compose (vu rouge sans le + correctif : 135 px au lieu de plus de 904). + +## 6. 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. 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 00000000..e2940c83 --- /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 00000000..e2940c83 --- /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 00000000..e2940c83 --- /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 f70f079b..170b9681 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 00000000..e66f77a0 --- /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 00000000..e66f77a0 --- /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 00000000..e66f77a0 --- /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 5948a2f1..94097e95 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 00000000..25689b4c --- /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 00000000..25689b4c --- /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 00000000..25689b4c --- /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 bf5d420c..f0cdbcd7 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 00000000..00c30df0 --- /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 00000000..00c30df0 --- /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 00000000..00c30df0 --- /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 69ae9684..b2688543 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 00000000..a94adbcc --- /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 00000000..a94adbcc --- /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 00000000..a94adbcc --- /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 a5d21f1c..02c01017 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 7c7105a4..00e5467b 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