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

30 KiB

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
  2. Problèmes Critiques de Performance
  3. Optimisations du Démarrage (Cold Start)
  4. Optimisations Réseau & Synchronisation
  5. Optimisations Base de Données (Room)
  6. Optimisations UI/Compose
  7. Optimisations Mémoire
  8. Optimisations du Build Release
  9. Expérience Utilisateur — Réduction des Temps d'Attente
  10. Plan d'Action Priorisé
  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)

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 :

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)

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 :

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)

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à !) :

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 :

// 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 :

!searchTerm.isNullOrBlank() -> linkDao.searchLinks(searchTerm)

par :

!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
@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 :

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)

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 :

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 :

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.ktpullFromServer() (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
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) :

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 :

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)

@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 :
@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 :

@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)

.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 :

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 :

@Database(
    // ...
    exportSchema = true
)

Et ajouter dans build.gradle.kts :

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

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 :

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)

.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 :

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)

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 :

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 :

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)

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 :

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)

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 :

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 :

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
// 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 :

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
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 :

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

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)