From f29157abeac7cc38d063bca3e64f1b988ac25c42 Mon Sep 17 00:00:00 2001 From: Patrice Haltaya Date: Mon, 9 Nov 2026 19:43:00 +0100 Subject: [PATCH 01/11] =?UTF-8?q?docs:=203.4.0=20specification=20=E2=80=94?= =?UTF-8?q?=20blind=20drop,=20notes=20with=20a=20lifetime,=20sealed=20note?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decisions D-040 to D-044, written before the code, with the facts read in the code that shape them. REPRISE points to it. Co-Authored-By: Claude Opus 5.5 --- REPRISE.md | 10 +++- docs/15-VERSION-3.4.md | 106 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 docs/15-VERSION-3.4.md diff --git a/REPRISE.md b/REPRISE.md index e50c98e..f90163e 100644 --- a/REPRISE.md +++ b/REPRISE.md @@ -3,7 +3,15 @@ > Écrit le 2026-08-15 au soir, mis à jour le 2026-08-19, le 2026-09-24, le 2026-09-25, le 2026-10-05, puis le 2026-10-10, puis le 2026-10-30 (deux fois), le 2026-10-31, **puis le 2026-11-09**. À lire en premier, avant `docs/00-PLAN.md`. > Ce fichier ne remplace pas les docs : il dit **où on en est** et **quoi faire ensuite**. -## 🧭 À LA REPRISE (écrit le 2026-11-09) — la 3.3.0 est PUBLIÉE +## 🧭 À LA REPRISE (écrit le 2026-11-09 au soir) — la 3.4.0 est EN COURS (branche `feat/3.4.0`) + +Patrice : « fais 2, 3, 4 ce serait génial pour la 3.4.0 + raccourcis et clavier sans apprentissage », +« en intégrant les upgrade AGP aussi » (déjà dans `main`, PR #51). **Spécification, décisions D-040 à +D-044 et état coché au fur et à mesure : `docs/15-VERSION-3.4.md`** — la lire en premier. +La release n'est PAS demandée : la proposer à la fin, comme pour la 3.3.0. Avant le tag, regarder +comment F-Droid a construit SMS 1.30.0 (première build sous AGP 9.5). + +## Précédemment (écrit le 2026-11-09) — la 3.3.0 est PUBLIÉE Issue #48 (koad1x) : trois suggestions. Patrice : « fais les 3 maintenant pour la version 3.3.0, proprement, avec vigilance », carte blanche (coder, tester, commiter, fusionner), « tous les audits diff --git a/docs/15-VERSION-3.4.md b/docs/15-VERSION-3.4.md new file mode 100644 index 0000000..b6bc5f8 --- /dev/null +++ b/docs/15-VERSION-3.4.md @@ -0,0 +1,106 @@ +# Notes Tech 3.4.0 — dépôt à l'aveugle, notes à durée de vie, notes scellées à partager + +> Écrit le 2026-11-09 au soir. Origine : Patrice a demandé « des supers idées […] vraiment uniques », +> puis : « fais 2, 3, 4 ce serait génial pour la 3.4.0 + raccourcis et clavier sans apprentissage », +> « en intégrant les upgrade AGP aussi » (déjà dans `main` : PR #51). Ses quatre réponses du même soir +> sont les options recommandées ci-dessous. +> +> **Ce document est la spécification.** Il est écrit AVANT le code pour survivre à une compaction de la +> conversation ; chaque section dit ce qui est fait (✅) au fur et à mesure. + +## Faits relevés avant de concevoir (exploration du 2026-11-09) + +- **Le verrou de l'appli est une barrière d'interface**, pas un chiffrement : la base s'ouvre au + démarrage sans secret de l'utilisateur (`AppLockKeystore.kt:8-9`, `KeystoreSealedKekSource` sans + `setUserAuthenticationRequired`). `NotesRepository.create` ne consulte aucun verrou. Écrire une note + appli verrouillée est donc possible sans rien changer à la clé. +- **`MainActivity` ignore tout ce que porte un intent** (`sansNavigationImposee`, `MainActivity.kt:76-79`) : + elle est exportée, aucune autre appli ne doit pouvoir y imposer une navigation. Le partage reçu passe + par une activité à part qui dépose en mémoire (`PartagesEnAttente`, 2 min) puis lance l'appli ; rien + n'est écrit tant que l'appli est verrouillée. +- **La sauvegarde chiffrée** (`FichierDeSauvegarde`) : en-tête de 48 octets (magie, version, Argon2id + t=3 m=64 Mio p=1, sel 16, nonce 12) servant d'AAD, AES-256-GCM en flux, code généré de 24 signes + Crockford (120 bits, `SecretDeSauvegarde`). Constantes privées câblées : à paramétrer pour un autre + conteneur. +- **Réglages** dans `FlutterSharedPreferences` (`LegacyPreferences`) ; la panique les efface tous sauf + trois clés (`PanicService.kt:691`) — un nouveau réglage y revient à sa valeur par défaut. +- **`IME_FLAG_NO_PERSONALIZED_LEARNING`** n'est posé que pour les notes de coffre (`SaisieDeCoffre`, + `ChampDuCorps:486`), couplé au presse-papiers sensible et au glisser. +- **Room** : version 10, une seule migration additive (`MIGRATION_9_10`, `color_id`), schémas exportés, + rejouée par `audits/verifier-schema-room-vs-flutter.py`. Le trigger `notes_au` réécrit la ligne FTS à + tout UPDATE. +- **Aucun** raccourci, tuile, widget, ni destination « nouvelle note » ; la dictée ne démarre que d'un + toucher sur le micro de l'éditeur. + +## D-040 — Clavier sans apprentissage, pour toute l'appli, activé par défaut + +- Réglage booléen `keyboard_no_learning` (défaut **vrai**, Patrice) : tous les champs de Notes Tech + demandent au clavier `IME_FLAG_NO_PERSONALIZED_LEARNING`. La panique le remet à vrai (défaut). +- **Les champs de secret l'ont toujours**, quel que soit le réglage (phrases de coffre et de sauvegarde, + code d'une note scellée). +- Découplé de « protégée » : le presse-papiers sensible et le glisser restent réservés aux notes de coffre. +- Honnêteté : c'est une DEMANDE au clavier ; Gboard et SwiftKey la respectent, un clavier peut l'ignorer. + Les suggestions restent ; seul l'apprentissage est coupé. + +## D-041 — Raccourcis sur l'icône : Nouvelle note, Dictée, Dépôt rapide + +- Raccourcis statiques (`res/xml/shortcuts.xml`), aucune permission. +- « Nouvelle note » et « Dictée » visent une activité **non exportée** de transit qui dépose la demande en + mémoire puis lance l'appli : `MainActivity` continue d'ignorer les intents, aucune autre appli ne peut + imposer une création ou un micro. La demande s'exécute **après** le déverrouillage de l'appli. +- « Dictée » crée une note et ouvre le micro comme un toucher sur le bouton : sans modèle → écran + d'installation ; sans permission → demande de permission. +- « Dépôt rapide » ouvre le dépôt à l'aveugle (D-042). + +## D-042 — Dépôt à l'aveugle : écrire sans déverrouiller, sans rien pouvoir relire + +- Une activité à part, en écriture seule : un champ de titre, un champ de texte, « Déposer », « Annuler ». + Elle ne lit **rien** de la base, n'affiche aucune note, aucun dossier, aucun nombre ; après « Déposer », + le texte quitte l'écran. Aucun lien vers le reste de l'appli autre que « Ouvrir Notes Tech » (qui + demande le déverrouillage). +- Ouverte par la **tuile des réglages rapides** et le **raccourci** « Dépôt rapide ». Si le TÉLÉPHONE est + verrouillé, Android demande d'abord son déverrouillage (`unlockAndRun`) — seul le verrou de Notes Tech + est contourné (Patrice). +- La note va dans la **Boîte de réception**, jamais dans un coffre ; fenêtre protégée (pas de capture, + pas d'aperçu dans les applis récentes) ; clavier sans apprentissage selon D-040. +- Pas de brouillon gardé hors de l'écran : quitter sans déposer ne laisse rien (confirmation si du texte + a été tapé). + +## D-043 — Note à durée de vie + +- Durées proposées : **1 h, 24 h, 7 jours, 30 jours, après lecture** (Patrice). Menu ⋮ de l'éditeur, + « Durée de vie… » ; « Aucune » la retire. +- « Après lecture » : la note est effacée quand on la **referme après l'avoir rouverte** (la session où + l'option est posée ne compte pas). +- L'effacement est un effacement définitif (même chemin que « Supprimer définitivement » : index plein + texte et liens compris, `secure_delete`). Il a lieu **à l'ouverture de l'appli et au retour sur + l'accueil**, pas à la seconde près (pas d'alarme, pas de permission) — dit tel quel dans l'interface. +- **Hors sauvegardes et exports** (Patrice) : une restauration ne doit pas la faire revivre. +- Colonnes `expires_at INTEGER` (nullable) et `read_once INTEGER NOT NULL DEFAULT 0` : migration 10 → 11, + additive. +- La carte montre la durée restante ; une note de coffre fermé garde sa durée de vie (la date n'est pas + un secret : elle n'est pas chiffrée, comme `updated_at`). + +## D-044 — Note scellée à partager (`.ntnote`) + +- Menu ⋮ de l'éditeur, « Partager scellée… » : la note (titre, texte, couleur, étiquettes) est chiffrée + dans un fichier `.ntnote` par un **code généré de 24 signes** (même code que la sauvegarde), affiché + une fois ; on envoie le fichier par n'importe quelle messagerie et le code par un autre canal. +- Même chiffrement que la sauvegarde (Argon2id, AES-256-GCM, en-tête en AAD), avec sa propre magie + (`NTSEALED`) et une taille bornée (1 Mio). +- **Le nom du fichier ne dit rien de la note** (`note-scellee-AAAAMMJJ.ntnote`) : un titre dans un nom de + fichier fuirait par la messagerie. +- Option « Lecture unique chez le destinataire » : la note reçue y arrive avec la durée de vie « après + lecture » (D-043). Honnête : le destinataire peut garder le fichier. +- Réception (Patrice) : « Ouvrir avec » (`ACTION_VIEW` sur `application/octet-stream`, activité à part, + comme le partage reçu) et menu « Ouvrir une note scellée » de l'accueil. Un fichier qui n'est pas une + note scellée est refusé poliment. Le code se tape **après** le déverrouillage ; la note s'affiche, puis + « Ajouter » la met dans la Boîte de réception, jamais dans un coffre. +- Une note de coffre se partage scellée seulement coffre ouvert, comme l'export. + +## État + +- [ ] D-040 clavier · [ ] D-041 raccourcis · [ ] D-042 dépôt · [ ] D-043 durée de vie · [ ] D-044 scellée +- [ ] Textes 5 langues · [ ] version 3.4.0 (507) · [ ] journaux 5071-5073 · [ ] README, SECURITY, CGU/confidentialité +- [ ] Audits (3 axes, sécurité des nouvelles entrées, i18n, cohérence) · [ ] relecture externe · [ ] S9 · [ ] CI +- [ ] Avant le tag : build F-Droid sous AGP 9.5 (voir d'abord comment F-Droid a construit SMS 1.30.0) From 5e18eab5475b32b65ab1fb1152da96408450d813 Mon Sep 17 00:00:00 2001 From: Patrice Haltaya Date: Mon, 9 Nov 2026 20:04:39 +0100 Subject: [PATCH 02/11] feat: keyboard without learning, launcher shortcuts, blind drop (3.4.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D-040 — Keyboard without learning, on by default (Patrice). Every field asks IME_FLAG_NO_PERSONALIZED_LEARNING while the setting is on; a secret's field always does. The interceptor of SaisieDeCoffre is shared (ClavierSansApprentissage), the note's native body reads a CompositionLocal. Setting in Security, 5 languages. D-041 — Launcher shortcuts "New note" and "Dictation", published by the code (the app has several application ids), in the app's language. They land on a NON-exported activity that deposits the request in memory and opens the app, which carries it out once unlocked: MainActivity keeps ignoring intents. The in-memory deposit of the share receiver becomes a generic DepotEnMemoire. Dictation opens the microphone as a tap on its button would. Checked on the S9: the Samsung launcher starts the non-exported activity; New note opens a blank note to be written; Dictation, without a model, the install screen. D-042 — Blind drop: write a note without unlocking the app, read nothing. DepotActivity (not exported, protected window, text actions of other apps and classifier cut since it can be the first activity of its process, text never in the saved state), its quick settings tile (unlockAndRun when the phone is locked) and a third shortcut. The note goes to the Inbox, never a vault; nothing is written while a panic is pending. The language of an activity and the dark/light choice are shared helpers now (avecLaLangueChoisie, estSombre) instead of living in MainActivity alone. Tests: ClavierSansApprentissageTest (3), ChampDuCorpsTest +1, ReglagesHeritesTest +1 (instrumented, S9); RaccourcisTest (4), DepotViewModelTest (5) on the JVM. Co-Authored-By: Claude Opus 5.5 --- .../data/prefs/ReglagesHeritesTest.kt | 17 +++ .../notes_tech/ui/editor/ChampDuCorpsTest.kt | 19 +++ .../ui/secure/ClavierSansApprentissageTest.kt | 96 +++++++++++++ app/src/main/AndroidManifest.xml | 42 ++++++ .../notes_tech/data/prefs/AppSettings.kt | 16 +++ .../filestech/notes_tech/ui/MainActivity.kt | 44 +++--- .../notes_tech/ui/NotesTechNavHost.kt | 4 + .../notes_tech/ui/common/DepotEnMemoire.kt | 60 +++++++++ .../notes_tech/ui/common/LangueChoisie.kt | 25 ++++ .../notes_tech/ui/depot/DepotActivity.kt | 84 ++++++++++++ .../notes_tech/ui/depot/DepotViewModel.kt | 79 +++++++++++ .../notes_tech/ui/depot/EcranDeDepot.kt | 126 ++++++++++++++++++ .../notes_tech/ui/depot/TuileDeDepot.kt | 53 ++++++++ .../notes_tech/ui/editor/ChampDuCorps.kt | 21 ++- .../notes_tech/ui/editor/DicteeALOuverture.kt | 27 ++++ .../notes_tech/ui/editor/NoteEditorScreen.kt | 6 +- .../ui/editor/NoteEditorViewModel.kt | 3 + .../notes_tech/ui/navigation/Destination.kt | 15 ++- .../ui/raccourcis/RaccourciActivity.kt | 43 ++++++ .../ui/raccourcis/RaccourcisDeLIcone.kt | 78 +++++++++++ .../ui/raccourcis/RaccourcisEnAttente.kt | 29 ++++ .../ui/raccourcis/RaccourcisRecus.kt | 68 ++++++++++ .../ui/secure/ClavierSansApprentissage.kt | 54 ++++++++ .../notes_tech/ui/secure/SaisieDeCoffre.kt | 16 +-- .../notes_tech/ui/settings/SettingsScreen.kt | 17 +++ .../ui/settings/SettingsViewModel.kt | 7 + .../notes_tech/ui/share/PartagesEnAttente.kt | 50 ++----- .../ui/share/RecevoirUnPartageActivity.kt | 8 +- .../filestech/notes_tech/ui/theme/Theme.kt | 12 ++ .../notes_tech/ui/vault/VaultSheets.kt | 41 +++--- .../main/res/drawable/ic_raccourci_depot.xml | 20 +++ .../main/res/drawable/ic_raccourci_dictee.xml | 20 +++ .../main/res/drawable/ic_raccourci_note.xml | 20 +++ app/src/main/res/drawable/ic_tuile_depot.xml | 11 ++ app/src/main/res/values-de/strings.xml | 15 +++ app/src/main/res/values-es/strings.xml | 15 +++ app/src/main/res/values-fr/strings.xml | 15 +++ app/src/main/res/values-it/strings.xml | 15 +++ app/src/main/res/values/strings.xml | 15 +++ .../notes_tech/ui/depot/DepotViewModelTest.kt | 99 ++++++++++++++ .../ui/raccourcis/RaccourcisTest.kt | 48 +++++++ 41 files changed, 1344 insertions(+), 109 deletions(-) create mode 100644 app/src/androidTest/java/com/filestech/notes_tech/ui/secure/ClavierSansApprentissageTest.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/ui/common/DepotEnMemoire.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/ui/common/LangueChoisie.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/ui/depot/DepotActivity.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/ui/depot/DepotViewModel.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/ui/depot/EcranDeDepot.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/ui/depot/TuileDeDepot.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/ui/editor/DicteeALOuverture.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/ui/raccourcis/RaccourciActivity.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/ui/raccourcis/RaccourcisDeLIcone.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/ui/raccourcis/RaccourcisEnAttente.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/ui/raccourcis/RaccourcisRecus.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/ui/secure/ClavierSansApprentissage.kt create mode 100644 app/src/main/res/drawable/ic_raccourci_depot.xml create mode 100644 app/src/main/res/drawable/ic_raccourci_dictee.xml create mode 100644 app/src/main/res/drawable/ic_raccourci_note.xml create mode 100644 app/src/main/res/drawable/ic_tuile_depot.xml create mode 100644 app/src/test/java/com/filestech/notes_tech/ui/depot/DepotViewModelTest.kt create mode 100644 app/src/test/java/com/filestech/notes_tech/ui/raccourcis/RaccourcisTest.kt diff --git a/app/src/androidTest/java/com/filestech/notes_tech/data/prefs/ReglagesHeritesTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/data/prefs/ReglagesHeritesTest.kt index b6889db..9a8cf4f 100644 --- a/app/src/androidTest/java/com/filestech/notes_tech/data/prefs/ReglagesHeritesTest.kt +++ b/app/src/androidTest/java/com/filestech/notes_tech/data/prefs/ReglagesHeritesTest.kt @@ -228,6 +228,23 @@ class ReglagesHeritesTest { assertThat(fichier.contains("flutter.theme_mode_leurre")).isTrue() } + /** + * D-040 (3.4.0) : le clavier sans apprentissage est ACTIVÉ tant que rien n'est écrit — un fichier + * vidé (première ouverture, panique) le laisse actif — et s'écrit sous le préfixe du greffon. + */ + @Test + fun le_clavier_sans_apprentissage_est_actif_par_defaut_et_s_ecrit_dans_le_fichier_herite() { + assertThat(reglages.keyboardNoLearningNow()).isTrue() + + reglages.setKeyboardNoLearning(false) + + assertThat(fichier.getBoolean("flutter.keyboard_no_learning", true)).isFalse() + assertThat(reglages.keyboardNoLearningNow()).isFalse() + // Le contrôle : la clé nue, sans préfixe, n'est pas lue. + fichier.edit().remove("flutter.keyboard_no_learning").putBoolean("keyboard_no_learning", false).commit() + assertThat(reglages.keyboardNoLearningNow()).isTrue() + } + private companion object { /** Transcrit du greffon `shared_preferences`, pas lu depuis `LegacyPreferences`. */ const val FICHIER_DU_GREFFON = "FlutterSharedPreferences" 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 index d911bc5..07aef69 100644 --- 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 @@ -30,6 +30,7 @@ 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.LocalClavierSansApprentissage import com.filestech.notes_tech.ui.secure.LocalCopieDeSaisie import com.filestech.notes_tech.ui.secure.LocalSaisieProtegee import com.google.common.truth.Truth.assertThat @@ -53,6 +54,7 @@ class ChampDuCorpsTest { private var etat by mutableStateOf(TextFieldValue()) private var protegee by mutableStateOf(false) + private var sansApprentissage by mutableStateOf(false) private var surlignage by mutableStateOf(Surlignage.AUCUN) private val recues = mutableListOf() private val copiees = mutableListOf() @@ -70,6 +72,7 @@ class ChampDuCorpsTest { MaterialTheme { CompositionLocalProvider( LocalSaisieProtegee provides protegee, + LocalClavierSansApprentissage provides sansApprentissage, LocalCopieDeSaisie provides { texte -> copiees += texte attente?.await() @@ -268,6 +271,22 @@ class ChampDuCorpsTest { assertThat(options() and drapeau).isEqualTo(drapeau) } + /** D-040 (3.4.0): the setting asks it for every note, a note that is not in a vault included. */ + @Test + fun the_setting_asks_the_keyboard_not_to_learn_for_an_ordinary_note() { + poser("ordinaire") + val drapeau = EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING + fun options() = surLeChamp { EditorInfo().also { onCreateInputConnection(it) }.imeOptions } + + assertThat(options() and drapeau).isEqualTo(0) + + regle.runOnIdle { sansApprentissage = true } + regle.waitForIdle() + + assertThat(options() and drapeau).isEqualTo(drapeau) + assertThat(surLeChamp { protegee }).isFalse() + } + /** * 🔴 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 diff --git a/app/src/androidTest/java/com/filestech/notes_tech/ui/secure/ClavierSansApprentissageTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/ui/secure/ClavierSansApprentissageTest.kt new file mode 100644 index 0000000..f4e40f5 --- /dev/null +++ b/app/src/androidTest/java/com/filestech/notes_tech/ui/secure/ClavierSansApprentissageTest.kt @@ -0,0 +1,96 @@ +package com.filestech.notes_tech.ui.secure + +import android.view.inputmethod.EditorInfo +import androidx.activity.ComponentActivity +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.InterceptPlatformTextInput +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.performClick +import androidx.compose.ui.text.input.ImeAction +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.ui.vault.ChampDePhraseSecrete +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.awaitCancellation +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * D-040 (3.4.0) — **the keyboard is asked not to learn**, read where the keyboard reads it: an outer + * interceptor receives the request that opens the keyboard and fills an `EditorInfo` from it, as in + * [SaisieDeCoffreTest]. Each case has its control. + */ +@RunWith(AndroidJUnit4::class) +class ClavierSansApprentissageTest { + + @get:Rule + val regle = createAndroidComposeRule() + + private val drapeau = EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING + + @Test + fun the_setting_on_asks_every_field_not_to_learn() { + assertThat(optionsDuClavier(actif = true) { ChampOrdinaire() } and drapeau).isEqualTo(drapeau) + } + + @Test + fun the_control_the_setting_off_asks_nothing() { + assertThat(optionsDuClavier(actif = false) { ChampOrdinaire() } and drapeau).isEqualTo(0) + } + + /** A secret's field asks it whatever the setting says. */ + @Test + fun a_secret_field_asks_it_even_with_the_setting_off() { + val options = optionsDuClavier(actif = false) { + var phrase by remember { mutableStateOf("") } + ChampDePhraseSecrete( + valeur = phrase, + onValeurChange = { phrase = it }, + label = "phrase", + actionClavier = ImeAction.Done, + actif = true, + ) + } + + assertThat(options and drapeau).isEqualTo(drapeau) + } + + @Composable + private fun ChampOrdinaire() { + var valeur by remember { mutableStateOf(TextFieldValue("")) } + TextField(value = valeur, onValueChange = { valeur = it }) + } + + @OptIn(ExperimentalComposeUiApi::class) + private fun optionsDuClavier(actif: Boolean, champ: @Composable () -> Unit): Int { + var options: Int? = null + regle.setContent { + InterceptPlatformTextInput( + interceptor = { requete, _ -> + val attributs = EditorInfo() + requete.createInputConnection(attributs) + options = attributs.imeOptions + awaitCancellation() + }, + ) { + ClavierSansApprentissage(actif = actif, contenu = champ) + } + } + regle.onNode(hasSetTextAction()).performClick() + regle.waitUntil(ATTENTE_MS) { options != null } + return options!! + } + + private companion object { + const val ATTENTE_MS = 5_000L + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index fda4bbb..05dcc07 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -79,6 +79,48 @@ `noHistory` + `excludeFromRecents` + `taskAffinity=""`: it never shows in the recents nor stays in the sending app's task. --> + + + + + + + + + + + + + = observing { keyboardNoLearningNow() } + + fun setKeyboardNoLearning(value: Boolean) = prefs.putBoolean(KEY_KEYBOARD_NO_LEARNING, value) + // ── Auto-verrouillage des coffres ──────────────────────────────────────────────────────────── /** En minutes. `0` = jamais, jusqu'à un verrouillage manuel ou le passage en arrière-plan. */ @@ -195,5 +208,8 @@ class AppSettings @Inject constructor(private val prefs: LegacyPreferences) { /** `services/first_launch_flag.dart:16`. */ const val KEY_SPLASH_SHOWN = "splash_shown_v1" + + /** Né en 3.4.0 (D-040) : aucune version Flutter ne l'a écrit. */ + const val KEY_KEYBOARD_NO_LEARNING = "keyboard_no_learning" } } diff --git a/app/src/main/java/com/filestech/notes_tech/ui/MainActivity.kt b/app/src/main/java/com/filestech/notes_tech/ui/MainActivity.kt index ea01508..95d29fc 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/MainActivity.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/MainActivity.kt @@ -2,13 +2,11 @@ package com.filestech.notes_tech.ui import android.content.Context import android.content.Intent -import android.content.res.Configuration import android.os.Bundle import android.view.WindowManager import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -32,8 +30,6 @@ import androidx.fragment.app.FragmentActivity import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.filestech.notes_tech.data.prefs.AppSettings -import com.filestech.notes_tech.data.prefs.LegacyPreferences -import com.filestech.notes_tech.data.prefs.ThemePreference import com.filestech.notes_tech.security.applock.AppLockLifecycle import com.filestech.notes_tech.security.applock.AppLockManager import com.filestech.notes_tech.security.applock.AppLockState @@ -41,9 +37,13 @@ import com.filestech.notes_tech.ui.applock.AppLockRoute import com.filestech.notes_tech.ui.applock.RecentsGuard import com.filestech.notes_tech.ui.common.ExternalActivityGuard import com.filestech.notes_tech.ui.common.LocalExternalActivityGuard +import com.filestech.notes_tech.ui.common.avecLaLangueChoisie import com.filestech.notes_tech.ui.navigation.Destination import com.filestech.notes_tech.ui.panic.RecouvrementDePanique import com.filestech.notes_tech.ui.panic.activityPanicViewModel +import com.filestech.notes_tech.ui.raccourcis.RaccourcisRecus +import com.filestech.notes_tech.ui.raccourcis.publierLesRaccourcis +import com.filestech.notes_tech.ui.secure.ClavierSansApprentissage import com.filestech.notes_tech.ui.secure.LocalSecureWindow import com.filestech.notes_tech.ui.secure.SecureWindowController import com.filestech.notes_tech.ui.secure.sansActionsDeTexteDesAutresApps @@ -54,8 +54,8 @@ import com.filestech.notes_tech.ui.startup.StartupFailureScreen import com.filestech.notes_tech.ui.startup.StartupState import com.filestech.notes_tech.ui.startup.StartupViewModel import com.filestech.notes_tech.ui.theme.NotesTechTheme +import com.filestech.notes_tech.ui.theme.estSombre import dagger.hilt.android.AndroidEntryPoint -import java.util.Locale import javax.inject.Inject /** @@ -103,6 +103,8 @@ class MainActivity : FragmentActivity() { super.onCreate(savedInstanceState) // 🔴 Before `setContent`, where Navigation reads it (security audit of 2026-09-26, E1). intent = sansNavigationImposee(intent) + // 3.4.0 (D-041): with this activity's context, so in the language chosen in the app. + publierLesRaccourcis() // Before anything is composed, and synchronously: the first frame must already be the lock // screen when a lock is configured. Once per process — a recreated activity keeps the state. appLock.resolveAtLaunch() @@ -173,17 +175,7 @@ class MainActivity : FragmentActivity() { * question. */ override fun attachBaseContext(newBase: Context) { - val prefs = AppSettings(LegacyPreferences(newBase)) - // `SYSTEM` (no code) forces nothing: the context stays as Android built it, following the - // device's setting, even when it changes while the app runs. - val langue = prefs.localeNow().code?.let(Locale::forLanguageTag) - super.attachBaseContext(if (langue == null) newBase else newBase.avecLangue(langue)) - } - - private fun Context.avecLangue(locale: Locale): Context { - val configuration = Configuration(resources.configuration) - configuration.setLocale(locale) - return createConfigurationContext(configuration) + super.attachBaseContext(newBase.avecLaLangueChoisie()) } } @@ -205,14 +197,16 @@ private fun NotesTechApp(settings: AppSettings, secureWindow: SecureWindowContro val verrouConfigure by appLock.configured.collectAsState(initial = appLock.isConfigured()) RecentsGuard(lockConfigured = verrouConfigure) - NotesTechTheme( - darkTheme = when (theme) { - ThemePreference.LIGHT -> false - ThemePreference.DARK -> true - ThemePreference.SYSTEM -> isSystemInDarkTheme() - }, - ) { - ContenuPrincipal(settings, appLock, verrouConfigure) + // D-040: read, not assumed — a first frame without the flag would let the keyboard learn the first + // letters typed in a field focused at once (an editor opened to be written, 3.3.0). + val sansApprentissage by settings.keyboardNoLearning.collectAsStateWithLifecycle( + initialValue = settings.keyboardNoLearningNow(), + ) + + NotesTechTheme(darkTheme = estSombre(theme)) { + ClavierSansApprentissage(actif = sansApprentissage) { + ContenuPrincipal(settings, appLock, verrouConfigure) + } } } @@ -277,6 +271,8 @@ private fun ContenuPrincipal(settings: AppSettings, appLock: AppLockManager, ver // the panic mode is destroying the database (security review of 3.2.0, L3). if (!paniqueDeclenchee) { PartagesRecus(onOuvrirLaNote = { navController.navigate(Destination.Editor(it).route) }) + // 3.4.0 (D-041) — a launcher shortcut, carried out here for the same reasons. + RaccourcisRecus(onOuvrir = { navController.navigate(it.route) }) } } } diff --git a/app/src/main/java/com/filestech/notes_tech/ui/NotesTechNavHost.kt b/app/src/main/java/com/filestech/notes_tech/ui/NotesTechNavHost.kt index 789b693..5896ef4 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/NotesTechNavHost.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/NotesTechNavHost.kt @@ -57,6 +57,10 @@ fun NotesTechNavHost(navController: NavHostController) { nullable = true defaultValue = null }, + navArgument(Destination.ARG_DICTER) { + type = NavType.BoolType + defaultValue = false + }, ), ) { entree -> // L'identifiant n'est pas relu ici : `SavedStateHandle` le remet au ViewModel, qui est diff --git a/app/src/main/java/com/filestech/notes_tech/ui/common/DepotEnMemoire.kt b/app/src/main/java/com/filestech/notes_tech/ui/common/DepotEnMemoire.kt new file mode 100644 index 0000000..67c77be --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/ui/common/DepotEnMemoire.kt @@ -0,0 +1,60 @@ +package com.filestech.notes_tech.ui.common + +import android.app.Activity +import android.content.Intent +import android.os.SystemClock +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Something handed to the UNLOCKED app by an activity that runs without it — a share received (3.2.0), + * a launcher shortcut (3.4.0). + * + * 🔴 **In memory only, taken once, and short-lived.** The handing activity puts it here and opens the + * app ([ouvrirLAppli]); a composable that exists only once the app lock is passed takes it. Nothing is + * therefore done while the app is locked, and nothing goes through `MainActivity`'s intent, whose + * extras stay dropped (audit of 2026-09-26, E1). A newer deposit replaces one not yet taken; a process + * that dies in between loses it. + * + * Past [dureeDeVieMs] it is dropped (security review of 3.2.0, L3): handed over while the app was + * locked, it must not act hours later, at the next unlock, when nobody asked for it any more. + */ +open class DepotEnMemoire(private val dureeDeVieMs: Long) { + + /** Monotonic: changing the phone's clock neither expires a deposit nor keeps one alive. */ + internal var horloge: () -> Long = { SystemClock.elapsedRealtime() } + + private val enAttente = MutableStateFlow(null) + private var deposeLe = 0L + + /** What is waiting; whether it is still fresh is [prendre]'s call. */ + val contenu: StateFlow = enAttente.asStateFlow() + + @Synchronized + fun deposer(valeur: T) { + deposeLe = horloge() + enAttente.value = valeur + } + + /** The deposit if it is still fresh, and nothing left behind: two callers cannot both take it. */ + @Synchronized + fun prendre(): T? { + val valeur = enAttente.value ?: return null + enAttente.value = null + return valeur.takeIf { horloge() - deposeLe <= dureeDeVieMs } + } + + /** The user said no: it goes, undone. */ + @Synchronized + fun abandonner() { + enAttente.value = null + } +} + +/** The app as the launcher opens it: its task brought back if it exists, a new one otherwise. */ +internal fun Activity.ouvrirLAppli() { + val lancement = packageManager.getLaunchIntentForPackage(packageName) ?: return + lancement.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED) + startActivity(lancement) +} diff --git a/app/src/main/java/com/filestech/notes_tech/ui/common/LangueChoisie.kt b/app/src/main/java/com/filestech/notes_tech/ui/common/LangueChoisie.kt new file mode 100644 index 0000000..fb951c1 --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/ui/common/LangueChoisie.kt @@ -0,0 +1,25 @@ +package com.filestech.notes_tech.ui.common + +import android.content.Context +import android.content.res.Configuration +import com.filestech.notes_tech.data.prefs.AppSettings +import com.filestech.notes_tech.data.prefs.LegacyPreferences +import java.util.Locale + +/** + * [this], with the language chosen in the app's settings — for an activity's `attachBaseContext` + * (`MainActivity` since 3.0.0; moved here in 3.4.0 for the blind drop's activity, which shows text + * too: one answer to "which language", not two). + * + * ⚠️ **Hilt is not ready at that point**: `attachBaseContext` runs before the activity is injected. The + * preference is read directly, with the same class and keys — not with a copy of the logic. + * + * `SYSTEM` (no code) forces nothing: the context stays as Android built it, following the device's + * setting, even when it changes while the app runs. + */ +internal fun Context.avecLaLangueChoisie(): Context { + val langue = AppSettings(LegacyPreferences(this)).localeNow().code?.let(Locale::forLanguageTag) ?: return this + val configuration = Configuration(resources.configuration) + configuration.setLocale(langue) + return createConfigurationContext(configuration) +} diff --git a/app/src/main/java/com/filestech/notes_tech/ui/depot/DepotActivity.kt b/app/src/main/java/com/filestech/notes_tech/ui/depot/DepotActivity.kt new file mode 100644 index 0000000..637f01a --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/ui/depot/DepotActivity.kt @@ -0,0 +1,84 @@ +package com.filestech.notes_tech.ui.depot + +import android.content.Context +import android.os.Bundle +import android.view.WindowManager +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.filestech.notes_tech.R +import com.filestech.notes_tech.data.prefs.AppSettings +import com.filestech.notes_tech.ui.common.avecLaLangueChoisie +import com.filestech.notes_tech.ui.secure.ClavierSansApprentissage +import com.filestech.notes_tech.ui.secure.sansActionsDeTexteDesAutresApps +import com.filestech.notes_tech.ui.secure.sansClassificationDeTexte +import com.filestech.notes_tech.ui.theme.NotesTechTheme +import com.filestech.notes_tech.ui.theme.estSombre +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +/** + * The blind drop (D-042, 3.4.0): write a note without unlocking Notes Tech, and read nothing. + * + * Opened by the quick settings tile ([TuileDeDepot]) — which asks the PHONE to be unlocked first — and by + * the launcher shortcut "Quick drop". Only the app lock is stepped over, and only to write. + * + * 🔴 What this activity must hold to, every one of them because it can be the first of its process (the + * tile starts it in a fresh one, where `MainActivity` never ran): + * - **Not exported**: the tile and the shortcut start it as this app; no other app can. + * - **Protected window, always**: no screenshot, no preview in the recent apps, whatever the setting. + * - **The selection toolbar keeps editing entries only**: other apps' text actions and the classifier + * are cut here too ([sansClassificationDeTexte], [sansActionsDeTexteDesAutresApps]) — "Translate" sent + * the selected text to another app in 3.1.1. + * - **The keyboard's learning** follows the setting of D-040, as everywhere. + * - **The text never reaches the saved state** — see [DepotViewModel]. + */ +@AndroidEntryPoint +class DepotActivity : ComponentActivity() { + + @Inject + lateinit var settings: AppSettings + + private val viewModel: DepotViewModel by viewModels() + + override fun attachBaseContext(newBase: Context) { + super.attachBaseContext(newBase.avecLaLangueChoisie()) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) + sansClassificationDeTexte() + sansActionsDeTexteDesAutresApps() + enableEdgeToEdge() + setContent { + val theme by settings.theme.collectAsStateWithLifecycle(initialValue = settings.themeNow()) + val sansApprentissage by settings.keyboardNoLearning.collectAsStateWithLifecycle( + initialValue = settings.keyboardNoLearningNow(), + ) + val etat by viewModel.etat.collectAsStateWithLifecycle() + LaunchedEffect(etat.depose) { + if (etat.depose) { + Toast.makeText(this@DepotActivity, R.string.depot_done, Toast.LENGTH_SHORT).show() + finish() + } + } + NotesTechTheme(darkTheme = estSombre(theme)) { + ClavierSansApprentissage(actif = sansApprentissage) { + EcranDeDepot( + etat = etat, + onTitre = viewModel::titre, + onTexte = viewModel::texte, + onDeposer = viewModel::deposer, + onFermer = ::finish, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/filestech/notes_tech/ui/depot/DepotViewModel.kt b/app/src/main/java/com/filestech/notes_tech/ui/depot/DepotViewModel.kt new file mode 100644 index 0000000..c76924a --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/ui/depot/DepotViewModel.kt @@ -0,0 +1,79 @@ +package com.filestech.notes_tech.ui.depot + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.filestech.notes_tech.data.repository.NotesRepository +import com.filestech.notes_tech.domain.importation.LongueurDUneNote +import com.filestech.notes_tech.domain.model.Folder +import com.filestech.notes_tech.security.panic.PanicJournal +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +/** What the blind drop shows: what is being typed, and nothing else (D-042). */ +data class EtatDuDepot( + val titre: String = "", + val texte: String = "", + val enCours: Boolean = false, + val echec: Boolean = false, + val depose: Boolean = false, +) { + val peutDeposer: Boolean get() = (titre.isNotBlank() || texte.isNotBlank()) && !enCours && !depose +} + +/** + * The blind drop (D-042, 3.4.0): a note written without unlocking the app, and never read back. + * + * 🔴 **Writes, never reads.** Its one dependency on the notes is [NotesRepository.create]: no list, no + * search, no count, no folder name. What it holds is what is being typed, in memory — here, NOT in the + * saved state: a Bundle would keep the plaintext on disk with the activity's state, which the note's + * body field never allows either (`ChampDuCorps`). A rotation keeps the text, a process death loses it. + * + * The note goes to the Inbox — never a vault: a vault's note is sealed under its key, and the drop runs + * with no vault open, as the app lock is not passed. + */ +@HiltViewModel +class DepotViewModel @Inject constructor(private val notes: NotesRepository, private val panique: PanicJournal) : + ViewModel() { + + private val _etat = MutableStateFlow(EtatDuDepot()) + val etat: StateFlow = _etat.asStateFlow() + + fun titre(valeur: String) { + if (valeur.length <= NotesRepository.TITLE_MAX_LENGTH) _etat.update { it.copy(titre = valeur, echec = false) } + } + + fun texte(valeur: String) { + if (valeur.length <= LongueurDUneNote.CARACTERES_MAX) _etat.update { it.copy(texte = valeur, echec = false) } + } + + fun deposer() { + val courant = _etat.value + if (!courant.peutDeposer) return + // 🔴 A panic cut before its end is finished by the app before anything else (audit 2026-09-26, + // P2): the drop must not write into the database it is destroying. + if (panique.isPending()) { + _etat.update { it.copy(echec = true) } + return + } + _etat.update { it.copy(enCours = true, echec = false) } + viewModelScope.launch { + try { + notes.create(folderId = Folder.INBOX_ID, title = courant.titre.trim(), content = courant.texte) + // Gone from the screen at once: nothing typed stays to be read. + _etat.value = EtatDuDepot(depose = true) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.w(e, "depot refuse") + _etat.update { it.copy(enCours = false, echec = true) } + } + } + } +} diff --git a/app/src/main/java/com/filestech/notes_tech/ui/depot/EcranDeDepot.kt b/app/src/main/java/com/filestech/notes_tech/ui/depot/EcranDeDepot.kt new file mode 100644 index 0000000..0dc9b75 --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/ui/depot/EcranDeDepot.kt @@ -0,0 +1,126 @@ +package com.filestech.notes_tech.ui.depot + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.filestech.notes_tech.R +import com.filestech.notes_tech.ui.common.DialogueDestructif + +/** + * The blind drop's screen (D-042, 3.4.0): a title, a text, "Drop". Nothing of what the app holds — the + * screen has no way to it ([DepotViewModel]). + * + * Closing with something typed asks first: nothing is kept anywhere once the screen goes. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun EcranDeDepot( + etat: EtatDuDepot, + onTitre: (String) -> Unit, + onTexte: (String) -> Unit, + onDeposer: () -> Unit, + onFermer: () -> Unit, +) { + var abandonDemande by remember { mutableStateOf(false) } + val aDuTexte = etat.titre.isNotBlank() || etat.texte.isNotBlank() + val fermer = { if (aDuTexte) abandonDemande = true else onFermer() } + BackHandler(onBack = fermer) + + val focus = remember { FocusRequester() } + LaunchedEffect(Unit) { focus.requestFocus() } + + Scaffold( + topBar = { + TopAppBar( + navigationIcon = { + IconButton(onClick = fermer) { + Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.common_cancel)) + } + }, + title = { Text(stringResource(R.string.depot_title)) }, + ) + }, + ) { marges -> + Column( + modifier = Modifier.fillMaxSize().padding(marges).imePadding().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.depot_explanation), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = etat.titre, + onValueChange = onTitre, + label = { Text(stringResource(R.string.note_editor_title)) }, + singleLine = true, + enabled = !etat.enCours, + modifier = Modifier.fillMaxWidth().testTag(TAG_TITRE), + ) + OutlinedTextField( + value = etat.texte, + onValueChange = onTexte, + label = { Text(stringResource(R.string.depot_text)) }, + enabled = !etat.enCours, + modifier = Modifier.fillMaxWidth().weight(1f).focusRequester(focus).testTag(TAG_TEXTE), + ) + if (etat.echec) { + Text( + text = stringResource(R.string.depot_failed), + color = MaterialTheme.colorScheme.error, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) + } + Button(onClick = onDeposer, enabled = etat.peutDeposer, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.depot_action)) + } + } + } + + if (abandonDemande) { + DialogueDestructif( + titre = stringResource(R.string.depot_discard_title), + corps = stringResource(R.string.depot_discard_body), + libelleConfirmation = stringResource(R.string.depot_discard_confirm), + onConfirmer = { + abandonDemande = false + onFermer() + }, + onAnnuler = { abandonDemande = false }, + ) + } +} + +internal const val TAG_TITRE = "depot_titre" +internal const val TAG_TEXTE = "depot_texte" diff --git a/app/src/main/java/com/filestech/notes_tech/ui/depot/TuileDeDepot.kt b/app/src/main/java/com/filestech/notes_tech/ui/depot/TuileDeDepot.kt new file mode 100644 index 0000000..4b6bb30 --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/ui/depot/TuileDeDepot.kt @@ -0,0 +1,53 @@ +package com.filestech.notes_tech.ui.depot + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.Intent +import android.os.Build +import android.service.quicksettings.Tile +import android.service.quicksettings.TileService + +/** + * The quick settings tile of the blind drop (D-042, 3.4.0): a tap opens [DepotActivity]. + * + * 🔴 **The PHONE is unlocked first** (Patrice, 2026-11-09): on a locked phone, [unlockAndRun] asks for its + * unlocking, and only then opens the drop. Only Notes Tech's own lock is stepped over, and only to write + * — whoever holds a locked phone cannot drop anything into it. + * + * Bound by the system only: the service is exported for it, behind `BIND_QUICK_SETTINGS_TILE`, which no + * app holds. It reads nothing and shows nothing but its own label. + */ +class TuileDeDepot : TileService() { + + override fun onStartListening() { + super.onStartListening() + qsTile?.apply { + // Nothing to switch on or off: a door, shown at rest. + state = Tile.STATE_INACTIVE + updateTile() + } + } + + override fun onClick() { + super.onClick() + if (isLocked) unlockAndRun { ouvrirLeDepot() } else ouvrirLeDepot() + } + + @SuppressLint("StartActivityAndCollapseDeprecated") + private fun ouvrirLeDepot() { + val intention = Intent(this, DepotActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startActivityAndCollapse( + PendingIntent.getActivity( + this, + 0, + intention, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ), + ) + } else { + @Suppress("DEPRECATION") + startActivityAndCollapse(intention) + } + } +} diff --git a/app/src/main/java/com/filestech/notes_tech/ui/editor/ChampDuCorps.kt b/app/src/main/java/com/filestech/notes_tech/ui/editor/ChampDuCorps.kt index d31e926..0e72091 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/editor/ChampDuCorps.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/editor/ChampDuCorps.kt @@ -71,6 +71,7 @@ import androidx.core.view.inputmethod.EditorInfoCompat import androidx.core.widget.TextViewCompat import com.filestech.notes_tech.R import com.filestech.notes_tech.domain.recherche.RechercheDansLeTexte.Plage +import com.filestech.notes_tech.ui.secure.LocalClavierSansApprentissage import com.filestech.notes_tech.ui.secure.LocalCopieDeSaisie import com.filestech.notes_tech.ui.secure.LocalSaisieProtegee import com.filestech.notes_tech.ui.secure.copierDepuisUnChamp @@ -113,6 +114,7 @@ internal fun ChampDuCorps( modifier: Modifier = Modifier, ) { val protegee = LocalSaisieProtegee.current + val sansApprentissage = LocalClavierSansApprentissage.current val natif = LocalClipboard.current val copieDeSaisie = LocalCopieDeSaisie.current // Outside `SaisieDeCoffre` — the screen's tests — a plain clip, as there. @@ -160,6 +162,7 @@ internal fun ChampDuCorps( champ.onFocus = { focalise = it } champ.ecrire = { texte, fini -> portee.launch { fini(copie(texte)) } } champ.protegee = protegee + champ.sansApprentissage = sansApprentissage champ.apparence(Apparence.de(style, schema, champ.context, enHaut = !flottant)) champ.indications(indication = if (focalise) indication else libelle, nom = libelle) champ.appliquer(valeur) @@ -243,7 +246,8 @@ internal data class CouleursDeSurlignage(val fond: Int, val encre: Int, val fond * other app's `PROCESS_TEXT` — on Android 10 and below, every app answering it is listed, and * "Translate" sent the text online (3.1.1). The classifier is [TextClassifier.NO_OP] here too. * - 🔴 **A vault note's keyboard is asked not to learn** (F4): `IME_FLAG_NO_PERSONALIZED_LEARNING`, - * as `SaisieDeCoffre` asks it for Compose's fields. + * as `SaisieDeCoffre` asks it for Compose's fields — and every note's, while the setting of D-040 + * is on ([sansApprentissage], 3.4.0). * - 🔴 **A vault note's selection cannot be dragged to another app**: dragging is copying, with none * of [ecrire]'s protections. A drop INTO the field arrives as plain text, as a paste does. * - 🔴 **The text is never written to the saved state**: an `EditText` saves its text with the @@ -273,6 +277,17 @@ internal class ChampNatifDuCorps(context: Context) : EditText(context) { if (hasFocus()) clavier()?.restartInput(this) } + /** + * The setting of D-040: the keyboard is asked not to learn, as for a vault note — and that alone. + * Kept apart from [protegee], which also decides the clipboard and the dragging. + */ + var sansApprentissage: Boolean = false + set(valeur) { + if (field == valeur) return + field = valeur + if (hasFocus()) clavier()?.restartInput(this) + } + /** * Set once the constructor is done. `TextView`'s own constructor calls overridden methods — * [onSelectionChanged] among them — before this class's fields exist. @@ -483,7 +498,9 @@ internal class ChampNatifDuCorps(context: Context) : EditText(context) { override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? { val connexion = super.onCreateInputConnection(outAttrs) ?: return null - if (protegee) outAttrs.imeOptions = outAttrs.imeOptions or EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING + if (protegee || sansApprentissage) { + outAttrs.imeOptions = outAttrs.imeOptions or EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING + } return ConnexionBornee(connexion) } diff --git a/app/src/main/java/com/filestech/notes_tech/ui/editor/DicteeALOuverture.kt b/app/src/main/java/com/filestech/notes_tech/ui/editor/DicteeALOuverture.kt new file mode 100644 index 0000000..aa37b5a --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/ui/editor/DicteeALOuverture.kt @@ -0,0 +1,27 @@ +package com.filestech.notes_tech.ui.editor + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue + +/** + * The "Dictation" launcher shortcut (3.4.0, D-041): [demarrer] once the note is open — the microphone + * as a tap on its button opens it: no model, the install screen; no permission, the request. + * + * Once: saved, so a rotation or the app lock giving the screen back does not start it again. + */ +@Composable +internal fun DicteeALOuverture(demandee: Boolean, noteChargee: Boolean, demarrer: () -> Unit) { + var faite by rememberSaveable { mutableStateOf(false) } + val lancer by rememberUpdatedState(demarrer) + LaunchedEffect(noteChargee) { + if (demandee && noteChargee && !faite) { + faite = true + lancer() + } + } +} diff --git a/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorScreen.kt b/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorScreen.kt index 24721d5..d7e714d 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorScreen.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorScreen.kt @@ -171,7 +171,7 @@ fun NoteEditorRoute(onBack: () -> Unit, onOpenNote: (String) -> Unit, onInstalle if (lectureChoisie == null) lectureChoisie = state.content.text.isNotBlank() if (!ouvertureDecidee) { ouvertureDecidee = true - clavierALOuverture = lectureChoisie == false + clavierALOuverture = lectureChoisie == false && !viewModel.dicterALOuverture } } @@ -212,6 +212,10 @@ fun NoteEditorRoute(onBack: () -> Unit, onOpenNote: (String) -> Unit, onInstalle messages = messages, onInstallerLeModele = onInstallerLaDictee, ) + DicteeALOuverture(demandee = viewModel.dicterALOuverture, noteChargee = chargee) { + lectureChoisie = false + dictee.demarrer() + } val retourHaptique = LocalHapticFeedback.current val contexte = LocalContext.current diff --git a/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorViewModel.kt b/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorViewModel.kt index 4f326d1..92786b1 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorViewModel.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorViewModel.kt @@ -211,6 +211,9 @@ class NoteEditorViewModel @Inject constructor( /** Opened from a search result (3.2.0): what was searched, for the "Find in note" bar. */ val rechercheALOuverture: String? = savedState[Destination.ARG_RECHERCHE] + /** Opened by the "Dictation" launcher shortcut (3.4.0, D-041): the microphone at the opening. */ + val dicterALOuverture: Boolean = savedState[Destination.ARG_DICTER] ?: false + private val noteId: String = checkNotNull(savedState[Destination.ARG_NOTE_ID]) { "l'editeur a ete ouvert sans identifiant de note" } diff --git a/app/src/main/java/com/filestech/notes_tech/ui/navigation/Destination.kt b/app/src/main/java/com/filestech/notes_tech/ui/navigation/Destination.kt index c87e495..fe1112f 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/navigation/Destination.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/navigation/Destination.kt @@ -70,11 +70,21 @@ sealed interface Destination { * [recherche]: the search of the result the note is opened from (3.2.0), which "Find in note" * opens with. Encoded, like [noteId]: it is whatever the user typed. Absent when null or blank. */ - data class Editor(val noteId: String, val ecrire: Boolean = false, val recherche: String? = null) : Destination { + /** + * [dicter]: open the microphone once the note is open (3.4.0, the "Dictation" launcher shortcut, + * D-041) — as a tap on its button would. Absent from the route when false. + */ + data class Editor( + val noteId: String, + val ecrire: Boolean = false, + val recherche: String? = null, + val dicter: Boolean = false, + ) : Destination { override val route = "$EDITOR_PREFIX/${Uri.encode(noteId)}" + listOfNotNull( "$ARG_ECRIRE=true".takeIf { ecrire }, recherche?.takeIf { it.isNotBlank() }?.let { "$ARG_RECHERCHE=${Uri.encode(it)}" }, + "$ARG_DICTER=true".takeIf { dicter }, ).joinToString("&").let { if (it.isEmpty()) "" else "?$it" } } @@ -83,9 +93,10 @@ sealed interface Destination { const val ARG_NOTE_ID = "noteId" const val ARG_ECRIRE = "ecrire" const val ARG_RECHERCHE = "recherche" + const val ARG_DICTER = "dicter" /** Le motif déclaré au `NavHost`. Une seule déclaration, ici. */ const val EDITOR_PATTERN = - "$EDITOR_PREFIX/{$ARG_NOTE_ID}?$ARG_ECRIRE={$ARG_ECRIRE}&$ARG_RECHERCHE={$ARG_RECHERCHE}" + "$EDITOR_PREFIX/{$ARG_NOTE_ID}?$ARG_ECRIRE={$ARG_ECRIRE}&$ARG_RECHERCHE={$ARG_RECHERCHE}&$ARG_DICTER={$ARG_DICTER}" } } diff --git a/app/src/main/java/com/filestech/notes_tech/ui/raccourcis/RaccourciActivity.kt b/app/src/main/java/com/filestech/notes_tech/ui/raccourcis/RaccourciActivity.kt new file mode 100644 index 0000000..b36fd2b --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/ui/raccourcis/RaccourciActivity.kt @@ -0,0 +1,43 @@ +package com.filestech.notes_tech.ui.raccourcis + +import android.os.Bundle +import androidx.activity.ComponentActivity +import com.filestech.notes_tech.ui.common.ouvrirLAppli +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +/** + * Where a launcher shortcut lands (D-041, 3.4.0). No screen: it deposits what was asked in + * [RaccourcisEnAttente], opens the app and goes. + * + * 🔴 **Not exported.** A shortcut is started by the system as this app, so it reaches a component no + * other app can — the reason the request does not go through the exported `MainActivity`, whose intent + * stays ignored. An unknown action deposits nothing: the app simply opens. + */ +@AndroidEntryPoint +class RaccourciActivity : ComponentActivity() { + + @Inject + lateinit var enAttente: RaccourcisEnAttente + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // Recreated (rotation, process restored): the first instance did it. + if (savedInstanceState == null) { + raccourciDe(intent?.action)?.let(enAttente::deposer) + ouvrirLAppli() + } + finish() + } + + internal companion object { + const val ACTION_NOUVELLE_NOTE = "com.filestech.notes_tech.action.NOUVELLE_NOTE" + const val ACTION_DICTEE = "com.filestech.notes_tech.action.DICTEE" + + fun raccourciDe(action: String?): Raccourci? = when (action) { + ACTION_NOUVELLE_NOTE -> Raccourci.NOUVELLE_NOTE + ACTION_DICTEE -> Raccourci.DICTEE + else -> null + } + } +} diff --git a/app/src/main/java/com/filestech/notes_tech/ui/raccourcis/RaccourcisDeLIcone.kt b/app/src/main/java/com/filestech/notes_tech/ui/raccourcis/RaccourcisDeLIcone.kt new file mode 100644 index 0000000..483ef84 --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/ui/raccourcis/RaccourcisDeLIcone.kt @@ -0,0 +1,78 @@ +package com.filestech.notes_tech.ui.raccourcis + +import android.content.Context +import android.content.Intent +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat +import com.filestech.notes_tech.R +import com.filestech.notes_tech.ui.depot.DepotActivity +import timber.log.Timber + +/** + * The launcher shortcuts — a long press on the app's icon (D-041, 3.4.0). + * + * **Published by the code, not declared in XML**: a static shortcut names its package in its intent, + * and Notes Tech is built under more than one application id (the release, the development builds). The + * code builds the intent for the package it runs as. Published from `MainActivity`'s `onCreate`, with its + * context: the labels follow the language chosen in the app, and an app in the foreground is never + * rate-limited by the shortcut service. + * + * Each shortcut targets an activity of this app that is NOT exported — see [RaccourciActivity]. + */ +internal fun Context.publierLesRaccourcis() { + val raccourcis = listOf( + raccourci( + id = ID_NOUVELLE_NOTE, + court = R.string.shortcut_new_note, + long = R.string.shortcut_new_note_long, + icone = R.drawable.ic_raccourci_note, + intention = Intent(this, RaccourciActivity::class.java).setAction(RaccourciActivity.ACTION_NOUVELLE_NOTE), + ), + raccourci( + id = ID_DICTEE, + court = R.string.shortcut_dictation, + long = R.string.shortcut_dictation_long, + icone = R.drawable.ic_raccourci_dictee, + intention = Intent(this, RaccourciActivity::class.java).setAction(RaccourciActivity.ACTION_DICTEE), + ), + // The blind drop (D-042) opens its own activity directly: nothing to carry out after an unlock. + raccourci( + id = ID_DEPOT, + court = R.string.depot_title, + long = R.string.depot_title, + icone = R.drawable.ic_raccourci_depot, + intention = Intent(this, DepotActivity::class.java).setAction(ACTION_DEPOT), + ), + ) + try { + if (!ShortcutManagerCompat.setDynamicShortcuts(this, raccourcis)) Timber.w("raccourcis refuses (limite)") + } catch (e: IllegalStateException) { + // The user is locked out of the profile (direct boot), or the service refused: no shortcut, no harm. + Timber.w(e, "raccourcis non publies") + } catch (e: IllegalArgumentException) { + Timber.w(e, "raccourcis non publies") + } +} + +private fun Context.raccourci( + id: String, + @StringRes court: Int, + @StringRes long: Int, + @DrawableRes icone: Int, + intention: Intent, +): ShortcutInfoCompat = ShortcutInfoCompat.Builder(this, id) + .setShortLabel(getString(court)) + .setLongLabel(getString(long)) + .setIcon(IconCompat.createWithResource(this, icone)) + .setIntent(intention) + .build() + +private const val ID_NOUVELLE_NOTE = "nouvelle_note" +private const val ID_DICTEE = "dictee" +private const val ID_DEPOT = "depot" + +/** A shortcut's intent must carry an action; the drop's activity reads none. */ +private const val ACTION_DEPOT = "com.filestech.notes_tech.action.DEPOT" diff --git a/app/src/main/java/com/filestech/notes_tech/ui/raccourcis/RaccourcisEnAttente.kt b/app/src/main/java/com/filestech/notes_tech/ui/raccourcis/RaccourcisEnAttente.kt new file mode 100644 index 0000000..d416fd4 --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/ui/raccourcis/RaccourcisEnAttente.kt @@ -0,0 +1,29 @@ +package com.filestech.notes_tech.ui.raccourcis + +import com.filestech.notes_tech.ui.common.DepotEnMemoire +import javax.inject.Inject +import javax.inject.Singleton + +/** What a launcher shortcut asks the unlocked app to do (D-041, 3.4.0). */ +enum class Raccourci { + /** A blank note, opened to be written. */ + NOUVELLE_NOTE, + + /** A blank note, with the microphone opened as a tap on its button would. */ + DICTEE, +} + +/** + * A shortcut tapped and not yet carried out — a [DepotEnMemoire], which says why: in memory, taken + * once, carried out only once the app lock is passed ([RaccourcisRecus]), never through + * `MainActivity`'s intent. Any app can start `MainActivity`; none can make it create a note or open + * the microphone, since [RaccourciActivity], which deposits here, is not exported. + */ +@Singleton +class RaccourcisEnAttente @Inject constructor() : DepotEnMemoire(DUREE_DE_VIE_MS) { + + companion object { + /** Long enough to unlock the app; past it, a shortcut tapped and forgotten does nothing. */ + const val DUREE_DE_VIE_MS = 2 * 60 * 1000L + } +} diff --git a/app/src/main/java/com/filestech/notes_tech/ui/raccourcis/RaccourcisRecus.kt b/app/src/main/java/com/filestech/notes_tech/ui/raccourcis/RaccourcisRecus.kt new file mode 100644 index 0000000..8ba1634 --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/ui/raccourcis/RaccourcisRecus.kt @@ -0,0 +1,68 @@ +package com.filestech.notes_tech.ui.raccourcis + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.filestech.notes_tech.data.repository.NotesRepository +import com.filestech.notes_tech.domain.model.Folder +import com.filestech.notes_tech.ui.navigation.Destination +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +/** + * Carries out a launcher shortcut once the app is unlocked (D-041, 3.4.0): a blank note in the Inbox — + * never a vault —, opened to be written, with the microphone for [Raccourci.DICTEE]. + * + * 🔴 Composed only inside the app lock's content, and not while the panic mode runs — the place of + * `PartagesRecus`, for the same reasons: nothing is written while the app is locked, nothing on a + * database the panic is destroying. + */ +@Composable +fun RaccourcisRecus(onOuvrir: (Destination.Editor) -> Unit) { + val viewModel: RaccourcisViewModel = hiltViewModel() + val demande by viewModel.demande.collectAsStateWithLifecycle() + val ouvrir by rememberUpdatedState(onOuvrir) + + LaunchedEffect(viewModel) { viewModel.aOuvrir.collect { ouvrir(it) } } + LaunchedEffect(demande) { if (demande != null) viewModel.executer() } +} + +@HiltViewModel +class RaccourcisViewModel @Inject constructor( + private val enAttente: RaccourcisEnAttente, + private val notes: NotesRepository, +) : ViewModel() { + + val demande = enAttente.contenu + + // A channel, not a SharedFlow: an editor to open is not lost when nothing collects yet. + private val ouvertures = Channel(Channel.BUFFERED) + val aOuvrir: Flow = ouvertures.receiveAsFlow() + + fun executer() { + // Taken here, synchronously: a second recomposition finds nothing left to carry out. + val raccourci = enAttente.prendre() ?: return + viewModelScope.launch { + try { + val note = notes.create(folderId = Folder.INBOX_ID) + ouvertures.send(Destination.Editor(note.id, dicter = raccourci == Raccourci.DICTEE)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // The app is open on its list: the user taps "New note" — no message for a shortcut. + Timber.w(e, "raccourci non execute") + } + } + } +} diff --git a/app/src/main/java/com/filestech/notes_tech/ui/secure/ClavierSansApprentissage.kt b/app/src/main/java/com/filestech/notes_tech/ui/secure/ClavierSansApprentissage.kt new file mode 100644 index 0000000..e19b1a7 --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/ui/secure/ClavierSansApprentissage.kt @@ -0,0 +1,54 @@ +package com.filestech.notes_tech.ui.secure + +import android.view.inputmethod.EditorInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.InterceptPlatformTextInput +import androidx.compose.ui.platform.PlatformTextInputMethodRequest +import androidx.core.view.inputmethod.EditorInfoCompat + +/** + * Asks the keyboard not to learn what is typed in the fields under it (D-040, 3.4.0). + * + * Compose 1.12 sets no `IME_FLAG_NO_PERSONALIZED_LEARNING` and offers no option for it: the request + * that opens the keyboard is intercepted and the flag added — the mechanism [SaisieDeCoffre] brought + * for vault notes in 3.2.0 (audit F4), shared here. The note's body is a native `EditText`, which no + * interceptor reaches: it reads [LocalClavierSansApprentissage] and sets the flag itself. + * + * It is a REQUEST to the keyboard. Gboard and SwiftKey honour it; a keyboard may ignore it. The + * suggestions stay: only the learning stops. + * + * ⚠️ **Always composed, acting only while [actif]** — for the reason [SaisieDeCoffre] gives: a wrapper + * appearing or vanishing changes the structure of the composition, and every field under it would + * lose its state. Turning the setting on or off reaches a keyboard already open only at its next + * opening; the native body reopens its own at once. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun ClavierSansApprentissage(actif: Boolean, contenu: @Composable () -> Unit) { + val estActif by rememberUpdatedState(actif) + CompositionLocalProvider(LocalClavierSansApprentissage provides actif) { + InterceptPlatformTextInput( + interceptor = { requete, suivant -> + suivant.startInputMethod(if (estActif) sansApprentissage(requete) else requete) + }, + content = contenu, + ) + } +} + +/** [requete], with `IME_FLAG_NO_PERSONALIZED_LEARNING` added to what it tells the keyboard. */ +@OptIn(ExperimentalComposeUiApi::class) +internal fun sansApprentissage(requete: PlatformTextInputMethodRequest): PlatformTextInputMethodRequest = + PlatformTextInputMethodRequest { attributs: EditorInfo -> + requete.createInputConnection(attributs).also { + attributs.imeOptions = attributs.imeOptions or EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING + } + } + +/** Whether the NATIVE text fields under [ClavierSansApprentissage] must ask it — see `ChampDuCorps`. */ +internal val LocalClavierSansApprentissage = compositionLocalOf { false } diff --git a/app/src/main/java/com/filestech/notes_tech/ui/secure/SaisieDeCoffre.kt b/app/src/main/java/com/filestech/notes_tech/ui/secure/SaisieDeCoffre.kt index 5bab988..a8a994a 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/secure/SaisieDeCoffre.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/secure/SaisieDeCoffre.kt @@ -2,7 +2,6 @@ package com.filestech.notes_tech.ui.secure import android.content.ClipData import android.content.ClipboardManager -import android.view.inputmethod.EditorInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.compositionLocalOf @@ -16,9 +15,7 @@ import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.Clipboard import androidx.compose.ui.platform.InterceptPlatformTextInput import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.platform.PlatformTextInputMethodRequest import androidx.compose.ui.platform.nativeClipboardManager -import androidx.core.view.inputmethod.EditorInfoCompat import kotlinx.coroutines.CancellationException import timber.log.Timber @@ -69,19 +66,10 @@ fun SaisieDeCoffre( LocalSaisieProtegee provides actif, LocalCopieDeSaisie provides copie, ) { + // A vault note asks it whatever the setting of D-040 says. InterceptPlatformTextInput( interceptor = { requete, suivant -> - val transmise = if (!estActif) { - requete - } else { - PlatformTextInputMethodRequest { attributs: EditorInfo -> - requete.createInputConnection(attributs).also { - attributs.imeOptions = - attributs.imeOptions or EditorInfoCompat.IME_FLAG_NO_PERSONALIZED_LEARNING - } - } - } - suivant.startInputMethod(transmise) + suivant.startInputMethod(if (estActif) sansApprentissage(requete) else requete) }, content = contenu, ) diff --git a/app/src/main/java/com/filestech/notes_tech/ui/settings/SettingsScreen.kt b/app/src/main/java/com/filestech/notes_tech/ui/settings/SettingsScreen.kt index 0fe643b..088369d 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/settings/SettingsScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.material.icons.outlined.Archive import androidx.compose.material.icons.outlined.DarkMode import androidx.compose.material.icons.outlined.FileUpload import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Keyboard import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.LocalFireDepartment import androidx.compose.material.icons.outlined.LockClock @@ -168,6 +169,7 @@ fun SettingsRoute(onBack: () -> Unit, onOpenAbout: () -> Unit, onOpenVoiceSetup: }, onSort = viewModel::setSort, onSecureWindow = viewModel::setSecureWindow, + onKeyboardNoLearning = viewModel::setKeyboardNoLearning, onAutoLock = viewModel::setVaultAutoLockMinutes, onPanic = panique::trigger, onOpenAbout = onOpenAbout, @@ -214,6 +216,7 @@ fun SettingsScreen( /** A slot, like [ligneDExport]: the encrypted backup's two rows and their dialogs (D-029). */ lignesDeSauvegarde: @Composable () -> Unit = {}, sectionDeVerrouillage: @Composable () -> Unit = {}, + onKeyboardNoLearning: (Boolean) -> Unit = {}, ) { var choixDeTheme by remember { mutableStateOf(false) } var choixDeLangue by remember { mutableStateOf(false) } @@ -344,6 +347,20 @@ fun SettingsScreen( ), ) HorizontalDivider() + // D-040 (3.4.0): the same idiom as the row above — one node, toggled by the row. + ListItem( + headlineContent = { Text(stringResource(R.string.settings_keyboard_no_learning)) }, + supportingContent = { Text(stringResource(R.string.settings_keyboard_no_learning_subtitle)) }, + leadingContent = { Icon(Icons.Outlined.Keyboard, contentDescription = null) }, + trailingContent = { Switch(checked = state.keyboardNoLearning, onCheckedChange = null) }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = Modifier.toggleable( + value = state.keyboardNoLearning, + role = Role.Switch, + onValueChange = onKeyboardNoLearning, + ), + ) + HorizontalDivider() ListItem( headlineContent = { Text(stringResource(R.string.settings_vault_auto_lock)) }, supportingContent = { diff --git a/app/src/main/java/com/filestech/notes_tech/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/filestech/notes_tech/ui/settings/SettingsViewModel.kt index 4fefcfb..cc7d056 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/settings/SettingsViewModel.kt @@ -19,6 +19,7 @@ data class SettingsUiState( val secureWindow: Boolean = true, val vaultAutoLockMinutes: Int = 15, val sort: NoteSortMode = NoteSortMode.DEFAULT, + val keyboardNoLearning: Boolean = true, ) @HiltViewModel @@ -32,6 +33,9 @@ class SettingsViewModel @Inject constructor(private val settings: AppSettings) : settings.sort, ) { theme, locale, fenetre, delai, tri -> SettingsUiState(theme, locale, fenetre, delai, tri) + }.combine(settings.keyboardNoLearning) { etat, sansApprentissage -> + // A sixth flow: `combine` has typed overloads up to five. + etat.copy(keyboardNoLearning = sansApprentissage) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(ARRET_DIFFERE_MILLIS), @@ -44,6 +48,7 @@ class SettingsViewModel @Inject constructor(private val settings: AppSettings) : secureWindow = settings.secureWindowNow(), vaultAutoLockMinutes = settings.vaultAutoLockMinutesNow(), sort = settings.sortNow(), + keyboardNoLearning = settings.keyboardNoLearningNow(), ), ) @@ -53,6 +58,8 @@ class SettingsViewModel @Inject constructor(private val settings: AppSettings) : fun setSecureWindow(value: Boolean) = settings.setSecureWindow(value) + fun setKeyboardNoLearning(value: Boolean) = settings.setKeyboardNoLearning(value) + fun setVaultAutoLockMinutes(value: Int) = settings.setVaultAutoLockMinutes(value) /** diff --git a/app/src/main/java/com/filestech/notes_tech/ui/share/PartagesEnAttente.kt b/app/src/main/java/com/filestech/notes_tech/ui/share/PartagesEnAttente.kt index 334185c..4f1fdca 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/share/PartagesEnAttente.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/share/PartagesEnAttente.kt @@ -1,56 +1,24 @@ package com.filestech.notes_tech.ui.share -import android.os.SystemClock import com.filestech.notes_tech.domain.importation.PartageRecu -import kotlinx.coroutines.flow.MutableStateFlow +import com.filestech.notes_tech.ui.common.DepotEnMemoire import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import javax.inject.Inject import javax.inject.Singleton /** - * A share received and not yet turned into notes (3.2.0). + * A share received and not yet turned into notes (3.2.0) — a [DepotEnMemoire] since 3.4.0, which + * says why it lives in memory only, is taken once and expires. * - * 🔴 **In memory only, taken once, and short-lived.** [RecevoirUnPartageActivity] puts it here and - * opens the app; [PartagesRecus], composed only once the app lock is passed, asks the user and takes - * it. A share is therefore never written while the app is locked, and never through `MainActivity`'s - * intent, whose extras stay dropped (audit of 2026-09-26, E1). A newer share replaces one not yet - * taken; a process that dies in between loses it — the other app still has it. - * - * Past [DUREE_DE_VIE_MS] it is dropped (security review of 3.2.0, L3): shared while the app was - * locked, it must not surface hours later, at the next unlock, as a note nobody asked for. + * [RecevoirUnPartageActivity] puts it here and opens the app; [PartagesRecus], composed only once the + * app lock is passed, asks the user and takes it. The other app still has what it shared: a share + * lost to a dying process costs a second share. */ @Singleton -class PartagesEnAttente @Inject constructor() { - - /** Monotonic: changing the phone's clock neither expires a share nor keeps one alive. */ - internal var horloge: () -> Long = { SystemClock.elapsedRealtime() } - - private val enAttente = MutableStateFlow(null) - private var deposeLe = 0L +class PartagesEnAttente @Inject constructor() : DepotEnMemoire(DUREE_DE_VIE_MS) { - /** What is waiting, for [PartagesRecus] to show; whether it is still fresh is [prendre]'s call. */ - val partage: StateFlow = enAttente.asStateFlow() - - @Synchronized - fun deposer(recu: PartageRecu) { - deposeLe = horloge() - enAttente.value = recu - } - - /** The share if it is still fresh, and nothing left behind: two callers cannot both take it. */ - @Synchronized - fun prendre(): PartageRecu? { - val recu = enAttente.value ?: return null - enAttente.value = null - return recu.takeIf { horloge() - deposeLe <= DUREE_DE_VIE_MS } - } - - /** The user said no: the share goes, unwritten. */ - @Synchronized - fun abandonner() { - enAttente.value = null - } + /** What is waiting, for [PartagesRecus] to show. */ + val partage: StateFlow get() = contenu companion object { const val DUREE_DE_VIE_MS = 2 * 60 * 1000L diff --git a/app/src/main/java/com/filestech/notes_tech/ui/share/RecevoirUnPartageActivity.kt b/app/src/main/java/com/filestech/notes_tech/ui/share/RecevoirUnPartageActivity.kt index 0555884..ed0f34a 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/share/RecevoirUnPartageActivity.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/share/RecevoirUnPartageActivity.kt @@ -11,6 +11,7 @@ import androidx.lifecycle.lifecycleScope import com.filestech.notes_tech.domain.importation.FichierPartage import com.filestech.notes_tech.domain.importation.LectureDUnPartage import com.filestech.notes_tech.domain.importation.PartageRecu +import com.filestech.notes_tech.ui.common.ouvrirLAppli import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -127,13 +128,6 @@ class RecevoirUnPartageActivity : ComponentActivity() { null } - /** The app as the launcher opens it: its task brought back if it exists, a new one otherwise. */ - private fun ouvrirLAppli() { - val lancement = packageManager.getLaunchIntentForPackage(packageName) ?: return - lancement.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED) - startActivity(lancement) - } - internal companion object { /** * 🔴 **A `content://` of another app, never one of this app's own providers** — its export cache diff --git a/app/src/main/java/com/filestech/notes_tech/ui/theme/Theme.kt b/app/src/main/java/com/filestech/notes_tech/ui/theme/Theme.kt index b52d663..cd26dac 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/theme/Theme.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/theme/Theme.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp +import com.filestech.notes_tech.data.prefs.ThemePreference /** * La palette de l'application publiée, à l'octet près. @@ -203,3 +204,14 @@ fun NotesTechTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composa content = content, ) } + +/** + * Whether [choix] asks for the dark theme — `SYSTEM` follows the device. One answer for every activity + * that draws a screen: `MainActivity` and the blind drop's (3.4.0). + */ +@Composable +fun estSombre(choix: ThemePreference): Boolean = when (choix) { + ThemePreference.LIGHT -> false + ThemePreference.DARK -> true + ThemePreference.SYSTEM -> isSystemInDarkTheme() +} diff --git a/app/src/main/java/com/filestech/notes_tech/ui/vault/VaultSheets.kt b/app/src/main/java/com/filestech/notes_tech/ui/vault/VaultSheets.kt index d5cff75..5debeba 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/vault/VaultSheets.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/vault/VaultSheets.kt @@ -72,6 +72,7 @@ import com.filestech.notes_tech.ui.common.PointsDeSaisie import com.filestech.notes_tech.ui.common.displayName import com.filestech.notes_tech.ui.common.refusalMessageFor import com.filestech.notes_tech.ui.common.retryWaitMessage +import com.filestech.notes_tech.ui.secure.ClavierSansApprentissage import com.filestech.notes_tech.ui.secure.ProprietesDeFeuilleSecrete import com.filestech.notes_tech.ui.secure.SecureWindowGuard import com.filestech.notes_tech.ui.theme.Formes @@ -574,24 +575,28 @@ internal fun ChampDePhraseSecrete( ) { var visible by remember { mutableStateOf(false) } - OutlinedTextField( - value = valeur, - onValueChange = onValeurChange, - label = { Text(label) }, - singleLine = true, - enabled = actif, - visualTransformation = if (visible) VisualTransformation.None else PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = typeDeClavier, imeAction = actionClavier), - trailingIcon = { - IconButton(onClick = { visible = !visible }) { - Icon( - imageVector = if (visible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, - contentDescription = stringResource(if (visible) masquer else afficher), - ) - } - }, - modifier = Modifier.fillMaxWidth(), - ) + // A secret never teaches the keyboard anything, whatever the setting of D-040 says (3.4.0): a + // password field asks it already on most keyboards, and the eye turns it into a visible one. + ClavierSansApprentissage(actif = true) { + OutlinedTextField( + value = valeur, + onValueChange = onValeurChange, + label = { Text(label) }, + singleLine = true, + enabled = actif, + visualTransformation = if (visible) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = typeDeClavier, imeAction = actionClavier), + trailingIcon = { + IconButton(onClick = { visible = !visible }) { + Icon( + imageVector = if (visible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility, + contentDescription = stringResource(if (visible) masquer else afficher), + ) + } + }, + modifier = Modifier.fillMaxWidth(), + ) + } } // ── Code à quatre-six chiffres ─────────────────────────────────────────────────────────────────── diff --git a/app/src/main/res/drawable/ic_raccourci_depot.xml b/app/src/main/res/drawable/ic_raccourci_depot.xml new file mode 100644 index 0000000..95f95b7 --- /dev/null +++ b/app/src/main/res/drawable/ic_raccourci_depot.xml @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_raccourci_dictee.xml b/app/src/main/res/drawable/ic_raccourci_dictee.xml new file mode 100644 index 0000000..cb5040c --- /dev/null +++ b/app/src/main/res/drawable/ic_raccourci_dictee.xml @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_raccourci_note.xml b/app/src/main/res/drawable/ic_raccourci_note.xml new file mode 100644 index 0000000..654f78a --- /dev/null +++ b/app/src/main/res/drawable/ic_raccourci_note.xml @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_tuile_depot.xml b/app/src/main/res/drawable/ic_tuile_depot.xml new file mode 100644 index 0000000..db1a33a --- /dev/null +++ b/app/src/main/res/drawable/ic_tuile_depot.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 49042e7..9ae8d51 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -344,6 +344,21 @@ Dunkel In den letzten Apps ausblenden Verhindert Bildschirmfotos und blendet die Vorschau der App in der Android-Übersicht der letzten Apps aus. + Tastatur ohne Lernen + Bittet Ihre Tastatur, nichts von dem zu lernen, was Sie in Notes Tech tippen. Vorschläge bleiben; eine Tastatur kann die Bitte ignorieren. + Neue Notiz + Neue Notiz + Diktieren + Notiz diktieren + Schnellablage + Wird im Eingang gespeichert, ohne Notes Tech zu entsperren. Nichts, was die App enthält, ist hier lesbar. + Text + Ablegen + Die Notiz konnte nicht abgelegt werden. Ihr Text ist noch da. + Notiz im Eingang abgelegt + Diesen Text verwerfen? + Er wurde nicht abgelegt, und nichts bewahrt ihn auf, sobald dieser Bildschirm geschlossen ist. + Verwerfen Automatische Tresorsperre %1$d Minute diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index bbbe647..f8336bf 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -363,6 +363,21 @@ Oscuro Ocultar en aplicaciones recientes Impide las capturas de pantalla y oculta la vista previa de la aplicación en el selector de tareas de Android. + Teclado sin aprendizaje + Pide al teclado que no aprenda nada de lo que escribe en Notes Tech. Las sugerencias se mantienen; un teclado puede ignorar la petición. + Nueva nota + Nueva nota + Dictar + Dictar una nota + Depósito rápido + Se escribe en la Bandeja de entrada sin desbloquear Notes Tech. Aquí no se puede leer nada de lo que contiene la app. + Texto + Depositar + No se pudo depositar la nota. Su texto sigue aquí. + Nota depositada en la Bandeja de entrada + ¿Descartar este texto? + No se ha depositado, y nada lo conserva una vez cerrada esta pantalla. + Descartar Bloqueo automático de las cajas fuertes %1$d minuto diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 6632381..e2a8fef 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -368,6 +368,21 @@ Sombre Masquer dans les apps récentes Empêche la capture d\'écran et masque l\'aperçu de l\'app dans le sélecteur Android. + Clavier sans apprentissage + Demande au clavier de ne rien apprendre de ce que vous tapez dans Notes Tech. Les suggestions restent ; un clavier peut ignorer la demande. + Nouvelle note + Nouvelle note + Dicter + Dicter une note + Dépôt rapide + S\'écrit dans la Boîte de réception sans déverrouiller Notes Tech. Rien de ce que l\'appli contient ne se lit ici. + Texte + Déposer + La note n\'a pas pu être déposée. Votre texte est toujours là. + Note déposée dans la Boîte de réception + Abandonner ce texte ? + Il n\'a pas été déposé, et rien ne le garde une fois cet écran fermé. + Abandonner Verrouillage automatique des coffres %1$d minute diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 77ca602..e723254 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -363,6 +363,21 @@ Scuro Nascondi nelle app recenti Impedisce gli screenshot e nasconde l\'anteprima dell\'app nel selettore delle attività di Android. + Tastiera senza apprendimento + Chiede alla tastiera di non imparare nulla di ciò che digita in Notes Tech. I suggerimenti restano; una tastiera può ignorare la richiesta. + Nuova nota + Nuova nota + Dettare + Dettare una nota + Deposito rapido + Si scrive in In arrivo senza sbloccare Notes Tech. Qui non si può leggere nulla di ciò che l\'app contiene. + Testo + Deposita + Non è stato possibile depositare la nota. Il Suo testo è ancora qui. + Nota depositata in In arrivo + Scartare questo testo? + Non è stato depositato, e nulla lo conserva una volta chiusa questa schermata. + Scarta Blocco automatico delle casseforti %1$d minuto diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d4da595..7145d55 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -419,6 +419,21 @@ Dark Hide in recent apps Prevents screenshots and hides the app preview in the Android task switcher. + Keyboard without learning + Asks your keyboard not to learn what you type in Notes Tech. Suggestions stay; a keyboard may ignore the request. + New note + New note + Dictate + Dictate a note + Quick drop + Written to the Inbox without unlocking Notes Tech. Nothing the app holds can be read here. + Text + Drop + The note could not be dropped. Your text is still here. + Note dropped in the Inbox + Discard this text? + It has not been dropped, and nothing keeps it once this screen is closed. + Discard Vault auto-lock %1$d minute diff --git a/app/src/test/java/com/filestech/notes_tech/ui/depot/DepotViewModelTest.kt b/app/src/test/java/com/filestech/notes_tech/ui/depot/DepotViewModelTest.kt new file mode 100644 index 0000000..c58003c --- /dev/null +++ b/app/src/test/java/com/filestech/notes_tech/ui/depot/DepotViewModelTest.kt @@ -0,0 +1,99 @@ +package com.filestech.notes_tech.ui.depot + +import com.filestech.notes_tech.data.repository.NotesRepository +import com.filestech.notes_tech.domain.importation.LongueurDUneNote +import com.filestech.notes_tech.domain.model.Folder +import com.filestech.notes_tech.domain.model.Note +import com.filestech.notes_tech.security.panic.PanicJournal +import com.google.common.truth.Truth.assertThat +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.confirmVerified +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +/** + * The blind drop (D-042, 3.4.0): it writes one note to the Inbox and reads nothing — its only call on + * the notes is [NotesRepository.create], checked with `confirmVerified`. + */ +@DisplayName("Dépôt à l'aveugle") +class DepotViewModelTest { + + private val notes = mockk() + private val journal = mockk() + private lateinit var depot: DepotViewModel + + @BeforeEach + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + every { journal.isPending() } returns false + coEvery { notes.create(any(), any(), any(), any()) } returns mockk() + depot = DepotViewModel(notes, journal) + } + + @AfterEach + fun tearDown() = Dispatchers.resetMain() + + @Test + fun `a drop writes one note to the Inbox, then nothing typed stays on the screen`() { + depot.titre(" Idée ") + depot.texte("Le texte\nsur deux lignes") + + depot.deposer() + + coVerify(exactly = 1) { notes.create(Folder.INBOX_ID, "Idée", "Le texte\nsur deux lignes", any()) } + confirmVerified(notes) + assertThat(depot.etat.value).isEqualTo(EtatDuDepot(depose = true)) + } + + @Test + fun `nothing typed, nothing dropped`() { + depot.titre(" ") + + depot.deposer() + + assertThat(depot.etat.value.peutDeposer).isFalse() + coVerify(exactly = 0) { notes.create(any(), any(), any(), any()) } + } + + /** A panic cut before its end is the app's to finish: the drop must not write into that database. */ + @Test + fun `while a panic is pending, nothing is written and the text stays`() { + every { journal.isPending() } returns true + depot.texte("à garder") + + depot.deposer() + + coVerify(exactly = 0) { notes.create(any(), any(), any(), any()) } + assertThat(depot.etat.value.echec).isTrue() + assertThat(depot.etat.value.texte).isEqualTo("à garder") + } + + @Test + fun `a refused write keeps the text, to try again`() { + coEvery { notes.create(any(), any(), any(), any()) } throws IllegalStateException("base fermee") + depot.texte("à garder") + + depot.deposer() + + assertThat(depot.etat.value).isEqualTo(EtatDuDepot(texte = "à garder", echec = true)) + assertThat(depot.etat.value.peutDeposer).isTrue() + } + + @Test + fun `a title or a text beyond a note's limits is not taken`() { + depot.titre("t".repeat(NotesRepository.TITLE_MAX_LENGTH + 1)) + depot.texte("x".repeat(LongueurDUneNote.CARACTERES_MAX + 1)) + + assertThat(depot.etat.value.titre).isEmpty() + assertThat(depot.etat.value.texte).isEmpty() + } +} diff --git a/app/src/test/java/com/filestech/notes_tech/ui/raccourcis/RaccourcisTest.kt b/app/src/test/java/com/filestech/notes_tech/ui/raccourcis/RaccourcisTest.kt new file mode 100644 index 0000000..cbeabe1 --- /dev/null +++ b/app/src/test/java/com/filestech/notes_tech/ui/raccourcis/RaccourcisTest.kt @@ -0,0 +1,48 @@ +package com.filestech.notes_tech.ui.raccourcis + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +/** The launcher shortcuts (D-041, 3.4.0): what an action asks, and how long the request waits. */ +@DisplayName("Raccourcis de l'icône") +class RaccourcisTest { + + private var maintenant = 1_000L + private val enAttente = RaccourcisEnAttente().also { it.horloge = { maintenant } } + + @Test + fun `each shortcut's action asks its request, and any other action asks nothing`() { + val nouvelleNote = RaccourciActivity.raccourciDe(RaccourciActivity.ACTION_NOUVELLE_NOTE) + assertThat(nouvelleNote).isEqualTo(Raccourci.NOUVELLE_NOTE) + assertThat(RaccourciActivity.raccourciDe(RaccourciActivity.ACTION_DICTEE)).isEqualTo(Raccourci.DICTEE) + assertThat(RaccourciActivity.raccourciDe("android.intent.action.MAIN")).isNull() + assertThat(RaccourciActivity.raccourciDe(null)).isNull() + } + + @Test + fun `a request is carried out once`() { + enAttente.deposer(Raccourci.DICTEE) + + assertThat(enAttente.prendre()).isEqualTo(Raccourci.DICTEE) + assertThat(enAttente.prendre()).isNull() + } + + @Test + fun `a request left waiting past its life does nothing at the next unlock`() { + enAttente.deposer(Raccourci.NOUVELLE_NOTE) + maintenant += RaccourcisEnAttente.DUREE_DE_VIE_MS + 1 + + assertThat(enAttente.prendre()).isNull() + // Taken all the same: nothing is left for a later unlock. + assertThat(enAttente.contenu.value).isNull() + } + + @Test + fun `a newer request replaces one not yet carried out`() { + enAttente.deposer(Raccourci.NOUVELLE_NOTE) + enAttente.deposer(Raccourci.DICTEE) + + assertThat(enAttente.prendre()).isEqualTo(Raccourci.DICTEE) + } +} From 3ac16da3c03157df15c320fbb9baa2db38cb1ed9 Mon Sep 17 00:00:00 2001 From: Patrice Haltaya Date: Mon, 9 Nov 2026 20:22:58 +0100 Subject: [PATCH 03/11] =?UTF-8?q?feat(data):=20a=20note's=20lifetime=20?= =?UTF-8?q?=E2=80=94=20schema=2011=20(3.4.0,=20D-043)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two columns on notes, migration 10 -> 11, additive: expires_at (epoch ms, NULL for never) and read_once (INTEGER NOT NULL DEFAULT 0). Every existing note gets no lifetime. - NotesRepository.setLifetime writes those two columns only, not updated_at: giving a note a lifetime is not editing it. A duration counts from now. - purgeExpired: one DELETE, the one "Delete permanently" runs, so the FTS index (notes_ad, secure-delete), the links (foreign keys) and secure_delete treat an expired note as an erased one. The trash's notes too; a vault note needs no key. - Bulk export (listAllAlive) and the encrypted backup (poidsDesNotes) leave such notes out (Patrice: a restore must not bring them back); a restored note has no lifetime. - audits/verifier-schema-room-vs-flutter.py replays 10 -> 11: no divergence between the Flutter schema migrated to 11 and Room's 11.json. Tests on the S9: MigrationVersDixTest (3, a new case for the base of 3.1 to 3.3 at schema 10, migrated by MIGRATION_10_11 alone; the purge erases that note and that note only), LegacyDatabaseOpeningTest (15), NoteLinkTest (5), NotesRepositoryTest (67), SauvegardeChiffreeTest (9) — 0 failed. Co-Authored-By: Claude Opus 5.5 --- .../11.json | 290 ++++++++++++++++++ .../data/local/LegacyDatabaseOpeningTest.kt | 2 + .../data/local/MigrationVersDixTest.kt | 42 ++- .../notes_tech/data/local/NoteLinkTest.kt | 2 + .../data/repository/NotesRepositoryTest.kt | 2 + .../notes_tech/data/local/NotesDatabase.kt | 19 +- .../data/local/NotesDatabaseFactory.kt | 2 +- .../notes_tech/data/local/dao/NoteDao.kt | 10 +- .../notes_tech/data/local/dao/NoteWriteDao.kt | 8 + .../data/local/dao/SauvegardeDao.kt | 3 +- .../data/local/entity/NoteEntity.kt | 10 + .../data/local/mapper/NoteMapper.kt | 2 + .../data/repository/NotesRepository.kt | 22 ++ .../sauvegarde/RestaurationDeSauvegarde.kt | 3 + .../notes_tech/domain/model/DureeDeVie.kt | 17 + .../filestech/notes_tech/domain/model/Note.kt | 7 + audits/verifier-schema-room-vs-flutter.py | 4 + docs/15-VERSION-3.4.md | 31 +- 18 files changed, 465 insertions(+), 11 deletions(-) create mode 100644 app/schemas/com.filestech.notes_tech.data.local.NotesDatabase/11.json create mode 100644 app/src/main/java/com/filestech/notes_tech/domain/model/DureeDeVie.kt diff --git a/app/schemas/com.filestech.notes_tech.data.local.NotesDatabase/11.json b/app/schemas/com.filestech.notes_tech.data.local.NotesDatabase/11.json new file mode 100644 index 0000000..2a152a6 --- /dev/null +++ b/app/schemas/com.filestech.notes_tech.data.local.NotesDatabase/11.json @@ -0,0 +1,290 @@ +{ + "formatVersion": 1, + "database": { + "version": 11, + "identityHash": "1a9615da6e5de5c045cf8c66cc3c3970", + "entities": [ + { + "tableName": "folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `parent_id` TEXT, `color` INTEGER, `icon` TEXT, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `vault_salt` BLOB, `vault_kek_wrapped` BLOB, `vault_iv` BLOB, `vault_verifier` BLOB, `vault_mode` TEXT, `vault_pin_blob` BLOB, `vault_pin_iv` BLOB, `vault_attempts` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`), FOREIGN KEY(`parent_id`) REFERENCES `folders`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentId", + "columnName": "parent_id", + "affinity": "TEXT" + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER" + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "vaultSalt", + "columnName": "vault_salt", + "affinity": "BLOB" + }, + { + "fieldPath": "vaultKekWrapped", + "columnName": "vault_kek_wrapped", + "affinity": "BLOB" + }, + { + "fieldPath": "vaultIv", + "columnName": "vault_iv", + "affinity": "BLOB" + }, + { + "fieldPath": "vaultVerifier", + "columnName": "vault_verifier", + "affinity": "BLOB" + }, + { + "fieldPath": "vaultMode", + "columnName": "vault_mode", + "affinity": "TEXT" + }, + { + "fieldPath": "vaultPinBlob", + "columnName": "vault_pin_blob", + "affinity": "BLOB" + }, + { + "fieldPath": "vaultPinIv", + "columnName": "vault_pin_iv", + "affinity": "BLOB" + }, + { + "fieldPath": "vaultAttempts", + "columnName": "vault_attempts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "idx_folders_parent", + "unique": false, + "columnNames": [ + "parent_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_folders_parent` ON `${TABLE_NAME}` (`parent_id`)" + } + ], + "foreignKeys": [ + { + "table": "folders", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "parent_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `encrypted_content` BLOB, `folder_id` TEXT NOT NULL, `tags` TEXT NOT NULL DEFAULT '', `pinned` INTEGER NOT NULL DEFAULT 0, `favorite` INTEGER NOT NULL DEFAULT 0, `archived` INTEGER NOT NULL DEFAULT 0, `trashed_at` INTEGER, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `enc_v` INTEGER NOT NULL DEFAULT 1, `color_id` INTEGER, `expires_at` INTEGER, `read_once` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`), FOREIGN KEY(`folder_id`) REFERENCES `folders`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptedContent", + "columnName": "encrypted_content", + "affinity": "BLOB" + }, + { + "fieldPath": "folderId", + "columnName": "folder_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "favorite", + "columnName": "favorite", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "archived", + "columnName": "archived", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "trashedAt", + "columnName": "trashed_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "encVersion", + "columnName": "enc_v", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "colorId", + "columnName": "color_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "expiresAt", + "columnName": "expires_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "readOnce", + "columnName": "read_once", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "idx_notes_folder_active", + "unique": false, + "columnNames": [ + "folder_id", + "archived", + "trashed_at", + "updated_at" + ], + "orders": [ + "ASC", + "ASC", + "ASC", + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_notes_folder_active` ON `${TABLE_NAME}` (`folder_id` ASC, `archived` ASC, `trashed_at` ASC, `updated_at` DESC)" + }, + { + "name": "idx_notes_trashed", + "unique": false, + "columnNames": [ + "trashed_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_notes_trashed` ON `${TABLE_NAME}` (`trashed_at`)" + }, + { + "name": "idx_notes_updated", + "unique": false, + "columnNames": [ + "updated_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_notes_updated` ON `${TABLE_NAME}` (`updated_at`)" + } + ], + "foreignKeys": [ + { + "table": "folders", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "folder_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1a9615da6e5de5c045cf8c66cc3c3970')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/filestech/notes_tech/data/local/LegacyDatabaseOpeningTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/data/local/LegacyDatabaseOpeningTest.kt index 8d58d4f..ae3574b 100644 --- a/app/src/androidTest/java/com/filestech/notes_tech/data/local/LegacyDatabaseOpeningTest.kt +++ b/app/src/androidTest/java/com/filestech/notes_tech/data/local/LegacyDatabaseOpeningTest.kt @@ -253,6 +253,8 @@ class LegacyDatabaseOpeningTest { updatedAt = 1L, encVersion = 1, colorId = null, + expiresAt = null, + readOnce = false, ), ) diff --git a/app/src/androidTest/java/com/filestech/notes_tech/data/local/MigrationVersDixTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/data/local/MigrationVersDixTest.kt index ed4ec78..a45c160 100644 --- a/app/src/androidTest/java/com/filestech/notes_tech/data/local/MigrationVersDixTest.kt +++ b/app/src/androidTest/java/com/filestech/notes_tech/data/local/MigrationVersDixTest.kt @@ -89,6 +89,25 @@ class MigrationVersDixTest { assertThat(empreinte).isNotEqualTo(EMPREINTE_DU_SCHEMA_9) } + /** + * 3.4.0 (D-043) — **the base of every 3.1 to 3.3 user**: schema 10, adopted by Room. It must reach 11 + * through `MIGRATION_10_11` alone, every note kept. + */ + @Test + fun a_base_of_3_3_0_at_10_is_migrated_to_11_and_keeps_every_note(): Unit = runBlocking { + poserLaBaseDe330() + val avant = lignesBrutes() + + val base = provider.get() + + verifierMigree(base, avant) + val empreinte = base.openHelper.readableDatabase.query("SELECT identity_hash FROM room_master_table").use { + it.moveToFirst() + it.getString(0) + } + assertThat(empreinte).isNotEqualTo(EMPREINTE_DU_SCHEMA_10) + } + private suspend fun verifierMigree(base: NotesDatabase, avant: Map>) { val db = base.openHelper.readableDatabase val version = db.query("PRAGMA user_version").use { @@ -99,7 +118,7 @@ class MigrationVersDixTest { val colonnes = db.query("PRAGMA table_info(notes)").use { curseur -> buildList { while (curseur.moveToNext()) add(curseur.getString(1)) } } - assertThat(colonnes).contains("color_id") + assertThat(colonnes).containsAtLeast("color_id", "expires_at", "read_once") // Every note is there, its content and its sealed blob as they were, and no colour yet. val apres = base.noteDao().listAllAlive() @@ -109,12 +128,21 @@ class MigrationVersDixTest { assertThat(ligne.content).isEqualTo(contenu) assertThat(ligne.encryptedContent?.toList()).isEqualTo(blob?.toList()) assertThat(ligne.colorId).isNull() + assertThat(ligne.expiresAt).isNull() + assertThat(ligne.readOnce).isFalse() } // And the column takes a colour — the FTS update trigger runs on that write, and must hold. val premiere = apres.first() assertThat(base.noteWriteDao().updateColor(premiere.id, colorId = 3, updatedAt = 1L)).isEqualTo(1) assertThat(base.noteDao().findById(premiere.id)?.colorId).isEqualTo(3) + + // And a lifetime (3.4.0): written, then the purge erases that note and that note only. + assertThat(base.noteWriteDao().updateLifetime(premiere.id, expiresAt = 5L, readOnce = false)).isEqualTo(1) + assertThat(base.noteWriteDao().deleteExpired(now = 4L)).isEqualTo(0) + assertThat(base.noteWriteDao().deleteExpired(now = 5L)).isEqualTo(1) + assertThat(base.noteDao().findById(premiere.id)).isNull() + assertThat(base.noteDao().listAllAlive().map { it.id }).containsExactlyElementsIn(avant.keys - premiere.id) } /** The notes as the Flutter base holds them, read with SQLCipher directly, before Room opens it. */ @@ -130,6 +158,15 @@ class MigrationVersDixTest { db.execSQL("INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '$EMPREINTE_DU_SCHEMA_9')") } + /** A base as 3.1.0 to 3.3.0 leave it: schema 10 — the colour — and Room's identity of 10. */ + private fun poserLaBaseDe330() = ouvrirBrut().use { db -> + // The real migration object: SQLCipher's database is a SupportSQLiteDatabase. + NotesDatabase.MIGRATION_9_10.migrate(db) + db.execSQL("PRAGMA user_version = 10") + db.execSQL("CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)") + db.execSQL("INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '$EMPREINTE_DU_SCHEMA_10')") + } + private fun ouvrirBrut(): SQLiteDatabase = SQLiteDatabase.openDatabase( fichier.absolutePath, SqlCipherRawKey.encode(kek), @@ -141,5 +178,8 @@ class MigrationVersDixTest { private companion object { /** `identityHash` of `app/schemas/…/9.json`: the schema every 3.0.0 base was validated against. */ const val EMPREINTE_DU_SCHEMA_9 = "5ba6eb3f27afffe903eca6b0e5e9b38d" + + /** `identityHash` of `app/schemas/.../10.json`, the schema of 3.1.0 to 3.3.0. */ + const val EMPREINTE_DU_SCHEMA_10 = "e8320016c1e5a1f8f00635be6c9cad1f" } } diff --git a/app/src/androidTest/java/com/filestech/notes_tech/data/local/NoteLinkTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/data/local/NoteLinkTest.kt index 5dfd938..73e9260 100644 --- a/app/src/androidTest/java/com/filestech/notes_tech/data/local/NoteLinkTest.kt +++ b/app/src/androidTest/java/com/filestech/notes_tech/data/local/NoteLinkTest.kt @@ -159,6 +159,8 @@ class NoteLinkTest { updatedAt = 1L, encVersion = 1, colorId = null, + expiresAt = null, + readOnce = false, ) private fun openDatabase(): NotesDatabase { 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 c23a978..c9657a3 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 @@ -418,6 +418,8 @@ class NotesRepositoryTest { updatedAt = horloge.millis(), encVersion = 1, colorId = null, + expiresAt = null, + readOnce = false, ), ) diff --git a/app/src/main/java/com/filestech/notes_tech/data/local/NotesDatabase.kt b/app/src/main/java/com/filestech/notes_tech/data/local/NotesDatabase.kt index e93b76e..f1a8f83 100644 --- a/app/src/main/java/com/filestech/notes_tech/data/local/NotesDatabase.kt +++ b/app/src/main/java/com/filestech/notes_tech/data/local/NotesDatabase.kt @@ -96,10 +96,10 @@ abstract class NotesDatabase : RoomDatabase() { * the notes' colour (3.1.0, D-028). * * ⚠️ Raised only with a real migration, registered in `NotesDatabaseFactory`, never to "start clean": every installed - * base is 9 or 10, and `audits/verifier-schema-room-vs-flutter.py` checks that the Flutter + * base is 9, 10 or 11, and `audits/verifier-schema-room-vs-flutter.py` checks that the Flutter * schema, migrated, gives exactly what Room expects. */ - const val VERSION = 10 + const val VERSION = 11 /** * 9 → 10: the notes' colour (3.1.0). One nullable column: every existing note gets `NULL`, no @@ -115,6 +115,21 @@ abstract class NotesDatabase : RoomDatabase() { } } + /** + * 10 → 11: a note's lifetime (3.4.0, D-043). Two columns, nothing rewritten: every note gets no + * lifetime — `NULL` and `0` —, which is what it had. The FTS index and `note_links` do not read + * them; `notes_au` rewrites a note's FTS row on any UPDATE, these two included, as for the colour. + * + * ⚠️ Kept in step with `MIGRATIONS` of `audits/verifier-schema-room-vs-flutter.py`; + * `MigrationVersDixTest` replays it on a real SQLCipher base, from 9 and from 10. + */ + val MIGRATION_10_11 = object : Migration(10, 11) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE notes ADD COLUMN expires_at INTEGER") + db.execSQL("ALTER TABLE notes ADD COLUMN read_once INTEGER NOT NULL DEFAULT 0") + } + } + /** * Rattache à Room ce qu'il ne sait pas créer, et applique le profil d'ouverture. * diff --git a/app/src/main/java/com/filestech/notes_tech/data/local/NotesDatabaseFactory.kt b/app/src/main/java/com/filestech/notes_tech/data/local/NotesDatabaseFactory.kt index 078839c..95cc952 100644 --- a/app/src/main/java/com/filestech/notes_tech/data/local/NotesDatabaseFactory.kt +++ b/app/src/main/java/com/filestech/notes_tech/data/local/NotesDatabaseFactory.kt @@ -91,7 +91,7 @@ class NotesDatabaseFactory( .addCallback(NotesDatabase.callback(nowMillis)) // Every migration, by name: a new schema version adds its own here (detekt refuses the // spread of an array, which copied it for nothing). - .addMigrations(NotesDatabase.MIGRATION_9_10) + .addMigrations(NotesDatabase.MIGRATION_9_10, NotesDatabase.MIGRATION_10_11) // ⚠️ AUCUN `fallbackToDestructiveMigration*` ici, et cette absence est porteuse. // // Room 2.7 a changé la signature de `fallbackToDestructiveMigrationOnDowngrade` : le diff --git a/app/src/main/java/com/filestech/notes_tech/data/local/dao/NoteDao.kt b/app/src/main/java/com/filestech/notes_tech/data/local/dao/NoteDao.kt index a147301..e994375 100644 --- a/app/src/main/java/com/filestech/notes_tech/data/local/dao/NoteDao.kt +++ b/app/src/main/java/com/filestech/notes_tech/data/local/dao/NoteDao.kt @@ -110,10 +110,16 @@ interface NoteDao { * Toutes les notes hors corbeille, **archives comprises**. * * Balayage complet : réservé aux consommateurs qui en ont réellement besoin — statistiques, - * export, sauvegarde. Jamais dans une boucle de rendu, et jamais pour construire l'index + * export, sauvegarde. + * + * ⚠️ **Sans les notes à durée de vie** (D-043, 3.4.0) : l'export ZIP ne doit pas les emporter, une + * restauration les ferait revivre (Patrice, 2026-11-09). Jamais dans une boucle de rendu, et jamais pour construire l'index * des rétroliens : [titlesForLinking] existe pour ça et ne charge pas les blobs. */ - @Query("SELECT * FROM notes WHERE trashed_at IS NULL ORDER BY updated_at DESC") + @Query( + "SELECT * FROM notes WHERE trashed_at IS NULL AND expires_at IS NULL AND read_once = 0 " + + "ORDER BY updated_at DESC", + ) suspend fun listAllAlive(): List /** diff --git a/app/src/main/java/com/filestech/notes_tech/data/local/dao/NoteWriteDao.kt b/app/src/main/java/com/filestech/notes_tech/data/local/dao/NoteWriteDao.kt index 1367199..b0fdc20 100644 --- a/app/src/main/java/com/filestech/notes_tech/data/local/dao/NoteWriteDao.kt +++ b/app/src/main/java/com/filestech/notes_tech/data/local/dao/NoteWriteDao.kt @@ -119,6 +119,14 @@ interface NoteWriteDao { @Query("UPDATE notes SET color_id = :colorId, updated_at = :updatedAt WHERE id = :id") suspend fun updateColor(id: String, colorId: Int?, updatedAt: Long): Int + /** D-043 (3.4.0): the note's lifetime — see `NotesRepository.setLifetime`. `updated_at` untouched. */ + @Query("UPDATE notes SET expires_at = :expiresAt, read_once = :readOnce WHERE id = :id") + suspend fun updateLifetime(id: String, expiresAt: Long?, readOnce: Boolean): Int + + /** D-043: one DELETE, the one of [deletePermanently] — see `NotesRepository.purgeExpired`. */ + @Query("DELETE FROM notes WHERE expires_at IS NOT NULL AND expires_at <= :now") + suspend fun deleteExpired(now: Long): Int + /** * Écrit les seules étiquettes. * diff --git a/app/src/main/java/com/filestech/notes_tech/data/local/dao/SauvegardeDao.kt b/app/src/main/java/com/filestech/notes_tech/data/local/dao/SauvegardeDao.kt index 407716b..6b48787 100644 --- a/app/src/main/java/com/filestech/notes_tech/data/local/dao/SauvegardeDao.kt +++ b/app/src/main/java/com/filestech/notes_tech/data/local/dao/SauvegardeDao.kt @@ -18,7 +18,8 @@ interface SauvegardeDao { @Query( "SELECT id, folder_id AS dossierId, " + "length(title) + length(content) + length(tags) + ifnull(length(encrypted_content), 0) AS poids " + - "FROM notes ORDER BY id", + // D-043 (3.4.0): a note with a lifetime stays out — a restore would bring it back. + "FROM notes WHERE expires_at IS NULL AND read_once = 0 ORDER BY id", ) suspend fun poidsDesNotes(): List diff --git a/app/src/main/java/com/filestech/notes_tech/data/local/entity/NoteEntity.kt b/app/src/main/java/com/filestech/notes_tech/data/local/entity/NoteEntity.kt index a2ece9e..9c22daa 100644 --- a/app/src/main/java/com/filestech/notes_tech/data/local/entity/NoteEntity.kt +++ b/app/src/main/java/com/filestech/notes_tech/data/local/entity/NoteEntity.kt @@ -125,6 +125,16 @@ class NoteEntity( */ @ColumnInfo(name = "color_id") val colorId: Int?, + /** + * D-043 (3.4.0): when the note erases itself, epoch milliseconds; `null` for never. Read by the + * purge ([com.filestech.notes_tech.data.local.dao.NoteWriteDao.deleteExpired]), not a secret. + * No default, for [colorId]'s reason: every constructor says it. + */ + @ColumnInfo(name = "expires_at") + val expiresAt: Long?, + /** D-043: erased when closed after it was opened again. `DEFAULT 0` as the migration writes it. */ + @ColumnInfo(name = "read_once", defaultValue = "0") + val readOnce: Boolean, ) { /** `true` si la note est verrouillée dans un coffre. Unique test à utiliser — jamais * `content.isEmpty()`, qui est aussi vrai d'une note vide ordinaire. */ diff --git a/app/src/main/java/com/filestech/notes_tech/data/local/mapper/NoteMapper.kt b/app/src/main/java/com/filestech/notes_tech/data/local/mapper/NoteMapper.kt index 669906f..717b657 100644 --- a/app/src/main/java/com/filestech/notes_tech/data/local/mapper/NoteMapper.kt +++ b/app/src/main/java/com/filestech/notes_tech/data/local/mapper/NoteMapper.kt @@ -34,6 +34,8 @@ internal fun NoteEntity.toDomain(): Note = Note( encrypted = encryptedContent?.let(::EncryptedBody), encVersion = encVersion, color = NoteColor.fromId(colorId), + expiresAt = expiresAt?.let(Instant::ofEpochMilli), + readOnce = readOnce, ) internal fun List.toDomain(): List = map(NoteEntity::toDomain) diff --git a/app/src/main/java/com/filestech/notes_tech/data/repository/NotesRepository.kt b/app/src/main/java/com/filestech/notes_tech/data/repository/NotesRepository.kt index 7ca35f5..1d75cf5 100644 --- a/app/src/main/java/com/filestech/notes_tech/data/repository/NotesRepository.kt +++ b/app/src/main/java/com/filestech/notes_tech/data/repository/NotesRepository.kt @@ -16,6 +16,7 @@ import com.filestech.notes_tech.domain.links.TitleNormalizer import com.filestech.notes_tech.domain.links.WikiLinkParser import com.filestech.notes_tech.domain.model.EncryptedFormat import com.filestech.notes_tech.domain.model.Folder +import com.filestech.notes_tech.domain.model.DureeDeVie import com.filestech.notes_tech.domain.model.Note import com.filestech.notes_tech.domain.model.NoteColor import com.filestech.notes_tech.domain.model.NoteSortMode @@ -740,6 +741,25 @@ class NotesRepository @Inject constructor( true } + /** + * How long the note lives (D-043, 3.4.0); `null` for always. A duration counts from now: choosing + * "1 hour" again starts the hour again. Writes those two columns only — not `updated_at`, which + * orders the lists: giving a note a lifetime is not editing it. + */ + suspend fun setLifetime(id: String, duree: DureeDeVie?): Boolean { + val expireLe = duree?.duree?.let { clock.instant().plus(it).toEpochMilli() } + return databases.get().noteWriteDao().updateLifetime(id, expireLe, readOnce = duree == DureeDeVie.APRES_LECTURE) > 0 + } + + /** + * Erases the notes whose lifetime is over (D-043) — the trash's too —, as "Delete permanently" erases + * one: the same DELETE, so the full-text index (trigger `notes_ad`, `secure-delete`), the links + * (foreign keys) and `PRAGMA secure_delete` treat them alike. A vault note needs no key for that. + * + * @return how many were erased. + */ + suspend fun purgeExpired(): Int = databases.get().noteWriteDao().deleteExpired(clock.millis()) + /** * Purge les notes en corbeille au-delà de la rétention héritée de trente jours. * @@ -929,6 +949,8 @@ class NotesRepository @Inject constructor( updatedAt = updatedAt.toEpochMilli(), encVersion = encVersion, colorId = color?.id, + expiresAt = expiresAt?.toEpochMilli(), + readOnce = readOnce, ) private fun requireTitleWithinLimit(title: String) { diff --git a/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/RestaurationDeSauvegarde.kt b/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/RestaurationDeSauvegarde.kt index 50ed277..50102d9 100644 --- a/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/RestaurationDeSauvegarde.kt +++ b/app/src/main/java/com/filestech/notes_tech/data/sauvegarde/RestaurationDeSauvegarde.kt @@ -343,6 +343,9 @@ class RestaurationDeSauvegarde @Inject constructor( updatedAt = modifieeLe, encVersion = versionDeChiffrement, colorId = couleur, + // A backup never holds a note with a lifetime (D-043). + expiresAt = null, + readOnce = false, ) private companion object { diff --git a/app/src/main/java/com/filestech/notes_tech/domain/model/DureeDeVie.kt b/app/src/main/java/com/filestech/notes_tech/domain/model/DureeDeVie.kt new file mode 100644 index 0000000..95f2e99 --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/domain/model/DureeDeVie.kt @@ -0,0 +1,17 @@ +package com.filestech.notes_tech.domain.model + +import java.time.Duration + +/** + * How long a note lives (D-043, 3.4.0) — the five choices Patrice kept on 2026-11-09. + * + * A [duree] counts from the moment it is set; [APRES_LECTURE] has none: the note is erased when it is + * closed after it was opened again — the session where the choice is made does not count. + */ +enum class DureeDeVie(val duree: Duration?) { + UNE_HEURE(Duration.ofHours(1)), + UN_JOUR(Duration.ofDays(1)), + SEPT_JOURS(Duration.ofDays(7)), + TRENTE_JOURS(Duration.ofDays(30)), + APRES_LECTURE(null), +} diff --git a/app/src/main/java/com/filestech/notes_tech/domain/model/Note.kt b/app/src/main/java/com/filestech/notes_tech/domain/model/Note.kt index b08afbf..80ae0f9 100644 --- a/app/src/main/java/com/filestech/notes_tech/domain/model/Note.kt +++ b/app/src/main/java/com/filestech/notes_tech/domain/model/Note.kt @@ -46,6 +46,13 @@ data class Note( * `ui/home/CouleurVisible.kt`. */ val color: NoteColor? = null, + /** + * When the note erases itself (D-043, 3.4.0), `null` for never. Not a secret — outside the vault's + * envelope, like [updatedAt] — so a locked vault note keeps it and its card shows it. + */ + val expiresAt: Instant? = null, + /** Erased when closed after it was opened again (D-043): "after reading". */ + val readOnce: Boolean = false, ) { /** * `true` si la note est verrouillée dans un coffre. diff --git a/audits/verifier-schema-room-vs-flutter.py b/audits/verifier-schema-room-vs-flutter.py index 9311707..ca9d0d5 100644 --- a/audits/verifier-schema-room-vs-flutter.py +++ b/audits/verifier-schema-room-vs-flutter.py @@ -97,6 +97,10 @@ # point — and the instrumented migration test replays the real Kotlin object on a real base. MIGRATIONS = { 10: ["ALTER TABLE notes ADD COLUMN color_id INTEGER"], + 11: [ + "ALTER TABLE notes ADD COLUMN expires_at INTEGER", + "ALTER TABLE notes ADD COLUMN read_once INTEGER NOT NULL DEFAULT 0", + ], } SCHEMA_DIR = pathlib.Path(__file__).resolve().parent.parent / "app" / "schemas" diff --git a/docs/15-VERSION-3.4.md b/docs/15-VERSION-3.4.md index b6bc5f8..82540af 100644 --- a/docs/15-VERSION-3.4.md +++ b/docs/15-VERSION-3.4.md @@ -44,7 +44,11 @@ ## D-041 — Raccourcis sur l'icône : Nouvelle note, Dictée, Dépôt rapide -- Raccourcis statiques (`res/xml/shortcuts.xml`), aucune permission. +- Raccourcis **dynamiques**, publiés par `MainActivity.onCreate` (`publierLesRaccourcis`), aucune + permission. Pas de `shortcuts.xml` : un raccourci statique écrit son paquet en dur, et l'appli est + construite sous plusieurs identifiants (release, `.next`, `.next.debug`). Publiés avec le contexte de + l'activité : libellés dans la langue choisie dans l'appli ; une appli au premier plan n'est jamais + limitée par le service des raccourcis. - « Nouvelle note » et « Dictée » visent une activité **non exportée** de transit qui dépose la demande en mémoire puis lance l'appli : `MainActivity` continue d'ignorer les intents, aucune autre appli ne peut imposer une création ou un micro. La demande s'exécute **après** le déverrouillage de l'appli. @@ -56,8 +60,12 @@ - Une activité à part, en écriture seule : un champ de titre, un champ de texte, « Déposer », « Annuler ». Elle ne lit **rien** de la base, n'affiche aucune note, aucun dossier, aucun nombre ; après « Déposer », - le texte quitte l'écran. Aucun lien vers le reste de l'appli autre que « Ouvrir Notes Tech » (qui - demande le déverrouillage). + l'écran se referme (« Note déposée dans la Boîte de réception »). **Aucun lien** vers le reste de + l'appli : un « Ouvrir Notes Tech » prévu au départ n'a pas été fait, une porte de moins. +- Elle peut être la PREMIÈRE activité de son processus (la tuile la démarre dans un processus neuf) : + elle pose elle-même ce que `MainActivity` pose d'ordinaire — fenêtre protégée, actions de texte des + autres applis et classifieur coupés, langue choisie, thème, clavier sans apprentissage. +- Rien n'est écrit pendant une panique en cours (`PanicJournal.isPending`) : le texte reste à l'écran. - Ouverte par la **tuile des réglages rapides** et le **raccourci** « Dépôt rapide ». Si le TÉLÉPHONE est verrouillé, Android demande d'abord son déverrouillage (`unlockAndRun`) — seul le verrou de Notes Tech est contourné (Patrice). @@ -100,7 +108,22 @@ ## État -- [ ] D-040 clavier · [ ] D-041 raccourcis · [ ] D-042 dépôt · [ ] D-043 durée de vie · [ ] D-044 scellée +- [x] **D-040 clavier** (`5e18eab`) — tests instrumentés sur le S9 : `ClavierSansApprentissageTest` (3), + `ChampDuCorpsTest` +1, `ReglagesHeritesTest` +1, `SaisieDeCoffreTest` inchangé (8) ; la ligne du réglage + vue dans l'écran, section Sécurité. +- [x] **D-041 raccourcis** (`5e18eab`) — `RaccourcisTest` (4, JVM). Sur le S9 (lanceur Samsung) : les trois + raccourcis s'affichent à l'appui long, le lanceur démarre bien l'activité NON exportée (aucune + `SecurityException`), « Nouvelle note » ouvre une note vierge côté écriture, « Dicter une note » sans + modèle ouvre l'écran d'installation. Comme le bouton « Nouvelle note » depuis la 2.0.9, une note vide + quittée reste (pas d'effacement des notes vides : comportement inchangé). +- [x] **D-042 dépôt** (`5e18eab`) — `DepotViewModelTest` (5, JVM : Boîte de réception, `confirmVerified` + — rien d'autre n'est appelé —, panique en cours, échec qui garde le texte, limites). Sur le S9, **avec + le verrou de l'appli activé** (code posé pour l'essai) : dépôt par le raccourci sans code, l'appli + demande toujours son code à l'ouverture, la note est dans la Boîte de réception ; la tuile + (`cmd statusbar add-tile` / `click-tile`) ouvre le dépôt. **Non essayé sur appareil** : la tuile + téléphone verrouillé (`unlockAndRun`) — le S9 n'a pas de code d'écran, en poser un change un réglage + global. +- [ ] D-043 durée de vie · [ ] D-044 scellée - [ ] Textes 5 langues · [ ] version 3.4.0 (507) · [ ] journaux 5071-5073 · [ ] README, SECURITY, CGU/confidentialité - [ ] Audits (3 axes, sécurité des nouvelles entrées, i18n, cohérence) · [ ] relecture externe · [ ] S9 · [ ] CI - [ ] Avant le tag : build F-Droid sous AGP 9.5 (voir d'abord comment F-Droid a construit SMS 1.30.0) From 528a89e96529a51f607d47e72302e6f868fc0a5d Mon Sep 17 00:00:00 2001 From: Patrice Haltaya Date: Mon, 9 Nov 2026 20:46:25 +0100 Subject: [PATCH 04/11] feat: a note's lifetime, in the editor and on the cards (3.4.0, D-043) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The editor's ⋮ menu has "Lifetime…": none, 1 hour, 24 hours, 7 days, 30 days, after reading. A tap applies it. The card shows what is left ("5 h", "after reading"), TalkBack says it, on a locked vault note too — not a secret. - Notes whose lifetime is over are erased at the opening of the app (StartupViewModel) and every minute while the list is on screen. - "After reading" erases the note in the editor ViewModel's onCleared, after the last save — not on the screen's disposal, which a rotation triggers. A choice made while the note is open counts from its next opening; the reader is told once the note is open. - detekt's thresholds moved the lifetime out of NotesRepository (DureesDeVie) and grouped the card's two sheets (FeuillesDeLaFiche) on the editor screen. - Texts in the five languages, plurals included. Tests: ResteDeVieTest (5) and DureesDeVieTest (5) on the JVM; on the S9 LectureUniqueTest (3: erased at closing; control, chosen in the session; witness without lifetime), EditeurTest (36), RechercheDansLaNoteEcranTest (5), FermetureDuCoffreTest (9), LiensDansUnCoffreTest (3), GestesSurDesNotesTest (14), AccueilTest (20) — 0 failed. Co-Authored-By: Claude Opus 5.5 --- .../notes_tech/ui/editor/EditeurTest.kt | 2 +- .../ui/editor/FermetureDuCoffreTest.kt | 5 + .../notes_tech/ui/editor/LectureUniqueTest.kt | 171 ++++++++++++++++++ .../ui/editor/LiensDansUnCoffreTest.kt | 5 + .../ui/editor/RechercheDansLaNoteEcranTest.kt | 2 +- .../ui/home/GestesSurDesNotesTest.kt | 2 + .../notes_tech/data/repository/DureesDeVie.kt | 35 ++++ .../data/repository/NotesRepository.kt | 20 -- .../notes_tech/ui/common/ResteDeVie.kt | 101 +++++++++++ .../ui/editor/FeuilleDeDureeDeVie.kt | 162 +++++++++++++++++ .../notes_tech/ui/editor/NoteEditorScreen.kt | 42 ++++- .../ui/editor/NoteEditorViewModel.kt | 69 ++++++- .../filestech/notes_tech/ui/home/HomeRoute.kt | 11 ++ .../notes_tech/ui/home/HomeViewModel.kt | 19 ++ .../filestech/notes_tech/ui/home/NoteCard.kt | 27 +++ .../notes_tech/ui/startup/StartupViewModel.kt | 18 ++ app/src/main/res/values-de/strings.xml | 30 +++ app/src/main/res/values-es/strings.xml | 33 ++++ app/src/main/res/values-fr/strings.xml | 33 ++++ app/src/main/res/values-it/strings.xml | 33 ++++ app/src/main/res/values/strings.xml | 30 +++ .../data/repository/DureesDeVieTest.kt | 74 ++++++++ .../notes_tech/ui/common/ResteDeVieTest.kt | 50 +++++ .../ui/startup/StartupViewModelTest.kt | 6 +- docs/15-VERSION-3.4.md | 40 +++- 25 files changed, 987 insertions(+), 33 deletions(-) create mode 100644 app/src/androidTest/java/com/filestech/notes_tech/ui/editor/LectureUniqueTest.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/data/repository/DureesDeVie.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/ui/common/ResteDeVie.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/ui/editor/FeuilleDeDureeDeVie.kt create mode 100644 app/src/test/java/com/filestech/notes_tech/data/repository/DureesDeVieTest.kt create mode 100644 app/src/test/java/com/filestech/notes_tech/ui/common/ResteDeVieTest.kt diff --git a/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/EditeurTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/EditeurTest.kt index f31aaa6..7b1f783 100644 --- a/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/EditeurTest.kt +++ b/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/EditeurTest.kt @@ -181,7 +181,7 @@ class EditeurTest { onInfos = { infos += Unit }, onDeplacer = { deplacements += Unit }, onArchiver = { archivages += it }, - onCouleur = { couleurs += Unit }, + feuilles = FeuillesDeLaFiche(couleur = { couleurs += Unit }, dureeDeVie = {}), onExporter = { exports += Unit }, onCopier = { copies += Unit }, onCorbeille = { corbeilles += Unit }, 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 ea94960..7e999aa 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 @@ -11,6 +11,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.filestech.notes_tech.R import com.filestech.notes_tech.data.export.NoteExporter import com.filestech.notes_tech.data.prefs.AppSettings +import com.filestech.notes_tech.data.repository.DureesDeVie import com.filestech.notes_tech.data.repository.FoldersRepository import com.filestech.notes_tech.data.repository.LinksRepository import com.filestech.notes_tech.data.repository.NotesRepository @@ -56,6 +57,9 @@ class FermetureDuCoffreTest { @Inject lateinit var notes: NotesRepository + @Inject + lateinit var durees: DureesDeVie + @Inject lateinit var links: LinksRepository @@ -277,6 +281,7 @@ class FermetureDuCoffreTest { links = links, exporter = exporter, folders = folders, + durees = durees, vaults = vaults, settings = settings, clipboard = clipboard, diff --git a/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/LectureUniqueTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/LectureUniqueTest.kt new file mode 100644 index 0000000..7e7c778 --- /dev/null +++ b/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/LectureUniqueTest.kt @@ -0,0 +1,171 @@ +package com.filestech.notes_tech.ui.editor + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.filestech.notes_tech.data.export.NoteExporter +import com.filestech.notes_tech.data.prefs.AppSettings +import com.filestech.notes_tech.data.repository.DureesDeVie +import com.filestech.notes_tech.data.repository.FoldersRepository +import com.filestech.notes_tech.data.repository.LinksRepository +import com.filestech.notes_tech.data.repository.NotesRepository +import com.filestech.notes_tech.domain.model.DureeDeVie +import com.filestech.notes_tech.domain.model.Folder +import com.filestech.notes_tech.domain.model.Note +import com.filestech.notes_tech.security.clipboard.SensitiveClipboard +import com.filestech.notes_tech.security.vault.FolderVaultService +import com.filestech.notes_tech.ui.navigation.Destination +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +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 + +/** + * D-043 (3.4.0) — **"after reading": the note goes when its editor closes**, on the real editor and the + * real database. The editor's ViewModel is cleared as the back stack clears it when the screen is left; + * a rotation, which keeps the ViewModel, erases nothing — that is why the erasing sits in `onCleared`. + * Each case has its control. + */ +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class LectureUniqueTest { + + @get:Rule + val hilt = HiltAndroidRule(this) + + @Inject + lateinit var notes: NotesRepository + + @Inject + lateinit var durees: DureesDeVie + + @Inject + lateinit var links: LinksRepository + + @Inject + lateinit var exporter: NoteExporter + + @Inject + lateinit var folders: FoldersRepository + + @Inject + lateinit var vaults: FolderVaultService + + @Inject + lateinit var settings: AppSettings + + @Inject + lateinit var clipboard: SensitiveClipboard + + private val suffixe = UUID.randomUUID().toString().take(8) + private val portee = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val magasin = ViewModelStore() + private val creees = mutableListOf() + + @Before + fun setUp() { + hilt.inject() + } + + @After + fun tearDown(): Unit = runBlocking { + withContext(Dispatchers.Main) { magasin.clear() } + portee.cancel() + creees.forEach { notes.deletePermanently(it) } + } + + @Test + fun a_note_read_once_is_erased_when_its_editor_closes(): Unit = runBlocking { + val note = nouvelleNote("à lire une fois") + durees.poser(note.id, DureeDeVie.APRES_LECTURE) + + ouvrirPuisFermer(note) { editeur -> + assertThat(editeur.effaceeALaFermeture).isTrue() + } + + withTimeout(ATTENTE_MS) { + while (notes.find(note.id) != null) delay(PAS_MS) + } + } + + /** The choice made in this session counts from the NEXT opening: this closing erases nothing. */ + @Test + fun control_a_lifetime_chosen_while_the_note_is_open_does_not_erase_it_at_this_closing(): Unit = runBlocking { + val note = nouvelleNote("choisie maintenant") + + ouvrirPuisFermer(note) { editeur -> + editeur.setLifetime(DureeDeVie.APRES_LECTURE) + withTimeout(ATTENTE_MS) { editeur.state.first { it.note?.readOnce == true } } + assertThat(editeur.effaceeALaFermeture).isFalse() + } + + delay(ATTENTE_DE_L_EFFACEMENT_MS) + assertThat(notes.find(note.id)?.readOnce).isTrue() + } + + @Test + fun control_a_note_without_a_lifetime_stays(): Unit = runBlocking { + val note = nouvelleNote("ordinaire") + + ouvrirPuisFermer(note) { editeur -> assertThat(editeur.effaceeALaFermeture).isFalse() } + + delay(ATTENTE_DE_L_EFFACEMENT_MS) + assertThat(notes.find(note.id)).isNotNull() + } + + private suspend fun nouvelleNote(texte: String): Note = + notes.create(folderId = Folder.INBOX_ID, title = "Lecture unique $suffixe", content = "$texte $suffixe") + .also { creees += it.id } + + private suspend fun ouvrirPuisFermer(note: Note, pendant: suspend (NoteEditorViewModel) -> Unit) { + val editeur = withContext(Dispatchers.Main) { editeur(note) } + withTimeout(ATTENTE_MS) { editeur.state.first { !it.loading } } + pendant(editeur) + withContext(Dispatchers.Main) { magasin.clear() } + } + + @Suppress("UNCHECKED_CAST") + private fun editeur(note: Note): NoteEditorViewModel { + val fabrique = object : ViewModelProvider.Factory { + override fun create(modelClass: Class, extras: CreationExtras): T = NoteEditorViewModel( + notes = notes, + links = links, + exporter = exporter, + folders = folders, + durees = durees, + vaults = vaults, + settings = settings, + clipboard = clipboard, + applicationScope = portee, + savedState = SavedStateHandle(mapOf(Destination.ARG_NOTE_ID to note.id)), + ) as T + } + return ViewModelProvider.create(magasin, fabrique)[NoteEditorViewModel::class] + } + + private companion object { + const val ATTENTE_MS = 10_000L + const val PAS_MS = 100L + + /** Longer than an erasing takes on the S9 (a DELETE), to see that none came. */ + const val ATTENTE_DE_L_EFFACEMENT_MS = 1_500L + } +} diff --git a/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/LiensDansUnCoffreTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/LiensDansUnCoffreTest.kt index 100e24c..71c5501 100644 --- a/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/LiensDansUnCoffreTest.kt +++ b/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/LiensDansUnCoffreTest.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.viewmodel.CreationExtras import androidx.test.ext.junit.runners.AndroidJUnit4 import com.filestech.notes_tech.data.export.NoteExporter import com.filestech.notes_tech.data.prefs.AppSettings +import com.filestech.notes_tech.data.repository.DureesDeVie import com.filestech.notes_tech.data.repository.FoldersRepository import com.filestech.notes_tech.data.repository.LinksRepository import com.filestech.notes_tech.data.repository.NotesRepository @@ -55,6 +56,9 @@ class LiensDansUnCoffreTest { @Inject lateinit var notes: NotesRepository + @Inject + lateinit var durees: DureesDeVie + @Inject lateinit var links: LinksRepository @@ -185,6 +189,7 @@ class LiensDansUnCoffreTest { links = links, exporter = exporter, folders = folders, + durees = durees, vaults = vaults, settings = settings, clipboard = clipboard, 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 index b172b71..bedd391 100644 --- 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 @@ -87,7 +87,7 @@ class RechercheDansLaNoteEcranTest { onInfos = {}, onDeplacer = {}, onArchiver = {}, - onCouleur = {}, + feuilles = FeuillesDeLaFiche(couleur = {}, dureeDeVie = {}), onExporter = {}, onCopier = {}, onCorbeille = {}, diff --git a/app/src/androidTest/java/com/filestech/notes_tech/ui/home/GestesSurDesNotesTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/ui/home/GestesSurDesNotesTest.kt index 6118d3b..8e44641 100644 --- a/app/src/androidTest/java/com/filestech/notes_tech/ui/home/GestesSurDesNotesTest.kt +++ b/app/src/androidTest/java/com/filestech/notes_tech/ui/home/GestesSurDesNotesTest.kt @@ -13,6 +13,7 @@ import com.filestech.notes_tech.data.local.NotesDatabaseFactory import com.filestech.notes_tech.data.local.SqlCipherRawKey import com.filestech.notes_tech.data.prefs.AppSettings import com.filestech.notes_tech.data.prefs.LegacyPreferences +import com.filestech.notes_tech.data.repository.DureesDeVie import com.filestech.notes_tech.data.repository.FoldersRepository import com.filestech.notes_tech.data.repository.NotesEnLot import com.filestech.notes_tech.data.repository.NotesRepository @@ -113,6 +114,7 @@ class GestesSurDesNotesTest { HomeViewModel( notes = notes, enLot = enLot, + durees = DureesDeVie(provider, horloge), folders = dossiers, search = SearchRepository(provider), settings = AppSettings(LegacyPreferences(preferences)), diff --git a/app/src/main/java/com/filestech/notes_tech/data/repository/DureesDeVie.kt b/app/src/main/java/com/filestech/notes_tech/data/repository/DureesDeVie.kt new file mode 100644 index 0000000..44e3806 --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/data/repository/DureesDeVie.kt @@ -0,0 +1,35 @@ +package com.filestech.notes_tech.data.repository + +import com.filestech.notes_tech.data.local.DatabaseProvider +import com.filestech.notes_tech.domain.model.DureeDeVie +import java.time.Clock +import javax.inject.Inject + +/** + * Notes that erase themselves (D-043, 3.4.0): set a note's lifetime, erase those whose lifetime is over. + * + * Apart from [NotesRepository], as the gestures on several notes are ([NotesEnLot]): its threshold of + * functions holds a class that already says a great deal. + */ +class DureesDeVie @Inject constructor(private val databases: DatabaseProvider, private val clock: Clock) { + + /** + * How long the note lives; `null` for always. A duration counts from now: choosing "1 hour" again + * starts the hour again. Writes those two columns only — not `updated_at`, which orders the lists: + * giving a note a lifetime is not editing it. + */ + suspend fun poser(id: String, duree: DureeDeVie?): Boolean { + val expireLe = duree?.duree?.let { clock.instant().plus(it).toEpochMilli() } + val lectureUnique = duree == DureeDeVie.APRES_LECTURE + return databases.get().noteWriteDao().updateLifetime(id, expireLe, lectureUnique) > 0 + } + + /** + * Erases the notes whose lifetime is over — the trash's too —, as "Delete permanently" erases one: the + * same DELETE, so the full-text index (trigger `notes_ad`, `secure-delete`), the links (foreign keys) + * and `PRAGMA secure_delete` treat them alike. A vault note needs no key for that. + * + * @return how many were erased. + */ + suspend fun purger(): Int = databases.get().noteWriteDao().deleteExpired(clock.millis()) +} diff --git a/app/src/main/java/com/filestech/notes_tech/data/repository/NotesRepository.kt b/app/src/main/java/com/filestech/notes_tech/data/repository/NotesRepository.kt index 1d75cf5..f259ecb 100644 --- a/app/src/main/java/com/filestech/notes_tech/data/repository/NotesRepository.kt +++ b/app/src/main/java/com/filestech/notes_tech/data/repository/NotesRepository.kt @@ -16,7 +16,6 @@ import com.filestech.notes_tech.domain.links.TitleNormalizer import com.filestech.notes_tech.domain.links.WikiLinkParser import com.filestech.notes_tech.domain.model.EncryptedFormat import com.filestech.notes_tech.domain.model.Folder -import com.filestech.notes_tech.domain.model.DureeDeVie import com.filestech.notes_tech.domain.model.Note import com.filestech.notes_tech.domain.model.NoteColor import com.filestech.notes_tech.domain.model.NoteSortMode @@ -741,25 +740,6 @@ class NotesRepository @Inject constructor( true } - /** - * How long the note lives (D-043, 3.4.0); `null` for always. A duration counts from now: choosing - * "1 hour" again starts the hour again. Writes those two columns only — not `updated_at`, which - * orders the lists: giving a note a lifetime is not editing it. - */ - suspend fun setLifetime(id: String, duree: DureeDeVie?): Boolean { - val expireLe = duree?.duree?.let { clock.instant().plus(it).toEpochMilli() } - return databases.get().noteWriteDao().updateLifetime(id, expireLe, readOnce = duree == DureeDeVie.APRES_LECTURE) > 0 - } - - /** - * Erases the notes whose lifetime is over (D-043) — the trash's too —, as "Delete permanently" erases - * one: the same DELETE, so the full-text index (trigger `notes_ad`, `secure-delete`), the links - * (foreign keys) and `PRAGMA secure_delete` treat them alike. A vault note needs no key for that. - * - * @return how many were erased. - */ - suspend fun purgeExpired(): Int = databases.get().noteWriteDao().deleteExpired(clock.millis()) - /** * Purge les notes en corbeille au-delà de la rétention héritée de trente jours. * diff --git a/app/src/main/java/com/filestech/notes_tech/ui/common/ResteDeVie.kt b/app/src/main/java/com/filestech/notes_tech/ui/common/ResteDeVie.kt new file mode 100644 index 0000000..e05736a --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/ui/common/ResteDeVie.kt @@ -0,0 +1,101 @@ +package com.filestech.notes_tech.ui.common + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import com.filestech.notes_tech.R +import kotlinx.coroutines.delay +import java.time.Duration +import java.time.Instant + +/** What is left of a note's lifetime (D-043, 3.4.0), as the cards and the editor say it. */ +sealed interface ResteDeVie { + data class Minutes(val nombre: Int) : ResteDeVie + data class Heures(val nombre: Int) : ResteDeVie + data class Jours(val nombre: Int) : ResteDeVie + + /** The time is up and the purge has not run yet: it runs at the next opening, or on the list. */ + data object Bientot : ResteDeVie + + data object ApresLecture : ResteDeVie +} + +/** + * What is left at [maintenant] of a lifetime ending at [expireLe], or "after reading"; `null` when the note + * has none. Rounded UP: a note with 59 minutes 30 left says "60 min", never "59" — it must not seem to + * have less time than it has. Minutes below an hour, hours below two days, days beyond. + */ +fun resteDeVie(maintenant: Instant, expireLe: Instant?, lectureUnique: Boolean): ResteDeVie? { + if (lectureUnique) return ResteDeVie.ApresLecture + val reste = expireLe?.let { Duration.between(maintenant, it) } ?: return null + val minutes = arrondiAuDessus(reste, Duration.ofMinutes(1)) + val heures = arrondiAuDessus(reste, Duration.ofHours(1)) + return when { + reste.isNegative || reste.isZero -> ResteDeVie.Bientot + minutes < MINUTES_PAR_HEURE -> ResteDeVie.Minutes(minutes.toInt()) + heures < HEURES_AFFICHEES -> ResteDeVie.Heures(heures.toInt()) + else -> ResteDeVie.Jours(arrondiAuDessus(reste, Duration.ofDays(1)).toInt()) + } +} + +private fun arrondiAuDessus(duree: Duration, unite: Duration): Long = + (duree.toMillis() + unite.toMillis() - 1) / unite.toMillis() + +private const val MINUTES_PAR_HEURE = 60L +private const val HEURES_AFFICHEES = 48L + +/** What a card shows of a lifetime: [court] on its badge, [phrase] in what TalkBack reads. */ +data class VieAffichee(val court: String, val phrase: String) + +/** [VieAffichee.phrase] as the next sentence of a card's description — nothing for a note without one. */ +fun VieAffichee?.enPhraseSuivante(): String = if (this == null) "" else ". $phrase" + +/** + * A card's [VieAffichee], `null` for a note without a lifetime — which asks for no clock: only the cards of + * notes with a lifetime move every [rememberMaintenant] step. + */ +@Composable +fun vieAffichee(expireLe: Instant?, lectureUnique: Boolean): VieAffichee? { + if (expireLe == null && !lectureUnique) return null + val reste = resteDeVie(rememberMaintenant(), expireLe, lectureUnique) ?: return null + return VieAffichee(court = reste.court(), phrase = reste.phrase()) +} + +/** Short, for a card's badge: "12 min", "5 h", "3 d". */ +@Composable +fun ResteDeVie.court(): String = when (this) { + is ResteDeVie.Minutes -> stringResource(R.string.lifetime_left_minutes_short, nombre) + is ResteDeVie.Heures -> stringResource(R.string.lifetime_left_hours_short, nombre) + is ResteDeVie.Jours -> stringResource(R.string.lifetime_left_days_short, nombre) + ResteDeVie.Bientot -> stringResource(R.string.lifetime_soon_short) + ResteDeVie.ApresLecture -> stringResource(R.string.lifetime_after_reading) +} + +/** Whole, for TalkBack and the editor: "Erases itself in 12 minutes". */ +@Composable +fun ResteDeVie.phrase(): String = when (this) { + is ResteDeVie.Minutes -> pluralStringResource(R.plurals.lifetime_left_minutes, nombre, nombre) + is ResteDeVie.Heures -> pluralStringResource(R.plurals.lifetime_left_hours, nombre, nombre) + is ResteDeVie.Jours -> pluralStringResource(R.plurals.lifetime_left_days, nombre, nombre) + ResteDeVie.Bientot -> stringResource(R.string.lifetime_soon) + ResteDeVie.ApresLecture -> stringResource(R.string.lifetime_after_reading_phrase) +} + +/** + * Now, moved on every [pasMs]: a card that says "12 min" says "11 min" a minute later without the list + * having to change. Only the cards of notes with a lifetime ask for it. + */ +@Composable +fun rememberMaintenant(pasMs: Long = PAS_MS): Instant { + val maintenant by produceState(Instant.now()) { + while (true) { + delay(pasMs) + value = Instant.now() + } + } + return maintenant +} + +private const val PAS_MS = 30_000L diff --git a/app/src/main/java/com/filestech/notes_tech/ui/editor/FeuilleDeDureeDeVie.kt b/app/src/main/java/com/filestech/notes_tech/ui/editor/FeuilleDeDureeDeVie.kt new file mode 100644 index 0000000..f66503c --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/ui/editor/FeuilleDeDureeDeVie.kt @@ -0,0 +1,162 @@ +package com.filestech.notes_tech.ui.editor + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import com.filestech.notes_tech.R +import com.filestech.notes_tech.domain.model.DureeDeVie +import com.filestech.notes_tech.domain.model.Note +import com.filestech.notes_tech.ui.common.phrase +import com.filestech.notes_tech.ui.common.rememberMaintenant +import com.filestech.notes_tech.ui.common.resteDeVie +import java.time.Instant + +/** + * "Lifetime…" of the editor's ⋮ menu (D-043, 3.4.0): none, 1 hour, 24 hours, 7 days, 30 days, after + * reading. A tap applies it and closes, as the colour sheet does. + * + * The radio shows what is known: "None" or "After reading". A dated lifetime says what is left above the + * list — which of the durations set it is not stored, and a radio would claim it. + */ +@Composable +internal fun FeuilleDeDureeDeVie( + expireLe: Instant?, + lectureUnique: Boolean, + onChoisir: (DureeDeVie?) -> Unit, + onDismiss: () -> Unit, +) { + val reste = resteDeVie(rememberMaintenant(), expireLe, lectureUnique) + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.lifetime_title)) }, + text = { + Column(Modifier.verticalScroll(rememberScrollState())) { + reste?.let { Text(it.phrase(), style = MaterialTheme.typography.bodyMedium) } + Column(Modifier.selectableGroup().padding(top = 8.dp)) { + Choix(R.string.lifetime_none, choisi = reste == null) { onChoisir(null) } + DureeDeVie.entries.forEach { duree -> + Choix( + libelle = duree.libelle(), + detail = if (duree == + DureeDeVie.APRES_LECTURE + ) { + R.string.lifetime_after_reading_detail + } else { + null + }, + choisi = duree == DureeDeVie.APRES_LECTURE && lectureUnique, + ) { onChoisir(duree) } + } + } + Text( + text = stringResource(R.string.lifetime_explanation), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 12.dp), + ) + } + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_cancel)) } }, + ) +} + +@Composable +private fun Choix(@StringRes libelle: Int, choisi: Boolean, @StringRes detail: Int? = null, onClick: () -> Unit) { + // One node per choice, the row selectable — the radio itself takes no tap: one target, one stop. + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .selectable(selected = choisi, role = Role.RadioButton, onClick = onClick) + .padding(vertical = 4.dp), + ) { + RadioButton(selected = choisi, onClick = null) + Column(Modifier.padding(start = 12.dp)) { + Text(stringResource(libelle), style = MaterialTheme.typography.bodyLarge) + detail?.let { + Text( + text = stringResource(it), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +/** + * What opens the sheets about the note's card from the ⋮ menu — its colour (3.1.0), its lifetime (3.4.0). + * One parameter of `NoteEditorScreen` for both: it holds as many as detekt allows. No default, for the + * screen's reason: a menu entry wired to nothing would be a dead button that compiles. + */ +data class FeuillesDeLaFiche(val couleur: () -> Unit, val dureeDeVie: () -> Unit) + +/** The sheet as the editor shows it: while [ouverte], for a [note] that is readable. */ +@Composable +internal fun DureeDeVieDeLEditeur( + ouverte: Boolean, + note: Note?, + onChoisir: (DureeDeVie?) -> Unit, + onFermer: () -> Unit, +) { + if (!ouverte || note == null) return + FeuilleDeDureeDeVie( + expireLe = note.expiresAt, + lectureUnique = note.readOnce, + onChoisir = { duree -> + onFermer() + onChoisir(duree) + }, + onDismiss = onFermer, + ) +} + +/** + * Said once the note is open (D-043): closing it erases it. A note sent "read once" (D-044) must not go + * without its reader being told. Once per opening — saved, a rotation does not say it again. + */ +@Composable +internal fun AnnonceDeLectureUnique(noteChargee: Boolean, effaceeALaFermeture: Boolean, messages: SnackbarHostState) { + var annoncee by rememberSaveable { mutableStateOf(false) } + val phrase = stringResource(R.string.lifetime_read_once_notice) + LaunchedEffect(noteChargee) { + if (noteChargee && effaceeALaFermeture && !annoncee) { + annoncee = true + messages.showSnackbar(phrase, withDismissAction = true, duration = SnackbarDuration.Long) + } + } +} + +@StringRes +internal fun DureeDeVie.libelle(): Int = when (this) { + DureeDeVie.UNE_HEURE -> R.string.lifetime_1h + DureeDeVie.UN_JOUR -> R.string.lifetime_24h + DureeDeVie.SEPT_JOURS -> R.string.lifetime_7d + DureeDeVie.TRENTE_JOURS -> R.string.lifetime_30d + DureeDeVie.APRES_LECTURE -> R.string.lifetime_after_reading +} diff --git a/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorScreen.kt b/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorScreen.kt index d7e714d..36481e6 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorScreen.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.material.icons.outlined.Palette import androidx.compose.material.icons.outlined.PushPin import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.StarBorder +import androidx.compose.material.icons.outlined.Timer import androidx.compose.material.icons.outlined.Unarchive import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu @@ -187,6 +188,7 @@ fun NoteEditorRoute(onBack: () -> Unit, onOpenNote: (String) -> Unit, onInstalle var deplacementOuvert by rememberSaveable { mutableStateOf(false) } var couleurOuverte by rememberSaveable { mutableStateOf(false) } + var dureeDeVieOuverte by rememberSaveable { mutableStateOf(false) } var suppressionDemandee by rememberSaveable { mutableStateOf(false) } var infosOuvertes by rememberSaveable { mutableStateOf(false) } @@ -260,9 +262,21 @@ fun NoteEditorRoute(onBack: () -> Unit, onOpenNote: (String) -> Unit, onInstalle LaunchedEffect(noteLisible) { if (!noteLisible) { couleurOuverte = false + dureeDeVieOuverte = false suppressionDemandee = false } } + DureeDeVieDeLEditeur( + ouverte = dureeDeVieOuverte && noteLisible, + note = state.note, + onChoisir = viewModel::setLifetime, + onFermer = { dureeDeVieOuverte = false }, + ) + AnnonceDeLectureUnique( + noteChargee = chargee, + effaceeALaFermeture = viewModel.effaceeALaFermeture, + messages = messages, + ) if (couleurOuverte && noteLisible) { FeuilleDeCouleur( actuelle = state.note?.color, @@ -434,7 +448,9 @@ fun NoteEditorRoute(onBack: () -> Unit, onOpenNote: (String) -> Unit, onInstalle onInfos = { infosOuvertes = true }, onDeplacer = { deplacementOuvert = true }, onArchiver = viewModel::setArchived, - onCouleur = { couleurOuverte = true }, + feuilles = FeuillesDeLaFiche(couleur = { + couleurOuverte = true + }, dureeDeVie = { dureeDeVieOuverte = true }), onExporter = { viewModel.exporterLaNote(libelleBoiteDeReception, mentionDeCoffre) }, onCopier = { // Retour haptique sur un geste réussi, comme l'application publiée @@ -513,8 +529,8 @@ fun NoteEditorScreen( onDeplacer: () -> Unit, /** The ⋮ menu's "Archive" / "Unarchive" (3.3.0): the value to write — computed where the note is known. */ onArchiver: (Boolean) -> Unit, - /** The ⋮ menu's "Color" (3.1.0): the sheet that colours the note's card in the lists. */ - onCouleur: () -> Unit, + /** The ⋮ menu's "Color" (3.1.0) and "Lifetime…" (3.4.0): the sheets about the note's card. */ + feuilles: FeuillesDeLaFiche, onExporter: () -> Unit, onCopier: () -> Unit, onCorbeille: () -> Unit, @@ -765,7 +781,8 @@ fun NoteEditorScreen( onDeplacer = onDeplacer, archivee = note.archived, onArchiver = { onArchiver(!note.archived) }, - onCouleur = onCouleur, + onCouleur = feuilles.couleur, + onDureeDeVie = feuilles.dureeDeVie, onExporter = onExporter, onCopier = onCopier, onCorbeille = onCorbeille, @@ -1207,6 +1224,12 @@ internal fun IssueDUneAction( onConsommer() } + action.dureeDeVie != null -> { + val phrase = ressources.getString(R.string.lifetime_set, ressources.getString(action.dureeDeVie)) + portee.launch { messages.showSnackbar(phrase) } + onConsommer() + } + action.copiee -> { portee.launch { messages.showSnackbar(ressources.getString(R.string.note_editor_copied_to_clipboard)) } onConsommer() @@ -1238,6 +1261,7 @@ internal fun IssueDUneAction( ActionDEditeur.OrigineDErreur.SUPPRESSION, ActionDEditeur.OrigineDErreur.COPIE, ActionDEditeur.OrigineDErreur.ARCHIVAGE, + ActionDEditeur.OrigineDErreur.DUREE_DE_VIE, null, -> phrase } @@ -1374,6 +1398,7 @@ private fun MenuDeDebordement( archivee: Boolean, onArchiver: () -> Unit, onCouleur: () -> Unit, + onDureeDeVie: () -> Unit, onExporter: () -> Unit, onCopier: () -> Unit, onCorbeille: () -> Unit, @@ -1479,6 +1504,15 @@ private fun MenuDeDebordement( onArchiver() }, ) + // After "Archive" (3.4.0, D-043): both say how long, and where, the note is kept. + DropdownMenuItem( + text = { Text(stringResource(R.string.note_lifetime)) }, + leadingIcon = { Icon(Icons.Outlined.Timer, contentDescription = null) }, + onClick = { + ouvert = false + onDureeDeVie() + }, + ) DropdownMenuItem( text = { Text(stringResource(R.string.note_editor_menu_export)) }, leadingIcon = { Icon(Icons.Outlined.FileDownload, contentDescription = null) }, diff --git a/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorViewModel.kt b/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorViewModel.kt index 92786b1..fede65d 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorViewModel.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorViewModel.kt @@ -10,11 +10,13 @@ import com.filestech.notes_tech.data.export.ExportResult import com.filestech.notes_tech.data.export.NoteExporter import com.filestech.notes_tech.data.local.dao.NoteLinkRow import com.filestech.notes_tech.data.prefs.AppSettings +import com.filestech.notes_tech.data.repository.DureesDeVie import com.filestech.notes_tech.data.repository.FoldersRepository import com.filestech.notes_tech.data.repository.LinksRepository import com.filestech.notes_tech.data.repository.NotesRepository import com.filestech.notes_tech.di.ApplicationScope import com.filestech.notes_tech.domain.export.NoteMarkdown +import com.filestech.notes_tech.domain.model.DureeDeVie import com.filestech.notes_tech.domain.model.Folder import com.filestech.notes_tech.domain.model.Note import com.filestech.notes_tech.domain.model.NoteColor @@ -157,6 +159,8 @@ data class ActionDEditeur( * not this action. Said by a message: the note's card, which would show it, is not on screen. */ val archivee: Boolean? = null, + /** D-043 (3.4.0): the lifetime just chosen, by its label — `lifetime_none` when removed. */ + @StringRes val dureeDeVie: Int? = null, /** Le contenu est dans le presse-papiers. `false` aussi quand il n'y avait rien à copier. */ val copiee: Boolean = false, /** La note était vide : le presse-papiers n'a **pas** été touché. */ @@ -173,7 +177,7 @@ data class ActionDEditeur( */ val origine: OrigineDErreur? = null, ) { - enum class OrigineDErreur { DEPLACEMENT, EXPORT, CREATION, CORBEILLE, SUPPRESSION, COPIE, ARCHIVAGE } + enum class OrigineDErreur { DEPLACEMENT, EXPORT, CREATION, CORBEILLE, SUPPRESSION, COPIE, ARCHIVAGE, DUREE_DE_VIE } } /** @@ -198,6 +202,7 @@ class NoteEditorViewModel @Inject constructor( private val links: LinksRepository, private val exporter: NoteExporter, private val folders: FoldersRepository, + private val durees: DureesDeVie, private val vaults: FolderVaultService, private val settings: AppSettings, private val clipboard: SensitiveClipboard, @@ -223,6 +228,21 @@ class NoteEditorViewModel @Inject constructor( private var sauvegardeDifferee: Job? = null + /** The last save of [saveNow]: "after reading" erases the note once it is written (D-043). */ + private var derniereSauvegarde: Job? = null + + /** + * D-043 (3.4.0) — "after reading": the note was marked so when it opened, and its text was shown. + * A vault note left locked was not read, and stays. + */ + private var lueUneFoisALOuverture = false + + /** A lifetime chosen in this session: no erasing at this closing — "after reading" counts from the next. */ + private var dureeDeVieChoisieIci = false + + /** Whether closing this editor erases the note: read once, and no lifetime chosen since. */ + val effaceeALaFermeture: Boolean get() = lueUneFoisALOuverture && !dureeDeVieChoisieIci + /** * Serialise les ecritures de cette note. * @@ -865,6 +885,22 @@ class NoteEditorViewModel @Inject constructor( * outcome — success or failure — is said. The editor stays open: an archived note is read and * written like any other, from the archive. */ + /** + * The note's lifetime (D-043, 3.4.0), `null` for none. Choosing one here — "after reading" included — + * means this closing erases nothing: the choice counts from now, or from the next opening. + */ + fun setLifetime(duree: DureeDeVie?) = tenterUneAction(ActionDEditeur.OrigineDErreur.DUREE_DE_VIE) { + if (durees.poser(noteId, duree)) { + dureeDeVieChoisieIci = true + // Read back: the date is the repository's clock's, and the card and the sheet show it. + val ecrite = notes.find(noteId) + _state.update { etat -> + etat.copy(note = etat.note?.copy(expiresAt = ecrite?.expiresAt, readOnce = ecrite?.readOnce == true)) + } + _action.value = ActionDEditeur(dureeDeVie = duree?.libelle() ?: R.string.lifetime_none) + } + } + fun setArchived(archived: Boolean) = tenterUneAction(ActionDEditeur.OrigineDErreur.ARCHIVAGE) { if (notes.setArchived(noteId, archived)) { _state.update { etat -> etat.copy(note = etat.note?.copy(archived = archived)) } @@ -939,11 +975,38 @@ class NoteEditorViewModel @Inject constructor( // qu'il faut rattraper, c'est la PORTEE qui doit survivre au geste qu'elle exécute. Relevé // par une relecture externe (Gemini, 2026-08-14). val instantane = _state.value - applicationScope.launch { + derniereSauvegarde = applicationScope.launch { withContext(NonCancellable) { enregistrer(instantane) } } } + /** + * 🔴 **"After reading" (D-043): the note goes when the editor CLOSES** — here, and not on the screen's + * disposal, which a rotation triggers too: the ViewModel is cleared only when the editor leaves the + * back stack. A process killed while the note is open keeps it, for its next reading. + * + * After the last save — the text typed this time is written, then erased with the note: what the + * user chose. The process's scope, for [saveNow]'s reason. + */ + override fun onCleared() { + super.onCleared() + if (!effaceeALaFermeture) return + val sauvegarde = derniereSauvegarde + applicationScope.launch { + withContext(NonCancellable) { + sauvegarde?.join() + try { + notes.deletePermanently(noteId) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // Kept: its next reading erases it — "after reading" is still on it. + Timber.w(e, "note a lecture unique non effacee") + } + } + } + } + private fun charger() { viewModelScope.launch { val note = notes.find(noteId) @@ -953,6 +1016,7 @@ class NoteEditorViewModel @Inject constructor( } val dossier = folders.find(note.folderId) if (!note.isLocked) { + lueUneFoisALOuverture = note.readOnce _state.value = EditorUiState( loading = false, titre = TextFieldValue(note.title), @@ -1013,6 +1077,7 @@ class NoteEditorViewModel @Inject constructor( _state.value = EditorUiState(loading = false, note = note, folder = dossier, lockedVault = dossier) return@launch } + lueUneFoisALOuverture = note.readOnce _state.value = EditorUiState( loading = false, titre = TextFieldValue(claire.title), diff --git a/app/src/main/java/com/filestech/notes_tech/ui/home/HomeRoute.kt b/app/src/main/java/com/filestech/notes_tech/ui/home/HomeRoute.kt index b800516..507fc74 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/home/HomeRoute.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/home/HomeRoute.kt @@ -48,6 +48,7 @@ import com.filestech.notes_tech.ui.vault.CreateVaultSheet import com.filestech.notes_tech.ui.vault.UnlockVaultSheet import com.filestech.notes_tech.ui.vault.VaultAttempt import com.filestech.notes_tech.ui.vault.VaultViewModel +import kotlinx.coroutines.delay import kotlinx.coroutines.launch /** @@ -145,6 +146,13 @@ fun HomeRoute( // La purge de la corbeille est un rattrapage d'arrière-plan, lancé une fois par entrée sur // l'écran. `Unit` en clé : la relancer à chaque recomposition ferait un balayage par frappe. LaunchedEffect(Unit) { homeViewModel.purgeExpiredTrash() } + // D-043 (3.4.0): the notes whose lifetime is over, every minute while the list shows them. + LaunchedEffect(Unit) { + while (true) { + homeViewModel.purgerLesNotesExpirees() + delay(PURGE_DES_EXPIREES_MS) + } + } MessagesDeDossier(foldersViewModel, snackbars) MessagesDesGestesDeNote(homeViewModel, snackbars) @@ -630,3 +638,6 @@ private fun actionsDeSelection( onSupprimerDefinitivement = { gestes.suppression = ids }, ) } + +/** D-043: how often the list erases the notes whose lifetime is over. */ +private const val PURGE_DES_EXPIREES_MS = 60_000L diff --git a/app/src/main/java/com/filestech/notes_tech/ui/home/HomeViewModel.kt b/app/src/main/java/com/filestech/notes_tech/ui/home/HomeViewModel.kt index f2ec131..f593507 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/home/HomeViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.filestech.notes_tech.data.prefs.AppSettings +import com.filestech.notes_tech.data.repository.DureesDeVie import com.filestech.notes_tech.data.repository.FoldersRepository import com.filestech.notes_tech.data.repository.NotesEnLot import com.filestech.notes_tech.data.repository.NotesRepository @@ -123,6 +124,7 @@ sealed interface HomeEvent { class HomeViewModel @Inject constructor( private val notes: NotesRepository, private val enLot: NotesEnLot, + private val durees: DureesDeVie, private val folders: FoldersRepository, private val search: SearchRepository, private val settings: AppSettings, @@ -480,6 +482,23 @@ class HomeViewModel @Inject constructor( settings.clearVaultLostDrafts() } + /** + * D-043 (3.4.0): erases the notes whose lifetime is over — called every minute while the list is on + * screen (`HomeRoute`), so a "1 hour" note goes within a minute of its hour. A failure says nothing: + * the next minute tries again. + */ + fun purgerLesNotesExpirees() { + viewModelScope.launch { + try { + durees.purger() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.w(e, "purge des notes expirees differee") + } + } + } + /** Purge les notes dont le séjour en corbeille a expiré. Sans effet visible si rien n'expire. */ fun purgeExpiredTrash() { viewModelScope.launch { diff --git a/app/src/main/java/com/filestech/notes_tech/ui/home/NoteCard.kt b/app/src/main/java/com/filestech/notes_tech/ui/home/NoteCard.kt index 155ecb5..b49a5c4 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/home/NoteCard.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/home/NoteCard.kt @@ -17,6 +17,7 @@ import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Timer import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -38,8 +39,11 @@ import com.filestech.notes_tech.R import com.filestech.notes_tech.domain.model.Note import com.filestech.notes_tech.domain.model.NoteColor import com.filestech.notes_tech.domain.model.NoteExcerpt +import com.filestech.notes_tech.ui.common.VieAffichee +import com.filestech.notes_tech.ui.common.enPhraseSuivante import com.filestech.notes_tech.ui.common.nom import com.filestech.notes_tech.ui.common.rememberNoteDateFormatter +import com.filestech.notes_tech.ui.common.vieAffichee import com.filestech.notes_tech.ui.theme.CouleursDeNote import com.filestech.notes_tech.ui.theme.SemanticColors @@ -135,6 +139,8 @@ fun NoteCard( // n'a rien protégé. La garde suit la branche visuelle, ligne pour ligne. val teinte = couleur?.let { CouleursDeNote.teinte(it, couleurs) } val nomDeCouleur = couleur?.let { stringResource(it.nom()) } + // D-043 (3.4.0): what is left of the note's lifetime — not a secret, shown on a locked vault note too. + val vie = vieAffichee(note.expiresAt, note.readOnce) val description = buildString { append(titreAffiche) if (!verrouillee && extrait.isNotEmpty()) append(". ").append(extrait) @@ -142,6 +148,7 @@ fun NoteCard( folderName?.let { append(". ").append(it) } // Said as it is shown: a colour drawn on the card is a colour TalkBack names. nomDeCouleur?.let { append(". ").append(it) } + append(vie.enPhraseSuivante()) if (!verrouillee && note.tags.isNotEmpty()) { append(". ").append(note.tags.joinToString(" ") { "#$it" }) } @@ -259,6 +266,7 @@ fun NoteCard( style = MaterialTheme.typography.labelMedium, color = couleurs.onSurfaceVariant, ) + BadgeDeDureeDeVie(vie) if (folderName != null) FolderChip(folderName) if (!verrouillee && note.tags.isNotEmpty()) { Text( @@ -288,6 +296,25 @@ internal fun titreAffiche(note: Note): String = when { } /** La puce discrète qui dit d'où vient une note, en vue non filtrée. */ +/** D-043: the hourglass and what is left — "5 h", "after reading". Read by TalkBack in the card's description. */ +@Composable +private fun BadgeDeDureeDeVie(vie: VieAffichee?) { + val texte = vie?.court ?: return + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp)) { + Icon( + imageVector = Icons.Outlined.Timer, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = texte, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + @Composable private fun FolderChip(label: String, modifier: Modifier = Modifier) { val couleurs = MaterialTheme.colorScheme diff --git a/app/src/main/java/com/filestech/notes_tech/ui/startup/StartupViewModel.kt b/app/src/main/java/com/filestech/notes_tech/ui/startup/StartupViewModel.kt index 7c0fe65..b406fb2 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/startup/StartupViewModel.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/startup/StartupViewModel.kt @@ -3,9 +3,11 @@ package com.filestech.notes_tech.ui.startup import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.filestech.notes_tech.data.local.DatabaseProvider +import com.filestech.notes_tech.data.repository.DureesDeVie import com.filestech.notes_tech.security.kek.KekFailure import com.filestech.notes_tech.security.panic.PanicJournal import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -58,6 +60,7 @@ enum class FailureReason { class StartupViewModel @Inject constructor( private val databaseProvider: DatabaseProvider, private val panicJournal: PanicJournal, + private val durees: DureesDeVie, ) : ViewModel() { private val _state = MutableStateFlow(StartupState.Opening) @@ -92,6 +95,7 @@ class StartupViewModel @Inject constructor( viewModelScope.launch { try { databaseProvider.get() + purgerLesNotesExpirees() _state.value = StartupState.Ready } catch (e: KekFailure) { // Pas de `runCatching` ici, et pas de `catch (e: Exception)` : le premier avale @@ -102,6 +106,20 @@ class StartupViewModel @Inject constructor( } } + /** + * D-043 (3.4.0): the notes whose lifetime is over go at the opening, before anything shows them. A + * failure is not the opening's: the list purges again every minute. + */ + private suspend fun purgerLesNotesExpirees() { + try { + durees.purger() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.w(e, "purge des notes expirees differee") + } + } + private fun KekFailure.toReason(): FailureReason { // Le message est journalisé, jamais affiché : il décrit une cause technique et ne dit pas // à l'utilisateur quoi faire. L'écran, lui, part du geste attendu. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 9ae8d51..dcf69b8 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -359,6 +359,36 @@ Diesen Text verwerfen? Er wurde nicht abgelegt, und nichts bewahrt ihn auf, sobald dieser Bildschirm geschlossen ist. Verwerfen + Lebensdauer… + Lebensdauer + Keine — die Notiz bleibt + 1 Stunde + 24 Stunden + 7 Tage + 30 Tage + Nach dem Lesen + Gelöscht, wenn Sie sie nach dem erneuten Öffnen schließen. + Endgültig gelöscht, sobald die Zeit abgelaufen ist, beim nächsten Öffnen der App oder bei der Rückkehr zur Liste – nicht auf die Sekunde genau. Nicht in Sicherungen und Exporten enthalten. + Lebensdauer: %1$s + Diese Notiz wird gelöscht, wenn Sie sie schließen. + %1$d Min. + %1$d Std. + %1$d T. + bald + Wird bald gelöscht + Wird nach dem nächsten Lesen gelöscht + + Wird in %1$d Minute gelöscht + Wird in %1$d Minuten gelöscht + + + Wird in %1$d Stunde gelöscht + Wird in %1$d Stunden gelöscht + + + Wird in %1$d Tag gelöscht + Wird in %1$d Tagen gelöscht + Automatische Tresorsperre %1$d Minute diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index f8336bf..976de33 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -378,6 +378,39 @@ ¿Descartar este texto? No se ha depositado, y nada lo conserva una vez cerrada esta pantalla. Descartar + Duración… + Duración + Ninguna — la nota se queda + 1 hora + 24 horas + 7 días + 30 días + Tras la lectura + Se borra cuando la cierra tras haberla vuelto a abrir. + Se borra definitivamente al acabar el plazo, en la siguiente apertura de la app o al volver a la lista — no al segundo. Queda fuera de las copias de seguridad y de las exportaciones. + Duración: %1$s + Esta nota se borra cuando la cierra. + %1$d min + %1$d h + %1$d d + pronto + Se borra pronto + Se borra tras su próxima lectura + + Se borra en %1$d minuto + Se borra en %1$d minutos + Se borra en %1$d minutos + + + Se borra en %1$d hora + Se borra en %1$d horas + Se borra en %1$d horas + + + Se borra en %1$d día + Se borra en %1$d días + Se borra en %1$d días + Bloqueo automático de las cajas fuertes %1$d minuto diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index e2a8fef..7327062 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -383,6 +383,39 @@ Abandonner ce texte ? Il n\'a pas été déposé, et rien ne le garde une fois cet écran fermé. Abandonner + Durée de vie… + Durée de vie + Aucune — la note reste + 1 heure + 24 heures + 7 jours + 30 jours + Après lecture + Effacée quand vous la refermez après l\'avoir rouverte. + Effacée pour de bon une fois le délai passé, à la prochaine ouverture de l\'appli ou au retour sur la liste — pas à la seconde près. Elle n\'entre ni dans les sauvegardes ni dans les exports. + Durée de vie : %1$s + Cette note s\'efface quand vous la refermez. + %1$d min + %1$d h + %1$d j + bientôt + S\'efface bientôt + S\'efface après sa prochaine lecture + + S\'efface dans %1$d minute + S\'efface dans %1$d minutes + S\'efface dans %1$d minutes + + + S\'efface dans %1$d heure + S\'efface dans %1$d heures + S\'efface dans %1$d heures + + + S\'efface dans %1$d jour + S\'efface dans %1$d jours + S\'efface dans %1$d jours + Verrouillage automatique des coffres %1$d minute diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index e723254..fab1ffc 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -378,6 +378,39 @@ Scartare questo testo? Non è stato depositato, e nulla lo conserva una volta chiusa questa schermata. Scarta + Durata… + Durata + Nessuna — la nota resta + 1 ora + 24 ore + 7 giorni + 30 giorni + Dopo la lettura + Cancellata quando la chiude dopo averla riaperta. + Cancellata definitivamente allo scadere del tempo, alla successiva apertura dell\'app o al ritorno all\'elenco — non al secondo. Esclusa da backup ed esportazioni. + Durata: %1$s + Questa nota si cancella quando la chiude. + %1$d min + %1$d h + %1$d g + a breve + Si cancella a breve + Si cancella dopo la prossima lettura + + Si cancella tra %1$d minuto + Si cancella tra %1$d minuti + Si cancella tra %1$d minuti + + + Si cancella tra %1$d ora + Si cancella tra %1$d ore + Si cancella tra %1$d ore + + + Si cancella tra %1$d giorno + Si cancella tra %1$d giorni + Si cancella tra %1$d giorni + Blocco automatico delle casseforti %1$d minuto diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7145d55..96257bc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -434,6 +434,36 @@ Discard this text? It has not been dropped, and nothing keeps it once this screen is closed. Discard + Lifetime… + Lifetime + None — the note stays + 1 hour + 24 hours + 7 days + 30 days + After reading + Erased when you close it after opening it again. + Erased for good once the time is up, at the next opening of the app or return to the list — not to the second. Left out of backups and exports. + Lifetime: %1$s + This note erases itself when you close it. + %1$d min + %1$d h + %1$d d + soon + Erases itself soon + Erases itself after its next reading + + Erases itself in %1$d minute + Erases itself in %1$d minutes + + + Erases itself in %1$d hour + Erases itself in %1$d hours + + + Erases itself in %1$d day + Erases itself in %1$d days + Vault auto-lock %1$d minute diff --git a/app/src/test/java/com/filestech/notes_tech/data/repository/DureesDeVieTest.kt b/app/src/test/java/com/filestech/notes_tech/data/repository/DureesDeVieTest.kt new file mode 100644 index 0000000..3867a25 --- /dev/null +++ b/app/src/test/java/com/filestech/notes_tech/data/repository/DureesDeVieTest.kt @@ -0,0 +1,74 @@ +package com.filestech.notes_tech.data.repository + +import com.filestech.notes_tech.data.local.DatabaseProvider +import com.filestech.notes_tech.data.local.NotesDatabase +import com.filestech.notes_tech.data.local.dao.NoteWriteDao +import com.filestech.notes_tech.domain.model.DureeDeVie +import com.google.common.truth.Truth.assertThat +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.time.ZoneOffset + +/** What a lifetime writes (D-043, 3.4.0) — the purge itself runs on a real base in `MigrationVersDixTest`. */ +@DisplayName("Durées de vie") +class DureesDeVieTest { + + private val maintenant = Instant.parse("2026-11-09T20:00:00Z") + private val ecriture = mockk() + private lateinit var durees: DureesDeVie + + @BeforeEach + fun setUp() { + val base = mockk { every { noteWriteDao() } returns ecriture } + val bases = mockk { coEvery { get() } returns base } + coEvery { ecriture.updateLifetime(any(), any(), any()) } returns 1 + coEvery { ecriture.deleteExpired(any()) } returns 2 + durees = DureesDeVie(bases, Clock.fixed(maintenant, ZoneOffset.UTC)) + } + + @Test + fun `a duration counts from now, without the reading flag`() = runTest { + assertThat(durees.poser("n", DureeDeVie.UNE_HEURE)).isTrue() + durees.poser("n", DureeDeVie.TRENTE_JOURS) + + coVerify { ecriture.updateLifetime("n", maintenant.plus(Duration.ofHours(1)).toEpochMilli(), false) } + coVerify { ecriture.updateLifetime("n", maintenant.plus(Duration.ofDays(30)).toEpochMilli(), false) } + } + + @Test + fun `after reading has no date, only its flag`() = runTest { + durees.poser("n", DureeDeVie.APRES_LECTURE) + + coVerify { ecriture.updateLifetime("n", null, true) } + } + + @Test + fun `none clears both`() = runTest { + durees.poser("n", null) + + coVerify { ecriture.updateLifetime("n", null, false) } + } + + @Test + fun `the purge erases what is over at now, and says how many`() = runTest { + assertThat(durees.purger()).isEqualTo(2) + + coVerify { ecriture.deleteExpired(maintenant.toEpochMilli()) } + } + + @Test + fun `an unknown note is not written`() = runTest { + coEvery { ecriture.updateLifetime("absente", any(), any()) } returns 0 + + assertThat(durees.poser("absente", DureeDeVie.UN_JOUR)).isFalse() + } +} diff --git a/app/src/test/java/com/filestech/notes_tech/ui/common/ResteDeVieTest.kt b/app/src/test/java/com/filestech/notes_tech/ui/common/ResteDeVieTest.kt new file mode 100644 index 0000000..f6600c0 --- /dev/null +++ b/app/src/test/java/com/filestech/notes_tech/ui/common/ResteDeVieTest.kt @@ -0,0 +1,50 @@ +package com.filestech.notes_tech.ui.common + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import java.time.Duration +import java.time.Instant + +/** What a card says of a note's lifetime (D-043, 3.4.0). */ +@DisplayName("Reste de vie d'une note") +class ResteDeVieTest { + + private val maintenant = Instant.parse("2026-11-09T20:00:00Z") + + private fun dans(duree: Duration) = resteDeVie(maintenant, maintenant.plus(duree), lectureUnique = false) + + @Test + fun `no lifetime, nothing to say`() { + assertThat(resteDeVie(maintenant, null, lectureUnique = false)).isNull() + } + + @Test + fun `after reading wins over a date`() { + assertThat(resteDeVie(maintenant, maintenant.plusSeconds(60), lectureUnique = true)) + .isEqualTo(ResteDeVie.ApresLecture) + } + + @Test + fun `rounded up, never less than what is left`() { + assertThat(dans(Duration.ofSeconds(1))).isEqualTo(ResteDeVie.Minutes(1)) + assertThat(dans(Duration.ofMinutes(59).plusSeconds(30))).isEqualTo(ResteDeVie.Heures(1)) + assertThat(dans(Duration.ofMinutes(59))).isEqualTo(ResteDeVie.Minutes(59)) + assertThat(dans(Duration.ofHours(1))).isEqualTo(ResteDeVie.Heures(1)) + assertThat(dans(Duration.ofHours(23).plusMinutes(1))).isEqualTo(ResteDeVie.Heures(24)) + } + + @Test + fun `hours below two days, days beyond`() { + assertThat(dans(Duration.ofHours(47))).isEqualTo(ResteDeVie.Heures(47)) + assertThat(dans(Duration.ofHours(48))).isEqualTo(ResteDeVie.Jours(2)) + assertThat(dans(Duration.ofDays(7))).isEqualTo(ResteDeVie.Jours(7)) + assertThat(dans(Duration.ofDays(30))).isEqualTo(ResteDeVie.Jours(30)) + } + + @Test + fun `the time is up, the purge has not run yet`() { + assertThat(dans(Duration.ZERO)).isEqualTo(ResteDeVie.Bientot) + assertThat(dans(Duration.ofMinutes(-5))).isEqualTo(ResteDeVie.Bientot) + } +} diff --git a/app/src/test/java/com/filestech/notes_tech/ui/startup/StartupViewModelTest.kt b/app/src/test/java/com/filestech/notes_tech/ui/startup/StartupViewModelTest.kt index 5c316f2..72fe76c 100644 --- a/app/src/test/java/com/filestech/notes_tech/ui/startup/StartupViewModelTest.kt +++ b/app/src/test/java/com/filestech/notes_tech/ui/startup/StartupViewModelTest.kt @@ -2,6 +2,7 @@ package com.filestech.notes_tech.ui.startup import com.filestech.notes_tech.data.local.DatabaseProvider import com.filestech.notes_tech.data.local.NotesDatabase +import com.filestech.notes_tech.data.repository.DureesDeVie import com.filestech.notes_tech.security.panic.PanicJournal import com.filestech.notes_tech.security.panic.PanicService import com.google.common.truth.Truth.assertThat @@ -27,6 +28,7 @@ class StartupViewModelTest { private val base = mockk() private val journal = mockk() + private val durees = mockk(relaxed = true) @BeforeEach fun setUp() { @@ -42,7 +44,7 @@ class StartupViewModelTest { fun pending_panic_is_resumed() { every { journal.isPending() } returns true - val demarrage = StartupViewModel(base, journal) + val demarrage = StartupViewModel(base, journal, durees) assertThat(demarrage.state.value).isEqualTo(StartupState.ResumingPanic) coVerify(exactly = 0) { base.get() } @@ -53,7 +55,7 @@ class StartupViewModelTest { fun no_pending_panic_opens_the_database() { every { journal.isPending() } returns false - val demarrage = StartupViewModel(base, journal) + val demarrage = StartupViewModel(base, journal, durees) assertThat(demarrage.state.value).isEqualTo(StartupState.Ready) coVerify(exactly = 1) { base.get() } diff --git a/docs/15-VERSION-3.4.md b/docs/15-VERSION-3.4.md index 82540af..bae0e65 100644 --- a/docs/15-VERSION-3.4.md +++ b/docs/15-VERSION-3.4.md @@ -94,8 +94,29 @@ - Menu ⋮ de l'éditeur, « Partager scellée… » : la note (titre, texte, couleur, étiquettes) est chiffrée dans un fichier `.ntnote` par un **code généré de 24 signes** (même code que la sauvegarde), affiché une fois ; on envoie le fichier par n'importe quelle messagerie et le code par un autre canal. -- Même chiffrement que la sauvegarde (Argon2id, AES-256-GCM, en-tête en AAD), avec sa propre magie - (`NTSEALED`) et une taille bornée (1 Mio). +- Même chiffrement que la sauvegarde (Argon2id, AES-256-GCM, en-tête de 48 octets en AAD), avec sa + propre magie (`NTSEALED`, 8 octets comme `NTBACKUP`) et sa propre borne : **4 Mio** (et non 1 Mio comme + écrit d'abord : une note peut faire 500 000 caractères, jusqu'à 1,5 Mo en UTF-8, plus le JSON). +- **Réalisation** (plan écrit avant le code, 2026-11-09 au soir) : + - `FichierDeSauvegarde` reçoit la magie et la borne en PARAMÈTRES, avec les valeurs de la sauvegarde par + défaut : la sauvegarde ne change pas d'un octet, ses tests restent tels quels. + - `data/scellee/FichierDeNoteScellee` : `sceller(note, secret): ByteArray` et `ouvrir(octets, secret)`, + en mémoire (une note est petite) ; JSON `android.util.JsonWriter/Reader` comme la sauvegarde, + `{"format":"notes-tech-sealed-note","version":1,"title","content","color","tags","readOnce"}` ; + limites d'une note à l'ouverture (titre 200, texte 500 000) ; tests instrumentés. + - Code : `SecretDeSauvegarde.nouveauCode()` (24 signes, 120 bits) et sa saisie tolérante. + - Envoi : menu ⋮ de l'éditeur « Partager scellée… » → feuille (explication, interrupteur « Lecture + unique chez le destinataire », « Créer ») → scellement hors du fil principal (Argon2 ≈ 1 s) → fichier + dans `cache/exports//note-scellee-AAAAMMJJ.ntnote` (purgé au démarrage et par la panique, + servi par le `FileProvider` des exports) → le code s'affiche, fenêtre protégée, puis « Partager le + fichier » (`ui/common/Partage.kt`). + - Réception : `OuvrirUneNoteScelleeActivity`, exportée, `ACTION_VIEW` sur `application/octet-stream`, + `content://` d'une autre appli seulement (même garde que le partage reçu), lecture bornée (4 Mio, + délai), magie vérifiée — un autre fichier : un message bref et rien d'autre. Sinon dépôt en mémoire + (`DepotEnMemoire`) puis ouverture de l'appli ; menu ⋮ de l'accueil « Ouvrir une note scellée » → + sélecteur SAF → même dépôt. Après le déverrouillage, un dialogue demande le code ; code juste → note + créée dans la Boîte de réception (jamais un coffre), couleur et étiquettes reprises, « après lecture » + si demandé, puis l'éditeur s'ouvre ; code faux ou fichier abîmé → même message, on peut réessayer. - **Le nom du fichier ne dit rien de la note** (`note-scellee-AAAAMMJJ.ntnote`) : un titre dans un nom de fichier fuirait par la messagerie. - Option « Lecture unique chez le destinataire » : la note reçue y arrive avec la durée de vie « après @@ -123,7 +144,20 @@ (`cmd statusbar add-tile` / `click-tile`) ouvre le dépôt. **Non essayé sur appareil** : la tuile téléphone verrouillé (`unlockAndRun`) — le S9 n'a pas de code d'écran, en poser un change un réglage global. -- [ ] D-043 durée de vie · [ ] D-044 scellée +- [x] **D-043 durée de vie** — données (`3ac16da`) : schéma 11, migration 10 → 11 additive, vérifiée par le + script de CI et sur le S9 (`MigrationVersDixTest`, dont le chemin réel d'une base de la 3.3.0) ; purge = + le DELETE de « Supprimer définitivement » ; export ZIP et sauvegarde les excluent. Interface : menu ⋮ de + l'éditeur « Durée de vie… », feuille à boutons radio, badge et phrase TalkBack sur la carte (aussi sur + une note de coffre fermé : la durée n'est pas un secret), purge à l'ouverture (`StartupViewModel`) et + chaque minute sur la liste (`HomeRoute`). « Après lecture » efface à `onCleared` de l'éditeur, pas à la + disposition de l'écran (une rotation l'aurait effacée), après le dernier enregistrement ; annoncé à + l'ouverture. detekt a fait sortir la durée de vie de `NotesRepository` (`DureesDeVie`, seuil de + fonctions) et regrouper couleur + durée de vie en `FeuillesDeLaFiche` (seuil de paramètres de l'écran). + Tests : `ResteDeVieTest` (5) et `DureesDeVieTest` (5) sur la JVM ; sur le S9 `LectureUniqueTest` (3 : + effacée à la fermeture ; contrôle, choisie dans la session ; témoin sans durée), `EditeurTest` (36), + `RechercheDansLaNoteEcranTest` (5), `FermetureDuCoffreTest` (9), `LiensDansUnCoffreTest` (3), + `GestesSurDesNotesTest` (14), `AccueilTest` (20) — 0 échec. +- [ ] D-044 scellée - [ ] Textes 5 langues · [ ] version 3.4.0 (507) · [ ] journaux 5071-5073 · [ ] README, SECURITY, CGU/confidentialité - [ ] Audits (3 axes, sécurité des nouvelles entrées, i18n, cohérence) · [ ] relecture externe · [ ] S9 · [ ] CI - [ ] Avant le tag : build F-Droid sous AGP 9.5 (voir d'abord comment F-Droid a construit SMS 1.30.0) From e1c18e97dd9d3ea4f6ba1c74f2a4ef14e1ed78f0 Mon Sep 17 00:00:00 2001 From: Patrice Haltaya Date: Mon, 9 Nov 2026 20:53:09 +0100 Subject: [PATCH 05/11] feat(data): the sealed note's file, .ntnote (3.4.0, D-044) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The encrypted backup's container — Argon2id, AES-256-GCM, the 48-byte header as AAD — under its own magic, NTSEALED, with its own bound (4 MiB: a note of 500 000 characters may weigh 1.5 MB in UTF-8). FichierDeSauvegarde takes the magic and the bound as parameters whose defaults are the backup's: a backup is written and read exactly as before. One note as JSON (title, content, colour, tags, readOnce), sealed by a generated code of 24 signs (120 bits). Only a code opens it: a typed text that is not one is refused before any derivation. The tag is checked before the JSON is read; a wrong code and a damaged file look the same. The limits of a note are checked on what is read. Tests on the S9: FichierDeNoteScelleeTest (6: round trip however the code is typed and nothing in clear in the file; another code; a damaged file; a text that is not a code; a backup or any other file; past the bound), and the backup unchanged: SauvegardeChiffreeTest (9), LectureDuContenuTest (4), its JVM tests. Co-Authored-By: Claude Opus 5.5 --- .../data/scellee/FichierDeNoteScelleeTest.kt | 88 ++++++++ .../data/scellee/FichierDeNoteScellee.kt | 209 ++++++++++++++++++ .../sauvegarde/FichierDeSauvegarde.kt | 27 ++- 3 files changed, 313 insertions(+), 11 deletions(-) create mode 100644 app/src/androidTest/java/com/filestech/notes_tech/data/scellee/FichierDeNoteScelleeTest.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/data/scellee/FichierDeNoteScellee.kt diff --git a/app/src/androidTest/java/com/filestech/notes_tech/data/scellee/FichierDeNoteScelleeTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/data/scellee/FichierDeNoteScelleeTest.kt new file mode 100644 index 0000000..81c9a21 --- /dev/null +++ b/app/src/androidTest/java/com/filestech/notes_tech/data/scellee/FichierDeNoteScelleeTest.kt @@ -0,0 +1,88 @@ +package com.filestech.notes_tech.data.scellee + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.filestech.notes_tech.data.scellee.FichierDeNoteScellee.NoteScelleeRefuseeException +import com.filestech.notes_tech.data.scellee.FichierDeNoteScellee.Raison +import com.filestech.notes_tech.security.sauvegarde.FichierDeSauvegarde +import com.filestech.notes_tech.security.sauvegarde.SecretDeSauvegarde +import com.google.common.truth.Truth.assertThat +import org.junit.Assert.assertThrows +import org.junit.Test +import org.junit.runner.RunWith +import java.io.ByteArrayOutputStream + +/** + * D-044 (3.4.0) — **a sealed note opens with its code, and only with it**; anything else is refused, said + * by its reason. On the phone: the JSON is Android's, the derivation the vaults' Argon2id. + */ +@RunWith(AndroidJUnit4::class) +class FichierDeNoteScelleeTest { + + private val note = NoteScellee( + titre = "Code de l'immeuble", + contenu = "B 4521 — « ne pas oublier » 🔑\nseconde ligne", + couleur = 3, + etiquettes = listOf("maison", "accès"), + lectureUnique = true, + ) + private val code = SecretDeSauvegarde.nouveauCode() + + @Test + fun the_note_comes_back_whole_with_its_code_however_it_is_typed() { + val fichier = FichierDeNoteScellee.sceller(note, code) + + // Typed lowercase, without its dashes, with spaces: the backup's tolerant reading. + val tape = code.lowercase().replace("-", " ") + assertThat(FichierDeNoteScellee.ouvrir(fichier, tape)).isEqualTo(note) + assertThat(FichierDeNoteScellee.aLaMagique(fichier)).isTrue() + // Nothing of the note in clear in the file. + assertThat(String(fichier, Charsets.ISO_8859_1)).doesNotContain("4521") + } + + @Test + fun control_another_code_is_refused_as_a_wrong_code_or_damaged_file() { + val fichier = FichierDeNoteScellee.sceller(note.copy(couleur = null, etiquettes = emptyList()), code) + + assertThat(refus { FichierDeNoteScellee.ouvrir(fichier, SecretDeSauvegarde.nouveauCode()) }) + .isEqualTo(Raison.CODE_OU_FICHIER) + } + + @Test + fun a_damaged_file_is_refused_like_a_wrong_code() { + val fichier = FichierDeNoteScellee.sceller(note, code) + fichier[fichier.size - 20] = (fichier[fichier.size - 20].toInt() xor 1).toByte() + + assertThat(refus { FichierDeNoteScellee.ouvrir(fichier, code) }).isEqualTo(Raison.CODE_OU_FICHIER) + } + + /** Refused before any derivation: a text that is not a code. */ + @Test + fun what_is_not_a_code_is_refused_before_anything_is_computed() { + val fichier = FichierDeNoteScellee.sceller(note, code) + + assertThat(refus { FichierDeNoteScellee.ouvrir(fichier, "mot de passe") }).isEqualTo(Raison.CODE_MAL_FORME) + } + + @Test + fun a_backup_or_any_other_file_is_not_a_sealed_note() { + val sauvegarde = ByteArrayOutputStream().also { sortie -> + FichierDeSauvegarde.ecrire(sortie, ByteArray(32), FichierDeSauvegarde.nouvelEnTete()).close() + }.toByteArray() + val autre = "%PDF-1.7 un document quelconque".toByteArray() + + assertThat(FichierDeNoteScellee.aLaMagique(sauvegarde)).isFalse() + assertThat(FichierDeNoteScellee.aLaMagique(autre)).isFalse() + assertThat(refus { FichierDeNoteScellee.ouvrir(sauvegarde, code) }).isEqualTo(Raison.PAS_UNE_NOTE_SCELLEE) + assertThat(refus { FichierDeNoteScellee.ouvrir(autre, code) }).isEqualTo(Raison.PAS_UNE_NOTE_SCELLEE) + } + + @Test + fun a_file_past_the_bound_is_refused_unread() { + val enorme = FichierDeNoteScellee.MAGIQUE + ByteArray(FichierDeNoteScellee.TAILLE_MAX.toInt()) + + assertThat(refus { FichierDeNoteScellee.ouvrir(enorme, code) }).isEqualTo(Raison.TROP_GROSSE) + } + + private fun refus(ouverture: () -> Unit): Raison = + assertThrows(NoteScelleeRefuseeException::class.java) { ouverture() }.raison +} diff --git a/app/src/main/java/com/filestech/notes_tech/data/scellee/FichierDeNoteScellee.kt b/app/src/main/java/com/filestech/notes_tech/data/scellee/FichierDeNoteScellee.kt new file mode 100644 index 0000000..fc58817 --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/data/scellee/FichierDeNoteScellee.kt @@ -0,0 +1,209 @@ +package com.filestech.notes_tech.data.scellee + +import android.util.JsonReader +import android.util.JsonToken +import android.util.JsonWriter +import com.filestech.notes_tech.data.repository.NotesRepository +import com.filestech.notes_tech.domain.importation.LongueurDUneNote +import com.filestech.notes_tech.security.sauvegarde.FichierDeSauvegarde +import com.filestech.notes_tech.security.sauvegarde.FichierDeSauvegarde.Refus +import com.filestech.notes_tech.security.sauvegarde.SecretDeSauvegarde +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.InputStreamReader +import java.io.OutputStreamWriter + +/** A note as it travels sealed (D-044, 3.4.0): what is shown of it, nothing of where it lived. */ +data class NoteScellee( + val titre: String, + val contenu: String, + val couleur: Int?, + val etiquettes: List, + /** The sender asked: erased after its first reading on the recipient's phone (D-043). */ + val lectureUnique: Boolean, +) + +/** + * A `.ntnote` file, version 1 (D-044, 3.4.0): the encrypted backup's container + * ([FichierDeSauvegarde] — Argon2id, AES-256-GCM, the 48-byte header as AAD) under its own magic, + * `NTSEALED`, holding one note as JSON: + * + * ``` + * {"format":"notes-tech-sealed-note","version":1,"title":…,"content":…,"color":…|null,"tags":[…],"readOnce":…} + * ``` + * + * Sealed by a generated code of 24 signs, 120 bits ([SecretDeSauvegarde]) — shown once to the sender, + * who sends it another way than the file. Only a code opens a sealed note: a typed text that is not one + * is refused before any derivation. + * + * - **In memory**: a note is at most [LongueurDUneNote.CARACTERES_MAX] characters, and the file + * [TAILLE_MAX] bytes, checked before anything is read. + * - **Nothing is believed before the tag**: the whole content is read — the tag checked — before the JSON + * is parsed. A wrong code and a damaged file look the same, as for the backup. + * - **The limits of a note**, checked on what is read: a sealed note is untrusted input, like an import. + */ +internal object FichierDeNoteScellee { + + val MAGIQUE: ByteArray = "NTSEALED".toByteArray(Charsets.US_ASCII) + + /** 500 000 characters may weigh up to 1.5 MB in UTF-8, and the JSON escapes some: room for both. */ + const val TAILLE_MAX = 4L * 1024 * 1024 + + const val EXTENSION = "ntnote" + private const val FORMAT = "notes-tech-sealed-note" + private const val VERSION = 1L + private const val ETIQUETTES_MAX = 100 + private const val LONGUEUR_MAX_D_UNE_ETIQUETTE = 100 + + enum class Raison { + PAS_UNE_NOTE_SCELLEE, + VERSION_INCONNUE, + TROP_GROSSE, + CODE_MAL_FORME, + CODE_OU_FICHIER, + ILLISIBLE, + } + + class NoteScelleeRefuseeException(val raison: Raison, cause: Throwable? = null) : + IOException("note scellee refusee : $raison", cause) + + /** Whether [debut] starts as a sealed note does — a first sorting, before any code is asked. */ + fun aLaMagique(debut: ByteArray): Boolean = + debut.size >= MAGIQUE.size && debut.copyOf(MAGIQUE.size).contentEquals(MAGIQUE) + + /** [note], sealed by [code] — a code of [SecretDeSauvegarde.nouveauCode]. Slow: Argon2id, off the main thread. */ + fun sceller(note: NoteScellee, code: String): ByteArray { + val secret = SecretDeSauvegarde.octets(requireNotNull(SecretDeSauvegarde.codeCanonique(code))) + val enTete = FichierDeSauvegarde.nouvelEnTete(MAGIQUE) + val cle = FichierDeSauvegarde.cle(secret, enTete) + try { + val sortie = ByteArrayOutputStream() + // Closing the writer closes the cipher stream once, which writes the tag. + JsonWriter(OutputStreamWriter(FichierDeSauvegarde.ecrire(sortie, cle, enTete), Charsets.UTF_8)).use { + ecrireLeJson(it, note) + } + return sortie.toByteArray().also { check(it.size <= TAILLE_MAX) { "note scellee trop grosse" } } + } finally { + cle.fill(0) + secret.fill(0) + } + } + + /** + * The note sealed in [octets] by [saisie], the code as the recipient typed it. Slow: Argon2id. + * + * @throws NoteScelleeRefuseeException with the [Raison] to say. + */ + fun ouvrir(octets: ByteArray, saisie: String): NoteScellee { + if (octets.size > TAILLE_MAX) throw NoteScelleeRefuseeException(Raison.TROP_GROSSE) + val code = SecretDeSauvegarde.codeCanonique(saisie) ?: throw NoteScelleeRefuseeException(Raison.CODE_MAL_FORME) + val entree = ByteArrayInputStream(octets) + val (enTete, octetsDeLEnTete) = lireLEnTete(entree) + val clair = dechiffrer(entree, code, enTete, octetsDeLEnTete) + try { + return lire(clair) + } finally { + clair.fill(0) + } + } + + private fun lireLEnTete(entree: ByteArrayInputStream): Pair = try { + FichierDeSauvegarde.lireLEnTete(entree, MAGIQUE) + } catch (e: FichierDeSauvegarde.SauvegardeRefuseeException) { + val raison = when (e.refus) { + Refus.VERSION_INCONNUE, Refus.PARAMETRES_INCONNUS -> Raison.VERSION_INCONNUE + Refus.TROP_GROS -> Raison.TROP_GROSSE + Refus.PAS_UNE_SAUVEGARDE, Refus.TRONQUE -> Raison.PAS_UNE_NOTE_SCELLEE + } + throw NoteScelleeRefuseeException(raison, e) + } + + /** The content, its tag checked: read to its end, where a wrong code throws. */ + private fun dechiffrer( + entree: ByteArrayInputStream, + code: String, + enTete: FichierDeSauvegarde.EnTete, + octetsDeLEnTete: ByteArray, + ): ByteArray { + val secret = SecretDeSauvegarde.octets(code) + val cle = FichierDeSauvegarde.cle(secret, enTete) + return try { + FichierDeSauvegarde.ouvrir(entree, cle, enTete, octetsDeLEnTete).use { it.readBytes() } + } catch (e: IOException) { + throw NoteScelleeRefuseeException(Raison.CODE_OU_FICHIER, e) + } finally { + cle.fill(0) + secret.fill(0) + } + } + + private fun lire(clair: ByteArray): NoteScellee = try { + JsonReader(InputStreamReader(ByteArrayInputStream(clair), Charsets.UTF_8)).use(::lireLeJson) + } catch (e: NoteScelleeRefuseeException) { + throw e + } catch (e: IOException) { + throw NoteScelleeRefuseeException(Raison.ILLISIBLE, e) + } catch (e: IllegalStateException) { + throw NoteScelleeRefuseeException(Raison.ILLISIBLE, e) + } + + private fun ecrireLeJson(json: JsonWriter, note: NoteScellee) { + json.beginObject() + json.name("format").value(FORMAT) + json.name("version").value(VERSION) + json.name("title").value(note.titre) + json.name("content").value(note.contenu) + json.name("color").also { if (note.couleur == null) it.nullValue() else it.value(note.couleur.toLong()) } + json.name("tags").beginArray() + note.etiquettes.forEach { json.value(it) } + json.endArray() + json.name("readOnce").value(note.lectureUnique) + json.endObject() + } + + private fun lireLeJson(json: JsonReader): NoteScellee { + var format: String? = null + var version = 0L + var titre: String? = null + var contenu: String? = null + var couleur: Int? = null + var etiquettes = emptyList() + var lectureUnique = false + json.beginObject() + while (json.hasNext()) { + when (json.nextName()) { + "format" -> format = json.nextString() + "version" -> version = json.nextLong() + "title" -> titre = json.nextString() + "content" -> contenu = json.nextString() + "color" -> couleur = if (json.peek() == JsonToken.NULL) json.nextNull().let { null } else json.nextInt() + "tags" -> etiquettes = lireLesEtiquettes(json) + "readOnce" -> lectureUnique = json.nextBoolean() + // A later version may add fields: its version number says whether this one can read it. + else -> json.skipValue() + } + } + json.endObject() + if (format != FORMAT || version != VERSION) throw NoteScelleeRefuseeException(Raison.VERSION_INCONNUE) + val lu = NoteScellee(titre.orEmpty(), contenu.orEmpty(), couleur, etiquettes, lectureUnique) + val dansLesLimites = lu.titre.length <= NotesRepository.TITLE_MAX_LENGTH && + lu.contenu.length <= LongueurDUneNote.CARACTERES_MAX + if (titre == null || contenu == null || !dansLesLimites) throw NoteScelleeRefuseeException(Raison.ILLISIBLE) + return lu + } + + private fun lireLesEtiquettes(json: JsonReader): List { + val lues = mutableListOf() + json.beginArray() + while (json.hasNext()) { + val etiquette = json.nextString() + if (lues.size >= ETIQUETTES_MAX || etiquette.length > LONGUEUR_MAX_D_UNE_ETIQUETTE) { + throw NoteScelleeRefuseeException(Raison.ILLISIBLE) + } + lues += etiquette + } + json.endArray() + return lues + } +} diff --git a/app/src/main/java/com/filestech/notes_tech/security/sauvegarde/FichierDeSauvegarde.kt b/app/src/main/java/com/filestech/notes_tech/security/sauvegarde/FichierDeSauvegarde.kt index 6c6f06d..557ae22 100644 --- a/app/src/main/java/com/filestech/notes_tech/security/sauvegarde/FichierDeSauvegarde.kt +++ b/app/src/main/java/com/filestech/notes_tech/security/sauvegarde/FichierDeSauvegarde.kt @@ -33,6 +33,8 @@ import java.nio.ByteBuffer * real backup does. Other parameters will come with another version of the format. * - **64 MiB at most, counted on what is really read** (R-9): a provider's announced size is only a * first refusal. + * - **The sealed note of 3.4.0 (D-044) is the same container** with its own magic and bound — passed as + * parameters whose defaults are the backup's: a backup is written and read exactly as before. */ internal object FichierDeSauvegarde { @@ -44,20 +46,22 @@ internal object FichierDeSauvegarde { const val VERSION = 1 private const val KDF_ARGON2ID: Byte = 1 private val MAGIQUE = "NTBACKUP".toByteArray(Charsets.US_ASCII) + private const val TAILLE_DE_LA_MAGIQUE = 8 /** What a file is refused for before its tag is even reached. */ enum class Refus { PAS_UNE_SAUVEGARDE, VERSION_INCONNUE, PARAMETRES_INCONNUS, TRONQUE, TROP_GROS } class SauvegardeRefuseeException(val refus: Refus) : IOException("sauvegarde refusee : $refus") - /** The salt and nonce of one file — drawn anew for every file. */ - class EnTete(val sel: ByteArray, val nonce: ByteArray) { + /** The salt and nonce of one file — drawn anew for every file —, under the [magique] of its kind. */ + class EnTete(val sel: ByteArray, val nonce: ByteArray, private val magique: ByteArray = MAGIQUE) { init { require(sel.size == VaultParams.SALT_BYTES && nonce.size == VaultParams.NONCE_BYTES) + require(magique.size == TAILLE_DE_LA_MAGIQUE) } fun octets(): ByteArray = ByteBuffer.allocate(TAILLE_DE_L_EN_TETE) - .put(MAGIQUE) + .put(magique) .putShort(VERSION.toShort()) .put(KDF_ARGON2ID) .putInt(VaultParams.PASSPHRASE_ITERATIONS) @@ -68,7 +72,8 @@ internal object FichierDeSauvegarde { .array() } - fun nouvelEnTete(): EnTete = EnTete(VaultCrypto.newSalt(), VaultCrypto.newNonce()) + fun nouvelEnTete(magique: ByteArray = MAGIQUE): EnTete = + EnTete(VaultCrypto.newSalt(), VaultCrypto.newNonce(), magique) /** The key of a file: Argon2id with the vaults' passphrase parameters. To be wiped by the caller. */ fun cle(secret: ByteArray, enTete: EnTete): ByteArray = VaultCrypto.derivePassphraseKey(secret, enTete.sel) @@ -81,7 +86,7 @@ internal object FichierDeSauvegarde { } /** Reads and checks the header of [entree] — strictly, nothing being authenticated yet. */ - fun lireLEnTete(entree: InputStream): Pair { + fun lireLEnTete(entree: InputStream, magiqueAttendue: ByteArray = MAGIQUE): Pair { // A loop, not `readNBytes`: that one exists from Android 13 only, and the app runs from 7. val octets = ByteArray(TAILLE_DE_L_EN_TETE) var lus = 0 @@ -91,8 +96,8 @@ internal object FichierDeSauvegarde { lus += n } val lecture = ByteBuffer.wrap(octets) - val magique = ByteArray(MAGIQUE.size).also { lecture.get(it) } - if (!magique.contentEquals(MAGIQUE)) throw SauvegardeRefuseeException(Refus.PAS_UNE_SAUVEGARDE) + val magique = ByteArray(TAILLE_DE_LA_MAGIQUE).also { lecture.get(it) } + if (!magique.contentEquals(magiqueAttendue)) throw SauvegardeRefuseeException(Refus.PAS_UNE_SAUVEGARDE) if (lecture.short.toInt() != VERSION) throw SauvegardeRefuseeException(Refus.VERSION_INCONNUE) val connus = lecture.get() == KDF_ARGON2ID && lecture.int == VaultParams.PASSPHRASE_ITERATIONS && @@ -101,7 +106,7 @@ internal object FichierDeSauvegarde { if (!connus) throw SauvegardeRefuseeException(Refus.PARAMETRES_INCONNUS) val sel = ByteArray(VaultParams.SALT_BYTES).also { lecture.get(it) } val nonce = ByteArray(VaultParams.NONCE_BYTES).also { lecture.get(it) } - return EnTete(sel, nonce) to octets + return EnTete(sel, nonce, magiqueAttendue) to octets } /** @@ -112,8 +117,8 @@ internal object FichierDeSauvegarde { fun ouvrir(entree: InputStream, cle: ByteArray, enTete: EnTete, octetsDeLEnTete: ByteArray): InputStream = CipherInputStream(entree, gcm(chiffrer = false, cle, enTete, octetsDeLEnTete)) - /** [entree], refused past [TAILLE_MAX] bytes really read. */ - fun borne(entree: InputStream): InputStream = object : FilterInputStream(entree) { + /** [entree], refused past [tailleMax] bytes really read. */ + fun borne(entree: InputStream, tailleMax: Long = TAILLE_MAX): InputStream = object : FilterInputStream(entree) { private var lus = 0L override fun read(): Int = super.read().also { if (it >= 0) compter(1) } @@ -125,7 +130,7 @@ internal object FichierDeSauvegarde { private fun compter(n: Int) { lus += n - if (lus > TAILLE_MAX) throw SauvegardeRefuseeException(Refus.TROP_GROS) + if (lus > tailleMax) throw SauvegardeRefuseeException(Refus.TROP_GROS) } } From 9464509ed8c8d936f06600b045c6e4aca634c656 Mon Sep 17 00:00:00 2001 From: Patrice Haltaya Date: Mon, 9 Nov 2026 21:03:40 +0100 Subject: [PATCH 06/11] feat: share a note sealed, open a sealed note (3.4.0, D-044) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sending — the editor's ⋮ menu, "Share sealed…": the note as it is on screen, sealed off the main thread into note-YYYYMMDD.ntnote (the name says nothing of the note) in the exports directory (purged at start and by the panic), with an option "read once by the recipient". The code is shown once, spelled for TalkBack, can be copied through the sensitive clipboard, and is forgotten when the sheet closes; a secured window while it is open. "Share the file" goes through the exports' FileProvider, as the export does. Receiving — "Open with" (an exported activity, VIEW on application/octet-stream, content:// of another app only, bounded in size and time, magic checked: any other file is said so and the app does not open) and the home screen's ⋮ menu, "Open a sealed note" (the system picker). Both deposit the still-sealed bytes in memory; once the app is unlocked, a dialog asks for the code (never in a Bundle). The right code makes it a note of the Inbox — never a vault —, colour and tags kept, "after reading" if asked, and opens it. The code's display moves to ui/common (CodeAffiche), shared with the backup. Texts in the five languages. Tests: LectureScelleeTest (4, JVM: a sealed note read whole; another file dropped at its first bytes, not read to its end; past the bound; empty). Co-Authored-By: Claude Opus 5.5 --- .../notes_tech/ui/editor/EditeurTest.kt | 4 +- .../ui/editor/RechercheDansLaNoteEcranTest.kt | 2 +- .../notes_tech/ui/home/AccueilTest.kt | 1 + app/src/main/AndroidManifest.xml | 21 ++ .../data/scellee/EnvoiDeNoteScellee.kt | 47 ++++ .../data/scellee/FichierDeNoteScellee.kt | 2 +- .../notes_tech/data/scellee/LectureScellee.kt | 61 +++++ .../filestech/notes_tech/ui/MainActivity.kt | 3 + .../notes_tech/ui/common/CodeAffiche.kt | 32 +++ .../ui/editor/FeuilleDeDureeDeVie.kt | 21 +- .../ui/editor/FeuilleDePartageScelle.kt | 197 ++++++++++++++++ .../notes_tech/ui/editor/NoteEditorScreen.kt | 28 ++- .../filestech/notes_tech/ui/home/HomeRoute.kt | 16 ++ .../notes_tech/ui/home/HomeScreen.kt | 23 +- .../ui/scellee/NotesScelleesEnAttente.kt | 18 ++ .../ui/scellee/NotesScelleesRecues.kt | 210 ++++++++++++++++++ .../scellee/OuvrirUneNoteScelleeActivity.kt | 78 +++++++ .../notes_tech/ui/settings/SauvegardeUi.kt | 20 +- app/src/main/res/values-de/strings.xml | 24 ++ app/src/main/res/values-es/strings.xml | 24 ++ app/src/main/res/values-fr/strings.xml | 24 ++ app/src/main/res/values-it/strings.xml | 24 ++ app/src/main/res/values/strings.xml | 24 ++ .../data/scellee/LectureScelleeTest.kt | 62 ++++++ .../notes_tech/ui/settings/CodeEpeleTest.kt | 1 + 25 files changed, 942 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/com/filestech/notes_tech/data/scellee/EnvoiDeNoteScellee.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/data/scellee/LectureScellee.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/ui/common/CodeAffiche.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/ui/editor/FeuilleDePartageScelle.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/ui/scellee/NotesScelleesEnAttente.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/ui/scellee/NotesScelleesRecues.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/ui/scellee/OuvrirUneNoteScelleeActivity.kt create mode 100644 app/src/test/java/com/filestech/notes_tech/data/scellee/LectureScelleeTest.kt diff --git a/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/EditeurTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/EditeurTest.kt index 7b1f783..2db3f65 100644 --- a/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/EditeurTest.kt +++ b/app/src/androidTest/java/com/filestech/notes_tech/ui/editor/EditeurTest.kt @@ -181,7 +181,9 @@ class EditeurTest { onInfos = { infos += Unit }, onDeplacer = { deplacements += Unit }, onArchiver = { archivages += it }, - feuilles = FeuillesDeLaFiche(couleur = { couleurs += Unit }, dureeDeVie = {}), + feuilles = FeuillesDeLaFiche(couleur = { + couleurs += Unit + }, dureeDeVie = {}, partageScelle = {}), onExporter = { exports += Unit }, onCopier = { copies += Unit }, onCorbeille = { corbeilles += Unit }, 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 index bedd391..05fa2f1 100644 --- 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 @@ -87,7 +87,7 @@ class RechercheDansLaNoteEcranTest { onInfos = {}, onDeplacer = {}, onArchiver = {}, - feuilles = FeuillesDeLaFiche(couleur = {}, dureeDeVie = {}), + feuilles = FeuillesDeLaFiche(couleur = {}, dureeDeVie = {}, partageScelle = {}), onExporter = {}, onCopier = {}, onCorbeille = {}, diff --git a/app/src/androidTest/java/com/filestech/notes_tech/ui/home/AccueilTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/ui/home/AccueilTest.kt index 72fbe47..3a06fc1 100644 --- a/app/src/androidTest/java/com/filestech/notes_tech/ui/home/AccueilTest.kt +++ b/app/src/androidTest/java/com/filestech/notes_tech/ui/home/AccueilTest.kt @@ -126,6 +126,7 @@ class AccueilTest { onOpenSearch = { recherchesOuvertes += Unit }, onOpenSettings = { reglagesOuverts += Unit }, onOpenAbout = {}, + onOpenSealedNote = {}, onDismissVaultLostBanner = { banniereFermee += Unit }, ) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 05dcc07..e88e7d5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -121,6 +121,27 @@ + + + + + + + + + FichierDeNoteScellee.TAILLE_MAX) return LectureScellee.TropGrosse + sortie.write(tampon, 0, lus) + if (!magieVue && sortie.size() >= FichierDeNoteScellee.MAGIQUE.size) { + if (!FichierDeNoteScellee.aLaMagique(sortie.toByteArray())) return LectureScellee.PasUneNoteScellee + magieVue = true + } + } + return if (magieVue) LectureScellee.Lue(sortie.toByteArray()) else LectureScellee.PasUneNoteScellee +} + +private const val TAMPON = 64 * 1024 diff --git a/app/src/main/java/com/filestech/notes_tech/ui/MainActivity.kt b/app/src/main/java/com/filestech/notes_tech/ui/MainActivity.kt index 95d29fc..3a5b78b 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/MainActivity.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/MainActivity.kt @@ -43,6 +43,7 @@ import com.filestech.notes_tech.ui.panic.RecouvrementDePanique import com.filestech.notes_tech.ui.panic.activityPanicViewModel import com.filestech.notes_tech.ui.raccourcis.RaccourcisRecus import com.filestech.notes_tech.ui.raccourcis.publierLesRaccourcis +import com.filestech.notes_tech.ui.scellee.NotesScelleesRecues import com.filestech.notes_tech.ui.secure.ClavierSansApprentissage import com.filestech.notes_tech.ui.secure.LocalSecureWindow import com.filestech.notes_tech.ui.secure.SecureWindowController @@ -273,6 +274,8 @@ private fun ContenuPrincipal(settings: AppSettings, appLock: AppLockManager, ver PartagesRecus(onOuvrirLaNote = { navController.navigate(Destination.Editor(it).route) }) // 3.4.0 (D-041) — a launcher shortcut, carried out here for the same reasons. RaccourcisRecus(onOuvrir = { navController.navigate(it.route) }) + // 3.4.0 (D-044) — a sealed note received: its code is asked here, unlocked. + NotesScelleesRecues(onOuvrirLaNote = { navController.navigate(Destination.Editor(it).route) }) } } } diff --git a/app/src/main/java/com/filestech/notes_tech/ui/common/CodeAffiche.kt b/app/src/main/java/com/filestech/notes_tech/ui/common/CodeAffiche.kt new file mode 100644 index 0000000..82b0c84 --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/ui/common/CodeAffiche.kt @@ -0,0 +1,32 @@ +package com.filestech.notes_tech.ui.common + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontFamily + +/** + * A generated code on screen — the backup's (3.2.0), the sealed note's (3.4.0): two lines of three groups, + * monospaced, spelled for TalkBack. One place, so the two show it alike. The screen around it is a secured + * window, as the code is. + */ +@Composable +fun CodeAffiche(code: String, modifier: Modifier = Modifier) { + Text( + // Two lines of three groups: left to the text layout, a line broke inside a group. + code.split('-').chunked(GROUPES_PAR_LIGNE).joinToString("\n") { it.joinToString("-") }, + style = MaterialTheme.typography.titleLarge.copy(fontFamily = FontFamily.Monospace), + // Spelled, group by group: read as text, TalkBack said "K7QM" as a word (audit 3 axes of 3.2.0, U2). + modifier = modifier.semantics { contentDescription = codeEpele(code) }, + ) +} + +/** [code] for TalkBack: its signs one by one, a pause between groups — "K, 7, Q, M. T, 2, X, B…". */ +internal fun codeEpele(code: String): String = + code.split('-').joinToString(". ") { groupe -> groupe.toList().joinToString(", ") } + +/** How many of the code's groups a line shows. */ +private const val GROUPES_PAR_LIGNE = 3 diff --git a/app/src/main/java/com/filestech/notes_tech/ui/editor/FeuilleDeDureeDeVie.kt b/app/src/main/java/com/filestech/notes_tech/ui/editor/FeuilleDeDureeDeVie.kt index f66503c..440d5db 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/editor/FeuilleDeDureeDeVie.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/editor/FeuilleDeDureeDeVie.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import com.filestech.notes_tech.R +import com.filestech.notes_tech.data.scellee.NoteScellee import com.filestech.notes_tech.domain.model.DureeDeVie import com.filestech.notes_tech.domain.model.Note import com.filestech.notes_tech.ui.common.phrase @@ -114,7 +115,25 @@ private fun Choix(@StringRes libelle: Int, choisi: Boolean, @StringRes detail: I * One parameter of `NoteEditorScreen` for both: it holds as many as detekt allows. No default, for the * screen's reason: a menu entry wired to nothing would be a dead button that compiles. */ -data class FeuillesDeLaFiche(val couleur: () -> Unit, val dureeDeVie: () -> Unit) +data class FeuillesDeLaFiche(val couleur: () -> Unit, val dureeDeVie: () -> Unit, val partageScelle: () -> Unit) + +/** "Share sealed…" (D-044) as the editor shows it: while [ouvert], for a note that is [lisible] — as it is on screen. */ +@Composable +internal fun PartageScelleDeLEditeur(ouvert: Boolean, lisible: Boolean, state: EditorUiState, onFermer: () -> Unit) { + if (!ouvert || !lisible) return + FeuilleDePartageScelle( + note = { lectureUnique -> + NoteScellee( + titre = state.titre.text, + contenu = state.content.text, + couleur = state.note?.color?.id, + etiquettes = state.note?.tags.orEmpty(), + lectureUnique = lectureUnique, + ) + }, + onFermer = onFermer, + ) +} /** The sheet as the editor shows it: while [ouverte], for a [note] that is readable. */ @Composable diff --git a/app/src/main/java/com/filestech/notes_tech/ui/editor/FeuilleDePartageScelle.kt b/app/src/main/java/com/filestech/notes_tech/ui/editor/FeuilleDePartageScelle.kt new file mode 100644 index 0000000..9c41d6d --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/ui/editor/FeuilleDePartageScelle.kt @@ -0,0 +1,197 @@ +package com.filestech.notes_tech.ui.editor + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.filestech.notes_tech.R +import com.filestech.notes_tech.data.scellee.EnvoiDeNoteScellee +import com.filestech.notes_tech.data.scellee.NoteScellee +import com.filestech.notes_tech.security.clipboard.SensitiveClipboard +import com.filestech.notes_tech.ui.common.CodeAffiche +import com.filestech.notes_tech.ui.common.partagerUnFichier +import com.filestech.notes_tech.ui.secure.SecureWindowGuard +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +/** + * "Share sealed…" of the editor's ⋮ menu (D-044, 3.4.0): the note, as it is on screen, sealed into a + * file that only its code opens; the code shown once, the file shared by any app. + * + * 🔴 A secured window while open — the code is on it —, and the code forgotten when the sheet closes: + * it lives in [PartageScelleViewModel] only. + * + * @param note the note to seal, with or without "read once" for its recipient. + */ +@Composable +internal fun FeuilleDePartageScelle(note: (lectureUnique: Boolean) -> NoteScellee, onFermer: () -> Unit) { + val viewModel: PartageScelleViewModel = hiltViewModel() + val etat by viewModel.etat.collectAsStateWithLifecycle() + var lectureUnique by rememberSaveable { mutableStateOf(false) } + val contexte = LocalContext.current + val titreDuSelecteur = stringResource(R.string.common_share) + val fermer = { + viewModel.oublier() + onFermer() + } + SecureWindowGuard(active = true) + + AlertDialog( + // Not while sealing: the work would go on for a sheet already closed. + onDismissRequest = { if (etat !is PartageScelleViewModel.Etat.EnCours) fermer() }, + title = { Text(stringResource(R.string.sealed_title)) }, + text = { + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { + when (val courant = etat) { + PartageScelleViewModel.Etat.Choix, PartageScelleViewModel.Etat.Echec -> { + Text(stringResource(R.string.sealed_explanation)) + LectureUniqueChoisie(lectureUnique) { lectureUnique = it } + if (courant == PartageScelleViewModel.Etat.Echec) { + Text(stringResource(R.string.sealed_failed), color = MaterialTheme.colorScheme.error) + } + } + PartageScelleViewModel.Etat.EnCours -> Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + CircularProgressIndicator() + Text(stringResource(R.string.sealed_sealing)) + } + is PartageScelleViewModel.Etat.Pret -> { + Text(stringResource(R.string.sealed_code_label)) + CodeAffiche(courant.envoi.code) + Text( + stringResource(R.string.sealed_code_once), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + TextButton(onClick = viewModel::copierLeCode) { + Text(stringResource(R.string.sealed_copy_code)) + } + } + } + } + }, + confirmButton = { + when (val courant = etat) { + PartageScelleViewModel.Etat.Choix, PartageScelleViewModel.Etat.Echec -> + TextButton(onClick = { viewModel.sceller(note(lectureUnique)) }) { + Text(stringResource(R.string.sealed_create)) + } + is PartageScelleViewModel.Etat.Pret -> TextButton( + onClick = { + partagerUnFichier( + contexte, + courant.envoi.uri, + TYPE_DU_FICHIER, + courant.envoi.nomDuFichier, + titreDuSelecteur, + ) + }, + ) { Text(stringResource(R.string.sealed_share_file)) } + PartageScelleViewModel.Etat.EnCours -> Unit + } + }, + dismissButton = { + if (etat !is PartageScelleViewModel.Etat.EnCours) { + TextButton(onClick = fermer) { Text(stringResource(R.string.common_close)) } + } + }, + ) +} + +@Composable +private fun LectureUniqueChoisie(choisie: Boolean, onChoisir: (Boolean) -> Unit) { + // One node, toggled by the row — the settings' idiom. + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().toggleable(value = choisie, role = Role.Switch, onValueChange = onChoisir), + ) { + Column(Modifier.weight(1f).padding(end = 12.dp)) { + Text(stringResource(R.string.sealed_read_once), style = MaterialTheme.typography.bodyLarge) + Text( + stringResource(R.string.sealed_read_once_detail), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch(checked = choisie, onCheckedChange = null) + } +} + +/** A `.ntnote` has no type of its own: what the messaging apps take for any file. */ +private const val TYPE_DU_FICHIER = "application/octet-stream" + +@HiltViewModel +class PartageScelleViewModel @Inject constructor( + private val envoi: EnvoiDeNoteScellee, + private val pressePapiers: SensitiveClipboard, +) : ViewModel() { + + sealed interface Etat { + data object Choix : Etat + data object EnCours : Etat + class Pret(val envoi: EnvoiDeNoteScellee.Envoi) : Etat + data object Echec : Etat + } + + private val _etat = MutableStateFlow(Etat.Choix) + val etat: StateFlow = _etat.asStateFlow() + + fun sceller(note: NoteScellee) { + if (_etat.value is Etat.EnCours) return + _etat.value = Etat.EnCours + viewModelScope.launch { + _etat.value = try { + Etat.Pret(envoi.preparer(note)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.w(e, "note scellee non preparee") + Etat.Echec + } + } + } + + /** Through the sensitive clipboard: marked, cleared after a minute, purged by the panic. */ + fun copierLeCode() { + val pret = _etat.value as? Etat.Pret ?: return + viewModelScope.launch { pressePapiers.copier(pret.envoi.code) } + } + + /** The sheet closes: the code goes with it. */ + fun oublier() { + _etat.value = Etat.Choix + } +} diff --git a/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorScreen.kt b/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorScreen.kt index 36481e6..d95ff31 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorScreen.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorScreen.kt @@ -41,6 +41,7 @@ import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.DeleteForever import androidx.compose.material.icons.outlined.DeleteOutline import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.EnhancedEncryption import androidx.compose.material.icons.outlined.ErrorOutline import androidx.compose.material.icons.outlined.FileDownload import androidx.compose.material.icons.outlined.Info @@ -189,6 +190,7 @@ fun NoteEditorRoute(onBack: () -> Unit, onOpenNote: (String) -> Unit, onInstalle var deplacementOuvert by rememberSaveable { mutableStateOf(false) } var couleurOuverte by rememberSaveable { mutableStateOf(false) } var dureeDeVieOuverte by rememberSaveable { mutableStateOf(false) } + var partageScelleOuvert by rememberSaveable { mutableStateOf(false) } var suppressionDemandee by rememberSaveable { mutableStateOf(false) } var infosOuvertes by rememberSaveable { mutableStateOf(false) } @@ -263,6 +265,7 @@ fun NoteEditorRoute(onBack: () -> Unit, onOpenNote: (String) -> Unit, onInstalle if (!noteLisible) { couleurOuverte = false dureeDeVieOuverte = false + partageScelleOuvert = false suppressionDemandee = false } } @@ -272,6 +275,12 @@ fun NoteEditorRoute(onBack: () -> Unit, onOpenNote: (String) -> Unit, onInstalle onChoisir = viewModel::setLifetime, onFermer = { dureeDeVieOuverte = false }, ) + PartageScelleDeLEditeur( + ouvert = partageScelleOuvert, + lisible = noteLisible, + state = state, + onFermer = { partageScelleOuvert = false }, + ) AnnonceDeLectureUnique( noteChargee = chargee, effaceeALaFermeture = viewModel.effaceeALaFermeture, @@ -448,9 +457,11 @@ fun NoteEditorRoute(onBack: () -> Unit, onOpenNote: (String) -> Unit, onInstalle onInfos = { infosOuvertes = true }, onDeplacer = { deplacementOuvert = true }, onArchiver = viewModel::setArchived, - feuilles = FeuillesDeLaFiche(couleur = { - couleurOuverte = true - }, dureeDeVie = { dureeDeVieOuverte = true }), + feuilles = FeuillesDeLaFiche( + couleur = { couleurOuverte = true }, + dureeDeVie = { dureeDeVieOuverte = true }, + partageScelle = { partageScelleOuvert = true }, + ), onExporter = { viewModel.exporterLaNote(libelleBoiteDeReception, mentionDeCoffre) }, onCopier = { // Retour haptique sur un geste réussi, comme l'application publiée @@ -783,6 +794,7 @@ fun NoteEditorScreen( onArchiver = { onArchiver(!note.archived) }, onCouleur = feuilles.couleur, onDureeDeVie = feuilles.dureeDeVie, + onPartagerScellee = feuilles.partageScelle, onExporter = onExporter, onCopier = onCopier, onCorbeille = onCorbeille, @@ -1399,6 +1411,7 @@ private fun MenuDeDebordement( onArchiver: () -> Unit, onCouleur: () -> Unit, onDureeDeVie: () -> Unit, + onPartagerScellee: () -> Unit, onExporter: () -> Unit, onCopier: () -> Unit, onCorbeille: () -> Unit, @@ -1521,6 +1534,15 @@ private fun MenuDeDebordement( onExporter() }, ) + // 3.4.0 (D-044): next to the export, the other gesture that sends the note out — sealed. + DropdownMenuItem( + text = { Text(stringResource(R.string.note_share_sealed)) }, + leadingIcon = { Icon(Icons.Outlined.EnhancedEncryption, contentDescription = null) }, + onClick = { + ouvert = false + onPartagerScellee() + }, + ) // Entre l'export et la corbeille, comme dans l'application publiée // (`note_editor_screen.dart:1035`) : les deux gestes qui sortent le texte de l'application // se suivent, et le geste destructif reste seul en bas. diff --git a/app/src/main/java/com/filestech/notes_tech/ui/home/HomeRoute.kt b/app/src/main/java/com/filestech/notes_tech/ui/home/HomeRoute.kt index 507fc74..603e6f4 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/home/HomeRoute.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/home/HomeRoute.kt @@ -2,6 +2,7 @@ package com.filestech.notes_tech.ui.home import android.content.res.Resources import androidx.activity.compose.BackHandler +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.navigationBarsPadding @@ -31,6 +32,7 @@ import com.filestech.notes_tech.domain.model.VaultMode import com.filestech.notes_tech.domain.model.estProtegee import com.filestech.notes_tech.ui.common.HoteDeMessages import com.filestech.notes_tech.ui.common.displayName +import com.filestech.notes_tech.ui.common.rememberAppResultLauncher import com.filestech.notes_tech.ui.folders.ConfirmDeleteFolderDialog import com.filestech.notes_tech.ui.folders.ConfirmRemoveVaultProtectionDialog import com.filestech.notes_tech.ui.folders.FolderAction @@ -43,6 +45,7 @@ import com.filestech.notes_tech.ui.folders.FoldersDrawerViewModel import com.filestech.notes_tech.ui.folders.GesteDeDossier import com.filestech.notes_tech.ui.folders.SortDesNotes import com.filestech.notes_tech.ui.folders.deverrouillageRequis +import com.filestech.notes_tech.ui.scellee.NotesScelleesViewModel import com.filestech.notes_tech.ui.vault.ChooseVaultModeSheet import com.filestech.notes_tech.ui.vault.CreateVaultSheet import com.filestech.notes_tech.ui.vault.UnlockVaultSheet @@ -146,6 +149,18 @@ fun HomeRoute( // La purge de la corbeille est un rattrapage d'arrière-plan, lancé une fois par entrée sur // l'écran. `Unit` en clé : la relancer à chaque recomposition ferait un balayage par frappe. LaunchedEffect(Unit) { homeViewModel.purgeExpiredTrash() } + + // 3.4.0 (D-044): "Open a sealed note" — the file picked is read and deposited; the code is asked by + // `NotesScelleesRecues`, the same as for "Open with". A file that is no sealed note is said here. + val scellees: NotesScelleesViewModel = hiltViewModel() + val selecteurDeNoteScellee = rememberAppResultLauncher(ActivityResultContracts.OpenDocument()) { uri -> + uri?.let(scellees::lireDepuisLeSelecteur) + } + val ouvrirUneNoteScellee: () -> Unit = { selecteurDeNoteScellee.launch(arrayOf("*/*")) } + val ressourcesDeLAccueil = LocalResources.current + LaunchedEffect(scellees) { + scellees.refusDuSelecteur.collect { snackbars.showSnackbar(ressourcesDeLAccueil.getString(it)) } + } // D-043 (3.4.0): the notes whose lifetime is over, every minute while the list shows them. LaunchedEffect(Unit) { while (true) { @@ -271,6 +286,7 @@ fun HomeRoute( onOpenSearch = onOpenSearch, onOpenSettings = onOpenSettings, onOpenAbout = onOpenAbout, + onOpenSealedNote = ouvrirUneNoteScellee, onDismissVaultLostBanner = homeViewModel::dismissVaultLostBanner, ) } diff --git a/app/src/main/java/com/filestech/notes_tech/ui/home/HomeScreen.kt b/app/src/main/java/com/filestech/notes_tech/ui/home/HomeScreen.kt index 6215de1..7fe9c17 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/home/HomeScreen.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/home/HomeScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.outlined.Archive import androidx.compose.material.icons.outlined.EditNote +import androidx.compose.material.icons.outlined.EnhancedEncryption import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.NoteAlt import androidx.compose.material.icons.outlined.SearchOff @@ -96,6 +97,8 @@ fun HomeScreen( onOpenSearch: () -> Unit, onOpenSettings: () -> Unit, onOpenAbout: () -> Unit, + /** The ⋮ menu's "Open a sealed note" (3.4.0, D-044): the system picker. */ + onOpenSealedNote: () -> Unit, onDismissVaultLostBanner: () -> Unit, modifier: Modifier = Modifier, ) { @@ -143,7 +146,15 @@ fun HomeScreen( actions = actionsDeSelection, ) } else { - BarreDeLaListe(state, onOpenDrawer, onSortSelected, onOpenSearch, onOpenSettings, onOpenAbout) + BarreDeLaListe( + state, + onOpenDrawer, + onSortSelected, + onOpenSearch, + onOpenSettings, + onOpenAbout, + onOpenSealedNote, + ) } }, floatingActionButton = { @@ -379,6 +390,7 @@ private fun BarreDeLaListe( onOpenSearch: () -> Unit, onOpenSettings: () -> Unit, onOpenAbout: () -> Unit, + onOpenSealedNote: () -> Unit, ) { var menuOuvert by remember { mutableStateOf(false) } var triOuvert by remember { mutableStateOf(false) } @@ -485,6 +497,15 @@ private fun BarreDeLaListe( onOpenAbout() }, ) + // 3.4.0 (D-044): a sealed note saved on the phone — "Open with" is the other way in. + DropdownMenuItem( + text = { Text(stringResource(R.string.home_open_sealed)) }, + leadingIcon = { Icon(Icons.Outlined.EnhancedEncryption, contentDescription = null) }, + onClick = { + menuOuvert = false + onOpenSealedNote() + }, + ) } }, ) diff --git a/app/src/main/java/com/filestech/notes_tech/ui/scellee/NotesScelleesEnAttente.kt b/app/src/main/java/com/filestech/notes_tech/ui/scellee/NotesScelleesEnAttente.kt new file mode 100644 index 0000000..d3d6c0e --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/ui/scellee/NotesScelleesEnAttente.kt @@ -0,0 +1,18 @@ +package com.filestech.notes_tech.ui.scellee + +import com.filestech.notes_tech.ui.common.DepotEnMemoire +import javax.inject.Inject +import javax.inject.Singleton + +/** + * A sealed note received — its file's bytes, still sealed — and not yet opened (D-044, 3.4.0). A + * [DepotEnMemoire]: in memory, taken once, opened only once the app lock is passed ([NotesScelleesRecues]). + * Two minutes to unlock the app; once taken, the code dialog keeps it as long as it is open. + */ +@Singleton +class NotesScelleesEnAttente @Inject constructor() : DepotEnMemoire(DUREE_DE_VIE_MS) { + + companion object { + const val DUREE_DE_VIE_MS = 2 * 60 * 1000L + } +} diff --git a/app/src/main/java/com/filestech/notes_tech/ui/scellee/NotesScelleesRecues.kt b/app/src/main/java/com/filestech/notes_tech/ui/scellee/NotesScelleesRecues.kt new file mode 100644 index 0000000..427944e --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/ui/scellee/NotesScelleesRecues.kt @@ -0,0 +1,210 @@ +package com.filestech.notes_tech.ui.scellee + +import android.content.Context +import android.net.Uri +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.filestech.notes_tech.R +import com.filestech.notes_tech.data.repository.DureesDeVie +import com.filestech.notes_tech.data.repository.NotesRepository +import com.filestech.notes_tech.data.scellee.FichierDeNoteScellee +import com.filestech.notes_tech.data.scellee.FichierDeNoteScellee.NoteScelleeRefuseeException +import com.filestech.notes_tech.data.scellee.FichierDeNoteScellee.Raison +import com.filestech.notes_tech.data.scellee.LectureScellee +import com.filestech.notes_tech.data.scellee.lireUneNoteScellee +import com.filestech.notes_tech.domain.model.DureeDeVie +import com.filestech.notes_tech.domain.model.Folder +import com.filestech.notes_tech.domain.model.NoteColor +import com.filestech.notes_tech.ui.secure.SecureWindowGuard +import com.filestech.notes_tech.ui.vault.ChampDePhraseSecrete +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber +import javax.inject.Inject + +/** + * A sealed note received, opened once the app is unlocked (D-044, 3.4.0): the code is asked; the right + * one makes it a note of the Inbox — never a vault —, its colour and tags kept, "after reading" if its + * sender asked, and the editor opens on it. + * + * 🔴 Composed only inside the app lock's content, not while the panic runs — `PartagesRecus`'s place. + * A secured window while the code is typed. + */ +@Composable +fun NotesScelleesRecues(onOuvrirLaNote: (String) -> Unit) { + val viewModel: NotesScelleesViewModel = hiltViewModel() + val deposee by viewModel.deposee.collectAsStateWithLifecycle() + val etat by viewModel.etat.collectAsStateWithLifecycle() + val ouvrir by rememberUpdatedState(onOuvrirLaNote) + + LaunchedEffect(viewModel) { viewModel.aOuvrir.collect { ouvrir(it) } } + LaunchedEffect(deposee) { if (deposee != null) viewModel.prendre() } + (etat as? NotesScelleesViewModel.Etat.Code)?.let { code -> + DialogueDuCode(code, onOuvrir = viewModel::ouvrir, onAnnuler = viewModel::abandonner) + } +} + +@Composable +private fun DialogueDuCode(etat: NotesScelleesViewModel.Etat.Code, onOuvrir: (String) -> Unit, onAnnuler: () -> Unit) { + // `remember`, never `rememberSaveable`: a code has nothing to do in a Bundle. + var saisie by remember { mutableStateOf("") } + SecureWindowGuard(active = true) + AlertDialog( + onDismissRequest = { if (!etat.enCours) onAnnuler() }, + title = { Text(stringResource(R.string.sealed_received_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(stringResource(R.string.sealed_received_body)) + ChampDePhraseSecrete( + valeur = saisie, + onValeurChange = { saisie = it }, + label = stringResource(R.string.sealed_code_field), + actionClavier = ImeAction.Done, + actif = !etat.enCours, + ) + etat.erreur?.let { Text(stringResource(it.message()), color = MaterialTheme.colorScheme.error) } + if (etat.enCours) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + CircularProgressIndicator() + Text(stringResource(R.string.sealed_opening)) + } + } + } + }, + confirmButton = { + TextButton(onClick = { onOuvrir(saisie) }, enabled = !etat.enCours && saisie.isNotBlank()) { + Text(stringResource(R.string.sealed_open)) + } + }, + dismissButton = { + TextButton(onClick = onAnnuler, enabled = !etat.enCours) { Text(stringResource(R.string.common_cancel)) } + }, + ) +} + +@StringRes +internal fun Raison.message(): Int = when (this) { + Raison.CODE_MAL_FORME -> R.string.sealed_error_malformed + Raison.CODE_OU_FICHIER -> R.string.sealed_error_code + Raison.VERSION_INCONNUE -> R.string.sealed_error_version + Raison.TROP_GROSSE -> R.string.sealed_too_big + Raison.PAS_UNE_NOTE_SCELLEE -> R.string.sealed_not_a_sealed_note + Raison.ILLISIBLE -> R.string.sealed_error_unreadable +} + +@HiltViewModel +class NotesScelleesViewModel @Inject constructor( + private val enAttente: NotesScelleesEnAttente, + private val notes: NotesRepository, + private val durees: DureesDeVie, + @ApplicationContext private val context: Context, +) : ViewModel() { + + sealed interface Etat { + data object Aucune : Etat + data class Code(val erreur: Raison? = null, val enCours: Boolean = false) : Etat + } + + /** What "Open with" or the picker deposited, for [NotesScelleesRecues] to take. */ + val deposee = enAttente.contenu + + private var octets: ByteArray? = null + private val _etat = MutableStateFlow(Etat.Aucune) + val etat: StateFlow = _etat.asStateFlow() + + // Channels, not SharedFlows: an editor to open, a refusal to say, are not lost when nothing collects yet. + private val ouvertures = Channel(Channel.BUFFERED) + val aOuvrir: Flow = ouvertures.receiveAsFlow() + private val refus = Channel(Channel.BUFFERED) + + /** A file picked in the app that is no sealed note: said where it was picked. */ + val refusDuSelecteur: Flow = refus.receiveAsFlow() + + fun prendre() { + val pris = enAttente.prendre() ?: return + octets?.fill(0) + octets = pris + _etat.value = Etat.Code() + } + + fun ouvrir(saisie: String) { + val fichier = octets ?: return + if ((_etat.value as? Etat.Code)?.enCours == true) return + _etat.value = Etat.Code(enCours = true) + viewModelScope.launch { + try { + val note = withContext(Dispatchers.Default) { FichierDeNoteScellee.ouvrir(fichier, saisie) } + val creee = notes.create(Folder.INBOX_ID, note.titre, note.contenu, note.etiquettes) + NoteColor.fromId(note.couleur)?.let { notes.setColor(creee.id, it) } + if (note.lectureUnique) durees.poser(creee.id, DureeDeVie.APRES_LECTURE) + abandonner() + ouvertures.send(creee.id) + } catch (e: CancellationException) { + throw e + } catch (e: NoteScelleeRefuseeException) { + _etat.value = Etat.Code(erreur = e.raison) + } catch (e: Exception) { + Timber.w(e, "note scellee non ajoutee") + _etat.value = Etat.Code(erreur = Raison.ILLISIBLE) + } + } + } + + fun abandonner() { + octets?.fill(0) + octets = null + _etat.value = Etat.Aucune + } + + /** The home screen's "Open a sealed note": the picked file, read and deposited as "Open with" does. */ + fun lireDepuisLeSelecteur(uri: Uri) { + viewModelScope.launch { + when (val lue = lireUneNoteScellee(context.contentResolver, uri)) { + is LectureScellee.Lue -> enAttente.deposer(lue.octets) + LectureScellee.PasUneNoteScellee -> refus.send(R.string.sealed_not_a_sealed_note) + LectureScellee.TropGrosse -> refus.send(R.string.sealed_too_big) + LectureScellee.Illisible -> refus.send(R.string.sealed_error_unreadable) + } + } + } + + override fun onCleared() { + octets?.fill(0) + super.onCleared() + } +} diff --git a/app/src/main/java/com/filestech/notes_tech/ui/scellee/OuvrirUneNoteScelleeActivity.kt b/app/src/main/java/com/filestech/notes_tech/ui/scellee/OuvrirUneNoteScelleeActivity.kt new file mode 100644 index 0000000..833585b --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/ui/scellee/OuvrirUneNoteScelleeActivity.kt @@ -0,0 +1,78 @@ +package com.filestech.notes_tech.ui.scellee + +import android.content.Intent +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.lifecycle.lifecycleScope +import com.filestech.notes_tech.R +import com.filestech.notes_tech.data.scellee.LectureScellee +import com.filestech.notes_tech.data.scellee.lireUneNoteScellee +import com.filestech.notes_tech.ui.common.avecLaLangueChoisie +import com.filestech.notes_tech.ui.common.ouvrirLAppli +import com.filestech.notes_tech.ui.share.RecevoirUnPartageActivity +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import javax.inject.Inject + +/** + * "Open with → Notes Tech" on a `.ntnote` file (D-044, 3.4.0). No screen: it reads the file — bounded —, + * deposits it in memory ([NotesScelleesEnAttente]) and opens the app, which asks for the code once + * unlocked ([NotesScelleesRecues]). Nothing is decrypted, nothing written here. + * + * 🔴 Exported, as "Open with" requires, so read as the share receiver reads (3.2.0): + * - `content://` of ANOTHER app only ([RecevoirUnPartageActivity.estDUneAutreAppli]) — never this app's + * own providers, whose export cache holds plaintext; + * - bounded in size ([com.filestech.notes_tech.data.scellee.FichierDeNoteScellee.TAILLE_MAX]) and in time; + * - a file that is not a sealed note — the filter takes any `application/octet-stream` — is said so in + * one short message, and nothing else happens: the app does not even open. + */ +@AndroidEntryPoint +class OuvrirUneNoteScelleeActivity : ComponentActivity() { + + @Inject + lateinit var enAttente: NotesScelleesEnAttente + + override fun attachBaseContext(newBase: android.content.Context) { + super.attachBaseContext(newBase.avecLaLangueChoisie()) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // Recreated (rotation, process restored): the first instance did it. + if (savedInstanceState != null) { + finish() + return + } + val uri = intent?.takeIf { it.action == Intent.ACTION_VIEW }?.data + if (uri == null || !RecevoirUnPartageActivity.estDUneAutreAppli(uri, packageName)) { + finish() + return + } + lifecycleScope.launch { + val lecture = async { lireUneNoteScellee(contentResolver, uri) } + when (val lue = withTimeoutOrNull(DELAI_MAX_MS) { lecture.await() }) { + is LectureScellee.Lue -> { + enAttente.deposer(lue.octets) + ouvrirLAppli() + } + LectureScellee.PasUneNoteScellee -> dire(R.string.sealed_not_a_sealed_note) + LectureScellee.TropGrosse -> dire(R.string.sealed_too_big) + LectureScellee.Illisible, null -> { + lecture.cancel() + dire(R.string.sealed_error_unreadable) + } + } + finish() + } + } + + private fun dire(message: Int) = Toast.makeText(this, message, Toast.LENGTH_LONG).show() + + private companion object { + /** Past this, the reading is dropped: a provider that never answers keeps no window open. */ + const val DELAI_MAX_MS = 20_000L + } +} diff --git a/app/src/main/java/com/filestech/notes_tech/ui/settings/SauvegardeUi.kt b/app/src/main/java/com/filestech/notes_tech/ui/settings/SauvegardeUi.kt index 06e60a9..25e5ffc 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/settings/SauvegardeUi.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/settings/SauvegardeUi.kt @@ -34,8 +34,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp @@ -46,6 +44,7 @@ import com.filestech.notes_tech.data.sauvegarde.RestaurationDeSauvegarde import com.filestech.notes_tech.data.sauvegarde.SauvegardeChiffree import com.filestech.notes_tech.security.sauvegarde.SecretDeSauvegarde import com.filestech.notes_tech.security.vault.VaultParams +import com.filestech.notes_tech.ui.common.CodeAffiche import com.filestech.notes_tech.ui.common.rememberAppResultLauncher import com.filestech.notes_tech.ui.secure.SecureWindowGuard import com.filestech.notes_tech.ui.vault.ChampDePhraseSecrete @@ -244,14 +243,8 @@ private fun DialogueDeCreation( TextButton(onClick = { avecUnePhrase = false }) { Text(stringResource(R.string.backup_use_code)) } } else { Text(stringResource(R.string.backup_code_intro)) - // Two lines of three groups: left to the text layout, a line broke inside a group. - Text( - code.split('-').chunked(GROUPES_PAR_LIGNE).joinToString("\n") { it.joinToString("-") }, - style = MaterialTheme.typography.titleLarge.copy(fontFamily = FontFamily.Monospace), - // Spelled, group by group: read as text, TalkBack said "K7QM" as a word (audit 3 - // axes of 3.2.0, U2). The dialog is a secured window, as the code on screen. - modifier = Modifier.semantics { contentDescription = codeEpele(code) }, - ) + // The dialog is a secured window, as the code on screen. + CodeAffiche(code) ChampDePhraseSecrete( valeur = recopie, onValeurChange = { recopie = it }, @@ -427,12 +420,5 @@ private fun texteDuBilan(r: RestaurationDeSauvegarde.Resultat, apres: Boolean): /** A vault's PIN, as the vaults take it — their own bounds, not a copy of them. */ private val CODE_VALIDE = Regex("[0-9]{${VaultParams.PIN_MIN_LENGTH},${VaultParams.PIN_MAX_LENGTH}}") -/** How many of the code's groups a line shows. */ -private const val GROUPES_PAR_LIGNE = 3 - -/** [code] for TalkBack: its signs one by one, a pause between groups — "K, 7, Q, M. T, 2, X, B…". */ -internal fun codeEpele(code: String): String = - code.split('-').joinToString(". ") { groupe -> groupe.toList().joinToString(", ") } - /** What the system picker is told the file is: it has no type of its own. */ private const val TYPE_DU_FICHIER = "application/octet-stream" diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index dcf69b8..d1ac5c8 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -389,6 +389,30 @@ Wird in %1$d Tag gelöscht Wird in %1$d Tagen gelöscht + Versiegelt teilen… + Versiegelte Notiz öffnen + Versiegelte Notiz + Die Notiz wird in eine Datei verschlüsselt, die nur ihr Code öffnet. Senden Sie die Datei über eine beliebige App und den Code auf anderem Weg – mündlich, per SMS: Wer beides hat, kann sie lesen. + Beim Empfänger nur einmal lesbar + Nach dem ersten Lesen in seinem Notes Tech gelöscht. Die Datei kann er trotzdem behalten. + Versiegeln + Wird versiegelt… + Der Code – auf anderem Weg als die Datei senden: + Nur jetzt angezeigt: Er wird nirgends gespeichert. + Code kopieren + Datei teilen + Die Notiz konnte nicht versiegelt werden. + Versiegelte Notiz erhalten + Geben Sie den Code ein, den Sie auf anderem Weg als die Datei erhalten haben. + Code + Öffnen + Wird geöffnet… + Falscher Code oder beschädigte Datei. + Ein Code hat 24 Buchstaben und Ziffern. + Diese versiegelte Notiz stammt aus einem neueren Notes Tech: Aktualisieren Sie die App. + Diese versiegelte Notiz ist nicht lesbar. + Diese Datei ist keine versiegelte Notiz von Notes Tech. + Diese Datei ist zu groß für eine versiegelte Notiz. Automatische Tresorsperre %1$d Minute diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 976de33..d411795 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -411,6 +411,30 @@ Se borra en %1$d días Se borra en %1$d días + Compartir sellada… + Abrir una nota sellada + Nota sellada + La nota se cifra en un archivo que solo su código abre. Envíe el archivo con cualquier app y el código por otra vía — de viva voz, por SMS: quien tenga ambos puede leerla. + Lectura única para el destinatario + Se borra tras su primera lectura en su Notes Tech. Aun así, puede conservar el archivo. + Sellar + Sellando… + El código — envíelo por otra vía que el archivo: + Solo se muestra ahora: no se guarda en ningún sitio. + Copiar el código + Compartir el archivo + No se pudo sellar la nota. + Nota sellada recibida + Escriba el código recibido por otra vía que el archivo. + Código + Abrir + Abriendo… + Código incorrecto, o archivo dañado. + Un código tiene 24 letras y cifras. + Esta nota sellada viene de un Notes Tech más reciente: actualice la app. + Esta nota sellada no se puede leer. + Este archivo no es una nota sellada de Notes Tech. + Este archivo es demasiado grande para ser una nota sellada. Bloqueo automático de las cajas fuertes %1$d minuto diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 7327062..1d0d073 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -416,6 +416,30 @@ S\'efface dans %1$d jours S\'efface dans %1$d jours + Partager scellée… + Ouvrir une note scellée + Note scellée + La note est chiffrée dans un fichier que seul son code ouvre. Envoyez le fichier par n\'importe quelle appli, et le code par un autre moyen — de vive voix, par SMS : qui a les deux peut la lire. + Lecture unique chez le destinataire + Effacée après sa première lecture dans son Notes Tech. Il peut toutefois garder le fichier. + Sceller + Scellement… + Le code — à envoyer autrement que le fichier : + Affiché maintenant seulement : il n\'est gardé nulle part. + Copier le code + Partager le fichier + La note n\'a pas pu être scellée. + Note scellée reçue + Tapez le code reçu par un autre moyen que le fichier. + Code + Ouvrir + Ouverture… + Code incorrect, ou fichier abîmé. + Un code compte 24 lettres et chiffres. + Cette note scellée vient d\'un Notes Tech plus récent : mettez l\'appli à jour. + Cette note scellée est illisible. + Ce fichier n\'est pas une note scellée de Notes Tech. + Ce fichier est trop gros pour être une note scellée. Verrouillage automatique des coffres %1$d minute diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index fab1ffc..d678470 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -411,6 +411,30 @@ Si cancella tra %1$d giorni Si cancella tra %1$d giorni + Condividi sigillata… + Apri una nota sigillata + Nota sigillata + La nota viene cifrata in un file che solo il suo codice apre. Invii il file con qualsiasi app, e il codice in un altro modo — a voce, per SMS: chi ha entrambi può leggerla. + Lettura unica per il destinatario + Cancellata dopo la prima lettura nel suo Notes Tech. Può comunque conservare il file. + Sigilla + Sigillatura… + Il codice — da inviare in modo diverso dal file: + Mostrato solo ora: non viene conservato da nessuna parte. + Copia il codice + Condividi il file + Non è stato possibile sigillare la nota. + Nota sigillata ricevuta + Digiti il codice ricevuto in un modo diverso dal file. + Codice + Apri + Apertura… + Codice errato, o file danneggiato. + Un codice ha 24 lettere e cifre. + Questa nota sigillata proviene da un Notes Tech più recente: aggiorni l\'app. + Questa nota sigillata è illeggibile. + Questo file non è una nota sigillata di Notes Tech. + Questo file è troppo grande per essere una nota sigillata. Blocco automatico delle casseforti %1$d minuto diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 96257bc..4e218ab 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -464,6 +464,30 @@ Erases itself in %1$d day Erases itself in %1$d days + Share sealed… + Open a sealed note + Sealed note + The note is encrypted into a file that only its code opens. Send the file by any app, and the code another way — aloud, by text message: whoever has both can read it. + Read once by the recipient + Erased after its first reading in their Notes Tech. They can still keep the file. + Seal + Sealing… + The code — send it another way than the file: + Shown only now: it is kept nowhere. + Copy the code + Share the file + The note could not be sealed. + Sealed note received + Type the code you received another way than the file. + Code + Open + Opening… + Wrong code, or damaged file. + A code has 24 letters and digits. + This sealed note comes from a newer Notes Tech: update the app. + This sealed note cannot be read. + This file is not a Notes Tech sealed note. + This file is too big to be a sealed note. Vault auto-lock %1$d minute diff --git a/app/src/test/java/com/filestech/notes_tech/data/scellee/LectureScelleeTest.kt b/app/src/test/java/com/filestech/notes_tech/data/scellee/LectureScelleeTest.kt new file mode 100644 index 0000000..4cbae03 --- /dev/null +++ b/app/src/test/java/com/filestech/notes_tech/data/scellee/LectureScelleeTest.kt @@ -0,0 +1,62 @@ +package com.filestech.notes_tech.data.scellee + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import java.io.ByteArrayInputStream +import java.io.InputStream + +/** + * D-044 (3.4.0) — reading a file "opened with" Notes Tech: bounded, and a file that is no sealed note is + * dropped at its first bytes — the filter takes any `application/octet-stream`, a video included. + */ +@DisplayName("Lecture d'une note scellée") +class LectureScelleeTest { + + @Test + fun `a sealed note is read whole`() { + val fichier = FichierDeNoteScellee.MAGIQUE + ByteArray(100_000) { it.toByte() } + + val lue = lireBorne(ByteArrayInputStream(fichier)) + + assertThat(lue).isInstanceOf(LectureScellee.Lue::class.java) + assertThat((lue as LectureScellee.Lue).octets).isEqualTo(fichier) + } + + @Test + fun `another file is dropped at its first bytes, not read to its end`() { + val flux = FluxCompte("%PDF-1.7".toByteArray() + ByteArray(10_000_000)) + + assertThat(lireBorne(flux)).isEqualTo(LectureScellee.PasUneNoteScellee) + // One buffer at most: the rest of a large file is never read. + assertThat(flux.lus).isAtMost(64 * 1024) + } + + @Test + fun `a file past the bound is refused`() { + val fichier = FichierDeNoteScellee.MAGIQUE + ByteArray(FichierDeNoteScellee.TAILLE_MAX.toInt()) + + assertThat(lireBorne(ByteArrayInputStream(fichier))).isEqualTo(LectureScellee.TropGrosse) + } + + @Test + fun `an empty or too short file is no sealed note`() { + assertThat(lireBorne(ByteArrayInputStream(ByteArray(0)))).isEqualTo(LectureScellee.PasUneNoteScellee) + assertThat(lireBorne(ByteArrayInputStream("NTSE".toByteArray()))).isEqualTo(LectureScellee.PasUneNoteScellee) + } + + /** Counts what is really read. */ + private class FluxCompte(octets: ByteArray) : InputStream() { + private val entree = ByteArrayInputStream(octets) + var lus = 0 + private set + + override fun read(): Int = entree.read().also { if (it >= 0) lus++ } + + override fun read(b: ByteArray, off: Int, len: Int): Int { + val n = entree.read(b, off, len) + if (n > 0) lus += n + return n + } + } +} 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 index 75cdfa6..a84a69a 100644 --- 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 @@ -1,5 +1,6 @@ package com.filestech.notes_tech.ui.settings +import com.filestech.notes_tech.ui.common.codeEpele import com.google.common.truth.Truth.assertThat import org.junit.jupiter.api.Test From f680fe655e740871ad7e242341ced4c4363282bd Mon Sep 17 00:00:00 2001 From: Patrice Haltaya Date: Mon, 9 Nov 2026 21:08:28 +0100 Subject: [PATCH 07/11] docs: D-043 and D-044 checked on the S9 Co-Authored-By: Claude Opus 5.5 --- docs/15-VERSION-3.4.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/15-VERSION-3.4.md b/docs/15-VERSION-3.4.md index bae0e65..c74ccfa 100644 --- a/docs/15-VERSION-3.4.md +++ b/docs/15-VERSION-3.4.md @@ -157,7 +157,21 @@ effacée à la fermeture ; contrôle, choisie dans la session ; témoin sans durée), `EditeurTest` (36), `RechercheDansLaNoteEcranTest` (5), `FermetureDuCoffreTest` (9), `LiensDansUnCoffreTest` (3), `GestesSurDesNotesTest` (14), `AccueilTest` (20) — 0 échec. -- [ ] D-044 scellée +- [x] **D-044 scellée** — format (`e1c18e9`) : `FichierDeSauvegarde` paramétré (magie, borne ; la + sauvegarde inchangée, ses tests aussi), `FichierDeNoteScellee` (`NTSEALED`, 4 Mio, JSON, seul un code + ouvre). Envoi et réception (`9464509`) : feuille « Partager scellée… » (fichier `note-AAAAMMJJ.ntnote` + — le nom ne dit rien de la note —, code affiché une fois, copie par le presse-papiers sensible), + activité « Ouvrir avec » exportée et menu « Ouvrir une note scellée », dépôt en mémoire, code demandé + une fois l'appli déverrouillée. L'affichage du code passe dans `ui/common/CodeAffiche`, partagé avec la + sauvegarde. Tests : `FichierDeNoteScelleeTest` (6, S9), `LectureScelleeTest` (4, JVM : un autre fichier + est abandonné à ses premiers octets). **De bout en bout sur le S9** : note créée, scellée en lecture + unique (201 octets, `NTSEALED`, le texte absent du fichier), poussée dans Téléchargements, ouverte par + le menu de l'accueil ; un faux code refusé (« Code incorrect, ou fichier abîmé ») ; le bon, tapé en + minuscules avec des espaces, ouvre la note dans la Boîte de réception avec l'annonce de la lecture + unique ; refermée, elle s'efface — seule l'originale reste. Puis « Durée de vie… » → « 1 heure » sur + l'originale : message « Durée de vie : 1 heure », badge « 1 h » sur la carte. **Non essayé sur + appareil** : « Ouvrir avec » depuis une messagerie (l'activité exportée) — à faire avec une vraie + messagerie, ou au moins un gestionnaire de fichiers. - [ ] Textes 5 langues · [ ] version 3.4.0 (507) · [ ] journaux 5071-5073 · [ ] README, SECURITY, CGU/confidentialité - [ ] Audits (3 axes, sécurité des nouvelles entrées, i18n, cohérence) · [ ] relecture externe · [ ] S9 · [ ] CI - [ ] Avant le tag : build F-Droid sous AGP 9.5 (voir d'abord comment F-Droid a construit SMS 1.30.0) From c6074df6fc19f4d21c793ee02372951d507ac553 Mon Sep 17 00:00:00 2001 From: Patrice Haltaya Date: Mon, 9 Nov 2026 21:12:40 +0100 Subject: [PATCH 08/11] fix(manifest): open a .ntnote whatever type a file manager gives it A file manager gives an unknown extension no type, or a generic one. Two more VIEW filters on the .ntnote path, with no type and with any; the magic is checked in any case. Resolution checked with cmd package query-activities on the S9: no type, octet-stream, another type with a .ntnote path reach the activity; a .jpg does not. Samsung My Files still says no app can open it: with an empty MIME type it decides alone. Co-Authored-By: Claude Opus 5.5 --- app/src/main/AndroidManifest.xml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e88e7d5..1a678fa 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -140,6 +140,27 @@ + + + + + + + + + + + + + + + + + Date: Mon, 9 Nov 2026 21:15:34 +0100 Subject: [PATCH 09/11] =?UTF-8?q?release:=203.4.0=20(507)=20=E2=80=94=20te?= =?UTF-8?q?xts=20in=20the=20five=20languages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - version.properties: versionCode 507, versionName 3.4.0. - fastlane: changelogs 5071-5073 in the five locales (405 to 428 characters, under 500 bytes too); four lines in each full description (lifetime, sealed note, quick drop, keyboard). - Privacy policy, five languages (dated 9 November 2026): sealed notes (the file, its code, where it goes; a received one), quick drop, notes with a lifetime (left out of backup and export), the keyboard's learning. - README (EN, FR): what is new in 3.4.0. SECURITY (EN, FR): v3.4.0, the new doors and how narrow each is kept. Co-Authored-By: Claude Opus 5.5 --- README.fr.md | 20 ++++++++++++++ README.md | 17 ++++++++++++ SECURITY.fr.md | 26 ++++++++++++++++++- SECURITY.md | 24 ++++++++++++++++- app/src/main/res/raw-de/privacy.md | 6 ++++- app/src/main/res/raw-es/privacy.md | 6 ++++- app/src/main/res/raw-fr/privacy.md | 6 ++++- app/src/main/res/raw-it/privacy.md | 6 ++++- app/src/main/res/raw/privacy.md | 6 ++++- .../android/de-DE/changelogs/5071.txt | 1 + .../android/de-DE/changelogs/5072.txt | 1 + .../android/de-DE/changelogs/5073.txt | 1 + .../android/de-DE/full_description.txt | 4 +++ .../android/en-US/changelogs/5071.txt | 1 + .../android/en-US/changelogs/5072.txt | 1 + .../android/en-US/changelogs/5073.txt | 1 + .../android/en-US/full_description.txt | 4 +++ .../android/es-ES/changelogs/5071.txt | 1 + .../android/es-ES/changelogs/5072.txt | 1 + .../android/es-ES/changelogs/5073.txt | 1 + .../android/es-ES/full_description.txt | 4 +++ .../android/fr-FR/changelogs/5071.txt | 1 + .../android/fr-FR/changelogs/5072.txt | 1 + .../android/fr-FR/changelogs/5073.txt | 1 + .../android/fr-FR/full_description.txt | 4 +++ .../android/it-IT/changelogs/5071.txt | 1 + .../android/it-IT/changelogs/5072.txt | 1 + .../android/it-IT/changelogs/5073.txt | 1 + .../android/it-IT/full_description.txt | 4 +++ version.properties | 4 +-- 30 files changed, 147 insertions(+), 9 deletions(-) create mode 100644 fastlane/metadata/android/de-DE/changelogs/5071.txt create mode 100644 fastlane/metadata/android/de-DE/changelogs/5072.txt create mode 100644 fastlane/metadata/android/de-DE/changelogs/5073.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/5071.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/5072.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/5073.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/5071.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/5072.txt create mode 100644 fastlane/metadata/android/es-ES/changelogs/5073.txt create mode 100644 fastlane/metadata/android/fr-FR/changelogs/5071.txt create mode 100644 fastlane/metadata/android/fr-FR/changelogs/5072.txt create mode 100644 fastlane/metadata/android/fr-FR/changelogs/5073.txt create mode 100644 fastlane/metadata/android/it-IT/changelogs/5071.txt create mode 100644 fastlane/metadata/android/it-IT/changelogs/5072.txt create mode 100644 fastlane/metadata/android/it-IT/changelogs/5073.txt diff --git a/README.fr.md b/README.fr.md index 57f6e62..a2c67db 100644 --- a/README.fr.md +++ b/README.fr.md @@ -21,6 +21,26 @@ vérifier.** --- +## Nouveautés de la 3.4.0 + +- **Des notes à durée de vie** : ⋮ › Durée de vie… — une note s'efface pour de bon après 1 heure, + 24 heures, 7 ou 30 jours, ou quand on la referme après l'avoir rouverte. Sa carte montre ce qu'il lui + reste. L'effacement a lieu à la prochaine ouverture de l'appli ou sur la liste, pas à la seconde près, + et une telle note n'entre ni dans la sauvegarde ni dans l'export. +- **Partager une note scellée** : ⋮ › Partager scellée… — la note est chiffrée (Argon2id, AES-256-GCM) + dans un fichier `.ntnote` que seul un code de 24 signes ouvre, affiché une fois et gardé nulle part. + Envoyez le fichier par n'importe quelle appli et le code par un autre moyen ; elle peut être « à + lecture unique » chez son destinataire. Une note scellée reçue s'ouvre par « Ouvrir avec » ou ⋮ › + Ouvrir une note scellée, et arrive dans la Boîte de réception une fois son code tapé — jamais dans un + coffre. +- **Dépôt rapide** : écrire une note sans déverrouiller Notes Tech, depuis une tuile des réglages rapides + ou un appui long sur l'icône. Il ne fait qu'écrire, dans la Boîte de réception : rien de ce que l'appli + contient ne s'y lit. +- **Raccourcis sur l'icône** : Nouvelle note, Dicter, Dépôt rapide. +- **Clavier sans apprentissage** (Réglages › Sécurité, activé par défaut) : chaque champ demande au + clavier de ne rien apprendre ; les champs de secret le demandent toujours. +- Sous le capot : AGP 9.5.0, Gradle 9.8.2, SQLCipher 4.20.0 ; DataStore, jamais utilisé, retiré. + ## Nouveautés de la 3.3.0 Suggérées dans [#48](https://github.com/gitubpatrice/notes_tech/issues/48). diff --git a/README.md b/README.md index 228f0c8..cbc4046 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,23 @@ technically unable to send anything, and you can check that in its manifest.** --- +## What's new in 3.4.0 + +- **Notes with a lifetime**: ⋮ › Lifetime… — a note is erased for good after 1 hour, 24 hours, 7 or 30 + days, or when it is closed after its next reading. Its card shows what is left. The erasing happens at + the next opening of the app or on the list, not to the second, and such a note goes into neither the + backup nor the export. +- **Share a note sealed**: ⋮ › Share sealed… — the note is encrypted (Argon2id, AES-256-GCM) into a + `.ntnote` file that only a 24-sign code opens, shown once and kept nowhere. Send the file by any app and + the code another way; it can be "read once" by its recipient. A sealed note received opens with "Open + with" or ⋮ › Open a sealed note, and lands in the Inbox once its code is typed — never in a vault. +- **Quick drop**: write a note without unlocking Notes Tech, from a quick settings tile or a long press + on the app's icon. It only writes, to the Inbox: nothing the app holds can be read there. +- **Shortcuts on the icon**: New note, Dictate, Quick drop. +- **Keyboard without learning** (Settings › Security, on by default): every field asks the keyboard not + to learn what is typed; secret fields always do. +- Under the hood: AGP 9.5.0, Gradle 9.8.2, SQLCipher 4.20.0; DataStore, never used, removed. + ## What's new in 3.3.0 Suggested in [#48](https://github.com/gitubpatrice/notes_tech/issues/48). diff --git a/SECURITY.fr.md b/SECURITY.fr.md index 7e8402b..8a62484 100644 --- a/SECURITY.fr.md +++ b/SECURITY.fr.md @@ -2,10 +2,34 @@ *English version: [SECURITY.md](SECURITY.md)* -**Version actuelle : v3.3.0 — novembre 2026.** Notes Tech 3.0 est une réécriture en Kotlin. Le journal +**Version actuelle : v3.4.0 — novembre 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.4.0 — de nouvelles portes, tenues étroites (2026-11-09) + +- **Le dépôt rapide écrit sans le verrou de l'appli.** Le verrou est une barrière de lecture — la base + s'ouvre sans secret de l'utilisateur, comme depuis la 3.0.0. L'activité du dépôt n'est pas exportée + (la tuile et le raccourci la démarrent au nom de l'appli), ne lit rien, écrit une note dans la Boîte de + réception — jamais un coffre —, tient une fenêtre protégée, coupe elle-même les actions de texte des + autres applis et le classifieur (elle peut être la première activité de son processus), garde son texte + hors de l'état sauvegardé, et n'écrit rien pendant une panique en cours. La tuile demande d'abord le + déverrouillage du téléphone. +- **Les raccourcis de l'icône** arrivent sur une activité non exportée qui dépose la demande en mémoire ; + `MainActivity` continue d'ignorer ce que porte un intent ; la demande ne s'exécute qu'appli déverrouillée. +- **Les notes scellées** reprennent le conteneur de la sauvegarde (Argon2id m=64 Mio t=3, AES-256-GCM, + l'en-tête en AAD) sous leur propre magie. Seul un code généré de 24 signes (120 bits) en ouvre une ; il + est affiché une fois, gardé nulle part, copié seulement par le presse-papiers sensible ; le nom du + fichier ne dit rien de la note. À la réception : une activité `VIEW` exportée ne lit qu'un `content://` + d'une autre appli, borné en taille (4 Mio) et en temps, la magie vérifiée d'abord ; rien n'est + déchiffré avant le déverrouillage ; le code tapé n'atteint jamais un Bundle ; un code faux et un fichier + abîmé se ressemblent ; une note reçue va dans la Boîte de réception, jamais un coffre. +- **Les notes à durée de vie** sont effacées par le DELETE même de « Supprimer définitivement » (index + plein texte, `secure-delete`, liens) ; elles sont exclues de la sauvegarde et de l'export ; « après + lecture » efface à la vraie fermeture de l'éditeur, jamais sur une rotation. +- **Clavier** : `IME_FLAG_NO_PERSONALIZED_LEARNING` sur chaque champ par défaut, toujours sur les champs + de secret. + ## v3.3.0 — archives et sélection : les règles des coffres, pour plusieurs notes à la fois (2026-11-09) La sélection agit sur plusieurs notes à la fois — archiver, déplacer, mettre à la corbeille, épingler, diff --git a/SECURITY.md b/SECURITY.md index 3eb27c5..6c528c2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,10 +2,32 @@ *Version française : [SECURITY.fr.md](SECURITY.fr.md)* -**Current version: v3.3.0 — November 2026.** Notes Tech 3.0 is a rewrite in Kotlin. The security log +**Current version: v3.4.0 — November 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.4.0 — new doors, kept narrow (2026-11-09) + +- **Quick drop writes without the app lock.** The lock is a gate on reading — the database opens with no + user secret, as since 3.0.0. The drop's activity is not exported (the tile and the shortcut start it as + this app), reads nothing, writes one note to the Inbox — never a vault —, holds a protected window, cuts + the other apps' text actions and the classifier itself (it can be the first activity of its process), + keeps its text out of the saved state, and writes nothing while a panic is pending. The tile asks the + phone to be unlocked first. +- **Launcher shortcuts** land on a non-exported activity that deposits the request in memory; + `MainActivity` keeps ignoring what an intent carries; the request runs only once the app is unlocked. +- **Sealed notes** use the backup's container (Argon2id m=64 MiB t=3, AES-256-GCM, the header as AAD) + under their own magic. Only a generated code of 24 signs (120 bits) opens one; it is shown once, kept + nowhere, copied only through the sensitive clipboard; the file's name says nothing of the note. + Receiving: an exported `VIEW` activity reads only a `content://` of another app, bounded in size + (4 MiB) and in time, the magic checked first; nothing is decrypted before the app is unlocked; the + typed code never reaches a Bundle; a wrong code and a damaged file look the same; a received note goes + to the Inbox, never a vault. +- **Notes with a lifetime** are erased by the very DELETE of "Delete permanently" (FTS index, + `secure-delete`, links); they are left out of the backup and the export; "after reading" erases at the + editor's real closing, never on a rotation. +- **Keyboard**: `IME_FLAG_NO_PERSONALIZED_LEARNING` on every field by default, always on secret fields. + ## v3.3.0 — archive and selection: the vault rules, for many notes at once (2026-11-09) The selection acts on many notes at once — archive, move, trash, pin, favourite, colour, permanent diff --git a/app/src/main/res/raw-de/privacy.md b/app/src/main/res/raw-de/privacy.md index 2c70c8e..e031963 100644 --- a/app/src/main/res/raw-de/privacy.md +++ b/app/src/main/res/raw-de/privacy.md @@ -1,6 +1,6 @@ # Datenschutzerklärung — Notes Tech -_Zuletzt aktualisiert: 30. Oktober 2026_ +_Zuletzt aktualisiert: 9. November 2026_ ## In einem Satz @@ -17,6 +17,10 @@ Notes Tech erhebt, überträgt und speichert keinerlei Daten auf entfernten Serv - **Rückverweise `[[Titel]]`**: lokaler invertierter Index, nie übertragen. - **Verschlüsselte Sicherung (optional)**: eine `.ntbackup`-Datei, die Sie selbst erstellen, an dem Ort, den Sie über die Android-Dokumentauswahl wählen. Sie enthält Ihre Ordner und Notizen, Papierkorb inbegriffen, verschlüsselt mit **AES-256-GCM** unter einem Schlüssel, der mit **Argon2id** (m=64MB, t=3) aus einem von der App gezogenen Code, den Sie notieren, oder aus einer eigenen Passphrase abgeleitet wird — Notes Tech bewahrt keines von beiden auf. Sie wird zuerst, bereits verschlüsselt, im privaten Speicher der App geschrieben, dann an den gewählten Ort kopiert und aus dem Speicher der App gelöscht. Die Notizen eines Passphrase-Tresors bleiben in der Datei durch seine Passphrase versiegelt. Ein PIN-Tresor wird nur offen mitgenommen, mit seinem Schlüssel: In der Datei schützt ihn allein das Geheimnis der Sicherung; ein geschlossener wird ausgelassen. Die Wiederherstellung fügt hinzu, was fehlt, und überschreibt nichts. - **Importierte und geteilte Notizen**: Markdown- oder Textdateien – oder ein ZIP davon –, die Sie in der Dokumentauswahl von Android wählen, und Texte oder Dateien, die Sie aus einer anderen App an Notes Tech teilen, werden zu gewöhnlichen Notizen in Ihren Ordnern, nie in einem Tresor. Sie werden einmal gelesen, auf dem Telefon; die Originale bleiben unverändert, und neben den erstellten Notizen wird nichts aufbewahrt. Höchstens 500.000 Zeichen pro Notiz. +- **Versiegelte Notizen (optional)**: Eine Notiz, die Sie versiegelt teilen, wird in eine `.ntnote`-Datei verschlüsselt — **AES-256-GCM** unter einem Schlüssel, der per **Argon2id** (m=64MB, t=3) aus einem Code abgeleitet wird, den die App erzeugt, einmal anzeigt und nirgends speichert. Die Datei wird im privaten Speicher der App geschrieben und dann der App übergeben, die Sie zum Senden wählen; den Code übermitteln Sie auf anderem Weg. Ihr Name verrät nichts über die Notiz. Eine empfangene versiegelte Notiz wird einmal aus der Datei gelesen, die Sie mit Notes Tech öffnen, und wird erst nach Eingabe ihres Codes zu einer Notiz — im Eingang, nie in einem Tresor. +- **Schnellablage**: Über eine Kachel der Schnelleinstellungen oder eine Verknüpfung des App-Symbols schreibt sie eine Notiz in den Eingang, ohne die App zu entsperren; sie liest und zeigt nichts von dem, was die App enthält. +- **Notizen mit Lebensdauer (optional)**: endgültig gelöscht, sobald ihre Zeit abgelaufen ist — beim nächsten Öffnen der App oder bei der Rückkehr zur Liste —, oder beim Schließen nach dem Lesen, wenn Sie „Nach dem Lesen“ gewählt haben. Sie gehen weder in die verschlüsselte Sicherung noch in den Export ein. +- **Tastatur**: Die App bittet die Tastatur, nichts von dem zu lernen, was Sie tippen (Einstellung, standardmäßig aktiviert). Es ist eine Bitte, die eine Tastatur ignorieren kann; was eine Tastatur selbst speichert, entzieht sich jeder App. - **Diktiermodell (Whisper `.bin`)**: Sie beschaffen es selbst — die App zeigt den Dateinamen und seine Quelle an — und importieren es dann über die Android-Dokumentauswahl. Notes Tech hat keine Internetberechtigung und **bietet keine Möglichkeit, etwas herunterzuladen**. Sein SHA-256 wird beim Import und vor jedem Laden geprüft. - **Beim Diktat aufgenommenes Audio**: in eine temporäre Datei im privaten Speicher der App geschrieben — die Transkriptions-Engine liest eine Datei, anders geht es nicht — und **gelöscht, sobald die Transkription vorliegt**. Es wird außerdem beim Start der App und durch den Panikmodus gelöscht, sodass ein abruptes Beenden nichts zurücklässt. - **Einstellungen (Design, Sortierung, aktiviertes Diktat, automatische Tresorsperre, Optionen der App-Sperre)**: unverschlüsselt in den lokalen Einstellungen gespeichert (keine sensiblen Daten). diff --git a/app/src/main/res/raw-es/privacy.md b/app/src/main/res/raw-es/privacy.md index f49dcfa..27a1f8e 100644 --- a/app/src/main/res/raw-es/privacy.md +++ b/app/src/main/res/raw-es/privacy.md @@ -1,6 +1,6 @@ # Política de privacidad — Notes Tech -_Última actualización: 30 de octubre de 2026_ +_Última actualización: 9 de noviembre de 2026_ ## En una frase @@ -17,6 +17,10 @@ Notes Tech no recopila, no transmite ni almacena ningún dato en servidores remo - **Retroenlaces `[[Título]]`**: índice invertido local, nunca transmitido. - **Copia de seguridad cifrada (opcional)**: un archivo `.ntbackup` que usted mismo crea, en el lugar que elige mediante el selector de documentos de Android. Contiene sus carpetas y sus notas, papelera incluida, cifradas con **AES-256-GCM** con una clave derivada mediante **Argon2id** (m=64MB, t=3) de un código que genera la aplicación y que usted anota, o de una frase de contraseña propia; Notes Tech no guarda ni uno ni otra. Primero se escribe, ya cifrado, en el almacenamiento privado de la aplicación; luego se copia al lugar elegido y se borra de ese almacenamiento. Las notas de una caja fuerte con frase de contraseña siguen selladas por su frase en el archivo. Una caja fuerte con PIN solo viaja abierta, con su clave: en el archivo solo la protege el secreto de la copia; cerrada, queda fuera. La restauración añade lo que falta y no sobrescribe nada. - **Notas importadas y compartidas**: los archivos Markdown o de texto —o un archivo ZIP que los contenga— que usted elige en el selector de documentos de Android, y los textos o archivos que comparte con Notes Tech desde otra app, se convierten en notas normales en sus carpetas, nunca en una caja fuerte. Se leen una sola vez, en el teléfono; los originales no se modifican y no se conserva nada aparte de las notas creadas. Como máximo 500.000 caracteres por nota. +- **Notas selladas (opcionales)**: una nota que elige compartir sellada se cifra en un archivo `.ntnote` — **AES-256-GCM** con una clave derivada mediante **Argon2id** (m=64MB, t=3) de un código que la app genera, muestra una sola vez y no guarda en ningún sitio. El archivo se escribe en el almacenamiento privado de la app y luego se confía a la app que elija para enviarlo; el código lo transmite por otra vía. Su nombre no dice nada de la nota. Una nota sellada que recibe se lee una vez del archivo que abre con Notes Tech, y solo se convierte en una nota — de la Bandeja de entrada, nunca de una caja fuerte — una vez escrito su código. +- **Depósito rápido**: desde un mosaico de los ajustes rápidos o un acceso directo del icono, escribe una nota en la Bandeja de entrada sin desbloquear la app; no lee ni muestra nada de lo que contiene. +- **Notas con duración (opcionales)**: se borran definitivamente al acabar su plazo — en la siguiente apertura de la app o al volver a la lista —, o al cerrarlas tras su lectura si eligió «Tras la lectura». No entran ni en la copia de seguridad cifrada ni en la exportación. +- **Teclado**: la app pide al teclado que no aprenda nada de lo que escribe (ajuste activado por defecto). Es una petición, que un teclado puede ignorar; lo que un teclado guarda por su cuenta escapa a cualquier app. - **Modelo de dictado por voz (Whisper `.bin`)**: lo obtiene usted mismo —la aplicación muestra el nombre del archivo y su origen— y luego lo importa mediante el selector de documentos de Android. Notes Tech no tiene permiso de acceso a Internet y **no ofrece ninguna forma de descargar nada**. Su SHA-256 se comprueba al importarlo y antes de cada carga. - **Audio captado durante el dictado**: se escribe en un archivo temporal del almacenamiento privado de la aplicación —el motor de transcripción lee un archivo, no hay otra manera— y **se borra en cuanto llega la transcripción**. También se borra al iniciar la aplicación y con el modo pánico, de modo que un cierre brusco no deja nada. - **Ajustes (tema, orden, dictado activado, bloqueo automático de las cajas fuertes, opciones del bloqueo de la aplicación)**: guardados sin cifrar en las preferencias locales (ningún dato sensible). diff --git a/app/src/main/res/raw-fr/privacy.md b/app/src/main/res/raw-fr/privacy.md index a146e12..f0e5bd6 100644 --- a/app/src/main/res/raw-fr/privacy.md +++ b/app/src/main/res/raw-fr/privacy.md @@ -1,6 +1,6 @@ # Politique de confidentialité — Notes Tech -_Dernière mise à jour : 30 octobre 2026_ +_Dernière mise à jour : 9 novembre 2026_ ## En une phrase @@ -17,6 +17,10 @@ Notes Tech ne collecte, ne transmet et ne stocke aucune donnée sur des serveurs - **Backlinks `[[Titre]]`** : index inversé local, jamais transmis. - **Sauvegarde chiffrée (facultative)** : un fichier `.ntbackup` que vous créez vous-même, à l'endroit que vous choisissez par le sélecteur de documents Android. Il contient vos dossiers et vos notes, corbeille comprise, chiffrés en **AES-256-GCM** sous une clé dérivée par **Argon2id** (m=64MB, t=3) d'un code tiré par l'application et que vous notez, ou d'une passphrase de votre choix — Notes Tech ne garde ni l'un ni l'autre. Il est d'abord écrit, déjà chiffré, dans le stockage privé de l'application, puis copié à l'endroit choisi et effacé du stockage de l'application. Les notes d'un coffre à passphrase restent scellées par sa passphrase dans le fichier. Un coffre à PIN ne voyage qu'ouvert, avec sa clé : dans le fichier, seul le secret de la sauvegarde le protège ; fermé, il est laissé de côté. La restauration ajoute ce qui manque et n'écrase rien. - **Notes importées et partagées** : les fichiers Markdown ou texte — ou une archive ZIP de ces fichiers — que vous choisissez dans le sélecteur de documents d'Android, et les textes ou fichiers que vous partagez vers Notes Tech depuis une autre appli, deviennent des notes ordinaires dans vos dossiers, jamais dans un coffre. Ils sont lus une fois, sur le téléphone ; les originaux ne sont pas modifiés et rien n'est gardé à côté des notes créées. 500 000 caractères au plus par note. +- **Notes scellées (facultatives)** : une note que vous choisissez de partager scellée est chiffrée dans un fichier `.ntnote` — **AES-256-GCM** sous une clé dérivée par **Argon2id** (m=64MB, t=3) d'un code que l'application tire, affiche une seule fois et ne conserve nulle part. Le fichier est écrit dans le stockage privé de l'application, puis confié à l'application que vous choisissez pour l'envoyer ; vous transmettez le code par un autre moyen. Son nom ne dit rien de la note. Une note scellée que vous recevez est lue une fois depuis le fichier que vous ouvrez avec Notes Tech, et ne devient une note — de la Boîte de réception, jamais d'un coffre — qu'une fois son code tapé. +- **Dépôt rapide** : depuis une tuile des réglages rapides ou un raccourci de l'icône, il écrit une note dans la Boîte de réception sans déverrouiller l'application ; il ne lit et n'affiche rien de ce qu'elle contient. +- **Notes à durée de vie (facultatives)** : effacées définitivement une fois leur délai passé — à la prochaine ouverture de l'application ou au retour sur la liste —, ou à la fermeture après leur lecture si vous avez choisi « Après lecture ». Elles n'entrent ni dans la sauvegarde chiffrée ni dans l'export. +- **Clavier** : l'application demande au clavier de ne rien apprendre de ce que vous tapez (réglage activé par défaut). C'est une demande, qu'un clavier peut ignorer ; ce qu'un clavier garde de lui-même échappe à toute application. - **Modèle de dictée vocale (Whisper `.bin`)** : vous vous le procurez vous-même — l'application affiche le nom du fichier et sa source — puis vous l'importez par le sélecteur de documents Android. Notes Tech n'a pas la permission Internet et **n'expose aucun moyen de télécharger quoi que ce soit**. Son empreinte SHA-256 est vérifiée à l'import et avant chaque chargement. - **Audio capturé pendant la dictée** : écrit dans un fichier temporaire du stockage privé de l'application — le moteur de transcription lit un fichier, il ne peut pas en être autrement — puis **effacé dès la transcription obtenue**. Il l'est également au démarrage de l'application et par le mode panique, de sorte qu'un arrêt brutal ne laisse rien derrière lui. - **Préférences (thème, tri, dictée activée, auto-lock coffre, options du verrouillage)** : stockées en clair dans les préférences locales (pas de donnée sensible). diff --git a/app/src/main/res/raw-it/privacy.md b/app/src/main/res/raw-it/privacy.md index 0b068d5..898174c 100644 --- a/app/src/main/res/raw-it/privacy.md +++ b/app/src/main/res/raw-it/privacy.md @@ -1,6 +1,6 @@ # Informativa sulla privacy — Notes Tech -_Ultimo aggiornamento: 30 ottobre 2026_ +_Ultimo aggiornamento: 9 novembre 2026_ ## In una frase @@ -17,6 +17,10 @@ Notes Tech non raccoglie, non trasmette e non conserva alcun dato su server remo - **Collegamenti inversi `[[Titolo]]`**: indice invertito locale, mai trasmesso. - **Backup cifrato (facoltativo)**: un file `.ntbackup` che crea lei stesso, nel punto che sceglie tramite il selettore di documenti di Android. Contiene le sue cartelle e le sue note, cestino compreso, cifrate con **AES-256-GCM** con una chiave derivata tramite **Argon2id** (m=64MB, t=3) da un codice estratto dall'app e che lei annota, o da una passphrase di sua scelta — Notes Tech non conserva né l'uno né l'altra. Viene prima scritto, già cifrato, nella memoria privata dell'app, poi copiato nel punto scelto e cancellato dalla memoria dell'app. Le note di una cassaforte con passphrase restano sigillate dalla sua passphrase nel file. Una cassaforte con PIN viaggia solo aperta, con la sua chiave: nel file la protegge soltanto il segreto del backup; chiusa, viene esclusa. Il ripristino aggiunge ciò che manca e non sovrascrive nulla. - **Note importate e condivise**: i file Markdown o di testo — o un archivio ZIP che li contiene — che lei sceglie nel selettore di documenti di Android, e i testi o i file che condivide con Notes Tech da un'altra app, diventano note ordinarie nelle sue cartelle, mai in una cassaforte. Vengono letti una volta, sul telefono; gli originali non vengono modificati e nulla viene conservato oltre alle note create. Al massimo 500.000 caratteri per nota. +- **Note sigillate (facoltative)**: una nota che sceglie di condividere sigillata viene cifrata in un file `.ntnote` — **AES-256-GCM** con una chiave derivata tramite **Argon2id** (m=64MB, t=3) da un codice che l'app genera, mostra una sola volta e non conserva da nessuna parte. Il file viene scritto nella memoria privata dell'app, poi affidato all'app che sceglie per inviarlo; il codice lo trasmette in un altro modo. Il suo nome non dice nulla della nota. Una nota sigillata che riceve viene letta una volta dal file che apre con Notes Tech, e diventa una nota — in In arrivo, mai in una cassaforte — solo dopo aver digitato il suo codice. +- **Deposito rapido**: da un riquadro delle impostazioni rapide o da una scorciatoia dell'icona, scrive una nota in In arrivo senza sbloccare l'app; non legge né mostra nulla di ciò che l'app contiene. +- **Note con durata (facoltative)**: cancellate definitivamente allo scadere del tempo — alla successiva apertura dell'app o al ritorno all'elenco —, o alla chiusura dopo la lettura se ha scelto «Dopo la lettura». Non entrano né nel backup cifrato né nell'esportazione. +- **Tastiera**: l'app chiede alla tastiera di non imparare nulla di ciò che digita (impostazione attiva per impostazione predefinita). È una richiesta, che una tastiera può ignorare; ciò che una tastiera conserva da sé sfugge a qualsiasi app. - **Modello di dettatura vocale (Whisper `.bin`)**: se lo procura lei stesso — l'app mostra il nome del file e la sua origine — e poi lo importa tramite il selettore di documenti di Android. Notes Tech non ha l'autorizzazione di accesso a Internet e **non offre alcun modo di scaricare nulla**. Il suo SHA-256 viene verificato all'importazione e prima di ogni caricamento. - **Audio acquisito durante la dettatura**: scritto in un file temporaneo nello spazio privato dell'app — il motore di trascrizione legge un file, non c'è altro modo — e **cancellato appena arriva la trascrizione**. Viene cancellato anche all'avvio dell'app e dalla modalità panico, così una chiusura improvvisa non lascia nulla. - **Impostazioni (tema, ordinamento, dettatura attivata, blocco automatico delle casseforti, opzioni del blocco dell'app)**: conservate in chiaro nelle preferenze locali (nessun dato sensibile). diff --git a/app/src/main/res/raw/privacy.md b/app/src/main/res/raw/privacy.md index fda13ea..05aa704 100644 --- a/app/src/main/res/raw/privacy.md +++ b/app/src/main/res/raw/privacy.md @@ -1,6 +1,6 @@ # Privacy policy — Notes Tech -_Last updated: 30 October 2026_ +_Last updated: 9 November 2026_ ## In one sentence @@ -17,6 +17,10 @@ Notes Tech does not collect, transmit or store any data on remote servers. Every - **Backlinks `[[Title]]`**: local inverted index, never transmitted. - **Encrypted backup (optional)**: a `.ntbackup` file you create yourself, where you choose through the Android document picker. It holds your folders and notes, trash included, encrypted with **AES-256-GCM** under a key derived by **Argon2id** (m=64MB, t=3) from a code the app draws and you write down, or from a passphrase of your own — Notes Tech keeps neither. It is first written, already encrypted, in the app's private storage, then copied where you chose and erased from the app's storage. A passphrase vault's notes stay sealed by its passphrase in the file. A PIN vault travels only open, with its key: in the file, only the backup's secret protects it; a closed one is left out. Restoring adds what is missing and overwrites nothing. - **Imported and shared notes**: Markdown or text files — or a ZIP of them — you choose in the Android document picker, and text or files you share to Notes Tech from another app, become ordinary notes in your folders, never in a vault. They are read once, on the phone; the originals are left untouched and nothing is kept beside the notes created. At most 500,000 characters per note. +- **Sealed notes (optional)**: a note you choose to share sealed is encrypted into a `.ntnote` file — **AES-256-GCM** under a key derived by **Argon2id** (m=64MB, t=3) from a code the app draws, shows once and keeps nowhere. The file is written in the app's private storage, then handed to the app you choose to send it; you pass the code on another way. Its name says nothing of the note. A sealed note you receive is read once from the file you open with Notes Tech, and becomes a note — of the Inbox, never of a vault — only once you type its code. +- **Quick drop**: from a quick settings tile or a shortcut of the app's icon, it writes a note into the Inbox without unlocking the app; it reads and shows nothing of what the app holds. +- **Notes with a lifetime (optional)**: erased for good once their time is up — at the next opening of the app or return to the list —, or when closed after their reading if you chose "After reading". They go into neither the encrypted backup nor the export. +- **Keyboard**: the app asks the keyboard not to learn what you type (a setting, on by default). It is a request, which a keyboard may ignore; what a keyboard keeps on its own is out of any app's reach. - **Voice dictation model (Whisper `.bin`)**: you obtain it yourself — the app shows the file name and its source — then import it through the Android document picker. Notes Tech has no Internet permission and **exposes no way to download anything**. Its SHA-256 is verified on import and before every load. - **Audio captured during dictation**: written to a temporary file in the app's private storage — the transcription engine reads a file, there is no way around it — then **wiped as soon as the transcription is returned**. It is also wiped on app start and by panic mode, so an abrupt shutdown leaves nothing behind. - **Settings (theme, sort, dictation enabled, vault auto-lock, app lock options)**: stored in clear in local preferences (no sensitive data). diff --git a/fastlane/metadata/android/de-DE/changelogs/5071.txt b/fastlane/metadata/android/de-DE/changelogs/5071.txt new file mode 100644 index 0000000..66726fd --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/5071.txt @@ -0,0 +1 @@ +Neu: Notizen, die sich selbst löschen — nach 1 Std., 24 Std., 7 oder 30 Tagen oder nach dem Lesen (⋮ › Lebensdauer). Versiegelt teilen: eine verschlüsselte Datei, die nur ihr Code öffnet. Schnellablage: eine Notiz schreiben, ohne die App zu entsperren — per Kachel der Schnelleinstellungen oder langem Druck auf das Symbol (auch Neue Notiz, Diktieren). Die Tastatur wird gebeten, nichts zu lernen. diff --git a/fastlane/metadata/android/de-DE/changelogs/5072.txt b/fastlane/metadata/android/de-DE/changelogs/5072.txt new file mode 100644 index 0000000..66726fd --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/5072.txt @@ -0,0 +1 @@ +Neu: Notizen, die sich selbst löschen — nach 1 Std., 24 Std., 7 oder 30 Tagen oder nach dem Lesen (⋮ › Lebensdauer). Versiegelt teilen: eine verschlüsselte Datei, die nur ihr Code öffnet. Schnellablage: eine Notiz schreiben, ohne die App zu entsperren — per Kachel der Schnelleinstellungen oder langem Druck auf das Symbol (auch Neue Notiz, Diktieren). Die Tastatur wird gebeten, nichts zu lernen. diff --git a/fastlane/metadata/android/de-DE/changelogs/5073.txt b/fastlane/metadata/android/de-DE/changelogs/5073.txt new file mode 100644 index 0000000..66726fd --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/5073.txt @@ -0,0 +1 @@ +Neu: Notizen, die sich selbst löschen — nach 1 Std., 24 Std., 7 oder 30 Tagen oder nach dem Lesen (⋮ › Lebensdauer). Versiegelt teilen: eine verschlüsselte Datei, die nur ihr Code öffnet. Schnellablage: eine Notiz schreiben, ohne die App zu entsperren — per Kachel der Schnelleinstellungen oder langem Druck auf das Symbol (auch Neue Notiz, Diktieren). Die Tastatur wird gebeten, nichts zu lernen. diff --git a/fastlane/metadata/android/de-DE/full_description.txt b/fastlane/metadata/android/de-DE/full_description.txt index 39ab105..2018824 100644 --- a/fastlane/metadata/android/de-DE/full_description.txt +++ b/fastlane/metadata/android/de-DE/full_description.txt @@ -22,6 +22,10 @@ Notes Tech: Markdown-Notizen, 100 % lokal und im Ruhezustand verschlüsselt. In • Notizen öffnen sich zum Lesen (Markdown: Überschriften, Listen, Links); „Bearbeiten“ zum Schreiben, automatisches Speichern • Langes Drücken wählt Notizen aus: archivieren, verschieben, in den Papierkorb mit Rückgängig, anheften, einfärben oder löschen, alle auf einmal • Ein Archiv für die Notizen, die Sie aus Ihren Listen heraushalten +• Notizen, die sich selbst löschen: nach 1 Stunde, 24 Stunden, 7 oder 30 Tagen oder nach dem Lesen +• Versiegelt teilen: eine verschlüsselte Datei, die nur ihr Code öffnet, über jede App gesendet +• Schnellablage: eine Notiz schreiben, ohne die App zu entsperren, per Schnelleinstellungs-Kachel oder Verknüpfungen des Symbols +• Die Tastatur wird gebeten, nichts von dem zu lernen, was Sie tippen • 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, Suche in der Notiz mit hervorgehobenen Treffern, einstellbare Sortierung diff --git a/fastlane/metadata/android/en-US/changelogs/5071.txt b/fastlane/metadata/android/en-US/changelogs/5071.txt new file mode 100644 index 0000000..f4d4ccd --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/5071.txt @@ -0,0 +1 @@ +New: notes that erase themselves — in 1 h, 24 h, 7 or 30 days, or after reading (⋮ › Lifetime). Share a note sealed: an encrypted file that only its code opens, sent by any app (⋮ › Share sealed). Quick drop: write a note without unlocking the app, from a quick settings tile or a long press on the icon, which also offers New note and Dictate. The keyboard is asked not to learn what you type (Settings). diff --git a/fastlane/metadata/android/en-US/changelogs/5072.txt b/fastlane/metadata/android/en-US/changelogs/5072.txt new file mode 100644 index 0000000..f4d4ccd --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/5072.txt @@ -0,0 +1 @@ +New: notes that erase themselves — in 1 h, 24 h, 7 or 30 days, or after reading (⋮ › Lifetime). Share a note sealed: an encrypted file that only its code opens, sent by any app (⋮ › Share sealed). Quick drop: write a note without unlocking the app, from a quick settings tile or a long press on the icon, which also offers New note and Dictate. The keyboard is asked not to learn what you type (Settings). diff --git a/fastlane/metadata/android/en-US/changelogs/5073.txt b/fastlane/metadata/android/en-US/changelogs/5073.txt new file mode 100644 index 0000000..f4d4ccd --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/5073.txt @@ -0,0 +1 @@ +New: notes that erase themselves — in 1 h, 24 h, 7 or 30 days, or after reading (⋮ › Lifetime). Share a note sealed: an encrypted file that only its code opens, sent by any app (⋮ › Share sealed). Quick drop: write a note without unlocking the app, from a quick settings tile or a long press on the icon, which also offers New note and Dictate. The keyboard is asked not to learn what you type (Settings). diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 9a1e9f3..1e1e322 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -22,6 +22,10 @@ Notes Tech: 100% local Markdown note-taking, encrypted at rest. Written in Kotli • Notes open to be read (Markdown: headings, lists, links); “Edit” to write, autosave • Long press to select notes, then archive, move, trash with Undo, pin, color or delete them at once • An Archive for the notes you keep out of your lists +• Notes that erase themselves: after 1 hour, 24 hours, 7 or 30 days, or after reading +• Share a note sealed: an encrypted file that only its code opens, sent by any app +• Quick drop: write a note without unlocking the app, from a quick settings tile or the icon's shortcuts +• The keyboard is asked not to learn what you type • 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, find in note with highlighted matches, configurable sort diff --git a/fastlane/metadata/android/es-ES/changelogs/5071.txt b/fastlane/metadata/android/es-ES/changelogs/5071.txt new file mode 100644 index 0000000..80658d2 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/5071.txt @@ -0,0 +1 @@ +Nuevo: notas que se borran solas — en 1 h, 24 h, 7 o 30 días, o tras la lectura (⋮ › Duración). Compartir una nota sellada: un archivo cifrado que solo su código abre, enviado con cualquier app. Depósito rápido: escribir una nota sin desbloquear la app, desde un mosaico de los ajustes rápidos o una pulsación larga en el icono (también Nueva nota, Dictar). Se pide al teclado que no aprenda nada (Ajustes). diff --git a/fastlane/metadata/android/es-ES/changelogs/5072.txt b/fastlane/metadata/android/es-ES/changelogs/5072.txt new file mode 100644 index 0000000..80658d2 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/5072.txt @@ -0,0 +1 @@ +Nuevo: notas que se borran solas — en 1 h, 24 h, 7 o 30 días, o tras la lectura (⋮ › Duración). Compartir una nota sellada: un archivo cifrado que solo su código abre, enviado con cualquier app. Depósito rápido: escribir una nota sin desbloquear la app, desde un mosaico de los ajustes rápidos o una pulsación larga en el icono (también Nueva nota, Dictar). Se pide al teclado que no aprenda nada (Ajustes). diff --git a/fastlane/metadata/android/es-ES/changelogs/5073.txt b/fastlane/metadata/android/es-ES/changelogs/5073.txt new file mode 100644 index 0000000..80658d2 --- /dev/null +++ b/fastlane/metadata/android/es-ES/changelogs/5073.txt @@ -0,0 +1 @@ +Nuevo: notas que se borran solas — en 1 h, 24 h, 7 o 30 días, o tras la lectura (⋮ › Duración). Compartir una nota sellada: un archivo cifrado que solo su código abre, enviado con cualquier app. Depósito rápido: escribir una nota sin desbloquear la app, desde un mosaico de los ajustes rápidos o una pulsación larga en el icono (también Nueva nota, Dictar). Se pide al teclado que no aprenda nada (Ajustes). diff --git a/fastlane/metadata/android/es-ES/full_description.txt b/fastlane/metadata/android/es-ES/full_description.txt index 7a5b9d1..3db5104 100644 --- a/fastlane/metadata/android/es-ES/full_description.txt +++ b/fastlane/metadata/android/es-ES/full_description.txt @@ -22,6 +22,10 @@ Notes Tech: notas Markdown 100 % locales, cifradas en reposo. Escrita en Kotlin. • Las notas se abren para leer (Markdown: títulos, listas, enlaces); «Editar» para escribir, guardado automático • Pulsación larga para seleccionar notas y luego archivarlas, moverlas, enviarlas a la papelera con Deshacer, anclarlas, colorearlas o eliminarlas a la vez • Un Archivo para las notas que mantiene fuera de sus listas +• Notas que se borran solas: tras 1 hora, 24 horas, 7 o 30 días, o tras la lectura +• Compartir una nota sellada: un archivo cifrado que solo su código abre, enviado con cualquier app +• Depósito rápido: escribir una nota sin desbloquear la app, desde un mosaico de los ajustes rápidos o los accesos directos del icono +• Se pide al teclado que no aprenda nada de lo que escribe • 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, búsqueda en la nota con coincidencias resaltadas, orden configurable diff --git a/fastlane/metadata/android/fr-FR/changelogs/5071.txt b/fastlane/metadata/android/fr-FR/changelogs/5071.txt new file mode 100644 index 0000000..4a64b4f --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/5071.txt @@ -0,0 +1 @@ +Nouveau : des notes qui s'effacent seules — dans 1 h, 24 h, 7 ou 30 jours, ou après lecture (⋮ › Durée de vie). Partager une note scellée : un fichier chiffré que seul son code ouvre, envoyé par n'importe quelle appli. Dépôt rapide : écrire une note sans déverrouiller l'appli, depuis une tuile des réglages rapides ou un appui long sur l'icône (aussi Nouvelle note, Dicter). Le clavier est prié de ne rien apprendre (Réglages). diff --git a/fastlane/metadata/android/fr-FR/changelogs/5072.txt b/fastlane/metadata/android/fr-FR/changelogs/5072.txt new file mode 100644 index 0000000..4a64b4f --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/5072.txt @@ -0,0 +1 @@ +Nouveau : des notes qui s'effacent seules — dans 1 h, 24 h, 7 ou 30 jours, ou après lecture (⋮ › Durée de vie). Partager une note scellée : un fichier chiffré que seul son code ouvre, envoyé par n'importe quelle appli. Dépôt rapide : écrire une note sans déverrouiller l'appli, depuis une tuile des réglages rapides ou un appui long sur l'icône (aussi Nouvelle note, Dicter). Le clavier est prié de ne rien apprendre (Réglages). diff --git a/fastlane/metadata/android/fr-FR/changelogs/5073.txt b/fastlane/metadata/android/fr-FR/changelogs/5073.txt new file mode 100644 index 0000000..4a64b4f --- /dev/null +++ b/fastlane/metadata/android/fr-FR/changelogs/5073.txt @@ -0,0 +1 @@ +Nouveau : des notes qui s'effacent seules — dans 1 h, 24 h, 7 ou 30 jours, ou après lecture (⋮ › Durée de vie). Partager une note scellée : un fichier chiffré que seul son code ouvre, envoyé par n'importe quelle appli. Dépôt rapide : écrire une note sans déverrouiller l'appli, depuis une tuile des réglages rapides ou un appui long sur l'icône (aussi Nouvelle note, Dicter). Le clavier est prié de ne rien apprendre (Réglages). diff --git a/fastlane/metadata/android/fr-FR/full_description.txt b/fastlane/metadata/android/fr-FR/full_description.txt index 8b2940a..9f7876f 100644 --- a/fastlane/metadata/android/fr-FR/full_description.txt +++ b/fastlane/metadata/android/fr-FR/full_description.txt @@ -22,6 +22,10 @@ Notes Tech : prise de notes Markdown 100 % locale, chiffrée au repos. Écrite e • Les notes s'ouvrent en lecture (Markdown : titres, listes, liens) ; « Éditer » pour écrire, enregistrement automatique • Appui long pour sélectionner des notes, puis les archiver, déplacer, mettre à la corbeille avec Annuler, épingler, colorer ou supprimer d'un coup • Des Archives pour les notes que vous gardez hors de vos listes +• Des notes qui s'effacent seules : après 1 heure, 24 heures, 7 ou 30 jours, ou après lecture +• Partager une note scellée : un fichier chiffré que seul son code ouvre, envoyé par n'importe quelle appli +• Dépôt rapide : écrire une note sans déverrouiller l'appli, depuis une tuile des réglages rapides ou les raccourcis de l'icône +• Le clavier est prié de ne rien apprendre de ce que vous tapez • 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, recherche dans la note avec occurrences surlignées, tri réglable diff --git a/fastlane/metadata/android/it-IT/changelogs/5071.txt b/fastlane/metadata/android/it-IT/changelogs/5071.txt new file mode 100644 index 0000000..c50832b --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/5071.txt @@ -0,0 +1 @@ +Novità: note che si cancellano da sole — tra 1 h, 24 h, 7 o 30 giorni, o dopo la lettura (⋮ › Durata). Condividere una nota sigillata: un file cifrato che solo il suo codice apre, inviato con qualsiasi app. Deposito rapido: scrivere una nota senza sbloccare l'app, da un riquadro delle impostazioni rapide o tenendo premuta l'icona (anche Nuova nota, Dettare). Alla tastiera si chiede di non imparare nulla (Impostazioni). diff --git a/fastlane/metadata/android/it-IT/changelogs/5072.txt b/fastlane/metadata/android/it-IT/changelogs/5072.txt new file mode 100644 index 0000000..c50832b --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/5072.txt @@ -0,0 +1 @@ +Novità: note che si cancellano da sole — tra 1 h, 24 h, 7 o 30 giorni, o dopo la lettura (⋮ › Durata). Condividere una nota sigillata: un file cifrato che solo il suo codice apre, inviato con qualsiasi app. Deposito rapido: scrivere una nota senza sbloccare l'app, da un riquadro delle impostazioni rapide o tenendo premuta l'icona (anche Nuova nota, Dettare). Alla tastiera si chiede di non imparare nulla (Impostazioni). diff --git a/fastlane/metadata/android/it-IT/changelogs/5073.txt b/fastlane/metadata/android/it-IT/changelogs/5073.txt new file mode 100644 index 0000000..c50832b --- /dev/null +++ b/fastlane/metadata/android/it-IT/changelogs/5073.txt @@ -0,0 +1 @@ +Novità: note che si cancellano da sole — tra 1 h, 24 h, 7 o 30 giorni, o dopo la lettura (⋮ › Durata). Condividere una nota sigillata: un file cifrato che solo il suo codice apre, inviato con qualsiasi app. Deposito rapido: scrivere una nota senza sbloccare l'app, da un riquadro delle impostazioni rapide o tenendo premuta l'icona (anche Nuova nota, Dettare). Alla tastiera si chiede di non imparare nulla (Impostazioni). diff --git a/fastlane/metadata/android/it-IT/full_description.txt b/fastlane/metadata/android/it-IT/full_description.txt index 25de0ee..d564c40 100644 --- a/fastlane/metadata/android/it-IT/full_description.txt +++ b/fastlane/metadata/android/it-IT/full_description.txt @@ -22,6 +22,10 @@ Notes Tech: note Markdown 100% locali, cifrate a riposo. Scritta in Kotlin. • Le note si aprono in lettura (Markdown: titoli, elenchi, link); «Modifica» per scrivere, salvataggio automatico • Pressione prolungata per selezionare le note, poi archiviarle, spostarle, metterle nel cestino con Annulla, fissarle, colorarle o eliminarle tutte insieme • Un Archivio per le note che tiene fuori dai suoi elenchi +• Note che si cancellano da sole: dopo 1 ora, 24 ore, 7 o 30 giorni, o dopo la lettura +• Condividere una nota sigillata: un file cifrato che solo il suo codice apre, inviato con qualsiasi app +• Deposito rapido: scrivere una nota senza sbloccare l'app, da un riquadro delle impostazioni rapide o dalle scorciatoie dell'icona +• Alla tastiera si chiede di non imparare nulla di ciò che digita • 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, ricerca nella nota con occorrenze evidenziate, ordinamento regolabile diff --git a/version.properties b/version.properties index b333719..b20ef78 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=506 -versionName=3.3.0 +versionCode=507 +versionName=3.4.0 From 4eabc779240830152495faa403693cb0d0a82b81 Mon Sep 17 00:00:00 2001 From: Patrice Haltaya Date: Mon, 9 Nov 2026 21:24:09 +0100 Subject: [PATCH 10/11] docs: 3.4.0 resume point before compaction Co-Authored-By: Claude Opus 5.5 --- docs/15-VERSION-3.4.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/15-VERSION-3.4.md b/docs/15-VERSION-3.4.md index c74ccfa..7f0a822 100644 --- a/docs/15-VERSION-3.4.md +++ b/docs/15-VERSION-3.4.md @@ -129,6 +129,26 @@ ## État +> **🧭 REPRISE (2026-11-09, ~22 h, avant compaction)** — branche `feat/3.4.0`, dernier commit poussé `9699451` +> (textes 3.4.0). **NON COMMITTÉ dans l'arbre** (à vérifier par `git status`, puis porte + commit) : +> 1. corrections i18n (it « nella cartella In arrivo », « Il testo », de demi-cadratin, journal de 5071-5073) ; +> 2. relecture GPT (0,024 $) : note reçue créée en UN insert (`NotesRepository.create(color, readOnce)`), +> `LectureScellee` réécrite (une lecture à la fois, fil dédié, délai qui FERME le flux, lecture de 0 octet +> refusée, `RuntimeException` d'un fournisseur rattrapée), activité scellée simplifiée, test du flux vide ; +> 3. audit de cohérence (82/100) : C1 `DisposableEffect` → `oublier()` du code ; C4 `onKeyboardNoLearning` +> sans défaut (+ ReglagesTest, AppLockSettingsScreenTest) ; C5 `SelecteurDeNoteScelleeViewModel` (HomeRoute) ; +> C6 `DureesDeVie.purgerSansEchec`, forme de `RaccourciActivity` ; C7 KDoc replacés (setArchived, FolderChip, +> manifeste), références DAO ; C9 « … » retirés de note_lifetime / note_share_sealed ; C10 purge sous +> `repeatOnLifecycle(STARTED)` ; `ClavierSansApprentissageTest` en règle Compose **v2** (échec du +> `ComposeTestRulesAreV2Test`). +> **Reste** : compiler + ktlintFormat + porte locale ; test instrumenté de `NotesScelleesViewModel` (faux code +> puis bon, lecture unique et couleur posées, pas de doublon) ; résultat de l'audit 3 axes (agent lancé) et de +> la suite instrumentée complète du S9 (tâche de fond `begohs0pu` : porte exit=1 = le test v2, release OK) ; +> CI de la branche ; puis PR, fusion, et PROPOSER la release (pas demandée) — avant le tag, voir comment +> F-Droid a construit SMS 1.30.0 (AGP 9.5). Limite connue : Mes fichiers (Samsung) n'ouvre pas `.ntnote` +> (type vide, il conclut seul) ; le menu « Ouvrir une note scellée » marche partout. Le partage reçu (3.2.0) +> a la même lecture sans délai fermant le flux : à durcir plus tard, pas touché. + - [x] **D-040 clavier** (`5e18eab`) — tests instrumentés sur le S9 : `ClavierSansApprentissageTest` (3), `ChampDuCorpsTest` +1, `ReglagesHeritesTest` +1, `SaisieDeCoffreTest` inchangé (8) ; la ligne du réglage vue dans l'écran, section Sécurité. From ae5224cd8c72b673168b724e882989549e35c48c Mon Sep 17 00:00:00 2001 From: Patrice Haltaya Date: Mon, 9 Nov 2026 21:48:46 +0100 Subject: [PATCH 11/11] =?UTF-8?q?fix:=203.4.0=20audits=20=E2=80=94=20exter?= =?UTF-8?q?nal=20review,=20coherence,=20translations,=203=20axes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit External review (GPT): - a received sealed note is created in ONE insert, colour and "after reading" included; - reading a sealed file: one at a time, on a thread of its own, the stream CLOSED past the delay, a zero-byte read refused, any runtime error of another app's provider caught. Coherence audit: the sealing sheet forgets its code whenever it leaves; the keyboard setting's callback has no default; the picker's reading in its own ViewModel; one guarded purge (`purgerSansEchec`); the purge loop runs only while the list is started; KDoc put back in place; no ellipsis on the two new menu entries; `ClavierSansApprentissageTest` on the v2 Compose rule. Translations: Italian and German wording, German changelogs completed. 3-axes audit: - M4: a second sealed file deposited while the first is being opened waits for it — it no longer zeroes the bytes under decryption, nor is it erased by the first one's end; - M5: FLAG_SECURE from the first frame on the dialogs where a code is shown or typed — the two sealed ones and the three of the backup, which had the same defect (`ProprietesDeDialogueSecret`); - M7: a hand-made sealed file's tags are split, trimmed and deduplicated as the import does, its title put on one line without bidirectional overrides; - M1, M2: the lifetime sheet and SECURITY say the time follows the phone's clock, and when "after reading" really erases. Tests: `NotesScelleesRecuesTest` (wrong then right code, one note with what was sealed, no duplicate, the second file waiting), a hand-made file in `FichierDeNoteScelleeTest`, the settings tests with the keyboard row. Co-Authored-By: Claude Opus 5.5 --- SECURITY.fr.md | 6 +- SECURITY.md | 6 +- .../data/scellee/FichierDeNoteScelleeTest.kt | 14 ++ .../ui/applock/AppLockSettingsScreenTest.kt | 1 + .../ui/scellee/NotesScelleesRecuesTest.kt | 169 ++++++++++++++++++ .../ui/secure/ClavierSansApprentissageTest.kt | 2 +- .../notes_tech/ui/settings/ReglagesTest.kt | 41 +++-- app/src/main/AndroidManifest.xml | 14 +- .../notes_tech/data/local/dao/NoteDao.kt | 3 +- .../notes_tech/data/local/dao/NoteWriteDao.kt | 4 +- .../notes_tech/data/repository/DureesDeVie.kt | 16 ++ .../data/repository/NotesRepository.kt | 6 + .../data/scellee/FichierDeNoteScellee.kt | 25 ++- .../notes_tech/data/scellee/LectureScellee.kt | 80 +++++++-- .../domain/importation/LectureDUnImport.kt | 4 +- .../ui/editor/FeuilleDePartageScelle.kt | 7 + .../ui/editor/NoteEditorViewModel.kt | 18 +- .../filestech/notes_tech/ui/home/HomeRoute.kt | 20 ++- .../notes_tech/ui/home/HomeViewModel.kt | 10 +- .../filestech/notes_tech/ui/home/NoteCard.kt | 2 +- .../ui/raccourcis/RaccourciActivity.kt | 8 +- .../ui/scellee/NotesScelleesRecues.kt | 51 +++--- .../scellee/OuvrirUneNoteScelleeActivity.kt | 16 +- .../SelecteurDeNoteScelleeViewModel.kt | 46 +++++ .../notes_tech/ui/secure/FeuilleSecrete.kt | 8 + .../notes_tech/ui/settings/SauvegardeUi.kt | 4 + .../notes_tech/ui/settings/SettingsScreen.kt | 2 +- .../notes_tech/ui/startup/StartupViewModel.kt | 18 +- app/src/main/res/raw-it/privacy.md | 4 +- app/src/main/res/values-de/strings.xml | 8 +- app/src/main/res/values-es/strings.xml | 6 +- app/src/main/res/values-fr/strings.xml | 6 +- app/src/main/res/values-it/strings.xml | 12 +- app/src/main/res/values/strings.xml | 6 +- .../data/scellee/LectureScelleeTest.kt | 12 ++ docs/15-VERSION-3.4.md | 57 +++--- .../android/de-DE/changelogs/5071.txt | 2 +- .../android/de-DE/changelogs/5072.txt | 2 +- .../android/de-DE/changelogs/5073.txt | 2 +- 39 files changed, 538 insertions(+), 180 deletions(-) create mode 100644 app/src/androidTest/java/com/filestech/notes_tech/ui/scellee/NotesScelleesRecuesTest.kt create mode 100644 app/src/main/java/com/filestech/notes_tech/ui/scellee/SelecteurDeNoteScelleeViewModel.kt diff --git a/SECURITY.fr.md b/SECURITY.fr.md index 8a62484..ce6ee82 100644 --- a/SECURITY.fr.md +++ b/SECURITY.fr.md @@ -25,8 +25,10 @@ de sécurité des versions Flutter 1.x et 2.x est conservé tel quel, dans déchiffré avant le déverrouillage ; le code tapé n'atteint jamais un Bundle ; un code faux et un fichier abîmé se ressemblent ; une note reçue va dans la Boîte de réception, jamais un coffre. - **Les notes à durée de vie** sont effacées par le DELETE même de « Supprimer définitivement » (index - plein texte, `secure-delete`, liens) ; elles sont exclues de la sauvegarde et de l'export ; « après - lecture » efface à la vraie fermeture de l'éditeur, jamais sur une rotation. + plein texte, `secure-delete`, liens) ; elles sont exclues de la sauvegarde et de l'export. Une durée de + vie suit l'horloge du téléphone : avancer la date efface les notes dont elle dépasse le terme. « Après + lecture » efface à la fermeture de l'éditeur — retour, ou appli fermée —, jamais sur une rotation ; si + Android arrête l'appli avant, la note part à sa fermeture suivante. - **Clavier** : `IME_FLAG_NO_PERSONALIZED_LEARNING` sur chaque champ par défaut, toujours sur les champs de secret. diff --git a/SECURITY.md b/SECURITY.md index 6c528c2..2cc141b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -24,8 +24,10 @@ of the Flutter versions 1.x and 2.x is kept as it was, in typed code never reaches a Bundle; a wrong code and a damaged file look the same; a received note goes to the Inbox, never a vault. - **Notes with a lifetime** are erased by the very DELETE of "Delete permanently" (FTS index, - `secure-delete`, links); they are left out of the backup and the export; "after reading" erases at the - editor's real closing, never on a rotation. + `secure-delete`, links); they are left out of the backup and the export. A lifetime follows the phone's + clock: moving the date forward erases the notes whose time it passes. "After reading" erases when the + editor closes — going back, or the app closed —, never on a rotation; if Android ends the app first, the + note goes at its next closing. - **Keyboard**: `IME_FLAG_NO_PERSONALIZED_LEARNING` on every field by default, always on secret fields. ## v3.3.0 — archive and selection: the vault rules, for many notes at once (2026-11-09) diff --git a/app/src/androidTest/java/com/filestech/notes_tech/data/scellee/FichierDeNoteScelleeTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/data/scellee/FichierDeNoteScelleeTest.kt index 81c9a21..46633e8 100644 --- a/app/src/androidTest/java/com/filestech/notes_tech/data/scellee/FichierDeNoteScelleeTest.kt +++ b/app/src/androidTest/java/com/filestech/notes_tech/data/scellee/FichierDeNoteScelleeTest.kt @@ -76,6 +76,20 @@ class FichierDeNoteScelleeTest { assertThat(refus { FichierDeNoteScellee.ouvrir(autre, code) }).isEqualTo(Raison.PAS_UNE_NOTE_SCELLEE) } + /** A file made by hand: tags and title come in as the app would have written them (3-axes audit). */ + @Test + fun a_hand_made_file_gives_clean_tags_and_a_one_line_title() { + val fabriquee = note.copy( + titre = " Fac\u202Eture\nsuite\t", + etiquettes = listOf("a,b", " a ", "", "c\n", "b"), + ) + + val lue = FichierDeNoteScellee.ouvrir(FichierDeNoteScellee.sceller(fabriquee, code), code) + + assertThat(lue.titre).isEqualTo("Facture suite") + assertThat(lue.etiquettes).containsExactly("a", "b", "c").inOrder() + } + @Test fun a_file_past_the_bound_is_refused_unread() { val enorme = FichierDeNoteScellee.MAGIQUE + ByteArray(FichierDeNoteScellee.TAILLE_MAX.toInt()) diff --git a/app/src/androidTest/java/com/filestech/notes_tech/ui/applock/AppLockSettingsScreenTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/ui/applock/AppLockSettingsScreenTest.kt index c0be176..34473a5 100644 --- a/app/src/androidTest/java/com/filestech/notes_tech/ui/applock/AppLockSettingsScreenTest.kt +++ b/app/src/androidTest/java/com/filestech/notes_tech/ui/applock/AppLockSettingsScreenTest.kt @@ -201,6 +201,7 @@ class AppLockSettingsScreenTest { onLocale = {}, onSort = {}, onSecureWindow = { calls += "secure" }, + onKeyboardNoLearning = {}, onAutoLock = {}, onPanic = {}, onOpenAbout = {}, diff --git a/app/src/androidTest/java/com/filestech/notes_tech/ui/scellee/NotesScelleesRecuesTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/ui/scellee/NotesScelleesRecuesTest.kt new file mode 100644 index 0000000..8a490c0 --- /dev/null +++ b/app/src/androidTest/java/com/filestech/notes_tech/ui/scellee/NotesScelleesRecuesTest.kt @@ -0,0 +1,169 @@ +package com.filestech.notes_tech.ui.scellee + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.filestech.notes_tech.data.repository.NotesRepository +import com.filestech.notes_tech.data.scellee.FichierDeNoteScellee +import com.filestech.notes_tech.data.scellee.FichierDeNoteScellee.Raison +import com.filestech.notes_tech.data.scellee.NoteScellee +import com.filestech.notes_tech.domain.model.Folder +import com.filestech.notes_tech.domain.model.NoteColor +import com.filestech.notes_tech.security.sauvegarde.SecretDeSauvegarde +import com.filestech.notes_tech.ui.scellee.NotesScelleesViewModel.Etat +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +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 + +/** + * D-044 (3.4.0) — **a sealed note received becomes ONE note of the Inbox, with what its sender sealed**, + * on the real ViewModel and the real database: a wrong code first is refused and lets the right one + * through, the colour, the tags and "after reading" are kept, opening again adds nothing; and a second + * file deposited while the first is being opened waits for it instead of spoiling it (3-axes audit). + */ +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +class NotesScelleesRecuesTest { + + @get:Rule + val hilt = HiltAndroidRule(this) + + @Inject + lateinit var enAttente: NotesScelleesEnAttente + + @Inject + lateinit var notes: NotesRepository + + private val suffixe = UUID.randomUUID().toString().take(8) + private val magasin = ViewModelStore() + private val creees = mutableListOf() + + @Before + fun setUp() { + hilt.inject() + enAttente.abandonner() + } + + @After + fun tearDown(): Unit = runBlocking { + withContext(Dispatchers.Main) { magasin.clear() } + enAttente.abandonner() + creees.forEach { notes.deletePermanently(it) } + } + + @Test + fun the_right_code_after_a_wrong_one_gives_one_note_with_what_was_sealed(): Unit = runBlocking { + val code = SecretDeSauvegarde.nouveauCode() + val scellee = scellee("Code de l'immeuble", lectureUnique = true) + val recues = viewModel() + + deposerPuisPrendre(recues, FichierDeNoteScellee.sceller(scellee, code)) + withContext(Dispatchers.Main) { recues.ouvrir(SecretDeSauvegarde.nouveauCode()) } + assertThat(attendreUneFin(recues)).isEqualTo(Etat.Code(erreur = Raison.CODE_OU_FICHIER)) + + withContext(Dispatchers.Main) { recues.ouvrir(code.lowercase()) } + val id = withTimeout(ATTENTE_MS) { recues.aOuvrir.first() }.also(creees::add) + + val note = requireNotNull(notes.find(id)) + assertThat(note.folderId).isEqualTo(Folder.INBOX_ID) + assertThat(note.title).isEqualTo(scellee.titre) + assertThat(note.content).isEqualTo(scellee.contenu) + assertThat(note.tags).containsExactly("maison", "accès").inOrder() + assertThat(note.color).isEqualTo(NoteColor.fromId(scellee.couleur)) + assertThat(note.readOnce).isTrue() + assertThat(note.encrypted).isNull() + assertThat(recues.etat.value).isEqualTo(Etat.Aucune) + } + + /** Control: the bytes are let go once the note is in — opening again adds nothing. */ + @Test + fun control_opening_again_adds_no_second_note(): Unit = runBlocking { + val code = SecretDeSauvegarde.nouveauCode() + val scellee = scellee("Une seule fois", lectureUnique = false) + val recues = viewModel() + + deposerPuisPrendre(recues, FichierDeNoteScellee.sceller(scellee, code)) + withContext(Dispatchers.Main) { recues.ouvrir(code) } + creees += withTimeout(ATTENTE_MS) { recues.aOuvrir.first() } + withContext(Dispatchers.Main) { recues.ouvrir(code) } + + assertThat(recues.etat.value).isEqualTo(Etat.Aucune) + assertThat(notes.listAllAlive().count { it.title == scellee.titre }).isEqualTo(1) + } + + /** 🔴 3-axes audit of 3.4.0, M4: the second file is not taken under the first one's decryption. */ + @Test + fun a_second_file_deposited_while_the_first_opens_waits_for_it(): Unit = runBlocking { + val premierCode = SecretDeSauvegarde.nouveauCode() + val secondCode = SecretDeSauvegarde.nouveauCode() + val premiere = scellee("Premiere", lectureUnique = false) + val seconde = scellee("Seconde", lectureUnique = false) + val secondFichier = FichierDeNoteScellee.sceller(seconde, secondCode) + val recues = viewModel() + + deposerPuisPrendre(recues, FichierDeNoteScellee.sceller(premiere, premierCode)) + // One block on the main thread: the first opening cannot end in between, whatever the phone's speed. + withContext(Dispatchers.Main) { + recues.ouvrir(premierCode) + assertThat(recues.etat.value).isEqualTo(Etat.Code(enCours = true)) + enAttente.deposer(secondFichier) + recues.prendre() + assertThat(recues.etat.value).isEqualTo(Etat.Code(enCours = true)) + } + creees += withTimeout(ATTENTE_MS) { recues.aOuvrir.first() } + + // The first is in, the second taken after it: its own code opens it. + assertThat(withTimeout(ATTENTE_MS) { recues.etat.first { it == Etat.Code() } }).isEqualTo(Etat.Code()) + withContext(Dispatchers.Main) { recues.ouvrir(secondCode) } + creees += withTimeout(ATTENTE_MS) { recues.aOuvrir.first() } + + val titres = creees.mapNotNull { notes.find(it)?.title } + assertThat(titres).containsExactly(premiere.titre, seconde.titre).inOrder() + } + + private fun scellee(titre: String, lectureUnique: Boolean) = NoteScellee( + titre = "$titre $suffixe", + contenu = "B 4521 — « ne pas oublier » 🔑\nseconde ligne", + couleur = NoteColor.entries.last().id, + etiquettes = listOf("maison", "accès"), + lectureUnique = lectureUnique, + ) + + private suspend fun deposerPuisPrendre(recues: NotesScelleesViewModel, fichier: ByteArray) = + withContext(Dispatchers.Main) { + enAttente.deposer(fichier) + recues.prendre() + assertThat(recues.etat.value).isEqualTo(Etat.Code()) + } + + private suspend fun attendreUneFin(recues: NotesScelleesViewModel): Etat = + withTimeout(ATTENTE_MS) { recues.etat.first { it is Etat.Code && !it.enCours } } + + @Suppress("UNCHECKED_CAST") + private suspend fun viewModel(): NotesScelleesViewModel = withContext(Dispatchers.Main) { + val fabrique = object : ViewModelProvider.Factory { + override fun create(modelClass: Class, extras: CreationExtras): T = + NotesScelleesViewModel(enAttente, notes) as T + } + ViewModelProvider.create(magasin, fabrique)[NotesScelleesViewModel::class] + } + + private companion object { + /** Two Argon2id derivations (64 MiB, t=3) and an INSERT, on the S9, with margin. */ + const val ATTENTE_MS = 20_000L + } +} diff --git a/app/src/androidTest/java/com/filestech/notes_tech/ui/secure/ClavierSansApprentissageTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/ui/secure/ClavierSansApprentissageTest.kt index f4e40f5..957143c 100644 --- a/app/src/androidTest/java/com/filestech/notes_tech/ui/secure/ClavierSansApprentissageTest.kt +++ b/app/src/androidTest/java/com/filestech/notes_tech/ui/secure/ClavierSansApprentissageTest.kt @@ -11,7 +11,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.platform.InterceptPlatformTextInput import androidx.compose.ui.test.hasSetTextAction -import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.performClick import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue diff --git a/app/src/androidTest/java/com/filestech/notes_tech/ui/settings/ReglagesTest.kt b/app/src/androidTest/java/com/filestech/notes_tech/ui/settings/ReglagesTest.kt index c88729b..553fa37 100644 --- a/app/src/androidTest/java/com/filestech/notes_tech/ui/settings/ReglagesTest.kt +++ b/app/src/androidTest/java/com/filestech/notes_tech/ui/settings/ReglagesTest.kt @@ -75,6 +75,7 @@ class ReglagesTest { private val langues = mutableListOf() private val tris = mutableListOf() private val fenetresProtegees = mutableListOf() + private val clavierSansApprentissage = mutableListOf() private val delais = mutableListOf() private val paniques = mutableListOf() private val aPropos = mutableListOf() @@ -120,6 +121,7 @@ class ReglagesTest { onLocale = { langues += it }, onSort = { tris += it }, onSecureWindow = { fenetresProtegees += it }, + onKeyboardNoLearning = { clavierSansApprentissage += it }, onAutoLock = { delais += it }, onPanic = { paniques += Unit }, onOpenAbout = { aPropos += Unit }, @@ -267,22 +269,24 @@ class ReglagesTest { .getQuantityString(R.plurals.settings_vault_auto_lock_minutes, 15, 15) poser(SettingsUiState(vaultAutoLockMinutes = 0)) - regle.onNodeWithText(texte(R.string.settings_vault_auto_lock_never)).assertIsDisplayed() + regle.onNodeWithText(texte(R.string.settings_vault_auto_lock_never)).performScrollTo().assertIsDisplayed() poser(SettingsUiState(vaultAutoLockMinutes = 15)) - regle.onNodeWithText(quinze).assertIsDisplayed() + regle.onNodeWithText(quinze).performScrollTo().assertIsDisplayed() regle.onNodeWithText(texte(R.string.settings_vault_auto_lock_never)).assertDoesNotExist() } /** - * 🔴🔴 **Un SEUL nœud basculable, qui porte le libellé ET l'état** — c'est la propriété de §77. + * 🔴🔴 **Un SEUL nœud basculable par réglage, qui porte le libellé ET l'état** — c'est la propriété + * de §77. * - * Avant correctif, l'écran en portait deux : le `ListItem` avec le texte, et un `Switch` **sans - * nom** en `trailingContent`. Ce test l'interdit de trois façons à la fois, et chacune tomberait - * séparément si le correctif était défait : + * Avant correctif, l'écran en portait deux pour la fenêtre protégée : le `ListItem` avec le texte, et + * un `Switch` **sans nom** en `trailingContent`. Ce test l'interdit de trois façons à la fois, et + * chacune tomberait séparément si le correctif était défait : * - * 1. `assertCountEquals(1)` — deux cibles pour un seul réglage, dont une sous le minimum - * accessible, c'est exactement ce que l'idiome `onCheckedChange = null` évite ; + * 1. `assertCountEquals(2)` — deux réglages à bascule depuis la 3.4.0 (fenêtre protégée, clavier + * sans apprentissage), donc deux nœuds : un de plus serait un `Switch` sans nom, une seconde + * cible pour un seul réglage, ce que l'idiome `onCheckedChange = null` évite ; * 2. le nœud basculable **porte le libellé** de la ligne : sans ça il s'annonce « interrupteur, * activé » sans dire de quoi, ce qui était le défaut mesuré ; * 3. son état suit `state.secureWindow`, et le clic remonte **l'inverse**. @@ -295,8 +299,8 @@ class ReglagesTest { fun un_seul_noeud_basculable_porte_le_libelle_de_la_fenetre_protegee_et_son_etat() { poser(SettingsUiState(secureWindow = true)) - regle.onAllNodes(isToggleable()).assertCountEquals(1) - val interrupteur = regle.onNode(isToggleable()) + regle.onAllNodes(isToggleable()).assertCountEquals(2) + val interrupteur = regle.onNode(isToggleable() and hasText(texte(R.string.settings_secure_window))) interrupteur.assertIsOn() assertThat( interrupteur.fetchSemanticsNode().config.getOrNull(SemanticsProperties.Text) @@ -308,7 +312,22 @@ class ReglagesTest { assertThat(fenetresProtegees).containsExactly(false) poser(SettingsUiState(secureWindow = false)) - regle.onNode(isToggleable()).assertIsOff() + regle.onNode(isToggleable() and hasText(texte(R.string.settings_secure_window))).assertIsOff() + } + + /** D-040 (3.4.0) : la ligne du clavier, comme celle de la fenêtre protégée — un nœud, son libellé, son état. */ + @Test + fun le_clavier_sans_apprentissage_est_un_seul_noeud_qui_suit_son_etat() { + poser(SettingsUiState(keyboardNoLearning = true)) + + val interrupteur = regle.onNode(isToggleable() and hasText(texte(R.string.settings_keyboard_no_learning))) + interrupteur.performScrollTo().assertIsOn() + interrupteur.performClick() + assertThat(clavierSansApprentissage).containsExactly(false) + assertThat(fenetresProtegees).isEmpty() + + poser(SettingsUiState(keyboardNoLearning = false)) + regle.onNode(isToggleable() and hasText(texte(R.string.settings_keyboard_no_learning))).assertIsOff() } /** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1a678fa..719859e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -72,13 +72,6 @@ - > = observeSorted( SimpleSQLiteQuery( diff --git a/app/src/main/java/com/filestech/notes_tech/data/local/dao/NoteWriteDao.kt b/app/src/main/java/com/filestech/notes_tech/data/local/dao/NoteWriteDao.kt index b0fdc20..0279b2d 100644 --- a/app/src/main/java/com/filestech/notes_tech/data/local/dao/NoteWriteDao.kt +++ b/app/src/main/java/com/filestech/notes_tech/data/local/dao/NoteWriteDao.kt @@ -119,11 +119,11 @@ interface NoteWriteDao { @Query("UPDATE notes SET color_id = :colorId, updated_at = :updatedAt WHERE id = :id") suspend fun updateColor(id: String, colorId: Int?, updatedAt: Long): Int - /** D-043 (3.4.0): the note's lifetime — see `NotesRepository.setLifetime`. `updated_at` untouched. */ + /** D-043 (3.4.0): the note's lifetime — see `DureesDeVie.poser`. `updated_at` untouched. */ @Query("UPDATE notes SET expires_at = :expiresAt, read_once = :readOnce WHERE id = :id") suspend fun updateLifetime(id: String, expiresAt: Long?, readOnce: Boolean): Int - /** D-043: one DELETE, the one of [deletePermanently] — see `NotesRepository.purgeExpired`. */ + /** D-043: one DELETE, the one of [deletePermanently] — see `DureesDeVie.purger`. */ @Query("DELETE FROM notes WHERE expires_at IS NOT NULL AND expires_at <= :now") suspend fun deleteExpired(now: Long): Int diff --git a/app/src/main/java/com/filestech/notes_tech/data/repository/DureesDeVie.kt b/app/src/main/java/com/filestech/notes_tech/data/repository/DureesDeVie.kt index 44e3806..b209eae 100644 --- a/app/src/main/java/com/filestech/notes_tech/data/repository/DureesDeVie.kt +++ b/app/src/main/java/com/filestech/notes_tech/data/repository/DureesDeVie.kt @@ -2,6 +2,8 @@ package com.filestech.notes_tech.data.repository import com.filestech.notes_tech.data.local.DatabaseProvider import com.filestech.notes_tech.domain.model.DureeDeVie +import kotlinx.coroutines.CancellationException +import timber.log.Timber import java.time.Clock import javax.inject.Inject @@ -32,4 +34,18 @@ class DureesDeVie @Inject constructor(private val databases: DatabaseProvider, p * @return how many were erased. */ suspend fun purger(): Int = databases.get().noteWriteDao().deleteExpired(clock.millis()) + + /** + * [purger], its failure kept quiet: the opening and the list call it as a catch-up, which the next + * call — a minute later on the list — tries again. Not a failure to show. + */ + suspend fun purgerSansEchec() { + try { + purger() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.w(e, "purge des notes expirees differee") + } + } } diff --git a/app/src/main/java/com/filestech/notes_tech/data/repository/NotesRepository.kt b/app/src/main/java/com/filestech/notes_tech/data/repository/NotesRepository.kt index f259ecb..01e0215 100644 --- a/app/src/main/java/com/filestech/notes_tech/data/repository/NotesRepository.kt +++ b/app/src/main/java/com/filestech/notes_tech/data/repository/NotesRepository.kt @@ -291,6 +291,10 @@ class NotesRepository @Inject constructor( title: String = "", content: String = "", tags: List = emptyList(), + // 3.4.0 (D-044): a sealed note received keeps its colour and its "after reading" — in the INSERT + // itself: set afterwards, a failure or a process killed in between left a "read once" note for good. + color: NoteColor? = null, + readOnce: Boolean = false, ): Note { requireTitleWithinLimit(title) val now = clock.instant() @@ -308,6 +312,8 @@ class NotesRepository @Inject constructor( updatedAt = now, encrypted = null, encVersion = EncryptedFormat.CONTENT_ONLY, + color = color, + readOnce = readOnce, ) return inTransaction { database -> diff --git a/app/src/main/java/com/filestech/notes_tech/data/scellee/FichierDeNoteScellee.kt b/app/src/main/java/com/filestech/notes_tech/data/scellee/FichierDeNoteScellee.kt index 8c3afc9..3892a5e 100644 --- a/app/src/main/java/com/filestech/notes_tech/data/scellee/FichierDeNoteScellee.kt +++ b/app/src/main/java/com/filestech/notes_tech/data/scellee/FichierDeNoteScellee.kt @@ -4,6 +4,7 @@ import android.util.JsonReader import android.util.JsonToken import android.util.JsonWriter import com.filestech.notes_tech.data.repository.NotesRepository +import com.filestech.notes_tech.domain.importation.LectureDUnImport import com.filestech.notes_tech.domain.importation.LongueurDUneNote import com.filestech.notes_tech.security.sauvegarde.FichierDeSauvegarde import com.filestech.notes_tech.security.sauvegarde.FichierDeSauvegarde.Refus @@ -186,7 +187,8 @@ object FichierDeNoteScellee { } json.endObject() if (format != FORMAT || version != VERSION) throw NoteScelleeRefuseeException(Raison.VERSION_INCONNUE) - val lu = NoteScellee(titre.orEmpty(), contenu.orEmpty(), couleur, etiquettes, lectureUnique) + val lu = + NoteScellee(surUneLigne(titre.orEmpty()), contenu.orEmpty(), couleur, propres(etiquettes), lectureUnique) val dansLesLimites = lu.titre.length <= NotesRepository.TITLE_MAX_LENGTH && lu.contenu.length <= LongueurDUneNote.CARACTERES_MAX if (titre == null || contenu == null || !dansLesLimites) throw NoteScelleeRefuseeException(Raison.ILLISIBLE) @@ -206,4 +208,25 @@ object FichierDeNoteScellee { json.endArray() return lues } + + /** + * 🔴 A file made by hand holds what it likes (3-axes audit of 3.4.0): the tags as the import keeps + * them (`LecteurMarkdown`) — split on commas, as they are stored comma-joined, unescaped (`TagCodec`), + * so a tag carrying one would come back as two —, each on one line, none empty, none twice. + */ + private fun propres(etiquettes: List): List = etiquettes.asSequence() + .flatMap { it.split(',') }.map(::surUneLigne).filter(String::isNotEmpty).distinct().toList() + + /** + * [texte] on one line — a control character becomes a space — and without the bidirectional + * formatting that shows "fdp.exe" as "exe.pdf", as an imported folder's name; trimmed. + */ + private fun surUneLigne(texte: String): String = buildString(texte.length) { + for (c in texte) { + when { + Character.isISOControl(c) -> append(' ') + c !in LectureDUnImport.FORMATS_BIDI -> append(c) + } + } + }.trim() } diff --git a/app/src/main/java/com/filestech/notes_tech/data/scellee/LectureScellee.kt b/app/src/main/java/com/filestech/notes_tech/data/scellee/LectureScellee.kt index c60e973..56149d7 100644 --- a/app/src/main/java/com/filestech/notes_tech/data/scellee/LectureScellee.kt +++ b/app/src/main/java/com/filestech/notes_tech/data/scellee/LectureScellee.kt @@ -2,12 +2,19 @@ package com.filestech.notes_tech.data.scellee import android.content.ContentResolver import android.net.Uri -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.withTimeoutOrNull import timber.log.Timber import java.io.ByteArrayOutputStream import java.io.IOException import java.io.InputStream +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference /** What reading a file meant to be a sealed note gave (D-044, 3.4.0). */ sealed interface LectureScellee { @@ -20,25 +27,54 @@ sealed interface LectureScellee { /** * Reads [uri] as a sealed note — from "Open with" (another app's file) or the app's own picker. * - * Bounded: past [FichierDeNoteScellee.TAILLE_MAX] bytes really read, refused; and a file whose first - * bytes are not the magic is refused there, unread beyond them — a video opened with the app costs eight - * bytes. Nothing is decrypted here: the code is asked once the app is unlocked. + * Bounded: past [FichierDeNoteScellee.TAILLE_MAX] bytes really read, refused; a file whose first bytes are + * not the magic is refused there, unread beyond them — a video opened with the app costs eight bytes. + * Nothing is decrypted here: the code is asked once the app is unlocked. + * + * 🔴 **Another app's provider is untrusted** (external review of 3.4.0): it may never answer, or hand back + * files of 4 MiB as fast as it is asked. + * - **One reading at a time**, in the whole process: another one is refused, not queued — a burst of + * "Open with" cannot pile up 4 MiB buffers. + * - **On a thread of its own**: a provider that blocks holds that thread, never the shared IO pool. + * - **[delaiMs] at most**: past it the stream is CLOSED — a coroutine cannot stop a blocked `read`, closing + * its descriptor does —, and the reading is given up. */ -suspend fun lireUneNoteScellee(resolver: ContentResolver, uri: Uri): LectureScellee = withContext(Dispatchers.IO) { - try { - resolver.openInputStream(uri)?.use(::lireBorne) ?: LectureScellee.Illisible - } catch (e: IOException) { - Timber.w(e, "note scellee illisible") - LectureScellee.Illisible - } catch (e: SecurityException) { - Timber.w(e, "note scellee refusee par son fournisseur") - LectureScellee.Illisible - } catch (e: IllegalArgumentException) { - Timber.w(e, "note scellee : adresse refusee") +suspend fun lireUneNoteScellee(resolver: ContentResolver, uri: Uri, delaiMs: Long = DELAI_MS): LectureScellee { + if (!lectureEnCours.compareAndSet(false, true)) return LectureScellee.Illisible + val flux = AtomicReference() + // Not a child of the caller: a reading stuck in a provider must not keep the caller waiting past the delay. + val lecture = CoroutineScope(SupervisorJob() + FIL_DES_FOURNISSEURS).async { + try { + resolver.openInputStream(uri)?.also(flux::set)?.use(::lireBorne) ?: LectureScellee.Illisible + } catch (e: IOException) { + Timber.w(e, "note scellee illisible") + LectureScellee.Illisible + } catch (e: SecurityException) { + Timber.w(e, "note scellee refusee par son fournisseur") + LectureScellee.Illisible + } catch (e: CancellationException) { + throw e + } catch (e: RuntimeException) { + // A provider is another app's code: whatever it throws ends the reading, not the app. + Timber.w(e, "note scellee : fournisseur en erreur") + LectureScellee.Illisible + } finally { + lectureEnCours.set(false) + } + } + return withTimeoutOrNull(delaiMs) { lecture.await() } ?: run { + try { + flux.get()?.close() + } catch (e: IOException) { + Timber.w(e, "flux d'une note scellee non ferme") + } + lecture.cancel() + lectureEnCours.set(false) LectureScellee.Illisible } } +/** [entree] read whole, bounded — see [lireUneNoteScellee]. */ internal fun lireBorne(entree: InputStream): LectureScellee { val sortie = ByteArrayOutputStream() val tampon = ByteArray(TAMPON) @@ -47,6 +83,8 @@ internal fun lireBorne(entree: InputStream): LectureScellee { while (true) { val lus = entree.read(tampon) if (lus < 0) break + // A stream that gives nothing without ending would loop here for ever: refused. + if (lus == 0) return LectureScellee.Illisible total += lus if (total > FichierDeNoteScellee.TAILLE_MAX) return LectureScellee.TropGrosse sortie.write(tampon, 0, lus) @@ -58,4 +96,14 @@ internal fun lireBorne(entree: InputStream): LectureScellee { return if (magieVue) LectureScellee.Lue(sortie.toByteArray()) else LectureScellee.PasUneNoteScellee } +private val lectureEnCours = AtomicBoolean(false) + +/** One thread, for the providers of other apps only — see [lireUneNoteScellee]. */ +private val FIL_DES_FOURNISSEURS = Executors.newSingleThreadExecutor { tache -> + Thread(tache, "notes-scellees").apply { isDaemon = true } +}.asCoroutineDispatcher() + private const val TAMPON = 64 * 1024 + +/** As the share receiver's: a provider that never answers keeps no window open longer. */ +private const val DELAI_MS = 20_000L diff --git a/app/src/main/java/com/filestech/notes_tech/domain/importation/LectureDUnImport.kt b/app/src/main/java/com/filestech/notes_tech/domain/importation/LectureDUnImport.kt index ca9f0bb..cda8e5f 100644 --- a/app/src/main/java/com/filestech/notes_tech/domain/importation/LectureDUnImport.kt +++ b/app/src/main/java/com/filestech/notes_tech/domain/importation/LectureDUnImport.kt @@ -185,8 +185,8 @@ class LectureDUnImport( return DartTextSemantics.trim(nom.substring(0, fin)) } - /** LRM, RLM, ALM, the embeddings and overrides, the isolates. */ - private val FORMATS_BIDI = setOf( + /** LRM, RLM, ALM, the embeddings and overrides, the isolates — a sealed note's title drops them too. */ + internal val FORMATS_BIDI = setOf( '\u200E', '\u200F', '\u061C', '\u202A', '\u202B', '\u202C', '\u202D', '\u202E', '\u2066', '\u2067', '\u2068', '\u2069', diff --git a/app/src/main/java/com/filestech/notes_tech/ui/editor/FeuilleDePartageScelle.kt b/app/src/main/java/com/filestech/notes_tech/ui/editor/FeuilleDePartageScelle.kt index 9c41d6d..c149ae6 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/editor/FeuilleDePartageScelle.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/editor/FeuilleDePartageScelle.kt @@ -15,6 +15,7 @@ import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable @@ -35,6 +36,7 @@ import com.filestech.notes_tech.data.scellee.NoteScellee import com.filestech.notes_tech.security.clipboard.SensitiveClipboard import com.filestech.notes_tech.ui.common.CodeAffiche import com.filestech.notes_tech.ui.common.partagerUnFichier +import com.filestech.notes_tech.ui.secure.ProprietesDeDialogueSecret import com.filestech.notes_tech.ui.secure.SecureWindowGuard import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CancellationException @@ -66,10 +68,15 @@ internal fun FeuilleDePartageScelle(note: (lectureUnique: Boolean) -> NoteScelle onFermer() } SecureWindowGuard(active = true) + // 🔴 Whatever makes the sheet leave — closed, its note no longer readable (a vault locking), the + // screen recreated — the code goes with it (coherence audit of 3.4.0): kept, it showed again, with + // the file of the old text, at the next opening. A rotation therefore asks to seal again. + DisposableEffect(viewModel) { onDispose { viewModel.oublier() } } AlertDialog( // Not while sealing: the work would go on for a sheet already closed. onDismissRequest = { if (etat !is PartageScelleViewModel.Etat.EnCours) fermer() }, + properties = ProprietesDeDialogueSecret, title = { Text(stringResource(R.string.sealed_title)) }, text = { Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { diff --git a/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorViewModel.kt b/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorViewModel.kt index fede65d..3dfdf32 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorViewModel.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/editor/NoteEditorViewModel.kt @@ -876,15 +876,6 @@ class NoteEditorViewModel @Inject constructor( _state.update { etat -> etat.copy(note = etat.note?.copy(color = color)) } } - /** - * Into the archive, or back out of it (3.3.0) — the ⋮ menu's "Archive" / "Unarchive". - * - * The flag alone, like [setPinned]: neither the content nor the sealed blob — so no vault check, - * the note being on screen means its vault is open. Through [tenterUneAction] and not - * [enArrierePlan], unlike its twins: the pin shows on the screen, the archive does not, so its - * outcome — success or failure — is said. The editor stays open: an archived note is read and - * written like any other, from the archive. - */ /** * The note's lifetime (D-043, 3.4.0), `null` for none. Choosing one here — "after reading" included — * means this closing erases nothing: the choice counts from now, or from the next opening. @@ -901,6 +892,15 @@ class NoteEditorViewModel @Inject constructor( } } + /** + * Into the archive, or back out of it (3.3.0) — the ⋮ menu's "Archive" / "Unarchive". + * + * The flag alone, like [setPinned]: neither the content nor the sealed blob — so no vault check, + * the note being on screen means its vault is open. Through [tenterUneAction] and not + * [enArrierePlan], unlike its twins: the pin shows on the screen, the archive does not, so its + * outcome — success or failure — is said. The editor stays open: an archived note is read and + * written like any other, from the archive. + */ fun setArchived(archived: Boolean) = tenterUneAction(ActionDEditeur.OrigineDErreur.ARCHIVAGE) { if (notes.setArchived(noteId, archived)) { _state.update { etat -> etat.copy(note = etat.note?.copy(archived = archived)) } diff --git a/app/src/main/java/com/filestech/notes_tech/ui/home/HomeRoute.kt b/app/src/main/java/com/filestech/notes_tech/ui/home/HomeRoute.kt index 603e6f4..0a0385f 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/home/HomeRoute.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/home/HomeRoute.kt @@ -23,8 +23,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.dropUnlessResumed +import androidx.lifecycle.repeatOnLifecycle import com.filestech.notes_tech.R import com.filestech.notes_tech.domain.model.Folder import com.filestech.notes_tech.domain.model.Note @@ -45,7 +48,7 @@ import com.filestech.notes_tech.ui.folders.FoldersDrawerViewModel import com.filestech.notes_tech.ui.folders.GesteDeDossier import com.filestech.notes_tech.ui.folders.SortDesNotes import com.filestech.notes_tech.ui.folders.deverrouillageRequis -import com.filestech.notes_tech.ui.scellee.NotesScelleesViewModel +import com.filestech.notes_tech.ui.scellee.SelecteurDeNoteScelleeViewModel import com.filestech.notes_tech.ui.vault.ChooseVaultModeSheet import com.filestech.notes_tech.ui.vault.CreateVaultSheet import com.filestech.notes_tech.ui.vault.UnlockVaultSheet @@ -152,9 +155,9 @@ fun HomeRoute( // 3.4.0 (D-044): "Open a sealed note" — the file picked is read and deposited; the code is asked by // `NotesScelleesRecues`, the same as for "Open with". A file that is no sealed note is said here. - val scellees: NotesScelleesViewModel = hiltViewModel() + val scellees: SelecteurDeNoteScelleeViewModel = hiltViewModel() val selecteurDeNoteScellee = rememberAppResultLauncher(ActivityResultContracts.OpenDocument()) { uri -> - uri?.let(scellees::lireDepuisLeSelecteur) + uri?.let(scellees::lire) } val ouvrirUneNoteScellee: () -> Unit = { selecteurDeNoteScellee.launch(arrayOf("*/*")) } val ressourcesDeLAccueil = LocalResources.current @@ -162,10 +165,13 @@ fun HomeRoute( scellees.refusDuSelecteur.collect { snackbars.showSnackbar(ressourcesDeLAccueil.getString(it)) } } // D-043 (3.4.0): the notes whose lifetime is over, every minute while the list shows them. - LaunchedEffect(Unit) { - while (true) { - homeViewModel.purgerLesNotesExpirees() - delay(PURGE_DES_EXPIREES_MS) + val cycleDeVie = LocalLifecycleOwner.current + LaunchedEffect(cycleDeVie) { + cycleDeVie.repeatOnLifecycle(Lifecycle.State.STARTED) { + while (true) { + homeViewModel.purgerLesNotesExpirees() + delay(PURGE_DES_EXPIREES_MS) + } } } diff --git a/app/src/main/java/com/filestech/notes_tech/ui/home/HomeViewModel.kt b/app/src/main/java/com/filestech/notes_tech/ui/home/HomeViewModel.kt index f593507..8500d4f 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/home/HomeViewModel.kt @@ -488,15 +488,7 @@ class HomeViewModel @Inject constructor( * the next minute tries again. */ fun purgerLesNotesExpirees() { - viewModelScope.launch { - try { - durees.purger() - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Timber.w(e, "purge des notes expirees differee") - } - } + viewModelScope.launch { durees.purgerSansEchec() } } /** Purge les notes dont le séjour en corbeille a expiré. Sans effet visible si rien n'expire. */ diff --git a/app/src/main/java/com/filestech/notes_tech/ui/home/NoteCard.kt b/app/src/main/java/com/filestech/notes_tech/ui/home/NoteCard.kt index b49a5c4..dbfe40f 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/home/NoteCard.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/home/NoteCard.kt @@ -295,7 +295,6 @@ internal fun titreAffiche(note: Note): String = when { else -> note.title } -/** La puce discrète qui dit d'où vient une note, en vue non filtrée. */ /** D-043: the hourglass and what is left — "5 h", "after reading". Read by TalkBack in the card's description. */ @Composable private fun BadgeDeDureeDeVie(vie: VieAffichee?) { @@ -315,6 +314,7 @@ private fun BadgeDeDureeDeVie(vie: VieAffichee?) { } } +/** La puce discrète qui dit d'où vient une note, en vue non filtrée. */ @Composable private fun FolderChip(label: String, modifier: Modifier = Modifier) { val couleurs = MaterialTheme.colorScheme diff --git a/app/src/main/java/com/filestech/notes_tech/ui/raccourcis/RaccourciActivity.kt b/app/src/main/java/com/filestech/notes_tech/ui/raccourcis/RaccourciActivity.kt index b36fd2b..ab9e087 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/raccourcis/RaccourciActivity.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/raccourcis/RaccourciActivity.kt @@ -23,10 +23,12 @@ class RaccourciActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Recreated (rotation, process restored): the first instance did it. - if (savedInstanceState == null) { - raccourciDe(intent?.action)?.let(enAttente::deposer) - ouvrirLAppli() + if (savedInstanceState != null) { + finish() + return } + raccourciDe(intent?.action)?.let(enAttente::deposer) + ouvrirLAppli() finish() } diff --git a/app/src/main/java/com/filestech/notes_tech/ui/scellee/NotesScelleesRecues.kt b/app/src/main/java/com/filestech/notes_tech/ui/scellee/NotesScelleesRecues.kt index 427944e..5944940 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/scellee/NotesScelleesRecues.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/scellee/NotesScelleesRecues.kt @@ -1,7 +1,5 @@ package com.filestech.notes_tech.ui.scellee -import android.content.Context -import android.net.Uri import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -27,20 +25,16 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewModelScope import com.filestech.notes_tech.R -import com.filestech.notes_tech.data.repository.DureesDeVie import com.filestech.notes_tech.data.repository.NotesRepository import com.filestech.notes_tech.data.scellee.FichierDeNoteScellee import com.filestech.notes_tech.data.scellee.FichierDeNoteScellee.NoteScelleeRefuseeException import com.filestech.notes_tech.data.scellee.FichierDeNoteScellee.Raison -import com.filestech.notes_tech.data.scellee.LectureScellee -import com.filestech.notes_tech.data.scellee.lireUneNoteScellee -import com.filestech.notes_tech.domain.model.DureeDeVie import com.filestech.notes_tech.domain.model.Folder import com.filestech.notes_tech.domain.model.NoteColor +import com.filestech.notes_tech.ui.secure.ProprietesDeDialogueSecret import com.filestech.notes_tech.ui.secure.SecureWindowGuard import com.filestech.notes_tech.ui.vault.ChampDePhraseSecrete import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel @@ -83,6 +77,7 @@ private fun DialogueDuCode(etat: NotesScelleesViewModel.Etat.Code, onOuvrir: (St SecureWindowGuard(active = true) AlertDialog( onDismissRequest = { if (!etat.enCours) onAnnuler() }, + properties = ProprietesDeDialogueSecret, title = { Text(stringResource(R.string.sealed_received_title)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { @@ -131,8 +126,6 @@ internal fun Raison.message(): Int = when (this) { class NotesScelleesViewModel @Inject constructor( private val enAttente: NotesScelleesEnAttente, private val notes: NotesRepository, - private val durees: DureesDeVie, - @ApplicationContext private val context: Context, ) : ViewModel() { sealed interface Etat { @@ -147,15 +140,15 @@ class NotesScelleesViewModel @Inject constructor( private val _etat = MutableStateFlow(Etat.Aucune) val etat: StateFlow = _etat.asStateFlow() - // Channels, not SharedFlows: an editor to open, a refusal to say, are not lost when nothing collects yet. + // A channel, not a SharedFlow: an editor to open is not lost when nothing collects yet. private val ouvertures = Channel(Channel.BUFFERED) val aOuvrir: Flow = ouvertures.receiveAsFlow() - private val refus = Channel(Channel.BUFFERED) - - /** A file picked in the app that is no sealed note: said where it was picked. */ - val refusDuSelecteur: Flow = refus.receiveAsFlow() fun prendre() { + // 🔴 Not while a file is being opened (3-axes audit of 3.4.0): replaced now, the bytes under + // decryption would be zeroed, or the second file erased by the first one's end. It waits in + // [enAttente], taken once the first is done. + if (enCours()) return val pris = enAttente.prendre() ?: return octets?.fill(0) octets = pris @@ -164,14 +157,20 @@ class NotesScelleesViewModel @Inject constructor( fun ouvrir(saisie: String) { val fichier = octets ?: return - if ((_etat.value as? Etat.Code)?.enCours == true) return + if (enCours()) return _etat.value = Etat.Code(enCours = true) viewModelScope.launch { try { val note = withContext(Dispatchers.Default) { FichierDeNoteScellee.ouvrir(fichier, saisie) } - val creee = notes.create(Folder.INBOX_ID, note.titre, note.contenu, note.etiquettes) - NoteColor.fromId(note.couleur)?.let { notes.setColor(creee.id, it) } - if (note.lectureUnique) durees.poser(creee.id, DureeDeVie.APRES_LECTURE) + // One INSERT: the note never exists without its "after reading" (external review, 3.4.0). + val creee = notes.create( + folderId = Folder.INBOX_ID, + title = note.titre, + content = note.contenu, + tags = note.etiquettes, + color = NoteColor.fromId(note.couleur), + readOnce = note.lectureUnique, + ) abandonner() ouvertures.send(creee.id) } catch (e: CancellationException) { @@ -182,27 +181,19 @@ class NotesScelleesViewModel @Inject constructor( Timber.w(e, "note scellee non ajoutee") _etat.value = Etat.Code(erreur = Raison.ILLISIBLE) } + // A file deposited meanwhile — see [prendre]. + if (enAttente.contenu.value != null) prendre() } } + private fun enCours() = (_etat.value as? Etat.Code)?.enCours == true + fun abandonner() { octets?.fill(0) octets = null _etat.value = Etat.Aucune } - /** The home screen's "Open a sealed note": the picked file, read and deposited as "Open with" does. */ - fun lireDepuisLeSelecteur(uri: Uri) { - viewModelScope.launch { - when (val lue = lireUneNoteScellee(context.contentResolver, uri)) { - is LectureScellee.Lue -> enAttente.deposer(lue.octets) - LectureScellee.PasUneNoteScellee -> refus.send(R.string.sealed_not_a_sealed_note) - LectureScellee.TropGrosse -> refus.send(R.string.sealed_too_big) - LectureScellee.Illisible -> refus.send(R.string.sealed_error_unreadable) - } - } - } - override fun onCleared() { octets?.fill(0) super.onCleared() diff --git a/app/src/main/java/com/filestech/notes_tech/ui/scellee/OuvrirUneNoteScelleeActivity.kt b/app/src/main/java/com/filestech/notes_tech/ui/scellee/OuvrirUneNoteScelleeActivity.kt index 833585b..ab6d0c1 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/scellee/OuvrirUneNoteScelleeActivity.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/scellee/OuvrirUneNoteScelleeActivity.kt @@ -12,9 +12,7 @@ import com.filestech.notes_tech.ui.common.avecLaLangueChoisie import com.filestech.notes_tech.ui.common.ouvrirLAppli import com.filestech.notes_tech.ui.share.RecevoirUnPartageActivity import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.async import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeoutOrNull import javax.inject.Inject /** @@ -52,27 +50,19 @@ class OuvrirUneNoteScelleeActivity : ComponentActivity() { return } lifecycleScope.launch { - val lecture = async { lireUneNoteScellee(contentResolver, uri) } - when (val lue = withTimeoutOrNull(DELAI_MAX_MS) { lecture.await() }) { + // Bounded in time, one at a time, on a thread of its own: see `lireUneNoteScellee`. + when (val lue = lireUneNoteScellee(contentResolver, uri)) { is LectureScellee.Lue -> { enAttente.deposer(lue.octets) ouvrirLAppli() } LectureScellee.PasUneNoteScellee -> dire(R.string.sealed_not_a_sealed_note) LectureScellee.TropGrosse -> dire(R.string.sealed_too_big) - LectureScellee.Illisible, null -> { - lecture.cancel() - dire(R.string.sealed_error_unreadable) - } + LectureScellee.Illisible -> dire(R.string.sealed_error_unreadable) } finish() } } private fun dire(message: Int) = Toast.makeText(this, message, Toast.LENGTH_LONG).show() - - private companion object { - /** Past this, the reading is dropped: a provider that never answers keeps no window open. */ - const val DELAI_MAX_MS = 20_000L - } } diff --git a/app/src/main/java/com/filestech/notes_tech/ui/scellee/SelecteurDeNoteScelleeViewModel.kt b/app/src/main/java/com/filestech/notes_tech/ui/scellee/SelecteurDeNoteScelleeViewModel.kt new file mode 100644 index 0000000..3ebe245 --- /dev/null +++ b/app/src/main/java/com/filestech/notes_tech/ui/scellee/SelecteurDeNoteScelleeViewModel.kt @@ -0,0 +1,46 @@ +package com.filestech.notes_tech.ui.scellee + +import android.content.Context +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.filestech.notes_tech.R +import com.filestech.notes_tech.data.scellee.LectureScellee +import com.filestech.notes_tech.data.scellee.lireUneNoteScellee +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * The home screen's "Open a sealed note" (D-044, 3.4.0): the file picked is read — as "Open with" reads one + * ([lireUneNoteScellee]) — and deposited in [NotesScelleesEnAttente]; the code is asked by + * [NotesScelleesRecues], the same as for "Open with". Apart from [NotesScelleesViewModel], which holds the + * code dialog: one ViewModel per role (coherence audit of 3.4.0). + */ +@HiltViewModel +class SelecteurDeNoteScelleeViewModel @Inject constructor( + private val enAttente: NotesScelleesEnAttente, + @ApplicationContext private val context: Context, +) : ViewModel() { + + // A channel, not a SharedFlow: a refusal is not lost when nothing collects yet. + private val refus = Channel(Channel.BUFFERED) + + /** A file picked that is no sealed note: said where it was picked. */ + val refusDuSelecteur: Flow = refus.receiveAsFlow() + + fun lire(uri: Uri) { + viewModelScope.launch { + when (val lue = lireUneNoteScellee(context.contentResolver, uri)) { + is LectureScellee.Lue -> enAttente.deposer(lue.octets) + LectureScellee.PasUneNoteScellee -> refus.send(R.string.sealed_not_a_sealed_note) + LectureScellee.TropGrosse -> refus.send(R.string.sealed_too_big) + LectureScellee.Illisible -> refus.send(R.string.sealed_error_unreadable) + } + } + } +} diff --git a/app/src/main/java/com/filestech/notes_tech/ui/secure/FeuilleSecrete.kt b/app/src/main/java/com/filestech/notes_tech/ui/secure/FeuilleSecrete.kt index 5a8cf5d..9294ccc 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/secure/FeuilleSecrete.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/secure/FeuilleSecrete.kt @@ -1,6 +1,7 @@ package com.filestech.notes_tech.ui.secure import androidx.compose.material3.ModalBottomSheetProperties +import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.SecureFlagPolicy /** @@ -18,3 +19,10 @@ import androidx.compose.ui.window.SecureFlagPolicy * The guard stays: it protects the window behind the sheet, and the recent-apps thumbnail. */ val ProprietesDeFeuilleSecrete = ModalBottomSheetProperties(securePolicy = SecureFlagPolicy.SecureOn) + +/** + * The same, for a dialog where a code is shown or typed — the backup's, a sealed note's (3-axes audit of + * 3.4.0): an `AlertDialog` is a window of its own too, and `DialogProperties()` inherits the flag as the + * sheet did. + */ +val ProprietesDeDialogueSecret = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn) diff --git a/app/src/main/java/com/filestech/notes_tech/ui/settings/SauvegardeUi.kt b/app/src/main/java/com/filestech/notes_tech/ui/settings/SauvegardeUi.kt index 25e5ffc..6d369c9 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/settings/SauvegardeUi.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/settings/SauvegardeUi.kt @@ -46,6 +46,7 @@ import com.filestech.notes_tech.security.sauvegarde.SecretDeSauvegarde import com.filestech.notes_tech.security.vault.VaultParams import com.filestech.notes_tech.ui.common.CodeAffiche import com.filestech.notes_tech.ui.common.rememberAppResultLauncher +import com.filestech.notes_tech.ui.secure.ProprietesDeDialogueSecret import com.filestech.notes_tech.ui.secure.SecureWindowGuard import com.filestech.notes_tech.ui.vault.ChampDePhraseSecrete import java.time.LocalDate @@ -186,6 +187,7 @@ private fun DialogueDeCreation( AlertDialog( onDismissRequest = onAnnuler, + properties = ProprietesDeDialogueSecret, title = { Text(stringResource(R.string.backup_create)) }, text = { Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) { @@ -277,6 +279,7 @@ private fun DialogueDeSecret(@StringRes erreur: Int?, onEssayer: (String) -> Uni var secret by remember { mutableStateOf("") } AlertDialog( onDismissRequest = onAnnuler, + properties = ProprietesDeDialogueSecret, title = { Text(stringResource(R.string.backup_restore)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { @@ -322,6 +325,7 @@ private fun DialogueDEssai( AlertDialog( onDismissRequest = onAnnuler, + properties = ProprietesDeDialogueSecret, title = { Text(stringResource(R.string.backup_restore)) }, text = { Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) { diff --git a/app/src/main/java/com/filestech/notes_tech/ui/settings/SettingsScreen.kt b/app/src/main/java/com/filestech/notes_tech/ui/settings/SettingsScreen.kt index 088369d..5be7899 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/settings/SettingsScreen.kt @@ -203,6 +203,7 @@ fun SettingsScreen( onLocale: (LocalePreference) -> Unit, onSort: (NoteSortMode) -> Unit, onSecureWindow: (Boolean) -> Unit, + onKeyboardNoLearning: (Boolean) -> Unit, onAutoLock: (Int) -> Unit, onPanic: () -> Unit, onOpenAbout: () -> Unit, @@ -216,7 +217,6 @@ fun SettingsScreen( /** A slot, like [ligneDExport]: the encrypted backup's two rows and their dialogs (D-029). */ lignesDeSauvegarde: @Composable () -> Unit = {}, sectionDeVerrouillage: @Composable () -> Unit = {}, - onKeyboardNoLearning: (Boolean) -> Unit = {}, ) { var choixDeTheme by remember { mutableStateOf(false) } var choixDeLangue by remember { mutableStateOf(false) } diff --git a/app/src/main/java/com/filestech/notes_tech/ui/startup/StartupViewModel.kt b/app/src/main/java/com/filestech/notes_tech/ui/startup/StartupViewModel.kt index b406fb2..168f514 100644 --- a/app/src/main/java/com/filestech/notes_tech/ui/startup/StartupViewModel.kt +++ b/app/src/main/java/com/filestech/notes_tech/ui/startup/StartupViewModel.kt @@ -7,7 +7,6 @@ import com.filestech.notes_tech.data.repository.DureesDeVie import com.filestech.notes_tech.security.kek.KekFailure import com.filestech.notes_tech.security.panic.PanicJournal import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -95,7 +94,8 @@ class StartupViewModel @Inject constructor( viewModelScope.launch { try { databaseProvider.get() - purgerLesNotesExpirees() + // D-043 (3.4.0): before anything shows the notes; a failure is not the opening's. + durees.purgerSansEchec() _state.value = StartupState.Ready } catch (e: KekFailure) { // Pas de `runCatching` ici, et pas de `catch (e: Exception)` : le premier avale @@ -106,20 +106,6 @@ class StartupViewModel @Inject constructor( } } - /** - * D-043 (3.4.0): the notes whose lifetime is over go at the opening, before anything shows them. A - * failure is not the opening's: the list purges again every minute. - */ - private suspend fun purgerLesNotesExpirees() { - try { - durees.purger() - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Timber.w(e, "purge des notes expirees differee") - } - } - private fun KekFailure.toReason(): FailureReason { // Le message est journalisé, jamais affiché : il décrit une cause technique et ne dit pas // à l'utilisateur quoi faire. L'écran, lui, part du geste attendu. diff --git a/app/src/main/res/raw-it/privacy.md b/app/src/main/res/raw-it/privacy.md index 898174c..e1ba85c 100644 --- a/app/src/main/res/raw-it/privacy.md +++ b/app/src/main/res/raw-it/privacy.md @@ -17,8 +17,8 @@ Notes Tech non raccoglie, non trasmette e non conserva alcun dato su server remo - **Collegamenti inversi `[[Titolo]]`**: indice invertito locale, mai trasmesso. - **Backup cifrato (facoltativo)**: un file `.ntbackup` che crea lei stesso, nel punto che sceglie tramite il selettore di documenti di Android. Contiene le sue cartelle e le sue note, cestino compreso, cifrate con **AES-256-GCM** con una chiave derivata tramite **Argon2id** (m=64MB, t=3) da un codice estratto dall'app e che lei annota, o da una passphrase di sua scelta — Notes Tech non conserva né l'uno né l'altra. Viene prima scritto, già cifrato, nella memoria privata dell'app, poi copiato nel punto scelto e cancellato dalla memoria dell'app. Le note di una cassaforte con passphrase restano sigillate dalla sua passphrase nel file. Una cassaforte con PIN viaggia solo aperta, con la sua chiave: nel file la protegge soltanto il segreto del backup; chiusa, viene esclusa. Il ripristino aggiunge ciò che manca e non sovrascrive nulla. - **Note importate e condivise**: i file Markdown o di testo — o un archivio ZIP che li contiene — che lei sceglie nel selettore di documenti di Android, e i testi o i file che condivide con Notes Tech da un'altra app, diventano note ordinarie nelle sue cartelle, mai in una cassaforte. Vengono letti una volta, sul telefono; gli originali non vengono modificati e nulla viene conservato oltre alle note create. Al massimo 500.000 caratteri per nota. -- **Note sigillate (facoltative)**: una nota che sceglie di condividere sigillata viene cifrata in un file `.ntnote` — **AES-256-GCM** con una chiave derivata tramite **Argon2id** (m=64MB, t=3) da un codice che l'app genera, mostra una sola volta e non conserva da nessuna parte. Il file viene scritto nella memoria privata dell'app, poi affidato all'app che sceglie per inviarlo; il codice lo trasmette in un altro modo. Il suo nome non dice nulla della nota. Una nota sigillata che riceve viene letta una volta dal file che apre con Notes Tech, e diventa una nota — in In arrivo, mai in una cassaforte — solo dopo aver digitato il suo codice. -- **Deposito rapido**: da un riquadro delle impostazioni rapide o da una scorciatoia dell'icona, scrive una nota in In arrivo senza sbloccare l'app; non legge né mostra nulla di ciò che l'app contiene. +- **Note sigillate (facoltative)**: una nota che sceglie di condividere sigillata viene cifrata in un file `.ntnote` — **AES-256-GCM** con una chiave derivata tramite **Argon2id** (m=64MB, t=3) da un codice che l'app genera, mostra una sola volta e non conserva da nessuna parte. Il file viene scritto nella memoria privata dell'app, poi affidato all'app che sceglie per inviarlo; il codice lo trasmette in un altro modo. Il suo nome non dice nulla della nota. Una nota sigillata che riceve viene letta una volta dal file che apre con Notes Tech, e diventa una nota — della cartella In arrivo, mai di una cassaforte — solo dopo aver digitato il suo codice. +- **Deposito rapido**: da un riquadro delle impostazioni rapide o da una scorciatoia dell'icona, scrive una nota nella cartella In arrivo senza sbloccare l'app; non legge né mostra nulla di ciò che l'app contiene. - **Note con durata (facoltative)**: cancellate definitivamente allo scadere del tempo — alla successiva apertura dell'app o al ritorno all'elenco —, o alla chiusura dopo la lettura se ha scelto «Dopo la lettura». Non entrano né nel backup cifrato né nell'esportazione. - **Tastiera**: l'app chiede alla tastiera di non imparare nulla di ciò che digita (impostazione attiva per impostazione predefinita). È una richiesta, che una tastiera può ignorare; ciò che una tastiera conserva da sé sfugge a qualsiasi app. - **Modello di dettatura vocale (Whisper `.bin`)**: se lo procura lei stesso — l'app mostra il nome del file e la sua origine — e poi lo importa tramite il selettore di documenti di Android. Notes Tech non ha l'autorizzazione di accesso a Internet e **non offre alcun modo di scaricare nulla**. Il suo SHA-256 viene verificato all'importazione e prima di ogni caricamento. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index d1ac5c8..aea9c21 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -359,16 +359,16 @@ Diesen Text verwerfen? Er wurde nicht abgelegt, und nichts bewahrt ihn auf, sobald dieser Bildschirm geschlossen ist. Verwerfen - Lebensdauer… + Lebensdauer Lebensdauer - Keine — die Notiz bleibt + Keine – die Notiz bleibt 1 Stunde 24 Stunden 7 Tage 30 Tage Nach dem Lesen Gelöscht, wenn Sie sie nach dem erneuten Öffnen schließen. - Endgültig gelöscht, sobald die Zeit abgelaufen ist, beim nächsten Öffnen der App oder bei der Rückkehr zur Liste – nicht auf die Sekunde genau. Nicht in Sicherungen und Exporten enthalten. + Endgültig gelöscht, sobald die Zeit abgelaufen ist, beim nächsten Öffnen der App oder bei der Rückkehr zur Liste – nicht auf die Sekunde genau. Nicht in Sicherungen und Exporten enthalten. Die Zeit folgt der Uhr des Telefons: Wer das Datum vorstellt, verkürzt sie. Lebensdauer: %1$s Diese Notiz wird gelöscht, wenn Sie sie schließen. %1$d Min. @@ -389,7 +389,7 @@ Wird in %1$d Tag gelöscht Wird in %1$d Tagen gelöscht - Versiegelt teilen… + Versiegelt teilen Versiegelte Notiz öffnen Versiegelte Notiz Die Notiz wird in eine Datei verschlüsselt, die nur ihr Code öffnet. Senden Sie die Datei über eine beliebige App und den Code auf anderem Weg – mündlich, per SMS: Wer beides hat, kann sie lesen. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index d411795..f0b16e8 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -378,7 +378,7 @@ ¿Descartar este texto? No se ha depositado, y nada lo conserva una vez cerrada esta pantalla. Descartar - Duración… + Duración Duración Ninguna — la nota se queda 1 hora @@ -387,7 +387,7 @@ 30 días Tras la lectura Se borra cuando la cierra tras haberla vuelto a abrir. - Se borra definitivamente al acabar el plazo, en la siguiente apertura de la app o al volver a la lista — no al segundo. Queda fuera de las copias de seguridad y de las exportaciones. + Se borra definitivamente al acabar el plazo, en la siguiente apertura de la app o al volver a la lista — no al segundo. Queda fuera de las copias de seguridad y de las exportaciones. El plazo sigue el reloj del teléfono: adelantar la fecha lo acorta. Duración: %1$s Esta nota se borra cuando la cierra. %1$d min @@ -411,7 +411,7 @@ Se borra en %1$d días Se borra en %1$d días - Compartir sellada… + Compartir sellada Abrir una nota sellada Nota sellada La nota se cifra en un archivo que solo su código abre. Envíe el archivo con cualquier app y el código por otra vía — de viva voz, por SMS: quien tenga ambos puede leerla. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 1d0d073..34f3b60 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -383,7 +383,7 @@ Abandonner ce texte ? Il n\'a pas été déposé, et rien ne le garde une fois cet écran fermé. Abandonner - Durée de vie… + Durée de vie Durée de vie Aucune — la note reste 1 heure @@ -392,7 +392,7 @@ 30 jours Après lecture Effacée quand vous la refermez après l\'avoir rouverte. - Effacée pour de bon une fois le délai passé, à la prochaine ouverture de l\'appli ou au retour sur la liste — pas à la seconde près. Elle n\'entre ni dans les sauvegardes ni dans les exports. + Effacée pour de bon une fois le délai passé, à la prochaine ouverture de l\'appli ou au retour sur la liste — pas à la seconde près. Elle n\'entre ni dans les sauvegardes ni dans les exports. Le délai suit l\'horloge du téléphone : avancer la date l\'écourte. Durée de vie : %1$s Cette note s\'efface quand vous la refermez. %1$d min @@ -416,7 +416,7 @@ S\'efface dans %1$d jours S\'efface dans %1$d jours - Partager scellée… + Partager scellée Ouvrir une note scellée Note scellée La note est chiffrée dans un fichier que seul son code ouvre. Envoyez le fichier par n\'importe quelle appli, et le code par un autre moyen — de vive voix, par SMS : qui a les deux peut la lire. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index d678470..38ff475 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -370,15 +370,15 @@ Dettare Dettare una nota Deposito rapido - Si scrive in In arrivo senza sbloccare Notes Tech. Qui non si può leggere nulla di ciò che l\'app contiene. + Viene scritta nella cartella In arrivo senza sbloccare Notes Tech. Qui non si può leggere nulla di ciò che l\'app contiene. Testo Deposita - Non è stato possibile depositare la nota. Il Suo testo è ancora qui. - Nota depositata in In arrivo + Non è stato possibile depositare la nota. Il testo è ancora qui. + Nota depositata nella cartella In arrivo Scartare questo testo? Non è stato depositato, e nulla lo conserva una volta chiusa questa schermata. Scarta - Durata… + Durata Durata Nessuna — la nota resta 1 ora @@ -387,7 +387,7 @@ 30 giorni Dopo la lettura Cancellata quando la chiude dopo averla riaperta. - Cancellata definitivamente allo scadere del tempo, alla successiva apertura dell\'app o al ritorno all\'elenco — non al secondo. Esclusa da backup ed esportazioni. + Cancellata definitivamente allo scadere del tempo, alla successiva apertura dell\'app o al ritorno all\'elenco — non al secondo. Esclusa da backup ed esportazioni. Il tempo segue l\'orologio del telefono: spostare avanti la data lo accorcia. Durata: %1$s Questa nota si cancella quando la chiude. %1$d min @@ -411,7 +411,7 @@ Si cancella tra %1$d giorni Si cancella tra %1$d giorni - Condividi sigillata… + Condividi sigillata Apri una nota sigillata Nota sigillata La nota viene cifrata in un file che solo il suo codice apre. Invii il file con qualsiasi app, e il codice in un altro modo — a voce, per SMS: chi ha entrambi può leggerla. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4e218ab..5b4cae8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -434,7 +434,7 @@ Discard this text? It has not been dropped, and nothing keeps it once this screen is closed. Discard - Lifetime… + Lifetime Lifetime None — the note stays 1 hour @@ -443,7 +443,7 @@ 30 days After reading Erased when you close it after opening it again. - Erased for good once the time is up, at the next opening of the app or return to the list — not to the second. Left out of backups and exports. + Erased for good once the time is up, at the next opening of the app or return to the list — not to the second. Left out of backups and exports. The time follows the phone\'s clock: moving the date forward ends it. Lifetime: %1$s This note erases itself when you close it. %1$d min @@ -464,7 +464,7 @@ Erases itself in %1$d day Erases itself in %1$d days - Share sealed… + Share sealed Open a sealed note Sealed note The note is encrypted into a file that only its code opens. Send the file by any app, and the code another way — aloud, by text message: whoever has both can read it. diff --git a/app/src/test/java/com/filestech/notes_tech/data/scellee/LectureScelleeTest.kt b/app/src/test/java/com/filestech/notes_tech/data/scellee/LectureScelleeTest.kt index 4cbae03..6ee16ee 100644 --- a/app/src/test/java/com/filestech/notes_tech/data/scellee/LectureScelleeTest.kt +++ b/app/src/test/java/com/filestech/notes_tech/data/scellee/LectureScelleeTest.kt @@ -45,6 +45,18 @@ class LectureScelleeTest { assertThat(lireBorne(ByteArrayInputStream("NTSE".toByteArray()))).isEqualTo(LectureScellee.PasUneNoteScellee) } + /** A stream that hands nothing back without ever ending — as a hostile provider could. */ + @Test + fun `a stream that gives nothing without ending is refused, not looped on`() { + val vide = object : InputStream() { + override fun read(): Int = 0 + + override fun read(b: ByteArray, off: Int, len: Int): Int = 0 + } + + assertThat(lireBorne(vide)).isEqualTo(LectureScellee.Illisible) + } + /** Counts what is really read. */ private class FluxCompte(octets: ByteArray) : InputStream() { private val entree = ByteArrayInputStream(octets) diff --git a/docs/15-VERSION-3.4.md b/docs/15-VERSION-3.4.md index 7f0a822..332cef7 100644 --- a/docs/15-VERSION-3.4.md +++ b/docs/15-VERSION-3.4.md @@ -129,25 +129,37 @@ ## État -> **🧭 REPRISE (2026-11-09, ~22 h, avant compaction)** — branche `feat/3.4.0`, dernier commit poussé `9699451` -> (textes 3.4.0). **NON COMMITTÉ dans l'arbre** (à vérifier par `git status`, puis porte + commit) : -> 1. corrections i18n (it « nella cartella In arrivo », « Il testo », de demi-cadratin, journal de 5071-5073) ; -> 2. relecture GPT (0,024 $) : note reçue créée en UN insert (`NotesRepository.create(color, readOnce)`), -> `LectureScellee` réécrite (une lecture à la fois, fil dédié, délai qui FERME le flux, lecture de 0 octet -> refusée, `RuntimeException` d'un fournisseur rattrapée), activité scellée simplifiée, test du flux vide ; -> 3. audit de cohérence (82/100) : C1 `DisposableEffect` → `oublier()` du code ; C4 `onKeyboardNoLearning` -> sans défaut (+ ReglagesTest, AppLockSettingsScreenTest) ; C5 `SelecteurDeNoteScelleeViewModel` (HomeRoute) ; -> C6 `DureesDeVie.purgerSansEchec`, forme de `RaccourciActivity` ; C7 KDoc replacés (setArchived, FolderChip, -> manifeste), références DAO ; C9 « … » retirés de note_lifetime / note_share_sealed ; C10 purge sous -> `repeatOnLifecycle(STARTED)` ; `ClavierSansApprentissageTest` en règle Compose **v2** (échec du -> `ComposeTestRulesAreV2Test`). -> **Reste** : compiler + ktlintFormat + porte locale ; test instrumenté de `NotesScelleesViewModel` (faux code -> puis bon, lecture unique et couleur posées, pas de doublon) ; résultat de l'audit 3 axes (agent lancé) et de -> la suite instrumentée complète du S9 (tâche de fond `begohs0pu` : porte exit=1 = le test v2, release OK) ; -> CI de la branche ; puis PR, fusion, et PROPOSER la release (pas demandée) — avant le tag, voir comment -> F-Droid a construit SMS 1.30.0 (AGP 9.5). Limite connue : Mes fichiers (Samsung) n'ouvre pas `.ntnote` -> (type vide, il conclut seul) ; le menu « Ouvrir une note scellée » marche partout. Le partage reçu (3.2.0) -> a la même lecture sans délai fermant le flux : à durcir plus tard, pas touché. +> **Audits et relectures (2026-11-09)** — tous appliqués avant la PR, sauf ce que « Limites connues » dit. +> - **Relecture externe GPT** (0,024 $) : 3 défauts corrigés — note reçue créée en UN insert (couleur et +> « après lecture » compris), lecture d'un fichier scellé bornée (une à la fois, fil dédié, flux FERMÉ +> passé le délai, lecture de 0 octet refusée, toute `RuntimeException` d'un fournisseur rattrapée). +> - **Cohérence** (82/100) : C1-C7, C9, C10 appliqués (code oublié à toute sortie de la feuille, rappel du +> réglage clavier sans défaut, sélecteur dans son propre ViewModel, `purgerSansEchec`, purge sous +> `repeatOnLifecycle(STARTED)`, KDoc replacés, « … » retirés) ; C8 (idiomes d'événement) laissé. +> - **Traductions** : aucun grave ; 3 moyens corrigés (it, de, journaux de-DE). +> - **3 axes** : 0 critique, 0 élevé, 7 moyens. M3 déjà corrigé par la relecture externe ; **M4** un second +> fichier scellé attend la fin du premier ; **M5** `FLAG_SECURE` dès la première image sur les dialogues +> d'un code (les deux scellés ET les trois de la sauvegarde, même défaut : `ProprietesDeDialogueSecret`) ; +> **M7** étiquettes et titre d'un fichier fabriqué normalisés comme à l'import ; **M1/M2** l'horloge du +> téléphone et le vrai moment de l'effacement « après lecture » dits dans la feuille et SECURITY. +> - **Tests** : `NotesScelleesRecuesTest` (3 : faux puis bon code → UNE note avec couleur, étiquettes, +> lecture unique ; pas de doublon ; le second fichier attend), un fichier fabriqué dans +> `FichierDeNoteScelleeTest`, `ReglagesTest` avec la ligne du clavier (2 échecs de la suite complète : +> le nouvel interrupteur, « Jamais » descendu sous l'écran). 106 cas des classes touchées verts sur le S9. +> +> **Limites connues (non corrigées, consignées)** : +> - **M6** : la tuile (non « active ») fait lier le service, donc démarrer le processus, à chaque ouverture +> du volet où elle est visible ; le démarrage purge `cache/exports`. Un `.ntnote` (ou un export) partagé +> vers une appli qui le lirait plus tard, Notes Tech tuée entre-temps, peut disparaître. Rare, sans perte +> (l'envoi échoue, on repartage). Non mesuré : One UI 2 (S9) refuse `cmd statusbar add-tile`. Pistes : +> `ACTIVE_TILE` (état affiché à vérifier selon les versions) ou purge au premier écran plutôt qu'au +> démarrage du processus (touche la purge du clair : à peser). +> - Mes fichiers (Samsung) n'ouvre pas `.ntnote` (type vide, il conclut seul) ; le menu « Ouvrir une note +> scellée » marche partout. +> - Le partage reçu (3.2.0) lit encore sans délai qui ferme le flux : à durcir plus tard. +> - Faibles de l'audit laissés : `DepotActivity` garde un brouillon en mémoire après Home (voulu : copier +> un numéro ailleurs puis revenir) ; collage de plus de 500 000 caractères ignoré sans message ; l'écran +> d'export ne dit pas que les notes à durée de vie en sont exclues ; pas d'index partiel pour la purge. - [x] **D-040 clavier** (`5e18eab`) — tests instrumentés sur le S9 : `ClavierSansApprentissageTest` (3), `ChampDuCorpsTest` +1, `ReglagesHeritesTest` +1, `SaisieDeCoffreTest` inchangé (8) ; la ligne du réglage @@ -192,6 +204,7 @@ l'originale : message « Durée de vie : 1 heure », badge « 1 h » sur la carte. **Non essayé sur appareil** : « Ouvrir avec » depuis une messagerie (l'activité exportée) — à faire avec une vraie messagerie, ou au moins un gestionnaire de fichiers. -- [ ] Textes 5 langues · [ ] version 3.4.0 (507) · [ ] journaux 5071-5073 · [ ] README, SECURITY, CGU/confidentialité -- [ ] Audits (3 axes, sécurité des nouvelles entrées, i18n, cohérence) · [ ] relecture externe · [ ] S9 · [ ] CI -- [ ] Avant le tag : build F-Droid sous AGP 9.5 (voir d'abord comment F-Droid a construit SMS 1.30.0) +- [x] Textes 5 langues · [x] version 3.4.0 (507) · [x] journaux 5071-5073 · [x] README, SECURITY, confidentialité +- [x] Audits (3 axes, cohérence, i18n) · [x] relecture externe · [x] S9 (suite complète puis classes touchées) · [ ] CI +- [ ] Avant le tag : build F-Droid sous AGP 9.5 — au 2026-11-09 au soir, F-Droid n'a construit ni SMS 1.30.0 + (pas de journal `_307`) ni Notes 3.3.0 : aucune preuve encore ; essai de mise à jour depuis la 3.3.0 sur le S9 diff --git a/fastlane/metadata/android/de-DE/changelogs/5071.txt b/fastlane/metadata/android/de-DE/changelogs/5071.txt index 66726fd..75adc17 100644 --- a/fastlane/metadata/android/de-DE/changelogs/5071.txt +++ b/fastlane/metadata/android/de-DE/changelogs/5071.txt @@ -1 +1 @@ -Neu: Notizen, die sich selbst löschen — nach 1 Std., 24 Std., 7 oder 30 Tagen oder nach dem Lesen (⋮ › Lebensdauer). Versiegelt teilen: eine verschlüsselte Datei, die nur ihr Code öffnet. Schnellablage: eine Notiz schreiben, ohne die App zu entsperren — per Kachel der Schnelleinstellungen oder langem Druck auf das Symbol (auch Neue Notiz, Diktieren). Die Tastatur wird gebeten, nichts zu lernen. +Neu: Notizen, die sich selbst löschen — nach 1 Std., 24 Std., 7 oder 30 Tagen oder nach dem Lesen (⋮ › Lebensdauer). Versiegelt teilen: eine verschlüsselte Datei, die nur ihr Code öffnet, über jede App gesendet (⋮ › Versiegelt teilen). Schnellablage: eine Notiz schreiben, ohne die App zu entsperren — per Kachel der Schnelleinstellungen oder langem Druck auf das Symbol (auch Neue Notiz, Diktieren). Die Tastatur wird gebeten, nichts zu lernen. diff --git a/fastlane/metadata/android/de-DE/changelogs/5072.txt b/fastlane/metadata/android/de-DE/changelogs/5072.txt index 66726fd..75adc17 100644 --- a/fastlane/metadata/android/de-DE/changelogs/5072.txt +++ b/fastlane/metadata/android/de-DE/changelogs/5072.txt @@ -1 +1 @@ -Neu: Notizen, die sich selbst löschen — nach 1 Std., 24 Std., 7 oder 30 Tagen oder nach dem Lesen (⋮ › Lebensdauer). Versiegelt teilen: eine verschlüsselte Datei, die nur ihr Code öffnet. Schnellablage: eine Notiz schreiben, ohne die App zu entsperren — per Kachel der Schnelleinstellungen oder langem Druck auf das Symbol (auch Neue Notiz, Diktieren). Die Tastatur wird gebeten, nichts zu lernen. +Neu: Notizen, die sich selbst löschen — nach 1 Std., 24 Std., 7 oder 30 Tagen oder nach dem Lesen (⋮ › Lebensdauer). Versiegelt teilen: eine verschlüsselte Datei, die nur ihr Code öffnet, über jede App gesendet (⋮ › Versiegelt teilen). Schnellablage: eine Notiz schreiben, ohne die App zu entsperren — per Kachel der Schnelleinstellungen oder langem Druck auf das Symbol (auch Neue Notiz, Diktieren). Die Tastatur wird gebeten, nichts zu lernen. diff --git a/fastlane/metadata/android/de-DE/changelogs/5073.txt b/fastlane/metadata/android/de-DE/changelogs/5073.txt index 66726fd..75adc17 100644 --- a/fastlane/metadata/android/de-DE/changelogs/5073.txt +++ b/fastlane/metadata/android/de-DE/changelogs/5073.txt @@ -1 +1 @@ -Neu: Notizen, die sich selbst löschen — nach 1 Std., 24 Std., 7 oder 30 Tagen oder nach dem Lesen (⋮ › Lebensdauer). Versiegelt teilen: eine verschlüsselte Datei, die nur ihr Code öffnet. Schnellablage: eine Notiz schreiben, ohne die App zu entsperren — per Kachel der Schnelleinstellungen oder langem Druck auf das Symbol (auch Neue Notiz, Diktieren). Die Tastatur wird gebeten, nichts zu lernen. +Neu: Notizen, die sich selbst löschen — nach 1 Std., 24 Std., 7 oder 30 Tagen oder nach dem Lesen (⋮ › Lebensdauer). Versiegelt teilen: eine verschlüsselte Datei, die nur ihr Code öffnet, über jede App gesendet (⋮ › Versiegelt teilen). Schnellablage: eine Notiz schreiben, ohne die App zu entsperren — per Kachel der Schnelleinstellungen oder langem Druck auf das Symbol (auch Neue Notiz, Diktieren). Die Tastatur wird gebeten, nichts zu lernen.