Fork channel

Create a new channel as a copy of main.

Rename channel

Rename main to:

Delete channel

Delete main? This cannot be undone.

mode name
drwxr-xr-x app/
drwxr-xr-x archive/
drwxr-xr-x gradle/
drwxr-xr-x native/
-rw-r--r-- .gitignore
-rw-r--r-- .ignore
-rw-r--r-- LICENCES.md
-rw-r--r-- LICENSE
-rw-r--r-- README.md
-rw-r--r-- android.nix
-rw-r--r-- build.gradle.kts
-rw-r--r-- default.nix
-rw-r--r-- fhs.sh
-rw-r--r-- gradlew
-rw-r--r-- gradlew.bat
-rw-r--r-- tap.sh
README

Rhuba — mes recettes et ma liste de courses

Application Android (Kotlin / Jetpack Compose / Room) pour enregistrer ses recettes de cuisine et en tirer une liste de courses triée par rayon.

Fonctionnalités

  • Nouvelle recette depuis une photo d’un livre de cuisine : le texte est reconnu sur l’appareil (ML Kit), photo du plat optionnelle. La page étant découpée en sections, la recette est analysée sans modèle, à l’instant où elle est créée : titre, nombre de personnes, temps, présentation, préparation et ingrédients structurés. La recette s’ouvre déjà remplie ; l’analyse par le modèle n’est plus qu’un complément, à la demande. Éprouvé sur quatre livres de mises en page très différentes, en français et en anglais, y compris un livre pour enfants aux titres en lettres cursives et des doubles pages photographiées en travers.
  • Nouvelle recette depuis un site (Marmiton et tout site publiant des données schema.org/Recipe) : titre, nombre de personnes, ingrédients, étapes et photo. Le partage d’un lien vers Rhuba (« Partager → Rhuba ») ouvre directement l’import.
  • Compléter par un modèle de langage sur l’appareil (MediaPipe LLM Inference), sans compte ni clé, sans envoi de données. Sur une page de livre bien reconnue l’analyse déterministe ne lui laisse rien à faire ; il reste utile là où elle cale : page sans encadré de temps, recette en prose, ligne abîmée par la reconnaissance (« 1 c. à s. » rendu « lcàs »). Le modèle extrait le titre, le nombre de personnes, les temps de préparation et de cuisson, et les ingrédients structurés (quantité, unité, rayon) ; ce que le texte donnait déjà lui sert de repli, il ne peut donc rien effacer. Pour la description et la préparation il ne réécrit rien : il indique les premiers et derniers mots de chaque section, et le texte d’origine est recopié tel quel entre ces bornes (AnalysisFormat.sliceVerbatim, comparaison sans accents ni casse, repli heuristique si la borne est introuvable). Tout reste éditable. La progression est affichée au fil de la génération (chargement du modèle, lecture, ingrédients trouvés sur attendus, préparation) avec le temps écoulé ; la génération s’arrête dès que l’objet JSON est complet, et une réponse tronquée est réparée plutôt que rejetée. Au premier usage, le modèle par défaut (Qwen 2.5 1.5B, 1,6 Go, empreinte SHA-256 vérifiée) est téléchargé automatiquement. Les réglages permettent d’en choisir un autre (Gemma 3 1B en 555 Mo, Gemma officiel avec jeton Hugging Face, adresse libre ou fichier .task) et d’activer le GPU (à réserver aux modèles légers : il double l’empreinte mémoire). Un parseur heuristique (data/parse/) redécoupe la sortie du modèle et sert aussi à l’import web.
  • Traduction automatique en français d’une recette écrite dans une autre langue (livre anglais, site italien…), sur l’appareil (ML Kit Translation) : la langue est reconnue, puis le titre, la présentation, les étapes et les noms d’ingrédients sont traduits ; quantités, unités, temps et rayons ne changent pas. On traduit le résultat de l’analyse, pas la page : le parseur sait lire l’anglais, pas une traduction automatique. Les lignes coupées par la colonne sont recollées en phrases avant traduction, et un petit glossaire (RecipeTranslation) évite les contresens culinaires du traducteur (« ground cumin » → « cumin au sol », « bay leaves » → « feuilles de baie »). Le texte d’origine reste dans « Texte de la recette ». Le modèle de traduction (une trentaine de Mo par langue) est téléchargé au premier usage ; sans réseau, la recette reste dans sa langue. Désactivable dans les réglages.
  • Recherche par aliment ou par titre.
  • Saison : fruits et légumes de saison par mois, et les recettes qui les utilisent.
  • Liste de courses composée depuis une ou plusieurs recettes avec choix du nombre de personnes (quantités ajustées et fusionnées), triée par rayon (fruits & légumes, viande, poisson, crèmerie, épicerie salée/sucrée, boulangerie, surgelés, boissons, autre). Ajout manuel d’articles, cases à cocher, retrait des articles cochés, vidage de la liste.
  • Partage entre utilisateurs d’une recette ou de la liste de courses, par le menu « Partager » d’Android (WhatsApp, Messenger, courriel, Dropbox, Drive…), sans serveur ni compte. On envoie un fichier .rhuba (un zip : rhuba.json et les photos), accompagné du même contenu en texte, lisible sans l’appli. Le destinataire touche le fichier et choisit Rhuba, ou passe par « Nouvelle recette → Choisir un fichier ». Chaque recette porte un identifiant (uid) qui voyage avec elle : une recette qu’on a déjà est reconnue, soit identique (rien à faire), soit dans une autre version (différences résumées, dates de modification, « Remplacer ma version » ou « Garder les deux »). Une liste de courses reçue s’ajoute à la sienne (quantités fusionnées) ou la remplace, et la même liste ouverte deux fois est reconnue. Le fichier porte un numéro de format : une version plus ancienne de l’appli refuse un fichier trop récent au lieu de le lire de travers.
  • Synchronisation entre deux téléphones (icône en haut de la liste des recettes) : l’un crée un lien valable 10 minutes (rhuba://sync/7-mot-mot) et l’envoie par une messagerie ; l’autre l’ouvre, ou colle le message entier si la messagerie ne rend pas le lien cliquable. Chacun envoie tout son carnet, photos comprises, et fusionne celui de l’autre : une recette inconnue est ajoutée, une recette connue (même uid) prend la version la plus récente. Rien n’est supprimé : une recette effacée d’un seul côté revient de l’autre. La liste de courses n’est pas synchronisée. Transport par Magic Wormhole (crate Rust magic-wormhole, dans native/) : le code court sert de mot de passe (PAKE), les serveurs publics de rendez-vous et de relais ne voient que des données chiffrées, et un code ne sert qu’une fois. Connexion directe sur un même réseau, par le relais sinon. La crate est sous la même licence EUPL-1.2 que Rhuba.

Licence

Rhuba est un logiciel libre, sous licence publique de l’Union européenne EUPL-1.2 (LICENSE ; version française officielle sur https://interoperable-europe.ec.europa.eu/collection/eupl/eupl-text-eupl-12). Les licences des composants de l’APK sont listées dans l’appli (Réglages › Licences) et commentées dans LICENCES.md.

Environnement de développement (NixOS)

Le shell reprend l’approche de coturnix/acc/app-android : un buildFHSEnv avec JDK 17, Gradle, le SDK Android du store Nix et un patch patchelf d’aapt2.

nix-shell                     # shell interactif (écrit local.properties)
./gradlew assembleDebug       # APK debug → app/build/outputs/apk/debug/
./gradlew testDebugUnitTest   # tests unitaires (parseur, rayons, saisons)
./gradlew installDebug        # sur un émulateur/appareil connecté

Sans shell interactif : ./fhs.sh ./gradlew assembleDebug.

La bibliothèque de synchronisation (native/, Rust) est compilée par cargo ndk depuis Gradle (tâches cargoNdk et uniffiBindings, refaites seulement si native/ change) pour l’émulateur (x86_64) et les téléphones (arm64), avec ses bindings Kotlin générés par UniFFI (uniffi.rhuba_sync, appelés via JNA). Pour l’essayer sans second téléphone, un client en ligne de commande échange un fichier avec l’appli :

cd native
cargo run --example sync -- join 7-mot-mot envoi.zip recu.zip   # code affiché par l'appli
cargo run --example sync -- open envoi.zip recu.zip             # puis ouvrir rhuba://sync/<code>

envoi.zip est un carnet au format de LibraryFormat (rhuba.json avec "kind": "library", photos sous photos/).

Émulateur (SDK autonome dans ~/Android/Sdk, patché par le shell). L’AVD Rhuba_API_36 (Android 36.1, Google Play, partition de données de 12 Go) a été créé pour ce projet dans ~/.android/avd/ :

emulator -avd Rhuba_API_36 &
adb logcat -s "Rhuba:D"

Build hermétique

android.nix produit un APK et un AAB release hors ligne dans le sandbox Nix (cache Gradle en dérivation à sortie fixe, puis dépôt Maven local, la bibliothèque de synchronisation étant construite à part, crates vendorisées depuis native/Cargo.lock).

# (après un changement de dépendances : relancer avec --argstr depsHash "" pour
#  obtenir le nouveau hash, puis le reporter dans android.nix)
nix-build android.nix
ls result/   # rhuba.apk rhuba.aab

Signature : passer --arg keystoreFile /chemin/vers/upload.jks --argstr keystorePassword … --argstr keyAlias … --argstr keyPassword ….

Architecture

app/src/main/kotlin/fr/rhuba/app/
├── RhubaApp.kt              conteneur de dépendances (DB, dépôts, réglages)
├── MainActivity.kt          Compose + réception des liens partagés
├── data/
│   ├── db/                  Room : recettes, ingrédients, articles de courses
│   ├── model/               types de domaine (ProductCategory, ParsedIngredient…)
│   ├── parse/               parseur d'ingrédients, unités, rayons, normalisation
│   ├── importer/            OCR (ML Kit) et import web (JSON-LD via Jsoup)
│   ├── ai/                  LocalRecipeAnalyzer (MediaPipe), LocalModelStore (téléchargement),
│   │                        AnalysisFormat (consigne, schéma, lecture de la réponse)
│   ├── season/              calendrier des fruits et légumes de saison
│   ├── settings/            clé API et modèle (EncryptedSharedPreferences)
│   ├── share/               fichiers .rhuba, carnet entier (LibraryFormat), comparaison
│   ├── licenses/            écran Licences : listes générées (crates, bibliothèques Android)
│   ├── sync/                synchronisation : RecipeSync, SyncService (premier plan), SyncCode
│   └── repo/                RecipeRepository, ShoppingRepository, ImageStore
native/                       bibliothèque Rust de synchronisation (Magic Wormhole, UniFFI)
├── archive/                 code retiré (analyse Claude / Mistral), non compilé
├── viewmodel/               un ViewModel par écran
└── ui/                      thème, navigation (3 onglets) et écrans Compose

Le classement par rayon et la saisonnalité reposent sur des listes de mots-clés en français (Categorizer.kt, SeasonalProduce.kt) : les enrichir est le moyen le plus simple d’améliorer les résultats hors IA. Un mot-clé doit désigner le produit et non sa découpe : le nom lu sur une page de livre traîne la sienne (« cavolo nero, les tiges retirées, et les feuilles grossièrement hachées »), et un « haché » isolé y rangeait le chou au rayon boucherie.

Réseau

Le client HTTP commun (data/net/RhubaHttp.kt) essaie les adresses IPv4 avant les IPv6 avec un délai de connexion de 10 s : sur un réseau domestique qui annonce une IPv6 non routée, un client naïf reste bloqué plusieurs minutes par adresse avant de retomber en IPv4. Il sert au téléchargement des modèles et à l’import web. La synchronisation fait de même pour les serveurs de Magic Wormhole, qui ont des adresses IPv6 : le serveur de rendez-vous est joint par la première adresse qui répond, IPv4 d’abord, et le relais est annoncé adresse par adresse pour que toutes soient essayées en même temps.

Base de données

Room, version 2. La migration 1 → 2 ajoute description, prepTimeMinutes et cookTimeMinutes à la table recipes (RhubaDatabase.MIGRATION_1_2). Les schémas exportés sont dans app/schemas/.

Reconnaissance de texte

La page est découpée en sections avant tout traitement : sur une page de livre à deux colonnes, la reconnaissance entrelace sinon la préparation et les ingrédients, et le modèle comme le parseur mélangent les deux. Le découpage se fait sur la géométrie rendue par ML Kit, pas sur les pixels : ML Kit donne déjà la position de chaque ligne, recadrer la photo pour la relire n’apprendrait rien de plus.

  1. Redressement (OcrLines, data/importer/OcrLayout.kt) — on travaille à la ligne et non au bloc (deux colonnes qui se touchent finissent parfois dans un même bloc), et chaque ligne est ramenée à une boîte de l’épaisseur du texte, centrée sur elle. Une photo de livre est toujours de travers, et la page est courbée : sur les photos d’essai l’inclinaison passe de -6° en haut de page à -1° en bas, aucune rotation d’ensemble ne la redresse. Or la boîte englobante d’une ligne penchée de 6° sur 2 500 pixels est étirée de 260 pixels en hauteur : elle chevauche ses voisines, et les bandes blanches qui séparent paragraphes et colonnes disparaissent. Seul le quart de tour fait exception : un livre ouvert en grand se photographie volontiers en travers, et ML Kit en lit alors toutes les lignes à la verticale (prises jusqu’ici pour des onglets de marge, et jetées). Le sens dominant du texte est reconnu (OcrLines.rotationOf), l’image remise d’aplomb et relue (OcrService.quads) : la seconde lecture est nettement meilleure que la géométrie tournée — les lettres cursives et les lignes voisines s’y mêlent moins — et ne coûte que sur ces photos-là.

  2. Découpage (OcrLayout) — « XY-cut » récursif : on coupe à la plus large bande blanche traversante, verticale d’abord (la gouttière entre colonnes), horizontale à défaut (l’espace entre paragraphes), et on recommence. Les zones sortent dans l’ordre de lecture. La gouttière se mesure à la zone en cours de découpe (1,5 % de sa largeur, 0,6 hauteur de ligne au moins) : un livre pour enfants imprimé gros serre ses trois colonnes à 0,7 ligne, et une double page n’écarte pas ses colonnes du double de sa largeur. Dans une zone, les morceaux d’une même ligne imprimée sont recollés bout à bout : sur une page courbée, ML Kit rend « mérite d’être tentée », « de faire une expérience » et « de chimie », chacun plus bas que le précédent, et le dernier finissait rangé sous la ligne suivante.

  3. Étiquetage (OcrSections) — chaque zone reçoit une section. Aucun indice n’est fiable seul, mais ensemble ils le sont : un titre est imprimé nettement plus gros que le texte ; une liste d’ingrédients aligne des lignes courtes commençant par une quantité ; des étapes contiennent des impératifs (« Mélangez », « Mets la levure », « Heat the oil ») ou sont numérotées ; l’encadré des temps tient en quelques mots qu’ouvrent « POUR », « PRÉPARATION », « CUISSON » — pas un « cuisson » au milieu d’une phrase. Le verdict se prend par zone entière, ce qui résiste aux lignes ambiguës, et des rattrapages traitent les cas où la géométrie a coupé trop fin : l’encadré des temps, réduit en miettes par sa grille de pictogrammes, est reconstitué de proche en proche (sans avaler les ingrédients voisins) ; une ligne isolée entre deux zones de même nature en fait partie ; les lignes muettes d’une liste (« Sel, poivre », « HOUMOUS », « To serve ») la rejoignent quand elles s’alignent à gauche sur elle.

    Le reste tient à la page entière. Le titre est le plus gros texte, où qu’il soit — sauf s’il surmonte, sans rien entre les deux qu’une illustration, un titre à peine plus petit : c’est un titre de chapitre (« Le riz », puis « Risotto »). Les rubriques imprimées en petit en haut de page (« Plat », « INFUSION ») sont écartées. Une page qui numérote ses étapes commence sa préparation à « 1. » : ce qui précède est la présentation, même quand elle conseille (« réduisez le temps de cuisson »). Les encadrés « Astuce », « Le conseil du chef », « Idée gourmande » ont leur section (## ASTUCE), rendue à la suite des étapes. Et une page qui porte deux recettes (une recette de base, puis celle qui s’en sert) est partagée entre ses deux titres de même taille : on garde la plus fournie, l’autre reste dans le texte reconnu.

La reconnaissance lit la photo d’origine, pas la copie réduite qu’on conserve pour l’affichage (1600 pixels). Mesuré sur les trois pages d’essai : à 1600 pixels « 10 cl » se lit « 10l », « ½ cuil. à café d’oignon » n’est plus analysable et le nombre de couverts de la fiche disparaît ; à 3200 pixels tout cela revient, pour 2 044 ms par page contre 1 993 ms — la résolution ne coûte presque rien, ML Kit étant dominé par son modèle et non par la taille de l’entrée. La borne à 3200 pixels reste nécessaire : une photo de 100 mégapixels décompressée ne tiendrait pas en mémoire (ici 30 Mo au plus).

Le texte rendu porte ses sections en clair (## INGRÉDIENTS, ## PRÉPARATION…, cf. data/parse/RecipeSections.kt) : il reste une simple chaîne, affichée et corrigeable par l’utilisateur, mais le parseur et le modèle savent où chercher. Le modèle est prévenu du découpage, la recopie verbatim de la description et de la préparation est bornée à leur section, la liste d’ingrédients est lue dans la sienne, et les temps comme le nombre de personnes sont lus dans l’encadré (« POUR » suivi du seul nombre de couverts). Un texte sans marqueur — saisi à la main, ou une recette d’avant — est traité comme auparavant.

La liste d’ingrédients est ensuite extraite par le parseur déterministe (IngredientParser), qui reconnaît aussi bien un en-tête « Ingrédients » que les sections « Pour la pâte : » des livres, et recolle les ingrédients coupés sur plusieurs lignes. Une colonne étroite en coupe certains en trois — « 20 g de coriandre fraîche, / les feuilles retirées, et les tiges / finement hachées » : la longueur qui distingue un ingrédient d’une phrase se mesure donc sur la ligne imprimée, jamais sur le groupe recollé, qu’une étape dépasse dès sa première ligne. Sur une liste structurée il bat nettement un modèle de 1,5 milliard de paramètres : sa liste remplace celle du modèle dès qu’elle est plus complète. Les temps sont lus sous les étiquettes « PRÉPARATION » et « CUISSON » des fiches (« CUISSON : 10 MINUTES + 20 À 30 MINUTES » font 30, la borne basse d’une fourchette comme pour les quantités). Le modèle garde le titre, le nombre de personnes et les bornes de la description.

Le parseur connaît aussi les faiblesses de la reconnaissance et des mises en page. Une ligne sans quantité est la suite de l’ingrédient du dessus — sauf quand elle commence par un produit, en minuscules, après un ingrédient complet (« origan », « sucre en poudre » d’un livre pour enfants ; la « courge de 1 kg » dont le « 1 » s’est perdu) ; une parenthèse ouverte, un mot de liaison en fin de ligne (« au moulin à », « cut into ») ou un « + » en tête prolongent l’ingrédient. Le « 1 » lu « l » ou « I », la fraction lue « % », le « g » lu « q », l’« œ » lu « ce » sont réparés en tête de ligne. Un livre en anglais donne « 200 g/7 oz (2 cups…) gram flour » : la mesure métrique est gardée, l’équivalence impériale écartée, et les unités anglaises (« tablespoon », « cloves », « sticks »…) comme les rayons ont leur vocabulaire. Enfin le titre imprimé en capitales est ramené en casse normale, et son esperluette ornée, lue « 8 », redevient un « & ».

Tests

Vingt et une vraies pages de livre servent de non-régression, avec la géométrie que ML Kit leur donne (app/src/test/resources/ocr/*.txt). Les quatre premières, rejouées par OcrSectionsTest : deux colonnes avec les ingrédients à droite, les mêmes avec un titre pleine largeur, une page courbée où les ingrédients sont à gauche, et une page sans encadré de temps dont la colonne d’ingrédients est si étroite que chacun tient sur trois lignes. Les dix-sept suivantes, rejouées par RecipePagesTest, viennent de quatre livres : fiche en toutes lettres et encadré « ASTUCE » ; livre pour enfants aux titres cursifs, trois colonnes serrées et doubles pages en travers ; livre en anglais ; pages à longue présentation, étapes numérotées, et deux recettes sur une page. Toute l’analyse se rejoue ainsi en JVM, en quelques secondes, sans émulateur.

Pour comprendre pourquoi une page est mal lue, OcrDiagnostic écrit dans app/build/diag/ le résultat de chaque gabarit, son texte découpé et ses zones avec leur boîte :

RHUBA_DIAG=1 ./fhs.sh ./gradlew testDebugUnitTest --tests '*OcrDiagnostic'

Ces gabarits sont produits sur appareil par OcrFixtureDump (app/src/androidTest/), qui rejoue la reconnaissance sur les photos déposées dans app/src/androidTest/assets/pages/ — ML Kit ne tourne que sur un appareil, la géométrie qu’il rend se rejoue partout. La photo y est décodée par ImageStore.decodeForOcr, comme dans l’application : orientée et bornée à 3200 pixels. C’est cette image-là que la reconnaissance voit en vrai — un gabarit pris autrement testerait autre chose que le produit. Ces photos ne sont pas suivies (elles pèsent cent fois le reste du dépôt) : il faut les y remettre, sous le même nom, pour régénérer les gabarits. La marche à suivre est en tête du fichier OcrFixtureDump.kt. RecipeFromPhotoTest, sur appareil lui aussi, rejoue la chaîne entière — photo, reconnaissance, découpage, enregistrement — et vérifie qu’une recette photographiée arrive analysée sans qu’aucun modèle n’ait été chargé, qu’elle soit photographiée droite ou en travers. Un dernier test rejoue le texte à plat d’une page, d’avant le découpage : app/src/test/resources/ocr-steak-chou-fleur.txt.

Diagnostic

L’analyse écrit une ligne de bilan dans le journal système :

adb logcat -s "Rhuba:D"
# Rhuba: analyse 49 s (CPU, 1149 car.) → 6 ingr., 6 pers., prépa -, cuisson -, description oui

Détails d’interface

  • Ajouter un ingrédient place le curseur dans le champ créé.
  • Les photos d’une recette (plat et page du livre) s’ouvrent en plein écran et défilent horizontalement (ui/components/PhotoViewer.kt).
  • La liste des recettes affiche le nombre de personnes et le temps de préparation ; la fiche les reprend en pastilles avec le temps de cuisson.