Overview: Why Offline-First Edge AI Matters in Africa
Welcome to the official companion codelab for "On-Device AI with Flutter & Gemma: Building Offline-First AI Mobile Apps for Africa" at droidCon Uganda 2026 in Kampala!
Bienvenue dans le codelab officiel de la session "On-Device AI with Flutter & Gemma: Building Offline-First AI Mobile Apps for Africa" présentée au droidCon Uganda 2026 à Kampala !
Proven Production Architecture
Every code snippet in this codelab comes directly from Gemmea (Gemma + Mmea, Kiswahili for "plant/crop"), winner of 1st Place at GDG on Campus UCB Build with Gemma 2026.
Chaque extrait de code de ce codelab provient directement de Gemmea (Gemma + Mmea, qui signifie "plante/culture" en kiswahili), lauréat du 1er Prix au hackathon GDG on Campus UCB Build with Gemma 2026.
1. Context & The "Why"
The Connectivity Reality: How often do your users lose signal when traveling outside Kampala to Gulu, Mbarara, Entebbe, or across the border into rural Kivu?
The Unit Economics Trap: Can an African startup sustainably pay Cloud LLM API bills in USD while users pay in UGX or CDF; or cannot afford daily mobile data bundles at all?
The Hardware Boundary: What happens when you load an unoptimized LLM onto a $120 Android phone with 3GB to 6GB RAM? (Spoiler: Android's Low Memory Killer terminates your process immediately).
La réalité de la connectivité : À quelle fréquence vos utilisateurs perdent-ils le réseau lorsqu'ils quittent Kampala pour Gulu, Mbarara, Entebbe ou les zones rurales du Kivu ?
Le piège économique des API Cloud : Une startup africaine peut-elle durablement payer des factures d'API LLM Cloud en USD alors que ses utilisateurs paient en UGX ou CDF, ou n'ont pas les moyens d'acheter des forfaits data quotidiens ?
La limite matérielle : Que se passe-t-il lorsque vous chargez un LLM non optimisé sur un smartphone Android à 120 $ doté de 3 Go à 6 Go de RAM ? (Spoiler : le Low Memory Killer d'Android tue votre processus instantanément).
Cloud AI vs. On-Device Edge AI in East Africa
Metric
Cloud LLM APIs (REST / WebSocket)
On-Device (flutter_gemma + LiteRT-LM)
Offline Availability
0% (Fails completely in rural zones)
100% (Runs in Airplane Mode)
Time-to-First-Token (TTFT)
2,000ms to 8,000ms on unstable 3G/Edge
180ms to 600ms on local CPU/GPU
Image Scan Data Cost
2MB to 5MB upload per leaf photo
0 MB network data consumed
Recurring API Token Cost
Scales linearly with active users ($$$)
$0.00 / token forever
Privacy & Data Sovereignty
User photos & voice leave the country
100% private on the device
Métrique
API LLM Cloud (REST / WebSocket)
IA Embarquée (flutter_gemma + LiteRT-LM)
Disponibilité Hors-Ligne
0 % (Échoue totalement en zone rurale)
100 % (Fonctionne en Mode Avion)
Time-to-First-Token (TTFT)
2 000 ms à 8 000 ms sur 3G/Edge instable
180 ms à 600 ms sur CPU/GPU local
Coût Data par Scan Photo
2 Mo à 5 Mo d'upload par photo de feuille
0 Mo de données mobiles consommées
Coût Récurrent par Token API
Augmente linéairement avec les utilisateurs ($$$)
0,00 $ / token à vie
Confidentialité & Souveraineté
Photos et voix envoyées sur des serveurs distants
100 % privé sur l'appareil
2. Learning Goals & What You Will Build
Configure flutter_gemma and flutter_gemma_litertlm to run multimodal Gemma models locally on Android and iOS.
Implement atomic .tmp chunked model downloads via path_provider so interrupted 3G downloads never corrupt native binaries or bloat your APK.
Prevent native C++ FFI concurrency deadlocks and Flutter Hot-Reload freezes with a thread-safe GemmaEngineManager mutex.
Eliminate Out-Of-Memory (OOM) kills during multimodal camera scans by downsampling images to 512px and building prompt-level Early-Exit Guardrails.
Ground small quantized models without a heavy vector database using Zero-RAM SQLite FTS5 + BM25 Local RAG.
Bonus Finale: Distill and quantize a domain-specific Gemma Small Language Model (SLM) to under 150 MB (134 MB INT4) using PyTorch and ai-edge-torch.
Configurer flutter_gemma et flutter_gemma_litertlm pour exécuter des modèles Gemma multimodaux localement sur Android et iOS.
Implémenter le téléchargement atomique par blocs via fichier .tmp avec path_provider afin que les coupures 3G ne corrompent jamais les binaires natifs et n'alourdissent pas votre APK.
Prévenir les deadlocks de concurrence C++ FFI natifs et les blocages lors du Hot-Reload Flutter grâce au mutex thread-safe GemmaEngineManager.
Éliminer les crashs Out-Of-Memory (OOM) lors des scans caméra multimodaux en réduisant les images à 512px et en intégrant des garde-fous d'arrêt anticipé (Early-Exit) dans le prompt.
Ancrer factuellement les petits modèles quantifiés sans base vectorielle lourde grâce au RAG local SQLite FTS5 + BM25 à Zéro-RAM.
Bonus Final : Distiller et quantifier un modèle Gemma spécialisé (SLM) sous la barre des 150 Mo (134 Mo en INT4) avec PyTorch et ai-edge-torch.
3. Prerequisites & System Requirements
Before starting the hands-on steps, verify that your laptop and test device meet the following requirements so native C++ MediaPipe / LiteRT-LM bindings compile and execute without errors:
Avant de commencer les étapes pratiques, vérifiez que votre ordinateur et votre téléphone de test respectent la configuration suivante afin que les bindings C++ natifs MediaPipe / LiteRT-LM compilent et s'exécutent sans erreur :
Requirement
Minimum / Recommended Specification
Why It Is Required
Flutter & Dart SDK
Flutter 3.24.0+ (Dart 3.5.0+)
Required by flutter_gemma, flutter_gemma_litertlm, and modern FFI bindings.
Java Development Kit
JDK 17 (bundled with Android Studio Ladybug+)
Required by Android Gradle Plugin (AGP 8+) to build the APK.
Android SDK & NDK
compileSdk 34+ and minSdk 26 (Android 8.0+)
LiteRT-LM native .so libraries require Android API 26+ (default Flutter is 21/24).
Target Android Device
Physical 64-bit ARM (arm64-v8a) phone with 3GB to 6GB+ RAM & 2.0 GB free storage
Standard x86_64 PC emulators lack native ARM64 GenAI acceleration; always test on a real phone via USB Debugging (or ARM64 simulator on Apple Silicon).
Python / Colab (Optional)
Google Colab (Free T4 GPU) or Python 3.10+
Only needed if you run the bonus Step 8 model distillation scripts.
Composant
Spécification Minimale / Recommandée
Pourquoi c'est requis
SDK Flutter & Dart
Flutter 3.24.0+ (Dart 3.5.0+)
Requis par flutter_gemma, flutter_gemma_litertlm et les bindings FFI modernes.
Java Development Kit
JDK 17 (inclus avec Android Studio Ladybug+)
Requis par Android Gradle Plugin (AGP 8+) pour compiler l'APK.
Android SDK & NDK
compileSdk 34+ et minSdk 26 (Android 8.0+)
Les librairies natives .so de LiteRT-LM exigent Android API 26+ (le défaut Flutter est 21/24).
Appareil Android Cible
Smartphone physique ARM 64-bit (arm64-v8a) avec 3 Go à 6 Go+ de RAM et 2,0 Go d'espace libre
Les émulateurs PC x86_64 n'ont pas l'accélération ARM64 GenAI native ; testez sur un vrai téléphone via débogage USB (ou simulateur ARM64 sur Apple Silicon).
Python / Colab (Optionnel)
Google Colab (GPU T4 gratuit) ou Python 3.10+
Uniquement nécessaire si vous exécutez les scripts bonus de distillation à l'Étape 8.
# 1. Verify Flutter (>= 3.24.0), Dart (>= 3.5.0), Android toolchain & JDK 17
flutter doctor -v
# 2. Verify your physical Android phone is connected with USB Debugging enabled
flutter devices
Step 2 of 9 | System Design
Offline-First Architecture & flutter_gemma Setup
Smallholder farmers across Uganda, DRC, Rwanda, and Burundi lose 40% to 80% of their yields to threats like Cassava Mosaic Disease (CMD), Banana Bacterial Wilt (BXW / Kayovu), and Maize Rust. To give farmers accurate, scientifically grounded remedies in the middle of a field with zero connectivity, Gemmea combines three local subsystems:
Les petits exploitants agricoles en Ouganda, en RDC, au Rwanda et au Burundi perdent entre 40 % et 80 % de leurs récoltes à cause de fléaux tels que la Mosaïque du Manioc (CMD), le Flétrissement Bactérien du Bananier (BXW / Kayovu) et la Rouille du Maïs. Pour offrir aux agriculteurs des remèdes précis et scientifiquement fondés au milieu d'un champ sans aucune connexion, Gemmea combine trois sous-systèmes locaux :
1. Create the Flutter Project & Minimal File Structure
If you are coding along from scratch, create a new Flutter project and set up only the 6 self-contained Dart files below (skipping custom fonts, PDF assets, and extra screens so your app compiles and runs on any laptop in minutes):
Si vous codez depuis zéro durant l'atelier, créez un nouveau projet Flutter et préparez uniquement les 6 fichiers Dart autonomes ci-dessous (sans polices personnalisées, assets PDF ni écrans secondaires afin que votre application compile et tourne en quelques minutes) :
Replace your pubspec.yaml with the complete configuration below (compatible with Dart >=3.5.0 <4.0.0 and including uses-material-design: true so Material icons render properly), then run flutter pub get:
Remplacez votre fichier pubspec.yaml par la configuration complète ci-dessous (compatible avec Dart >=3.5.0 <4.0.0 et incluant uses-material-design: true pour l'affichage des icônes Material), puis exécutez flutter pub get :
3. Set Android minSdk = 26 & Hardware Acceleration
Crucial Step:flutter_gemma and flutter_gemma_litertlm link native 64-bit C++ MediaPipe / LiteRT-LM binaries that require Android API 26+ (Android 8.0). Open android/app/build.gradle.kts (or android/app/build.gradle) and update minSdk inside defaultConfig from flutter.minSdkVersion to 26:
Étape cruciale :flutter_gemma et flutter_gemma_litertlm lient des binaires C++ 64-bit natifs MediaPipe / LiteRT-LM qui exigent Android API 26+ (Android 8.0). Ouvrez android/app/build.gradle.kts (ou android/app/build.gradle) et remplacez minSdk dans defaultConfig par 26 :
Next, replace android/app/src/main/AndroidManifest.xml with this complete manifest so INTERNET and CAMERA permissions and GPU/CPU hardware acceleration are enabled while preserving the default @mipmap/ic_launcher icon and MAIN launcher intent:
Ensuite, remplacez android/app/src/main/AndroidManifest.xml par ce manifeste complet afin d'activer les permissions INTERNET et CAMERA ainsi que l'accélération matérielle GPU/CPU tout en conservant l'icône par défaut @mipmap/ic_launcher et l'intent MAIN :
Model Quantization (INT4 vs. INT8) & Atomic Asset Delivery
On a mid-tier 4GB RAM Android phone, Android OS and system services consume ~1.6GB of RAM, and the Flutter UI + Dart VM take ~300MB. That leaves a strict ceiling of ~1.8GB to 2.0GB before Android's Low Memory Killer (lmkd) terminates your app.
Sur un smartphone Android milieu de gamme doté de 4 Go de RAM, le système Android et ses services consomment ~1,6 Go de RAM, tandis que l'interface Flutter et la VM Dart occupent ~300 Mo. Cela laisse un plafond strict de ~1,8 Go à 2,0 Go avant que le Low Memory Killer d'Android (lmkd) ne tue votre application.
~4.0 GB2B Model in FP16 (Crashes on 4GB RAM)
~2.1 GB2B Model in INT8 (Zero headroom for Vision)
~1.2 GBE2B in INT4 .litertlm (75% smaller!)
134 MBDistilled 240M SLM in INT4 (See Step 8)
Production Pitfall: Never Put Multi-Hundred-MB Models in Flutter assets/
Bundling a 1.3GB model inside pubspec.yaml assets violates Google Play's 150MB base APK/AAB limit and forces Flutter to copy/decompress the asset on first boot: causing instant OutOfMemoryError crashes on budget phones.
Embarquer un modèle de 1,3 Go dans les assets de pubspec.yaml dépasse la limite de 150 Mo d'un APK/AAB Google Play et oblige Flutter à copier/décompresser l'asset au premier démarrage : ce qui provoque un crash OutOfMemoryError immédiat sur les téléphones d'entrée de gamme.
The Solution: Chunked Streaming + Atomic .tmp Swap
In lib/data/repositories/chat_repository_impl.dart, we stream the quantized .litertlm binary directly to getApplicationDocumentsDirectory() using an IOSink (so Dart's RAM heap stays flat at <5MB). Crucially, we write to gemma.litertlm.tmp first and only rename it to gemma.litertlm when 100% of bytes are on disk. If a user's connection drops at 89%, the native C++ engine will never attempt to memory-map a corrupted binary! (You will paste the complete chat_repository_impl.dart file in Step 7).
Dans lib/data/repositories/chat_repository_impl.dart, nous streamons le binaire quantifié .litertlm directement vers getApplicationDocumentsDirectory() à l'aide d'un IOSink (afin que le tas mémoire Dart reste sous 5 Mo). Point crucial : nous écrivons d'abord dans gemma.litertlm.tmp et ne le renommons en gemma.litertlm qu'une fois 100 % des octets écrits sur le disque. Si la connexion coupe à 89 %, le moteur C++ natif ne tentera jamais de projeter en mémoire (mmap) un fichier corrompu ! (Vous collerez le fichier complet chat_repository_impl.dart à l'Étape 7).
What Failed in Production #1: Concurrent Native Sessions & Orphaned Isolates
MediaPipe / LiteRT-LM allocates a single native C++ execution graph in memory. During field testing, if a user started a leaf scan and switched to the Chat tab before the scan finished; or if a developer triggered a Flutter Hot Reload while a stream was open: the native FFI channel deadlocked permanently or threw PlatformException(channel-error).
MediaPipe / LiteRT-LM alloue un seul graphe d'exécution C++ natif en mémoire. Lors des tests sur le terrain, si un utilisateur lançait un scan de feuille puis basculait vers l'onglet Chat avant la fin du scan, ou si un développeur déclenchait un Hot Reload Flutter pendant qu'un flux était ouvert : le canal FFI natif se bloquait définitivement (deadlock) ou levait une PlatformException(channel-error).
The Solution: GemmaEngineManager
To make on-device inference rock-solid, we built GemmaEngineManager in lib/core/services/gemma_engine_manager.dart. It serializes all Future and Stream operations through a Completer mutex lock, includes an 8-second deadlock timeout to recover from orphaned Hot-Reload isolates, and automatically resets stale chat session pointers when a native channel error occurs:
Pour rendre l'inférence embarquée infaillible, nous avons conçu GemmaEngineManager dans lib/core/services/gemma_engine_manager.dart. Il sérialise toutes les opérations Future et Stream via un verrou mutex Completer, intègre un timeout anti-deadlock de 8 secondes pour récupérer après un Hot-Reload, et réinitialise automatiquement les pointeurs de session obsolètes en cas d'erreur de canal natif :
lib/core/services/gemma_engine_manager.dartDART
import 'dart:async';
import 'dart:developer' as dev;
import 'package:flutter_gemma/flutter_gemma.dart';
import 'package:flutter_gemma_litertlm/flutter_gemma_litertlm.dart';
class GemmaEngineManager {
static bool _isInitialized = false;
static Completer<void>? _initCompleter;
static Future<void>? _activeExecution;
static Function()? _onResetChatCallback;
static bool get isInitialized => _isInitialized;
static bool get isBusy => _activeExecution != null;
static void setResetChatCallback(Function() callback) {
_onResetChatCallback = callback;
}
static void resetChatSession() {
_onResetChatCallback?.call();
}
/// Force clears stale lock state and resets chat session on Hot Reload or FFI error
static void forceResetLockState() {
dev.log("Force resetting GemmaEngineManager lock state & session handles.");
_activeExecution = null;
_initCompleter = null;
resetChatSession();
}
static Future<void> ensureInitialized() async {
if (_isInitialized) return;
if (_initCompleter != null) return _initCompleter!.future;
_initCompleter = Completer<void>();
try {
await FlutterGemma.initialize(
inferenceEngines: const [LiteRtLmEngine()],
);
_isInitialized = true;
_initCompleter!.complete();
} catch (e) {
// Native FFI engine already bound across Hot Reload
_isInitialized = true;
if (!(_initCompleter?.isCompleted ?? true)) _initCompleter!.complete();
} finally {
_initCompleter = null;
}
}
/// Wraps a Future operation to guarantee single-threaded execution on LiteRT-LM
static Future<T> runWithLock<T>(Future<T> Function() action) async {
final previousExecution = _activeExecution;
final completer = Completer<void>();
_activeExecution = completer.future;
if (previousExecution != null) {
try {
await previousExecution.timeout(const Duration(seconds: 8));
} on TimeoutException {
forceResetLockState();
} catch (_) {}
}
try {
return await action();
} catch (e) {
if (e.toString().contains("channel-error") ||
e.toString().contains("PlatformException") ||
e.toString().contains("Null")) {
forceResetLockState();
}
rethrow;
} finally {
completer.complete();
if (_activeExecution == completer.future) _activeExecution = null;
}
}
/// Wraps a Stream operation with the same FFI mutex & deadlock protection
static Stream<T> runStreamWithLock<T>(Stream<T> Function() streamFactory) async* {
final previousExecution = _activeExecution;
final completer = Completer<void>();
_activeExecution = completer.future;
if (previousExecution != null) {
try {
await previousExecution.timeout(const Duration(seconds: 8));
} on TimeoutException {
forceResetLockState();
} catch (_) {}
}
try {
await for (final value in streamFactory()) {
yield value;
}
} catch (e) {
if (e.toString().contains("channel-error") ||
e.toString().contains("PlatformException")) {
forceResetLockState();
}
rethrow;
} finally {
completer.complete();
if (_activeExecution == completer.future) _activeExecution = null;
}
}
}
What Failed in Production #2 & #3: 12MP Camera OOMs & CPU Thermal Throttling
1. Vision OOMs: Passing a raw 4000x3000 (12MP) camera image into Message.withImage() allocates a 48MB RGBA buffer plus huge vision encoder tensors: crashing 4GB RAM phones. 2. Wasted Compute on Non-Leaves: When a farmer accidentally photographed their shoe, face, or tool, Gemma generated 800+ tokens of hallucinated plant advice, heating up the CPU and draining battery.
1. Crashs OOM Vision : Passer une photo brute de 4000x3000 (12 MP) à Message.withImage() alloue un tampon RGBA de 48 Mo ainsi que d'énormes tenseurs d'encodage visuel, faisant planter les téléphones de 4 Go de RAM. 2. Calcul gaspillé hors-sujet : Lorsqu'un agriculteur photographiait par erreur sa chaussure, son visage ou un outil, Gemma générait plus de 800 tokens de conseils agricoles hallucinés, faisant chauffer le CPU et vidant la batterie.
1. Downsample Camera Images to 512px & Dispose Native Handles
Gemma's vision encoder downscales inputs internally anyway. By resizing the image to targetWidth: 512 in Dart's dart:ui codec and calling image.dispose() immediately before invoking LiteRT-LM, we cut vision memory consumption by 85% and speed up vision encoding by 4x.
L'encodeur visuel de Gemma réduit de toute façon la résolution des images en interne. En redimensionnant l'image à targetWidth: 512 via le codec dart:ui et en appelant immédiatement image.dispose() avant d'invoquer LiteRT-LM, nous réduisons la consommation mémoire de 85 % et accélérons l'encodage visuel d'un facteur 4x.
2. Two-Stage Prompt with Immediate Early-Exit Short-Circuit
Below is the complete implementation from lib/data/datasources/gemma_local_source.dart (with the prompt translated to English for this workshop). Notice how Step 1 of the prompt forces Gemma to output a single 18-token rejection sentence and halt immediately if the image is not a plant leaf:
Voici l'implémentation complète de lib/data/datasources/gemma_local_source.dart. Observez comment l'Étape 1 du prompt oblige Gemma à produire une seule phrase de rejet de 18 tokens et à s'arrêter immédiatement si l'image n'est pas une feuille de plante :
lib/data/datasources/gemma_local_source.dartDART
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter_gemma/flutter_gemma.dart';
import 'package:gemmea/core/services/gemma_engine_manager.dart';
import 'package:gemmea/core/utils/text_sanitizer.dart';
import 'package:gemmea/data/datasources/sqlite_helper.dart';
class GemmaLocalSourceImpl {
Future<String> evaluateCropImage(File imageFile, String prompt) async {
return GemmaEngineManager.runWithLock(() async {
GemmaEngineManager.resetChatSession();
try {
await GemmaEngineManager.ensureInitialized();
final rawBytes = await imageFile.readAsBytes();
// 1. Downsample to 512px max width to prevent native OOM crashes
final imageBytes = await _optimizeImageBytes(rawBytes);
// 2. Fetch grounded FAO/IITA context from local SQLite FTS5
final faoContext = await _fetchRagContextFromSqlite(imageFile.path, prompt);
// 3. Prefer CPU backend on budget devices whose GPUs lack OpenCL vision buffers
InferenceModel model;
try {
model = await FlutterGemma.getActiveModel(
supportImage: true,
maxTokens: 4096,
preferredBackend: PreferredBackend.cpu,
);
} catch (_) {
model = await FlutterGemma.getActiveModel(
supportImage: true,
maxTokens: 4096,
);
}
final session = await model.createSession();
// 4. Multimodal VLM Prompt with Step 1 Early-Exit Short-Circuit (in English)
final vlmPrompt = '''
You are Gemmea, the offline AI agronomist for crop health diagnosis in East Africa.
Examine the provided image carefully.
ABSOLUTE AND URGENT RULE (IMMEDIATE EVALUATION):
Step 1: Look at the image. If it is NOT a plant leaf or agricultural crop
(e.g., human face, phone, shoe, furniture, animal, non-plant object):
Immediately STOP all generation and reply ONLY with this exact sentence:
"The captured image does not appear to be a plant leaf. Please retake a photo clearly targeting a leaf from your crop."
DO NOT GENERATE ANY HEADINGS AND DO NOT USE THE RAG CONTEXT BELOW.
Step 2: IF AND ONLY IF THE IMAGE IS GENUINELY A PLANT LEAF OR CROP:
Provide a detailed, structured agronomic analysis organized into these 7 sections (NO EMOJIS):
### 1. PRIMARY DIAGNOSIS & HYPOTHESES
- Crop: [Exact crop observed: Maize, Cassava, Bean, Banana, Tomato, Coffee, or Unknown]
- Disease: [If healthy, write EXPLICITLY: "No disease (Healthy Leaf)". If diseased, state exact pathogen/disease name]
- Confidence Level: [e.g., 90%]
- Differential Diagnoses: [Environmental or secondary factors if applicable]
### 2. DETAILED OBSERVED SYMPTOMS
(Chlorosis, pustules, mosaic patterns, curling, necrotic streaks, or intact lamina).
### 3. CAUSES & VECTORS (IITA STANDARDS)
(Pathogen agent and transmission vector such as Bemisia tabaci whitefly or Xanthomonas).
### 4. STEP-BY-STEP ORGANIC REMEDIES & TREATMENT
(Local bio-remedies: Neem leaf extract 200g/L, wood ash, baking soda NaHCO3 5g/L with black soap, Tithonia liquid manure).
### 5. SOIL MANAGEMENT, NUTRIENTS & IRRIGATION
(Drainage, composting, mulching, moisture control).
### 6. IMMEDIATE 3-STEP ACTION PLAN
(Step 1, Step 2, Step 3 for the farmer today).
### 7. OFFICIAL FAO & IITA REFERENCES
(Explicit citations from the local RAG context below).
REFERENCE RAG CONTEXT (IGNORE COMPLETELY IF NOT A PLANT):
$faoContext
User instruction: $prompt
''';
await session.addQueryChunk(
Message.withImage(
text: vlmPrompt,
imageBytes: imageBytes,
isUser: true,
),
);
final String response = await session.getResponse();
return TextSanitizer.cleanLatexAndFormatting(response);
} finally {
GemmaEngineManager.resetChatSession();
}
});
}
Future<String> _fetchRagContextFromSqlite(String imagePath, String prompt) async {
try {
final docs = await SQLiteHelper.instance.searchFAOKnowledge("$imagePath $prompt");
if (docs.isEmpty) return "Standard East African FAO/IITA visual crop pathology guidelines.";
return docs.map((d) {
return "- ${d['topic']} (${d['crop_name']}): ${d['content']} "
"Organic Remedy: ${d['organic_remedy']} (Source: ${d['source_citation']})";
}).join("\n");
} catch (_) {
return "Standard East African FAO/IITA visual crop pathology guidelines.";
}
}
Future<Uint8List> _optimizeImageBytes(Uint8List inputBytes) async {
try {
final codec = await ui.instantiateImageCodec(
inputBytes,
targetWidth: 512,
);
final frameInfo = await codec.getNextFrame();
final image = frameInfo.image;
final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
image.dispose(); // Crucial: release native C++ Skia/Impeller texture immediately!
if (byteData != null) {
return byteData.buffer.asUint8List();
}
} catch (_) {}
return inputBytes;
}
}
Step 6 of 9 | Grounded Local Retrieval
Zero-RAM-Overhead Local RAG with SQLite FTS5 & BM25
Quantized 2B models can hallucinate exact biopesticide dosages or regional planting dates if left ungrounded. However, on a 4GB RAM phone where Gemma is already consuming ~1.3GB of RAM, loading a second neural embedding model + vector database into RAM will push your app over the OOM cliff.
Les modèles 2B quantifiés peuvent halluciner les dosages exacts de biopesticides ou les dates de semis régionales s'ils ne sont pas ancrés sur des données vérifiées. Cependant, sur un téléphone de 4 Go de RAM où Gemma consomme déjà ~1,3 Go, charger un second modèle d'embeddings neuronaux et une base vectorielle en RAM fera basculer l'application en crash OOM.
SQLite's built-in FTS5 (Full-Text Search 5) engine executes in native C directly on disk using the BM25 relevance algorithm. It requires 0 MB of neural embedding RAM, completes searches in <2 milliseconds, and includes a multi-term weighted scorer fallback for custom Android OEM ROMs.
Le moteur intégré FTS5 (Full-Text Search 5) de SQLite s'exécute en C natif directement sur le disque avec l'algorithme de pertinence BM25. Il nécessite 0 Mo de RAM d'embedding neuronal, répond en moins de 2 millisecondes et inclut un scoreur pondéré multi-termes de secours pour les ROM Android OEM personnalisées.
In conversational mode, we face two engineering challenges on budget smartphones:
En mode conversationnel, nous faisons face à deux défis d'ingénierie sur les smartphones d'entrée de gamme :
KV-Cache Memory Explosion: If you feed 25 historical messages into every prompt, the Key-Value attention cache overflows RAM and slows prefill latency to a crawl.
Raw LaTeX Artifacts: Gemma often outputs chemical formulas like $NaHCO_3$ or \text{H}_2\text{O}, which look broken to farmers in plain text or Text-to-Speech.
Explosion mémoire du KV-Cache : Si vous injectez 25 messages d'historique dans chaque prompt, le cache d'attention Clé-Valeur (KV-Cache) sature la RAM et ralentit considérablement la latence de pré-remplissage (prefill).
Artefacts LaTeX bruts : Gemma génère souvent des formules chimiques telles que $NaHCO_3$ ou \text{H}_2\text{O}, illisibles pour un agriculteur en texte brut ou en synthèse vocale (TTS).
Copy this complete, self-contained repository into lib/data/repositories/chat_repository_impl.dart. It combines Step 3's atomic .tmp model downloader, SQLite FTS5 RAG injection, the 5-turn KV-Cache sliding window, and autonomous offline Function Calling for the irrigation calendar:
Copiez ce repository complet et autonome dans lib/data/repositories/chat_repository_impl.dart. Il réunit le téléchargeur atomique .tmp de l'Étape 3, l'injection RAG SQLite FTS5, la fenêtre glissante KV-Cache de 5 messages et le Function Calling autonome hors-ligne pour le calendrier d'irrigation :
Create lib/core/utils/text_sanitizer.dart so chemical formulas like $NaHCO_3$ and stray emojis are converted into clean plain text while streaming:
Créez lib/core/utils/text_sanitizer.dart afin que les formules chimiques telles que $NaHCO_3$ soient converties en texte brut lisible à la volée pendant le streaming :
lib/core/utils/text_sanitizer.dartDART
class TextSanitizer {
static String cleanLatexAndFormatting(String input, {bool preserveWhitespace = false}) {
if (input.isEmpty) return input;
String text = input;
// 1. Convert common LaTeX chemical formulas to farmer-friendly plain text
text = text.replaceAll(
RegExp(r'\$?\\?text\{NaHCO\}_?\{?3\}?\\?\$?|\$NaHCO_3\\?\$?', caseSensitive: false),
'Baking soda (NaHCO3)',
);
text = text.replaceAll(
RegExp(r'\$?\\?text\{H\}_?\{?2\}?\\?text\{O\}\\?\$?|\$H_2O\\?\$?', caseSensitive: false),
'water (H2O)',
);
// 2. Strip LaTeX wrappers and subscript underscores
text = text.replaceAllMapped(
RegExp(r'\\(?:text|mathrm|mathbf)\{([^}]+)\}'),
(m) => m.group(1) ?? '',
);
text = text.replaceAllMapped(
RegExp(r'([A-Za-z]+)_\{?(\d+)\}?'),
(m) => '${m.group(1)}${m.group(2)}',
);
text = text.replaceAll(r'$', '');
return preserveWhitespace ? text : text.trim();
}
}
Step 8 of 9 | Model Compression & Distillation
The Finale: Distilling Gemma into a Custom SLM Under 150 MB
While an INT4 2B model (~1.2GB) runs well on 4GB to 8GB phones, two barriers remain for rural mass adoption across Africa:
Bien qu'un modèle 2B en INT4 (~1,2 Go) tourne très bien sur les téléphones de 4 Go à 8 Go de RAM, deux obstacles subsistent pour une adoption massive en zone rurale en Afrique :
The 1.2GB Initial Download: Even a one-time 1.2GB download is expensive on prepaid mobile data bundles.
2GB to 3GB Android Go Devices: Entry-level phones without NPUs take 5 to 10 seconds for CPU inference on a 2B model.
Le téléchargement initial de 1,2 Go : Même ponctuel, un téléchargement de 1,2 Go coûte cher sur des forfaits mobiles prépayés.
Les appareils Android Go de 2 Go à 3 Go de RAM : Les téléphones d'entrée de gamme sans NPU mettent 5 à 10 secondes par inférence CPU sur un modèle 2B.
Why 150 MB is the Magic Threshold on Android
Google Play's base Android App Bundle (AAB) / install-time asset limit is 150 MB. When you distill your domain-specific Small Language Model (SLM) down to ~134 MB, you can ship the model directly inside the Play Store install or share it offline via Nearby Share / Xender with zero post-install download!
La limite de taille d'un Android App Bundle (AAB) de base sur Google Play est de 150 Mo. Lorsque vous distillez votre Small Language Model (SLM) métier jusqu'à ~134 Mo, vous pouvez embarquer le modèle directement dans l'installation Play Store ou le partager hors-ligne via Nearby Share / Xender sans aucun téléchargement supplémentaire après l'installation !
How the Math Works: Getting from 1.35 GB to 134 MB
Teacher → Student Knowledge Distillation: A crop-diagnosis assistant does not need to know how to write C++ shaders or summarize European tax law. We use gemma-2-2b-it (or gemma-2-9b-it) as the Teacher to transfer its domain reasoning on East African agronomy (FAO/IITA + Swahili/Luganda/English/French) into a compact 240M to 270M parameter Student SLM.
Vocabulary Pruning (256k → 32k Tokens): Gemma's 256,000-token embedding matrix alone occupies ~260 MB even in INT4! Pruning unused scripts and keeping the top 32,000 domain + East African tokens shrinks the embedding table by 87.5% (down to ~16 MB).
Distillation de connaissances Teacher → Student : Un assistant de diagnostic agricole n'a pas besoin de savoir écrire des shaders C++ ou résumer le droit fiscal européen. Nous utilisons gemma-2-2b-it (ou gemma-2-9b-it) comme Teacher (Professeur) pour transférer son raisonnement agronomique est-africain (FAO/IITA + swahili/luganda/anglais/français) vers un SLM Student compact de 240M à 270M de paramètres.
Élagage du vocabulaire (256k → 32k tokens) : La matrice d'embeddings de 256 000 tokens de Gemma occupe à elle seule ~260 Mo même en INT4 ! Élaguer les alphabets inutilisés pour conserver les 32 000 tokens métier et est-africains réduit la table d'embeddings de 87,5 % (à ~16 Mo).
Quantification INT4 par blocs de 32 (dynamic_int4_block32) :240M paramètres * 0,5 octet/poids (120 Mo) + 14 Mo de tokenizer SentencePiece & métadonnées Flatbuffer = 134 Mo (< 150 Mo !).
Prerequisites for Step 8 (Google Colab / Kaggle GPU)
If you want to run the distillation pipeline yourself, open a free Google Colab T4 GPU notebook and install the Google AI Edge and HuggingFace libraries first:
Si vous souhaitez exécuter le pipeline de distillation vous-même, ouvrez un notebook Google Colab GPU T4 gratuit et installez d'abord les librairies Google AI Edge et HuggingFace :
Part A: PyTorch Logit Distillation Script (Teacher → Student)
Run this Python script on a Colab / Kaggle GPU to distill your Teacher Gemma model into the compact Student checkpoint using combined Cross-Entropy + Soft-Target KL-Divergence Loss:
Exécutez ce script Python sur un GPU Colab / Kaggle pour distiller votre modèle Gemma Teacher vers le checkpoint Student compact en combinant la perte Cross-Entropy + divergence KL sur cibles douces (Soft-Targets) :
Part B: Quantize & Bundle into a 134 MB.litertlm File
Next, convert the distilled PyTorch checkpoint to LiteRT-LM format with dynamic_int4_block32 quantization using Google's ai-edge-torch and MediaPipe GenAI bundler:
Ensuite, convertissez le checkpoint PyTorch distillé au format LiteRT-LM avec la quantification dynamic_int4_block32 via ai-edge-torch et le bundler MediaPipe GenAI de Google :
Part C: Benchmark | Base E2B vs. Distilled 134 MB SLM on 3GB to 4GB Android
Metric
Base gemma-4-E2B-it (INT4)
Distilled gemmea_agro_134mb.litertlm
Binary Size on Disk
~1,350 MB
134.2 MB (Fits Play Store 150MB limit!)
Peak Runtime RAM
~1,450 MB
~215 MB (Runs on 2GB/3GB Android Go)
Time-to-First-Token (CPU)
~1,400 ms
~180 ms
Token Generation Speed
~11 tokens/sec
~46 tokens/sec
FAO/IITA RAG Accuracy
96.0%
94.5% (Domain-Specialized)
Métrique
Modèle de base gemma-4-E2B-it (INT4)
SLM distillé gemmea_agro_134mb.litertlm
Taille du binaire sur disque
~1 350 Mo
134,2 Mo (Respecte la limite Play Store de 150 Mo !)
RAM maximale à l'exécution
~1 450 Mo
~215 Mo (Tourne sur Android Go 2 Go / 3 Go)
Time-to-First-Token (CPU)
~1 400 ms
~180 ms
Vitesse de génération
~11 tokens/s
~46 tokens/s
Précision RAG FAO/IITA
96,0 %
94,5 % (Spécialisé sur le domaine)
Step 9 of 9 | Minimal Runnable App & Wrap-Up
Run the Minimal App, Verification & Key Engineering Takeaways
1. Complete File: lib/main.dart
Once you have saved the 5 files from Steps 4 to 7, replace lib/main.dart with this complete, self-contained UI (Atomic Model Downloader + 512px Camera/Gallery Leaf Scanner + Streaming SQLite FTS5 RAG Chat + Autonomous Calendar Function Calling):
Une fois les 5 fichiers des Étapes 4 à 7 enregistrés, remplacez lib/main.dart par cette interface complète et autonome (Téléchargeur atomique du modèle + Scanner de feuille Caméra/Galerie en 512px + Chat RAG SQLite FTS5 en streaming + Function Calling autonome sur le calendrier) :
Run static analysis to verify that all 6 files compile with zero warnings, or clone the full production Gemmea repository (with BLoC state management, Swahili/French voice assistant, irrigation calendar, and PDF farm health reports) and run in release mode:
Exécutez l'analyse statique pour vérifier que les 6 fichiers compilent sans aucun avertissement, ou clonez le dépôt de production complet Gemmea (avec gestion d'état BLoC, assistant vocal swahili/français, calendrier d'irrigation et rapports PDF) et lancez-le en mode release :
Terminal | Verify & Run in Release ModeBASH
# Verify your 6-file codelab project compiles with zero issues:
flutter analyze
flutter run --release
# Or clone and run the full production Gemmea repository:
git clone https://github.com/glosings0n/gemmea.git
cd gemmea
flutter pub get
flutter test
flutter run --release
3. Key Engineering Takeaways
Stream Downloads to .tmp & Swap Atomically: Never buffer large models in Dart's heap or let interrupted mobile downloads leave corrupted files for the native C++ runtime.
Serialize Native FFI Access: Wrap every flutter_gemma call and stream in a mutex lock (GemmaEngineManager) with deadlock timeouts for Hot Reload resilience.
Downsample Vision Inputs to 512px & Short-Circuit Early: Resize camera photos before tensor encoding, dispose native image handles immediately, and instruct the VLM to halt in Step 1 if the image is out-of-domain.
Choose SQLite FTS5 + BM25 Over Heavy Vector DBs on Budget RAM: Get sub-2ms factual grounding with 0 MB of extra RAM overhead.
Distill & Quantize to <150 MB: Combine Teacher-Student logit distillation, vocabulary pruning, and dynamic_int4_block32 quantization so your AI ships directly inside your app bundle.
Streamer les téléchargements vers .tmp & renommer atomiquement : Ne chargez jamais un modèle entier dans le tas mémoire Dart et ne laissez jamais un téléchargement interrompu exposer un binaire corrompu au moteur C++ natif.
Sérialiser les accès FFI natifs : Enveloppez chaque appel et flux flutter_gemma dans un verrou mutex (GemmaEngineManager) avec timeout anti-deadlock pour résister aux Hot Reloads.
Réduire les images Vision à 512px & court-circuiter tôt : Redimensionnez les photos avant l'encodage tensoriel, libérez immédiatement les textures natives et ordonnez au VLM de s'arrêter dès l'Étape 1 si l'image est hors-sujet.
Privilégier SQLite FTS5 + BM25 aux bases vectorielles lourdes sur RAM limitée : Obtenez un ancrage factuel en moins de 2 ms avec 0 Mo de surcharge RAM supplémentaire.
Distiller & quantifier sous 150 Mo : Combinez la distillation de logits Teacher-Student, l'élagage du vocabulaire et la quantification dynamic_int4_block32 pour embarquer votre IA directement dans l'installation Play Store.