Sharrit/docs/PERFORMANCE_ET_UX_OPTIMALE.md
Bruno Charest ec0931134c docs: Update comprehensive documentation with AI features, health checks, and architecture improvements
- Document automatic token verification on startup and note:// prefix for Markdown notes
- Add extensive AI capabilities section (Gemini integration, auto-tagging, content classification, multi-model fallback)
- Document link health monitoring system with dead link detection and exclusion features
- Add file sharing support (Markdown/text files) and deep links documentation
- Update technology
2026-02-10 21:15:30 -05:00

870 lines
30 KiB
Markdown

# ShaarIt — Analyse de Performance & Expérience Utilisateur Optimale
**Date de l'audit** : 9 février 2026
**Version analysée** : v1.0 (47 fichiers Kotlin, ~8 500 lignes de code)
**Objectif** : Identifier et corriger tous les points bloquants pour offrir une expérience instantanée, sans délai ni attente perceptible.
---
## Table des Matières
1. [Résumé Exécutif](#1-résumé-exécutif)
2. [Problèmes Critiques de Performance](#2-problèmes-critiques-de-performance)
3. [Optimisations du Démarrage (Cold Start)](#3-optimisations-du-démarrage-cold-start)
4. [Optimisations Réseau & Synchronisation](#4-optimisations-réseau--synchronisation)
5. [Optimisations Base de Données (Room)](#5-optimisations-base-de-données-room)
6. [Optimisations UI/Compose](#6-optimisations-uicompose)
7. [Optimisations Mémoire](#7-optimisations-mémoire)
8. [Optimisations du Build Release](#8-optimisations-du-build-release)
9. [Expérience Utilisateur — Réduction des Temps d'Attente](#9-expérience-utilisateur--réduction-des-temps-dattente)
10. [Plan d'Action Priorisé](#10-plan-daction-priorisé)
11. [Métriques de Suivi](#11-métriques-de-suivi)
---
## 1. Résumé Exécutif
L'application ShaarIt repose sur une architecture solide (Clean Architecture + MVVM, Hilt, Room, Compose). Cependant, l'analyse approfondie du code révèle **24 problèmes de performance** concrets, allant de critiques (R8 désactivé, requêtes N+1 en mémoire) à modérés (regex recompilées, caches non bornés). Corrigés, ces points transformeront l'expérience utilisateur en la rendant **instantanée** même avec des bibliothèques de plusieurs milliers de liens.
### Impact Attendu
| Métrique | Actuel (estimé) | Après optimisations |
|----------|-----------------|---------------------|
| **Taille APK** | ~15-20 MB | ~5-8 MB (-60%) |
| **Cold start** | ~1.5-2s | ~400-600ms (-70%) |
| **Affichage du flux** | ~500ms | <100ms (instantané) |
| **Sync complète (1000 liens)** | ~30s+ | ~3-5s (-85%) |
| **Recherche** | ~200-500ms (LIKE) | <50ms (FTS4) |
| **Mémoire au repos** | ~80-120 MB | ~40-60 MB (-50%) |
---
## 2. Problèmes Critiques de Performance
### 2.1 R8/ProGuard Désactivé en Release
**Fichier** : `app/build.gradle.kts` (lignes 47-48)
```kotlin
release {
isMinifyEnabled = false // ← CRITIQUE
isShrinkResources = false // ← CRITIQUE
}
```
**Impact** :
- APK 2-3x plus gros que nécessaire
- Aucune optimisation du bytecode (inlining, dead code elimination)
- Aucune suppression des ressources inutilisées
- Pas d'obfuscation (sécurité réduite)
**Solution** :
```kotlin
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
```
> **Note** : Les règles ProGuard actuelles (`proguard-rules.pro`) sont bien configurées et couvrent Retrofit, Moshi, Hilt, et les DTOs. L'activation est donc sûre. Cependant, la règle `-keep class com.shaarit.** { *; }` (ligne 108) est trop large et doit être affinée pour bénéficier pleinement de R8.
**Priorité** : 🔴 CRITIQUE
**Effort** : Faible (2 lignes + tests de non-régression)
**Gain** : -60% taille APK, +15-20% vitesse d'exécution
---
### 2.2 `SimpleDateFormat` recréé à chaque item
**Fichier** : `data/repository/LinkRepositoryImpl.kt` (ligne 544)
```kotlin
private fun LinkEntity.toDomainModel(): ShaarliLink {
return ShaarliLink(
// ...
date = java.text.SimpleDateFormat(
"yyyy-MM-dd'T'HH:mm:ss'Z'",
java.util.Locale.getDefault()
).format(java.util.Date(createdAt)),
// ...
)
}
```
**Impact** : `SimpleDateFormat` est coûteux à instancier. Cette méthode est appelée pour **chaque lien affiché** dans le flux paginé. Avec 20 liens par page, cela crée 20 instances inutiles à chaque chargement.
**Solution** : Utiliser un `companion object` avec un formatter thread-safe :
```kotlin
companion object {
private val dateFormatter = java.text.SimpleDateFormat(
"yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.US
)
}
```
Ou mieux, utiliser `java.time.Instant` + `DateTimeFormatter` (déjà utilisé dans `SyncManager`).
**Priorité** : 🔴 HAUTE
**Effort** : Très faible
**Gain** : Réduction des allocations GC à chaque scroll
---
### 2.3 `getLinksByTag()` charge TOUTE la base en mémoire
**Fichier** : `data/repository/LinkRepositoryImpl.kt` (lignes 116-134)
```kotlin
override suspend fun getLinksByTag(tag: String): Result<List<ShaarliLink>> {
return try {
val localLinks = linkDao.getAllLinks().firstOrNull() ?: emptyList() // ← CHARGE TOUT
if (localLinks.isNotEmpty()) {
val filtered = localLinks.filter { it.tags.contains(tag) } // ← FILTRE EN MÉMOIRE
// ...
}
}
}
```
**Impact** : Pour une bibliothèque de 5 000 liens, cette opération :
1. Charge ~5 000 entités complètes en mémoire (~5-10 MB)
2. Itère sur chacune pour un filtre que Room pourrait faire en SQL
3. Crée une copie filtrée
**Solution** : Utiliser une requête Room dédiée (le DAO `getLinksByTag(tag)` existe déjà !) :
```kotlin
override suspend fun getLinksByTag(tag: String): Result<List<ShaarliLink>> {
return try {
val sql = "SELECT * FROM links WHERE tags LIKE ? ORDER BY created_at DESC LIMIT 100"
val links = linkDao.getLinksByTagDirect(SimpleSQLiteQuery(sql, arrayOf("%\"$tag\"%")))
Result.success(links.map { it.toDomainModel() })
} catch (e: Exception) {
Result.failure(e)
}
}
```
**Priorité** : 🔴 HAUTE
**Effort** : Faible
**Gain** : -95% mémoire pour les requêtes par tag
---
### 2.4 FTS4 défini mais non utilisé pour la recherche
**Fichier** : `data/local/dao/LinkDao.kt`
L'entité FTS4 `LinkFtsEntity` est définie (ligne 129 de `LinkEntity.kt`) et la méthode `searchLinksFullText()` existe dans le DAO (ligne 51-57), mais le repository utilise `searchLinks()` avec des `LIKE '%query%'` à la place :
```kotlin
// Utilisé actuellement (LENT) :
fun searchLinks(query: String): PagingSource<Int, LinkEntity>
// WHERE title LIKE '%query%' OR description LIKE '%query%' OR url LIKE '%query%'
// Disponible mais NON UTILISÉ (RAPIDE) :
fun searchLinksFullText(query: String): PagingSource<Int, LinkEntity>
// WHERE links_fts MATCH :query ← Utilise l'index FTS4
```
**Impact** : Les requêtes `LIKE '%...%'` nécessitent un full table scan (O(n)). FTS4 utilise un index inversé (O(log n)). Pour 10 000 liens, la différence est de **10-100x** en vitesse.
**Solution** : Dans `LinkRepositoryImpl.getLinksStream()`, remplacer :
```kotlin
!searchTerm.isNullOrBlank() -> linkDao.searchLinks(searchTerm)
```
par :
```kotlin
!searchTerm.isNullOrBlank() -> linkDao.searchLinksFullText(searchTerm)
```
**Priorité** : 🔴 HAUTE
**Effort** : 1 ligne
**Gain** : Recherche 10-100x plus rapide
---
## 3. Optimisations du Démarrage (Cold Start)
### 3.1 Absence de Baseline Profiles
Les Baseline Profiles pré-compilent les chemins critiques (cold start, navigation, scroll) en AOT, évitant la compilation JIT au runtime.
**Impact** : Jetpack Compose bénéficie massivement des Baseline Profiles car le framework génère beaucoup de bytecode dynamique. Gain typique : **30-50% de réduction du temps de démarrage**.
**Solution** :
1. Ajouter le module `:benchmark` avec la dépendance `androidx.benchmark:benchmark-macro-junit4`
2. Créer un `BaselineProfileGenerator` qui navigue dans les écrans principaux
3. Générer le profil et l'inclure dans `src/main/baseline-prof.txt`
```kotlin
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {
@get:Rule
val rule = BaselineProfileRule()
@Test
fun generateProfile() {
rule.collect("com.shaarit") {
startActivityAndWait()
// Scroll feed, search, add link...
}
}
}
```
**Priorité** : 🔴 HAUTE
**Effort** : Moyen (1-2h de configuration)
**Gain** : -30-50% cold start
### 3.2 Traitement du Share Intent pendant la composition
**Fichier** : `MainActivity.kt` (lignes 56-82)
Le parsing de l'intent (extraction du Share Intent, deep links) se fait **dans le bloc `setContent {}`**, ce qui signifie qu'il est ré-exécuté à chaque recomposition.
**Solution** : Extraire le parsing dans `onCreate()` avant `setContent {}` et passer les valeurs en tant que paramètres stables :
```kotlin
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
// Parse intent ONCE, before composition
val shareData = parseShareIntent(intent)
setContent {
// Use shareData directly (stable, no recomposition)
}
}
```
**Priorité** : 🟡 MOYENNE
**Effort** : Faible
**Gain** : Évite les recompositions inutiles au démarrage
---
## 4. Optimisations Réseau & Synchronisation
### 4.1 Logging Interceptor en mode BODY en production
**Fichier** : `core/di/NetworkModule.kt` (ligne 29)
```kotlin
val logging = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY // ← TOUJOURS BODY
}
```
**Impact** : Log chaque requête et réponse complète (headers + body). Pour un sync de 1 000 liens en JSON, cela :
- Génère des méga-octets de logs
- Ralentit le I/O
- Consomme de la mémoire pour la sérialisation des logs
**Solution** :
```kotlin
val logging = HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE
}
}
```
**Priorité** : 🔴 HAUTE
**Effort** : Très faible
**Gain** : Significatif en release (réseau + mémoire)
### 4.2 Pas de timeouts explicites sur le client OkHttp principal
**Fichier** : `core/di/NetworkModule.kt`
Le `OkHttpClient` principal n'a aucun timeout configuré. Les défauts OkHttp sont de 10s pour connect, 10s pour read, 10s pour write ce qui peut causer des attentes longues si le serveur Shaarli est lent.
**Solution** :
```kotlin
return OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.addInterceptor(hostSelectionInterceptor)
.addInterceptor(authInterceptor)
.addInterceptor(logging)
.build()
```
**Priorité** : 🟡 MOYENNE
**Effort** : Très faible
### 4.3 Sync complète sans delta/incrémental
**Fichier** : `data/sync/SyncManager.kt` `pullFromServer()` (lignes 375-437)
Chaque sync récupère **tous les liens** du serveur, page par page (100 par page). Pour une bibliothèque de 5 000 liens, cela nécessite 50 requêtes HTTP séquentielles.
**Solution** : Sync incrémentale basée sur la date de dernière synchronisation :
1. Stocker le timestamp de dernière sync réussie dans `TokenManager`
2. Utiliser le paramètre `searchterm` ou un header `If-Modified-Since` si supporté par Shaarli
3. En fallback, comparer les `updatedAt` locaux vs serveur pour ne traiter que les changements
```kotlin
private suspend fun pullFromServer() {
val lastSyncTime = tokenManager.getLastSyncTimestamp()
// Ne récupérer que les liens modifiés depuis lastSyncTime
// ...
tokenManager.saveLastSyncTimestamp(System.currentTimeMillis())
}
```
> **Note** : L'API Shaarli v1 ne supporte pas nativement le filtrage par date de modification. Il faudra soit implémenter un checksum côté client, soit paginer intelligemment (s'arrêter quand on rencontre des liens déjà synchés).
**Priorité** : 🔴 HAUTE
**Effort** : Élevé (refactoring majeur)
**Gain** : -85% temps de sync pour les syncs régulières
### 4.4 `HttpURLConnection` dans `LinkHealthCheckWorker`
**Fichier** : `data/worker/LinkHealthCheckWorker.kt` (lignes 151-191)
Le health check utilise `HttpURLConnection` legacy au lieu du `OkHttpClient` déjà disponible via Hilt. Cela empêche le **connection pooling** et le **HTTP/2 multiplexing** d'OkHttp.
**Solution** : Injecter `OkHttpClient` dans le Worker (via `@AssistedInject`) et utiliser un client dédié (comme fait dans `GeminiRepositoryImpl`) :
```kotlin
private val healthCheckClient by lazy {
okHttpClient.newBuilder()
.connectTimeout(7, TimeUnit.SECONDS)
.readTimeout(7, TimeUnit.SECONDS)
.build()
}
```
**Priorité** : 🟡 MOYENNE
**Effort** : Faible
**Gain** : Meilleur connection pooling, HTTP/2
### 4.5 Pas de cache HTTP disque
**Fichier** : `core/di/NetworkModule.kt`
Aucun `Cache` OkHttp n'est configuré. Les réponses API ne sont jamais mises en cache sur disque.
**Solution** :
```kotlin
val cacheSize = 10L * 1024 * 1024 // 10 MB
val cache = Cache(File(context.cacheDir, "http_cache"), cacheSize)
return OkHttpClient.Builder()
.cache(cache)
// ...
.build()
```
**Priorité** : 🟢 FAIBLE
**Effort** : Très faible
**Gain** : Réduit la bande passante, accélère les requêtes répétées
---
## 5. Optimisations Base de Données (Room)
### 5.1 `getAllLinksForStats()` charge tout en mémoire
**Fichier** : `data/local/dao/LinkDao.kt` (ligne 234)
```kotlin
@Query("SELECT * FROM links WHERE sync_status != 'PENDING_DELETE'")
suspend fun getAllLinksForStats(): List<LinkEntity>
```
Utilisé par `BookmarkExporter` (3 fonctions d'export) et `LinkRepositoryImpl.getAllLinks()`. Charge **toutes les colonnes** de **tous les liens** en mémoire.
**Solutions** :
1. **Pour l'export** : Utiliser un `Cursor` ou un `Flow<List<LinkEntity>>` avec traitement par batch
2. **Pour les stats** : Créer des requêtes d'agrégation SQL dédiées au lieu de charger les entités :
```kotlin
@Query("SELECT COUNT(*) FROM links WHERE sync_status != 'PENDING_DELETE'")
suspend fun getTotalLinksCount(): Int
@Query("SELECT COUNT(*) FROM links WHERE created_at >= :since")
suspend fun getLinksCountSince(since: Long): Int
```
**Priorité** : 🔴 HAUTE
**Effort** : Moyen
**Gain** : -90% mémoire pour les stats et exports
### 5.2 Pas d'index sur les colonnes fréquemment filtrées
**Fichier** : `data/local/entity/LinkEntity.kt` (lignes 13-21)
Les index existants sont bons (`sync_status`, `is_private`, `created_at`, `is_pinned`, `url`), mais il manque :
- **`content_type`** utilisé par `getContentTypeDistribution()`
- **`site_name`** utilisé par `getTopSites()` et `getAllSites()`
- **`link_check_status`** utilisé par `getDeadLinks()`, `getDeadLinksCount()`, `getPendingLinksCount()`
**Solution** :
```kotlin
@Entity(
tableName = "links",
indices = [
Index(value = ["sync_status"]),
Index(value = ["is_private"]),
Index(value = ["created_at"]),
Index(value = ["is_pinned"]),
Index(value = ["url"], unique = true),
Index(value = ["content_type"]), // NOUVEAU
Index(value = ["site_name"]), // NOUVEAU
Index(value = ["link_check_status"]), // NOUVEAU
Index(value = ["last_health_check"]) // NOUVEAU
]
)
```
**Priorité** : 🟡 MOYENNE
**Effort** : Très faible (+ migration de schéma)
**Gain** : Requêtes de filtrage/stats 5-10x plus rapides
### 5.3 `fallbackToDestructiveMigration()` — Perte de données silencieuse
**Fichier** : `data/local/database/ShaarliDatabase.kt` (ligne 59)
```kotlin
.fallbackToDestructiveMigration()
```
**Impact UX** : À chaque changement de schéma (version de DB), toutes les données locales sont **silencieusement supprimées**. L'utilisateur perd son cache offline, ses épingles, ses métadonnées enrichies, sans aucun avertissement.
**Solution** : Écrire des migrations explicites :
```kotlin
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE links ADD COLUMN new_column TEXT DEFAULT ''")
}
}
Room.databaseBuilder(context, ShaarliDatabase::class.java, DATABASE_NAME)
.addMigrations(MIGRATION_4_5)
.build()
```
**Priorité** : 🔴 HAUTE (UX critique)
**Effort** : Moyen (pour chaque future migration)
**Gain** : Zéro perte de données utilisateur
### 5.4 `exportSchema = false`
**Fichier** : `data/local/database/ShaarliDatabase.kt` (ligne 32)
L'export de schéma est désactivé, ce qui empêche la vérification automatique des migrations Room et la détection des erreurs de migration en compile-time.
**Solution** : Activer l'export de schéma :
```kotlin
@Database(
// ...
exportSchema = true
)
```
Et ajouter dans `build.gradle.kts` :
```kotlin
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
```
**Priorité** : 🟡 MOYENNE
**Effort** : Très faible
---
## 6. Optimisations UI/Compose
### 6.1 Images non redimensionnées par Coil
**Fichier** : `presentation/feed/LinkItemViews.kt`
```kotlin
AsyncImage(
model = link.thumbnailUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(100.dp).clip(RoundedCornerShape(12.dp))
)
```
**Impact** : Coil télécharge et décode l'image en résolution originale (potentiellement 4K), puis Compose la redimensionne visuellement. Cela gaspille de la mémoire (un bitmap 1920x1080 = ~8 MB en ARGB_8888).
**Solution** : Utiliser le builder de requête Coil pour limiter la taille en mémoire :
```kotlin
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(link.thumbnailUrl)
.size(200) // Taille cible en pixels
.crossfade(true)
.memoryCacheKey(link.thumbnailUrl)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(100.dp).clip(RoundedCornerShape(12.dp))
)
```
**Priorité** : 🔴 HAUTE
**Effort** : Faible
**Gain** : -80% mémoire par image, scroll plus fluide
### 6.2 `MarkdownText` dans chaque item de liste
**Fichier** : `presentation/feed/LinkItemViews.kt` (lignes 179-184, 327-332)
Chaque item du flux rend du Markdown via `MarkdownText`. Le parsing Markdown est coûteux et se fait pour **chaque item visible**.
**Solutions** :
1. **Limiter l'affichage** : Dans les vues liste/grille, afficher un `Text()` simple avec le texte brut tronqué. Réserver `MarkdownText` pour la vue détail.
2. **Pré-parser** : Stocker le texte HTML pré-rendu dans la base de données (colonne `excerpt`) lors de la sync.
**Priorité** : 🟡 MOYENNE
**Effort** : Faible
**Gain** : Scroll plus fluide, moins de jank
### 6.3 Debounce de 300ms sur le flux principal
**Fichier** : `presentation/feed/FeedViewModel.kt` (ligne 82)
```kotlin
.debounce(300)
```
Ce debounce de 300ms s'applique à **tous les changements** (recherche, tags, filtres, refresh), ce qui ajoute un délai perceptible même quand l'utilisateur tape sur un filtre.
**Solution** : Appliquer le debounce uniquement à la recherche textuelle :
```kotlin
val debouncedSearch = _searchQuery.debounce(300)
val instantFilters = combine(_searchTags, _collectionId, _bookmarkFilter, _refreshTrigger) { ... }
val pagedLinks = combine(debouncedSearch, instantFilters) { query, filters ->
// ...
}.flatMapLatest { ... }
```
**Priorité** : 🟡 MOYENNE
**Effort** : Faible
**Gain** : Filtres instantanés au lieu de 300ms de délai
---
## 7. Optimisations Mémoire
### 7.1 Caches en mémoire non bornés (Gemini)
**Fichier** : `data/repository/GeminiRepositoryImpl.kt` (lignes 26-29)
```kotlin
private val tagsCache = mutableMapOf<String, List<String>>()
private val analysisCache = mutableMapOf<String, AiEnrichmentResult>()
```
Ces caches croissent indéfiniment durant la session. Si l'utilisateur analyse 100 URLs, le cache contient 100 résultats sans jamais les libérer.
**Solution** : Utiliser un `LruCache` avec une taille maximale :
```kotlin
private val analysisCache = object : LinkedHashMap<String, AiEnrichmentResult>(50, 0.75f, true) {
override fun removeEldestEntry(eldest: Map.Entry<String, AiEnrichmentResult>): Boolean {
return size > 50
}
}
```
Ou mieux, utiliser `LruCache` d'Android :
```kotlin
private val analysisCache = LruCache<String, AiEnrichmentResult>(50)
```
**Priorité** : 🟢 FAIBLE
**Effort** : Très faible
**Gain** : Prévient les fuites mémoire en session longue
### 7.2 `GenerativeModel` recréé à chaque appel Gemini
**Fichier** : `data/repository/GeminiRepositoryImpl.kt` (lignes 78, 193)
```kotlin
private suspend fun generateWithModel(apiKey: String, modelName: String, url: String): AiEnrichmentResult {
val generativeModel = GenerativeModel( // ← Recréé à chaque appel
modelName = modelName,
apiKey = apiKey,
// ...
)
}
```
**Solution** : Mettre en cache les instances par nom de modèle :
```kotlin
private val modelCache = mutableMapOf<String, GenerativeModel>()
private fun getOrCreateModel(apiKey: String, modelName: String): GenerativeModel {
return modelCache.getOrPut(modelName) {
GenerativeModel(modelName = modelName, apiKey = apiKey, ...)
}
}
```
**Priorité** : 🟢 FAIBLE
**Effort** : Très faible
**Gain** : Réduit les allocations lors d'appels AI successifs
### 7.3 Regex compilées à chaque appel
**Fichier** : `data/metadata/LinkMetadataExtractor.kt` (lignes 175-206)
```kotlin
url.contains(Regex("youtube\\.com|vimeo\\.com|dailymotion")) // ← Nouvelle Regex à chaque appel
```
Plus de **15 patterns Regex** sont compilés inline dans `detectContentType()` et `detectContentTypeFromUrl()`.
**Solution** : Pré-compiler en `companion object` :
```kotlin
companion object {
private val VIDEO_PATTERN = Regex("youtube\\.com|vimeo\\.com|dailymotion")
private val REPO_PATTERN = Regex("github\\.com|gitlab\\.com|bitbucket")
// ...
}
```
**Priorité** : 🟡 MOYENNE
**Effort** : Très faible
**Gain** : Réduit les allocations, accélère la détection de type
---
## 8. Optimisations du Build Release
### 8.1 ProGuard trop permissif
**Fichier** : `proguard-rules.pro` (lignes 107-109)
```
-keep class com.shaarit.** { *; }
-keepclassmembers class com.shaarit.** { *; }
```
Ces règles **empêchent R8 d'optimiser toute la codebase**. Aucun inlining, aucune suppression de code mort, aucune obfuscation.
**Solution** : Remplacer par des règles ciblées :
```
# Keep only what is strictly needed
-keep class com.shaarit.data.dto.** { *; }
-keep class com.shaarit.data.local.entity.** { *; }
-keep class com.shaarit.domain.model.** { *; }
-keep class com.shaarit.data.export.Exported* { *; }
```
**Priorité** : 🔴 HAUTE
**Effort** : Moyen (tests de non-régression requis)
**Gain** : R8 peut enfin optimiser le code
### 8.2 Ajout recommandé de la bibliothèque LeakCanary (Debug)
Pour détecter les fuites mémoire durant le développement :
```kotlin
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.13")
```
**Priorité** : 🟡 MOYENNE
**Effort** : 1 ligne
---
## 9. Expérience Utilisateur — Réduction des Temps d'Attente
### 9.1 Skeleton Loading déjà implémenté ✅
Le composant `SkeletonLinkCard` existe dans `ui/components/SkeletonLoader.kt`. **S'assurer qu'il est utilisé systématiquement** comme état de chargement initial au lieu d'un spinner circulaire.
### 9.2 Extraction de métadonnées — Feedback immédiat
**Fichier** : `presentation/add/AddLinkViewModel.kt`
L'extraction de métadonnées (JSoup, 10s timeout) et l'analyse Gemini se font **séquentiellement**. L'utilisateur attend sans feedback visuel clair.
**Solutions** :
1. **Optimistic UI** : Permettre la sauvegarde immédiate du lien, enrichir les métadonnées en arrière-plan
2. **Paralléliser** : Lancer JSoup et Gemini en parallèle si les deux sont disponibles
3. **Progressive loading** : Afficher le titre dès qu'il est extrait, puis la description, puis les tags AI
```kotlin
// Paralléliser extraction JSoup + analyse Gemini
viewModelScope.launch {
val metadataDeferred = async { metadataExtractor.extract(url) }
val aiDeferred = async { analyzeUrlWithAiUseCase(url) }
// Afficher les métadonnées JSoup dès qu'elles arrivent
metadataDeferred.await().let { metadata -> ... }
// Compléter avec l'AI
aiDeferred.await().onSuccess { result -> ... }
}
```
**Priorité** : 🔴 HAUTE
**Effort** : Moyen
**Gain** : UX perçue comme instantanée
### 9.3 Share Intent — Temps d'apparition de l'écran d'ajout
Quand l'utilisateur partage un lien depuis Chrome, le flux est :
1. Splash Screen 2. Login check 3. Navigation 4. AddLinkScreen 5. Extraction métadonnées
**Optimisations** :
- Vérifier le token **dans le splash screen** pour éviter l'écran de login
- Naviguer directement vers AddLinkScreen si le token est valide
- Commencer l'extraction des métadonnées **pendant la navigation**
### 9.4 Pull-to-Refresh — Feedback de sync
Le `refresh()` du `FeedViewModel` déclenche `syncManager.syncNow()` qui planifie un Worker. L'utilisateur ne voit pas immédiatement les résultats car :
1. Le Worker est planifié (pas exécuté immédiatement)
2. La pagination Room ne se rafraîchit pas automatiquement
**Solution** : Effectuer un `performFullSync()` directement dans un coroutine scope, puis invalider le PagingSource :
```kotlin
fun refresh() {
viewModelScope.launch {
syncManager.performFullSync()
_refreshTrigger.value++
}
}
```
**Priorité** : 🟡 MOYENNE
**Effort** : Faible
**Gain** : Refresh perçu comme immédiat
### 9.5 Haptic Feedback sur les actions clés
Ajouter du retour haptique pour :
- Toggle pin (long press feel)
- Ajout de lien réussi
- Suppression confirmée
- Pull-to-refresh
```kotlin
val haptic = LocalHapticFeedback.current
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
```
**Priorité** : 🟢 FAIBLE
**Effort** : Très faible
**Gain** : UX premium, feedback instantané
### 9.6 Préchargement intelligent du flux
Configurer Paging 3 pour précharger plus de données en avance :
```kotlin
PagingConfig(
pageSize = 20,
prefetchDistance = 10, // Commence à charger 10 items avant la fin
initialLoadSize = 40, // Charge 40 items au démarrage
enablePlaceholders = false
)
```
**Priorité** : 🟡 MOYENNE
**Effort** : Très faible
**Gain** : Scroll continu sans "loading" visible
---
## 10. Plan d'Action Priorisé
### Phase 1 — Quick Wins Critiques (1-2 jours)
| # | Action | Fichier | Effort | Impact |
|---|--------|---------|--------|--------|
| 1 | Activer R8 (`isMinifyEnabled = true`) | `build.gradle.kts` | 2 lignes | 🔴🔴🔴 |
| 2 | Activer FTS4 pour la recherche | `LinkRepositoryImpl.kt` | 1 ligne | 🔴🔴🔴 |
| 3 | Corriger `getLinksByTag()` (requête SQL) | `LinkRepositoryImpl.kt` | 10 lignes | 🔴🔴 |
| 4 | Désactiver les logs réseau en release | `NetworkModule.kt` | 5 lignes | 🔴🔴 |
| 5 | Singleton `SimpleDateFormat` | `LinkRepositoryImpl.kt` | 5 lignes | 🔴 |
| 6 | Pré-compiler les Regex | `LinkMetadataExtractor.kt` | 15 lignes | 🟡 |
### Phase 2 — Optimisations Structurelles (3-5 jours)
| # | Action | Fichier | Effort | Impact |
|---|--------|---------|--------|--------|
| 7 | Baseline Profiles | Nouveau module | 2h | 🔴🔴🔴 |
| 8 | Migrations Room explicites | `ShaarliDatabase.kt` | 2h | 🔴🔴 |
| 9 | Images Coil avec taille cible | `LinkItemViews.kt` | 30min | 🔴🔴 |
| 10 | Affiner les règles ProGuard | `proguard-rules.pro` | 1h | 🔴🔴 |
| 11 | Ajouter les index manquants | `LinkEntity.kt` | 15min | 🟡 |
| 12 | Texte brut dans la liste, Markdown en détail | `LinkItemViews.kt` | 1h | 🟡 |
### Phase 3 — Améliorations UX (1 semaine)
| # | Action | Fichier | Effort | Impact |
|---|--------|---------|--------|--------|
| 13 | Sync incrémentale (delta) | `SyncManager.kt` | 4h | 🔴🔴🔴 |
| 14 | Extraction métadonnées parallèle | `AddLinkViewModel.kt` | 2h | 🔴🔴 |
| 15 | Debounce uniquement sur la recherche | `FeedViewModel.kt` | 1h | 🟡 |
| 16 | Préchargement Paging amélioré | `LinkRepositoryImpl.kt` | 15min | 🟡 |
| 17 | Share Intent optimisé (skip login) | `MainActivity.kt` | 2h | 🟡 |
| 18 | Haptic feedback | Composables | 30min | 🟢 |
### Phase 4 — Maintenance Continue
| # | Action | Effort | Impact |
|---|--------|--------|--------|
| 19 | LeakCanary en debug | 1 ligne | 🟡 |
| 20 | Borner les caches Gemini | 15min | 🟢 |
| 21 | Cache HTTP OkHttp | 15min | 🟢 |
| 22 | Export schéma Room | 15min | 🟢 |
| 23 | Supprimer `LinkPagingSource` (code mort) | Suppression | 🟢 |
---
## 11. Métriques de Suivi
Pour mesurer l'impact des optimisations, instrumenter les métriques suivantes :
### Métriques de Démarrage
- **Time to First Frame** (TTFF) : Temps jusqu'au premier frame visible
- **Time to Interactive** (TTI) : Temps jusqu'à ce que le flux soit scrollable
- Outil : `adb shell am start -W com.shaarit/.MainActivity`
### Métriques de Rendu
- **Jank frames** : Frames dépassant 16ms
- **Frame drop rate** : % de frames perdues pendant le scroll
- Outil : Android Studio Profiler Frame Rendering
### Métriques de Sync
- **Sync duration** : Temps total d'une sync complète
- **Items/second** : Débit de sync
- Instrumenter avec `System.currentTimeMillis()` dans `SyncManager`
### Métriques Mémoire
- **Heap size** au repos et sous charge
- **GC frequency** pendant le scroll
- Outil : Android Studio Profiler Memory
### Recommandation : Firebase Performance Monitoring
```kotlin
implementation("com.google.firebase:firebase-perf-ktx")
```
Permet de tracer automatiquement les requêtes réseau, le temps de démarrage, et les traces custom en production.
---
## Annexe : Code Mort Identifié
| Fichier | Description |
|---------|-------------|
| `data/paging/LinkPagingSource.kt` | Ancien PagingSource réseau, remplacé par Room Paging. Peut être supprimé. |
| `data/sync/ConflictResolver.kt` | Défini mais jamais appelé par `SyncManager`. La résolution de conflits n'est pas implémentée. |
---
*Document généré le 9 février 2026 — Analyse basée sur l'intégralité du code source ShaarIt v1.0 (47 fichiers Kotlin)*