┌──────────────────────────────────────────────────┐
│ UI Layer │
│ Fragments · ViewModels · Adapters │
│ 5 Thèmes Material 3 · Animations · Navigation │
├──────────────────────────────────────────────────┤
│ Repository Layer │
│ ChatRepository — source de vérité unique │
├────────────────┬────────────────┬────────────────┤
│ Room DB │ Crypto │ Firebase │
│ (SQLCipher) │ CryptoMgr + │ Relay + │
│ │ Ratchet + │ FCM │
│ │ PQXDH(ML-KEM) │ │
└────────────────┴────────────────┴────────────────┘
Cloud Function (push triggers)
Principe
Détail
Single Activity
Navigation Component avec 15 fragments
Repository Pattern
ChatRepository coordonne Room, Crypto et Firebase
Aucun accès Firebase depuis l'UI
Tout passe par Repository → FirebaseRelay
Mutex par conversation
Les opérations ratchet sont sérialisées (thread-safe)
Envoi atomique
Le ratchet n'avance que si Firebase confirme l'envoi
Delete-after-delivery
Le ciphertext est supprimé de Firebase après déchiffrement
Delete-after-failure
Les messages dont le déchiffrement échoue sont nettoyés de Firebase
Double-listener guard
ConcurrentHashMap.putIfAbsent() + éviction LRU empêche le double-traitement
lastDeliveredAt
Borne inférieure par conversation pour éviter le re-traitement des messages Firebase
PQXDH différé
Upgrade post-quantique du root_key au premier message (pas de bootstrap)
Vérification indépendante
Chaque utilisateur vérifie l'empreinte de son côté (état local Room uniquement)
Système d'invitation
Demande → notification inbox → acceptation → conversation active
Couche
Rôle
Fichiers clés
UI
Écrans, navigation, interactions
ui/ — Fragments, ViewModels, Adapters (Material 3)
Repository
Coordination local/crypto/remote
data/repository/ChatRepository.kt
Crypto
X25519, ECDH, AES-GCM, Double Ratchet, BIP-39, Ed25519, PQXDH (ML-KEM-1024)
crypto/CryptoManager.kt, DoubleRatchet.kt, MnemonicManager.kt
Local DB
Room v17 — users, contacts, messages, ratchet (indexes composites)
data/local/ — DAOs, Database (SQLCipher)
Remote
Relay Firebase RTDB + Storage (ciphertext only)
data/remote/FirebaseRelay.kt
Util
QR, 5 thèmes, app lock, éphémère, dummy traffic, DeviceSecurityManager
util/ThemeManager.kt, AppLockManager.kt, DummyTrafficManager.kt, DeviceSecurityManager.kt
Push Notifications (opt-in)
Phone A → sendMessage() → Firebase RTDB
↓
Cloud Function (onCreate)
↓
/users/{uid}/fcm_token ?
↓ (si token existe)
FCM → Phone B notification
"Nouveau message reçu"
(ZÉRO contenu, ZÉRO metadata)
Flux des demandes de contact
Alice Firebase Bob
│ │ │
│ 1. Scan QR / colle clé pub │ │
│ 2. createConversation(pending) │ │
│ 3. sendContactRequest ──────────►│ inbox/{bob_hash}/{convId} │
│ │ │
│ │ listenForContactRequests() ◄─────│
│ │ │
│ │ 4. "Nouvelle demande de │
│ │ Alice" s'affiche │
│ │ │
│ │◄── 5. acceptContactRequest() ──────│
│ │ notifyRequestAccepted() │
│ │ │
│ listenForAcceptances() ◄────────│ accepted/{convId} │
│ 6. markConversationAccepted() │ │
│ │ │
│◄═══════════ Chat E2E actif ══════►│◄══════════════════════════════════►│
Création :
Onboarding → generateIdentityKeyPair() → registerPublicKey() → BackupPhrase (24 mots)
Backup (BIP-39, 24 mots) :
privateKey (32 bytes) → SHA-256 → 1er octet = checksum → 33 bytes → 24 × 11 bits → 24 mots
Restauration :
24 mots → mnemonicToPrivateKey() → restoreIdentityKey() → DH(privKey, basePoint u=9) → pubKey
→ removeOldUserByPublicKey() → registerPublicKey() → prêt
Suppression de compte (A supprime) :
A: deleteUserProfile(/users/{uid}) + deleteInbox(/inbox/{hash}) + deleteSigningKey(/signing_keys/{hash}) + deleteConversation(toutes)
B: envoie message → Permission Denied → isConversationAliveOnFirebase()=false → AlertDialog
B: re-invite A → dead convo détectée → deleteStaleConversation() → nouvelle invitation
Réception d'invitation (convo locale stale) :
Inbox listener → conversation locale existe? → isConversationAliveOnFirebase()
→ si morte: deleteStaleConversation() → affiche comme nouvelle demande
Dummy Traffic (anti analyse de trafic)
DummyTrafficManager.start(context):
→ isEnabled(context)? → Non: return
→ Oui: boucle CoroutineScope(Dispatchers.IO)
→ délai aléatoire 30–90 s
→ pour chaque conversation active (Room)
→ generateDummyMessage() : préfixe opaque + random bytes
→ chiffre avec Double Ratchet (même pipeline)
→ envoie sur Firebase RTDB (/messages/{convId})
→ le récepteur détecte le préfixe → drop silencieux (pas d'insertion Room)
Toggle: SecurityFragment → SharedPreferences("securechat_settings") → "dummy_traffic_enabled"
Envoi (ChatViewModel.sendFile):
fichier → generateFileKey() (AES-256-GCM, clé aléatoire)
→ encryptFile(fileKey, plainBytes) → cipherBytes
→ uploadToFirebaseStorage(/chat_files/{convId}/{uuid}) → downloadUrl
→ message texte = "FILE|" + downloadUrl + "|" + Base64(fileKey) + "|" + fileName
→ chiffre avec Double Ratchet → envoie sur Firebase RTDB
Réception (ChatRepository):
→ déchiffre message → détecte préfixe "FILE|"
→ parse: url, fileKey (Base64), fileName
→ downloadFromFirebaseStorage(url) → cipherBytes
→ decryptFile(fileKey, cipherBytes) → plainBytes
→ sauvegarde dans stockage interne app
→ affiche lien cliquable dans le chat
Photo One-Shot (vue unique)
Envoi :
fichier photo → chiffrement AES-256-GCM → upload Firebase Storage
→ message = "FILE|url|key|iv|fileName|fileSize|1" (flag one-shot = "1")
→ chiffre avec Double Ratchet → envoie sur Firebase RTDB
Réception :
→ déchiffre message → détecte flag one-shot (7ème champ = "1")
→ télécharge + déchiffre fichier → stockage local
→ affiche bulle avec icône 🔥 "Ouvrir (1 fois)"
Ouverture (2 phases) :
Phase 1 (immédiate) : flagOneShotOpened() → UPDATE oneShotOpened=1 dans Room
→ la bulle passe en état "Vérouillée" immédiatement
Phase 2 (5s delay) : coroutine delay(5000)
→ supprime fichier physique (File.delete())
→ markOneShotOpened() → UPDATE localFilePath=NULL
Anti-contournement :
• Le flag DB est posé AVANT l'ouverture de l'Intent viewer
• Même si l'utilisateur quitte la conversation, le flag est déjà en base
• Au retour, la bulle affiche "Éphémère déjà vue / Expirée"