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
This commit is contained in:
Bruno Charest 2026-02-10 21:15:30 -05:00
parent c8a9e6653b
commit ec0931134c
39 changed files with 4717 additions and 308 deletions

203
README.md
View File

@ -15,16 +15,19 @@
- Connexion sécurisée à votre instance Shaarli auto-hébergée (API v1)
- Stockage chiffré des tokens JWT et secrets API via `EncryptedSharedPreferences`
- Génération automatique de tokens JWT avec algorithme HS512
- Vérification automatique du token au démarrage (skip login si valide)
### 📚 Gestion des Favoris
- **Flux infini** : Défilement continu avec chargement progressif (Paging 3)
- **Recherche avancée** : Recherche locale avec FTS4 (full-text search) et filtrage par tags
- **Mode hors-ligne** : Consultation et modification des liens même sans connexion
- **Ajout rapide** : Création de liens privés/publics avec description, tags et extraction automatique de métadonnées
- **Notes Markdown** : Création de notes enrichies (pas uniquement des URLs) avec le préfixe `note://`
- **Édition** : Modification complète des liens existants avec éditeur Markdown
- **Suppression** : Gestion facile des favoris avec file d'attente de sync
- **Détection des doublons** : Alerte lors de l'ajout d'un lien existant avec option de mise à jour
- **Liens épinglés** : Mise en avant des liens importants
- **Liens épinglés** : Mise en avant des liens importants avec écran dédié
- **Vérification santé des liens** : Détection automatique des liens morts (Dead Links) en arrière-plan
### 🏷️ Gestion des Tags
- Vue dédiée pour parcourir tous les tags
@ -33,8 +36,9 @@
- Tags favoris pour accès rapide
### 📁 Collections
- Organisation des liens en collections
- Collections intelligentes avec filtres automatiques
- Organisation des liens en collections manuelles ou intelligentes
- Collections intelligentes avec filtres automatiques (basées sur les tags)
- Synchronisation des collections via le serveur Shaarli
- Vue grille adaptative pour les collections
### 📝 Éditeur Markdown
@ -43,11 +47,19 @@
- Mode lecture focus sans distraction
- Barre d'outils de formatage
### 🤖 Intelligence Artificielle (Google Gemini)
- **Analyse IA d'URL** : Extraction intelligente du titre, description et tags via Gemini
- **Suggestions de tags IA** : Génération automatique de tags pertinents
- **Classification de contenu** : Détection automatique du type (Article, Vidéo, Tutorial, Repository)
- **Fallback multi-modèles** : Essai automatique de plusieurs modèles Gemini (2.5 Flash Lite → 1.5 Flash)
- **Classification en lot** : Scan et classification de tous les bookmarks existants
### 🌐 Extraction de Métadonnées
- Extraction automatique des OpenGraph (titre, description, image)
- Détection du type de contenu (article, vidéo, image, audio, code, etc.)
- Extraction automatique des OpenGraph (titre, description, image) via JSoup
- Détection du type de contenu (article, vidéo, image, audio, code, repository, social, etc.)
- Estimation du temps de lecture
- Extraction du nom du site
- Extraction du nom du site et du favicon
- Suggestion automatique de tags basée sur le domaine
### 📊 Tableau de Bord
- Statistiques d'utilisation (liens totaux, cette semaine, ce mois)
@ -56,7 +68,15 @@
- Tags les plus utilisés
- Graphique d'activité sur 30 jours
### 💾 Import/Export
### <20> Vérification des Liens (Health Check)
- Détection automatique des liens morts via WorkManager (toutes les 12h)
- Système de seuil : 3 échecs consécutifs avant de marquer un lien comme « mort »
- Filtrage intelligent (exclusion des notes, réseau local, IPs privées)
- Vérification manuelle à la demande depuis les paramètres
- Écran dédié de gestion des liens morts
- Exclusion de liens spécifiques du health check
### <20>💾 Import/Export
- Export JSON (format complet avec métadonnées)
- Export CSV (compatible Excel)
- Export HTML (format Netscape/Chrome bookmarks)
@ -70,20 +90,21 @@
- File d'attente des opérations
### 🔗 Intégration système
- **Share Intent Android** : Sauvegarde rapide depuis n'importe quelle app
- **Share Intent Android** : Sauvegarde rapide depuis n'importe quelle app (URLs et fichiers texte/Markdown)
- **Partage de fichiers** : Import de fichiers `.md`, `.txt` directement comme notes Shaarli
- **App Shortcuts** : Accès rapide via appui long sur l'icône (Ajouter, Aléatoire, Rechercher, Collections)
- **Quick Settings Tile** : Tuile pour ajouter rapidement un lien
- **Widget** : Widget d'accueil affichant les liens récents
- **Deep Links** : Navigation directe via `shaarit://feed`, `shaarit://add`, `shaarit://search`, etc.
- Ouverture des liens dans le navigateur par défaut
### 🎨 Interface Utilisateur
- **Material You (Monet)** : Couleurs dynamiques basées sur le fond d'écran (Android 12+)
- **Mode OLED** : Noir pur pour les écrans AMOLED
- **Design premium** : Thème sombre moderne avec dégradés
- **Material Design 3** : Composants UI natifs Android
- **Animations fluides** : Transitions et effets visuels
- **15 thèmes sombres** : ShaarIt, GitHub, Linear, Spotify, Notion, Discord, Dracula, One Dark Pro, Tokyo Night, Nord, Night Owl, Anthracite, Cyberpunk, Navy Élégance, Tons Terreux
- **Design premium** : Composants glassmorphism avec effets visuels
- **Material Design 3** : Composants UI natifs Jetpack Compose
- **Skeleton Loading** : Chargement élégant avec shimmer effect
- **Trois modes d'affichage** : Liste détaillée, grille compacte, ou vue compacte
- **Pull-to-refresh** : Actualisation du flux par glissement
- **Edge-to-Edge** : Support des insets système (barre de statut, navigation)
---
@ -91,24 +112,30 @@
| Catégorie | Technologie |
|-----------|-------------|
| **Langage** | Kotlin 2.0.0 |
| **UI** | Jetpack Compose + Material Design 3 |
| **Langage** | Kotlin 1.9.20 |
| **UI** | Jetpack Compose (BOM 2023.08) + Material Design 3 |
| **Architecture** | Clean Architecture + MVVM |
| **Injection de dépendances** | Dagger Hilt 2.51.1 |
| **Réseau** | Retrofit 2.11.0 + Moshi 1.15.1 + OkHttp 4.12.0 |
| **Base de données locale** | Room 2.6.1 |
| **Pagination** | Paging 3 |
| **Injection de dépendances** | Dagger Hilt 2.48.1 |
| **Réseau** | Retrofit 2.9.0 + Moshi 1.15.0 + OkHttp 4.12.0 |
| **Base de données locale** | Room 2.6.1 (avec FTS4) |
| **Pagination** | Paging 3 (3.2.1) |
| **Concurrence** | Coroutines & Flow |
| **Background work** | WorkManager 2.9.0 |
| **Stockage sécurisé** | AndroidX Security Crypto |
| **Navigation** | Navigation Compose |
| **Compilation** | Gradle 8.0+ avec KSP |
| **Navigation** | Navigation Compose 2.7.6 |
| **Images** | Coil 2.6.0 |
| **Markdown** | compose-markdown 0.4.1 |
| **HTML Parsing** | JSoup 1.17.1 |
| **IA** | Google Gemini AI SDK 0.9.0 |
| **Sérialisation** | Kotlin Serialization 1.6.2 |
| **Compilation** | AGP 8.13.2 + KSP 1.9.20 |
### Compatibilité
- **minSdk**: 24 (Android 7.0)
- **targetSdk**: 34 (Android 14)
- **compileSdk**: 34
- **JDK requis**: 17+
- **minSdk** : 24 (Android 7.0)
- **targetSdk** : 34 (Android 14)
- **compileSdk** : 34
- **JDK requis** : 17+
- **Compose Compiler** : 1.5.4
---
@ -149,14 +176,27 @@ L'APK sera généré dans `app/build/outputs/apk/debug/`
### Première configuration
1. Ouvrez l'application
2. Entrez l'URL de votre instance Shaarli
2. Entrez l'URL de votre instance Shaarli (ex: `https://monserveur.com/shaarli`)
3. Entrez votre nom d'utilisateur et mot de passe
4. L'application générera automatiquement les tokens API nécessaires
5. *(Optionnel)* Configurez votre clé API Gemini dans Paramètres pour l'auto-tagging IA
### Ajouter un lien
- **Via l'app** : Appuyez sur le bouton + et entrez l'URL
- **Via le partage Android** : Partagez n'importe quelle URL vers ShaarIt depuis n'importe quelle app
- **Via Quick Settings** : Ajoutez la tuile ShaarIt dans vos paramètres rapides
- **Fichier Markdown** : Partagez un fichier `.md` ou `.txt` pour l'importer comme note
### Créer une note
- Sur l'écran d'ajout, basculez en mode **Note** (au lieu de Bookmark)
- Rédigez le titre et le contenu en Markdown
- La note sera sauvegardée sur votre instance Shaarli avec un identifiant unique
### Intelligence Artificielle
1. Obtenez une clé API sur [Google AI Studio](https://aistudio.google.com/)
2. Allez dans **Paramètres****Clé API Gemini** et entrez votre clé
3. Lors de l'ajout d'un lien, appuyez sur le bouton IA pour analyser automatiquement l'URL
4. L'IA remplira le titre, la description et suggérera des tags pertinents
### Organiser vos liens
- Utilisez les tags pour catégoriser vos liens
@ -176,52 +216,98 @@ Le projet suit les principes de la **Clean Architecture** avec une séparation c
```
├── data/ # Couche de données
│ ├── api/ # API Retrofit
│ ├── api/ # API Retrofit (ShaarliApi)
│ ├── dto/ # Data Transfer Objects (Moshi)
│ ├── local/ # Base de données Room
│ │ ├── dao/ # Data Access Objects
│ │ ├── entity/ # Entités Room
│ │ ├── dao/ # Data Access Objects (Link, Tag, Collection)
│ │ ├── entity/ # Entités Room + FTS4
│ │ ├── converter/ # Type converters Room
│ │ └── database/ # Configuration de la DB
│ ├── sync/ # Synchronisation
│ ├── export/ # Import/Export
│ └── repository/ # Implémentations des repositories
│ ├── mapper/ # Mappers DTO ↔ Entity ↔ Domain
│ ├── metadata/ # Extraction métadonnées (JSoup/OpenGraph)
│ ├── paging/ # PagingSource réseau
│ ├── sync/ # SyncManager + ConflictResolver + SyncWorker
│ ├── worker/ # LinkHealthCheckWorker
│ ├── export/ # BookmarkExporter (JSON, CSV, HTML)
│ └── repository/ # Implémentations (Link, Auth, Gemini)
├── domain/ # Couche domaine
│ ├── model/ # Modèles métier
│ └── repository/ # Interfaces des repositories
│ ├── model/ # Modèles métier (ShaarliLink, ShaarliTag, etc.)
│ ├── repository/ # Interfaces des repositories
│ └── usecase/ # Use cases (AnalyzeUrl, GenerateTags, Classify, Login)
├── presentation/ # Couche présentation
│ ├── feed/ # Écran principal
│ ├── add/ # Ajout de liens
│ ├── feed/ # Écran principal + vues (List, Grid, Compact)
│ ├── add/ # Ajout de liens / notes
│ ├── edit/ # Édition de liens
│ ├── auth/ # Écran de connexion
│ ├── tags/ # Gestion des tags
│ ├── collections/ # Collections
│ ├── dashboard/ # Tableau de bord
│ ├── settings/ # Paramètres
│ └── nav/ # Navigation
│ ├── dashboard/ # Tableau de bord / statistiques
│ ├── deadlinks/ # Gestion des liens morts
│ ├── pinned/ # Liens épinglés
│ ├── settings/ # Paramètres (sync, export, IA, thèmes)
│ ├── help/ # Écran d'aide
│ └── nav/ # Navigation (NavGraph + Deep Links)
├── ui/ # Composants UI réutilisables
│ ├── components/ # GlassCard, TagChip, MarkdownEditor, SkeletonLoader
│ └── theme/ # 15 thèmes + typographie + préférences
├── service/ # AddLinkTileService (Quick Settings)
└── core/ # Utilitaires
├── di/ # Injection de dépendances
├── network/ # Configuration réseau
└── storage/ # Stockage local
├── di/ # Modules Hilt (App, Database, Network, Repository)
├── network/ # AuthInterceptor + HostSelectionInterceptor
├── storage/ # TokenManager (EncryptedSharedPreferences)
└── util/ # JwtGenerator
```
---
## 🚀 Roadmap
### Complété ✅
- [x] Synchronisation en arrière-plan avec WorkManager
- [x] Mode hors-ligne avec Room
- [x] Éditeur Markdown
- [x] Extraction de métadonnées OpenGraph
- [x] Collections d'organisation
- [x] Liens épinglés
- [x] Widget d'accueil
- [x] App Shortcuts
- [x] Mode hors-ligne avec Room + architecture offline-first
- [x] Éditeur Markdown (édition + prévisualisation + split)
- [x] Extraction de métadonnées OpenGraph (JSoup)
- [x] Collections manuelles et intelligentes
- [x] Liens épinglés avec écran dédié
- [x] App Shortcuts + Deep Links (`shaarit://`)
- [x] Quick Settings Tile
- [x] Tableau de bord analytique
- [x] Import/Export
- [x] Material You (Monet)
- [ ] Recherche avancée avec filtres multiples
- [ ] Suggestions de tags par IA
- [ ] Mode lecture sans distraction pour les articles
- [ ] Partage de collections
- [x] Tableau de bord analytique (statistiques, graphiques)
- [x] Import/Export (JSON, CSV, HTML Netscape)
- [x] 15 thèmes sombres premium
- [x] Intelligence Artificielle (Google Gemini) — auto-tagging, analyse d'URL, classification
- [x] Recherche FTS4 (full-text search) + filtres multi-tags
- [x] Détection et gestion des liens morts (Health Check)
- [x] Support des notes Markdown (pas uniquement des URLs)
- [x] Partage de fichiers `.md` / `.txt` via Share Intent
- [x] Skeleton Loading (shimmer effect)
- [x] Résolution de conflits de synchronisation
- [x] Synchronisation des collections via le serveur Shaarli
- [x] Optimisations de performance — R8/ProGuard, Baseline Profiles, sync incrémentale
- [x] Recherche FTS4 activée dans le repository — Moteur FTS4 intégré dans `LinkRepositoryImpl`
- [x] Migrations Room explicites — `MIGRATION_4_5` explicite + fallback ciblé pour v1-v3
### Prochaines étapes 🔜
- [ ] Mode lecture sans distraction (Reader Mode) pour les articles
- [ ] Widget d'accueil interactif (Glance)
- [ ] Partage de collections entre utilisateurs
- [ ] Support multi-instances Shaarli
- [ ] Verrouillage biométrique
- [ ] Rappels de lecture (« Lire plus tard » avec notifications)
- [ ] Voice Input dans la recherche et l'ajout de lien
- [ ] Adaptive Layouts pour tablettes et foldables
- [ ] Thème clair et Material You (Monet) dynamique sur Android 12+
---
## 📄 Documentation
| Document | Description |
|----------|-------------|
| [Performance & UX Optimale](docs/PERFORMANCE_ET_UX_OPTIMALE.md) | Analyse de performance complète, 24 optimisations identifiées, plan d'action priorisé |
| [Analyse et Améliorations](docs/ANALYSE_ET_AMELIORATIONS.md) | Propositions d'améliorations fonctionnelles et architecturales |
| [Rapport d'Audit v2](docs/RAPPORT_AMELIORATION_v2.md) | Audit UX « Next Level » — micro-interactions, IA, ergonomie |
| [Instructions de Build Release](docs/RELEASE_BUILD.md) | Guide de création du keystore, signature et build release |
| [Roadmap Prochaines Fonctionnalités](docs/ROADMAP_PROCHAINES_FONCTIONNALITES.md) | Analyse détaillée des 9 fonctionnalités à implémenter (Reader Mode, Widget, Biométrie, Material You…) |
---
@ -253,6 +339,9 @@ Ce projet est sous licence MIT. Voir le fichier [LICENSE](LICENSE) pour plus de
- [Shaarli](https://github.com/shaarli/Shaarli) - Le projet original de gestionnaire de favoris
- [Jetpack Compose](https://developer.android.com/jetpack/compose) - Framework UI moderne d'Android
- [Google Gemini](https://ai.google.dev/) - IA générative pour l'enrichissement intelligent
- [JSoup](https://jsoup.org/) - Parsing HTML pour l'extraction de métadonnées
- [Coil](https://coil-kt.github.io/coil/) - Chargement d'images optimisé pour Compose
- La communauté open source pour les excellentes bibliothèques utilisées
---

View File

@ -44,8 +44,8 @@ android {
buildTypes {
release {
isMinifyEnabled = false
isShrinkResources = false
isMinifyEnabled = true
isShrinkResources = true
signingConfig = signingConfigs.getByName("release")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
@ -67,6 +67,7 @@ android {
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.4"
@ -78,6 +79,10 @@ android {
}
}
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
dependencies {
implementation(libs.androidx.core.ktx)
@ -145,9 +150,15 @@ dependencies {
// JSoup for HTML parsing (metadata extraction)
implementation(libs.jsoup)
// Biometric
implementation(libs.androidx.biometric)
// Google Gemini AI SDK
implementation("com.google.ai.client.generativeai:generativeai:0.9.0")
// Baseline Profiles
implementation("androidx.profileinstaller:profileinstaller:1.3.1")
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
@ -155,4 +166,5 @@ dependencies {
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.13")
}

View File

@ -104,9 +104,11 @@
}
-dontwarn kotlinx.coroutines.**
# Keep ALL Application classes to prevent runtime crashes
-keep class com.shaarit.** { *; }
-keepclassmembers class com.shaarit.** { *; }
# Keep only what is strictly needed for serialization and Room
-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* { *; }
# Keep Hilt generated classes
-keep class com.shaarit.Hilt_* { *; }

View File

@ -0,0 +1,514 @@
{
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "5f96db8ef034b3e68703165267fd3696",
"entities": [
{
"tableName": "links",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `tags` TEXT NOT NULL, `is_private` INTEGER NOT NULL, `is_pinned` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `sync_status` TEXT NOT NULL, `local_modified_at` INTEGER NOT NULL, `thumbnail_url` TEXT, `reading_time_minutes` INTEGER, `content_type` TEXT NOT NULL, `site_name` TEXT, `excerpt` TEXT, `link_check_status` TEXT NOT NULL, `fail_count` INTEGER NOT NULL, `last_health_check` INTEGER NOT NULL, `excluded_from_health_check` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "isPrivate",
"columnName": "is_private",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isPinned",
"columnName": "is_pinned",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "syncStatus",
"columnName": "sync_status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "localModifiedAt",
"columnName": "local_modified_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnail_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "readingTimeMinutes",
"columnName": "reading_time_minutes",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "contentType",
"columnName": "content_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "siteName",
"columnName": "site_name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "excerpt",
"columnName": "excerpt",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "linkCheckStatus",
"columnName": "link_check_status",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "failCount",
"columnName": "fail_count",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastHealthCheck",
"columnName": "last_health_check",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "excludedFromHealthCheck",
"columnName": "excluded_from_health_check",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_links_sync_status",
"unique": false,
"columnNames": [
"sync_status"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_sync_status` ON `${TABLE_NAME}` (`sync_status`)"
},
{
"name": "index_links_is_private",
"unique": false,
"columnNames": [
"is_private"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_is_private` ON `${TABLE_NAME}` (`is_private`)"
},
{
"name": "index_links_created_at",
"unique": false,
"columnNames": [
"created_at"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_created_at` ON `${TABLE_NAME}` (`created_at`)"
},
{
"name": "index_links_is_pinned",
"unique": false,
"columnNames": [
"is_pinned"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_is_pinned` ON `${TABLE_NAME}` (`is_pinned`)"
},
{
"name": "index_links_url",
"unique": true,
"columnNames": [
"url"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_links_url` ON `${TABLE_NAME}` (`url`)"
},
{
"name": "index_links_content_type",
"unique": false,
"columnNames": [
"content_type"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_content_type` ON `${TABLE_NAME}` (`content_type`)"
},
{
"name": "index_links_site_name",
"unique": false,
"columnNames": [
"site_name"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_site_name` ON `${TABLE_NAME}` (`site_name`)"
},
{
"name": "index_links_link_check_status",
"unique": false,
"columnNames": [
"link_check_status"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_link_check_status` ON `${TABLE_NAME}` (`link_check_status`)"
},
{
"name": "index_links_last_health_check",
"unique": false,
"columnNames": [
"last_health_check"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_links_last_health_check` ON `${TABLE_NAME}` (`last_health_check`)"
}
],
"foreignKeys": []
},
{
"ftsVersion": "FTS4",
"ftsOptions": {
"tokenizer": "simple",
"tokenizerArgs": [],
"contentTable": "links",
"languageIdColumnName": "",
"matchInfo": "FTS4",
"notIndexedColumns": [],
"prefixSizes": [],
"preferredOrder": "ASC"
},
"contentSyncTriggers": [
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_links_fts_BEFORE_UPDATE BEFORE UPDATE ON `links` BEGIN DELETE FROM `links_fts` WHERE `docid`=OLD.`rowid`; END",
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_links_fts_BEFORE_DELETE BEFORE DELETE ON `links` BEGIN DELETE FROM `links_fts` WHERE `docid`=OLD.`rowid`; END",
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_links_fts_AFTER_UPDATE AFTER UPDATE ON `links` BEGIN INSERT INTO `links_fts`(`docid`, `url`, `title`, `description`, `tags`, `excerpt`) VALUES (NEW.`rowid`, NEW.`url`, NEW.`title`, NEW.`description`, NEW.`tags`, NEW.`excerpt`); END",
"CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_links_fts_AFTER_INSERT AFTER INSERT ON `links` BEGIN INSERT INTO `links_fts`(`docid`, `url`, `title`, `description`, `tags`, `excerpt`) VALUES (NEW.`rowid`, NEW.`url`, NEW.`title`, NEW.`description`, NEW.`tags`, NEW.`excerpt`); END"
],
"tableName": "links_fts",
"createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`url` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `tags` TEXT NOT NULL, `excerpt` TEXT, content=`links`)",
"fields": [
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "tags",
"columnName": "tags",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "excerpt",
"columnName": "excerpt",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": []
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "tags",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `occurrences` INTEGER NOT NULL, `last_used_at` INTEGER NOT NULL, `color` INTEGER, `is_favorite` INTEGER NOT NULL, PRIMARY KEY(`name`))",
"fields": [
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "occurrences",
"columnName": "occurrences",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastUsedAt",
"columnName": "last_used_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "isFavorite",
"columnName": "is_favorite",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"name"
]
},
"indices": [
{
"name": "index_tags_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_tags_name` ON `${TABLE_NAME}` (`name`)"
},
{
"name": "index_tags_occurrences",
"unique": false,
"columnNames": [
"occurrences"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_tags_occurrences` ON `${TABLE_NAME}` (`occurrences`)"
}
],
"foreignKeys": []
},
{
"tableName": "link_tag_cross_ref",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`link_id` INTEGER NOT NULL, `tag_name` TEXT NOT NULL, PRIMARY KEY(`link_id`, `tag_name`))",
"fields": [
{
"fieldPath": "linkId",
"columnName": "link_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "tagName",
"columnName": "tag_name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"link_id",
"tag_name"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "collections",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT, `icon` TEXT NOT NULL, `color` INTEGER, `is_smart` INTEGER NOT NULL, `query` TEXT, `sort_order` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "color",
"columnName": "color",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "isSmart",
"columnName": "is_smart",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "query",
"columnName": "query",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "sortOrder",
"columnName": "sort_order",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updatedAt",
"columnName": "updated_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_collections_name",
"unique": false,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collections_name` ON `${TABLE_NAME}` (`name`)"
},
{
"name": "index_collections_is_smart",
"unique": false,
"columnNames": [
"is_smart"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collections_is_smart` ON `${TABLE_NAME}` (`is_smart`)"
},
{
"name": "index_collections_sort_order",
"unique": false,
"columnNames": [
"sort_order"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_collections_sort_order` ON `${TABLE_NAME}` (`sort_order`)"
}
],
"foreignKeys": []
},
{
"tableName": "collection_links",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`collection_id` INTEGER NOT NULL, `link_id` INTEGER NOT NULL, `added_at` INTEGER NOT NULL, PRIMARY KEY(`collection_id`, `link_id`))",
"fields": [
{
"fieldPath": "collectionId",
"columnName": "collection_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "linkId",
"columnName": "link_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "addedAt",
"columnName": "added_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"collection_id",
"link_id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5f96db8ef034b3e68703165267fd3696')"
]
}
}

View File

@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application
android:name=".ShaarItApp"

View File

@ -0,0 +1,15 @@
HSPLandroidx/compose/runtime/ComposerImpl;->startRestartGroup(I)Landroidx/compose/runtime/Composer;
HSPLandroidx/compose/runtime/ComposerImpl;->endRestartGroup()Landroidx/compose/runtime/ScopeUpdateScope;
HSPLandroidx/compose/material3/**;->**(**)**
HSPLandroidx/compose/foundation/**;->**(**)**
HSPLandroidx/compose/ui/**;->**(**)**
HSPLandroidx/compose/animation/**;->**(**)**
HSPLandroidx/navigation/**;->**(**)**
HSPLandroidx/paging/**;->**(**)**
HSPLandroidx/room/**;->**(**)**
HSPLcoil/**;->**(**)**
HSPLcom/shaarit/presentation/**;->**(**)**
HSPLcom/shaarit/ui/**;->**(**)**
HSPLcom/shaarit/core/**;->**(**)**
HSPLcom/shaarit/data/**;->**(**)**
HSPLcom/shaarit/domain/**;->**(**)**

View File

@ -2,18 +2,23 @@ package com.shaarit
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.fragment.app.FragmentActivity
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import com.shaarit.core.storage.BiometricAuthManager
import com.shaarit.core.storage.SecurityPreferences
import com.shaarit.presentation.auth.LockScreen
import com.shaarit.presentation.nav.AppNavGraph
import com.shaarit.ui.theme.ShaarItTheme
import com.shaarit.ui.theme.ThemePreferences
@ -23,9 +28,17 @@ import java.io.InputStreamReader
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
class MainActivity : FragmentActivity() {
@Inject lateinit var themePreferences: ThemePreferences
@Inject lateinit var tokenManager: com.shaarit.core.storage.TokenManager
@Inject lateinit var securityPreferences: SecurityPreferences
@Inject lateinit var biometricAuthManager: BiometricAuthManager
// Start as authenticated — lock only triggers after app goes to background
private var isAuthenticated by mutableStateOf(true)
private var lastBackgroundTime: Long = 0L
private var hasBeenBackgrounded = false
override fun onCreate(savedInstanceState: Bundle?) {
// Install splash screen before super.onCreate
@ -36,67 +49,128 @@ class MainActivity : ComponentActivity() {
// Enable edge-to-edge mode for proper keyboard (IME) insets detection
enableEdgeToEdge()
WindowCompat.setDecorFitsSystemWindows(window, false)
// On cold start, require auth if biometric + requireOnStartup are enabled
if (securityPreferences.isBiometricEnabled.value && securityPreferences.requireOnStartup.value) {
isAuthenticated = false
}
// Parse intent ONCE, before composition (avoids re-execution on recomposition)
val shareData = parseShareIntent(intent)
// Check if user is already logged in to skip login screen for share intents
val hasValidToken = tokenManager.getToken() != null && tokenManager.getBaseUrl() != null
val startDestination = if (hasValidToken) {
com.shaarit.presentation.nav.Screen.Feed.createRoute()
} else {
com.shaarit.presentation.nav.Screen.Login.route
}
setContent {
val currentTheme by themePreferences.currentTheme.collectAsState()
ShaarItTheme(appTheme = currentTheme) {
// A surface container using the 'background' color from the theme
val context = LocalContext.current
var shareUrl: String? = null
var shareTitle: String? = null
var shareDescription: String? = null
var shareTags: List<String>? = null
var deepLink: String? = null
var isFileShare = false
val currentThemeMode by themePreferences.themeMode.collectAsState()
val biometricEnabled by securityPreferences.isBiometricEnabled.collectAsState()
val activity = context as? androidx.activity.ComponentActivity
val intent = activity?.intent
// Handle share intent
if (intent?.action == android.content.Intent.ACTION_SEND) {
val mimeType = intent.type ?: ""
// Check if this is a file share (markdown or text file)
val fileUri = intent.getParcelableExtra<Uri>(android.content.Intent.EXTRA_STREAM)
if (fileUri != null && isTextOrMarkdownFile(mimeType, fileUri)) {
// Handle file sharing - use filename as title, content as description
isFileShare = true
val fileInfo = readFileContent(fileUri)
shareTitle = fileInfo.first // filename without extension
shareDescription = fileInfo.second // file content
shareTags = listOf("note", "fichier")
shareUrl = null // No URL for file shares
} else if (mimeType == "text/plain") {
// Regular text share (URL)
shareUrl = intent.getStringExtra(android.content.Intent.EXTRA_TEXT)
shareTitle = intent.getStringExtra(android.content.Intent.EXTRA_SUBJECT)
}
}
// Handle deep links from App Shortcuts
intent?.data?.let { uri ->
if (uri.scheme == "shaarit") {
deepLink = uri.toString()
}
}
val needsAuth = biometricEnabled && !isAuthenticated
ShaarItTheme(appTheme = currentTheme, themeMode = currentThemeMode) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
AppNavGraph(
shareUrl = shareUrl,
shareTitle = shareTitle,
shareDescription = shareDescription,
shareTags = shareTags,
isFileShare = isFileShare,
initialDeepLink = deepLink
)
) {
if (needsAuth) {
LockScreen(
activity = this@MainActivity,
biometricAuthManager = biometricAuthManager,
onAuthenticated = { isAuthenticated = true }
)
} else {
AppNavGraph(
startDestination = startDestination,
shareUrl = shareData.url,
shareTitle = shareData.title,
shareDescription = shareData.description,
shareTags = shareData.tags,
isFileShare = shareData.isFileShare,
initialDeepLink = shareData.deepLink
)
}
}
}
}
}
override fun onStop() {
super.onStop()
lastBackgroundTime = System.currentTimeMillis()
hasBeenBackgrounded = true
}
override fun onResume() {
super.onResume()
if (!hasBeenBackgrounded) return
if (securityPreferences.isBiometricEnabled.value) {
val elapsed = System.currentTimeMillis() - lastBackgroundTime
val timeout = securityPreferences.lockTimeout.value.delayMs
val shouldLock = if (securityPreferences.requireOnResume.value) {
lastBackgroundTime > 0 && elapsed > timeout
} else {
false
}
if (shouldLock) {
isAuthenticated = false
}
}
}
private data class ShareData(
val url: String? = null,
val title: String? = null,
val description: String? = null,
val tags: List<String>? = null,
val isFileShare: Boolean = false,
val deepLink: String? = null
)
private fun parseShareIntent(intent: android.content.Intent?): ShareData {
if (intent == null) return ShareData()
var shareUrl: String? = null
var shareTitle: String? = null
var shareDescription: String? = null
var shareTags: List<String>? = null
var isFileShare = false
var deepLink: String? = null
// Handle share intent
if (intent.action == android.content.Intent.ACTION_SEND) {
val mimeType = intent.type ?: ""
// Check if this is a file share (markdown or text file)
val fileUri = intent.getParcelableExtra<Uri>(android.content.Intent.EXTRA_STREAM)
if (fileUri != null && isTextOrMarkdownFile(mimeType, fileUri)) {
isFileShare = true
val fileInfo = readFileContent(fileUri)
shareTitle = fileInfo.first
shareDescription = fileInfo.second
shareTags = listOf("note", "fichier")
shareUrl = null
} else if (mimeType == "text/plain") {
shareUrl = intent.getStringExtra(android.content.Intent.EXTRA_TEXT)
shareTitle = intent.getStringExtra(android.content.Intent.EXTRA_SUBJECT)
}
}
// Handle deep links from App Shortcuts
intent.data?.let { uri ->
if (uri.scheme == "shaarit") {
deepLink = uri.toString()
}
}
return ShareData(shareUrl, shareTitle, shareDescription, shareTags, isFileShare, deepLink)
}
/**
* Checks if the shared content is a text or markdown file

View File

@ -1,5 +1,6 @@
package com.shaarit.core.di
import android.content.Context
import com.shaarit.core.network.AuthInterceptor
import com.shaarit.core.network.HostSelectionInterceptor
import com.shaarit.data.api.ShaarliApi
@ -7,8 +8,12 @@ import com.squareup.moshi.Moshi
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import java.io.File
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
import okhttp3.Cache
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
@ -23,11 +28,24 @@ object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(
@ApplicationContext context: Context,
authInterceptor: AuthInterceptor,
hostSelectionInterceptor: HostSelectionInterceptor
): OkHttpClient {
val logging = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY }
val logging = HttpLoggingInterceptor().apply {
level = if (com.shaarit.BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE
}
}
val cacheSize = 10L * 1024 * 1024 // 10 MB
val cache = Cache(File(context.cacheDir, "http_cache"), cacheSize)
return OkHttpClient.Builder()
.cache(cache)
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.addInterceptor(hostSelectionInterceptor) // Host selection first
.addInterceptor(authInterceptor) // Auth header second
.addInterceptor(logging)

View File

@ -0,0 +1,115 @@
package com.shaarit.core.storage
import android.content.Context
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
enum class BiometricAvailability {
AVAILABLE,
NO_HARDWARE,
NOT_ENROLLED,
UNAVAILABLE
}
@Singleton
class BiometricAuthManager @Inject constructor(
@ApplicationContext private val context: Context
) {
private val biometricManager = BiometricManager.from(context)
/**
* Returns the best available authenticator flags, trying from strongest to weakest:
* 1. BIOMETRIC_STRONG | DEVICE_CREDENTIAL
* 2. BIOMETRIC_WEAK | DEVICE_CREDENTIAL
* 3. DEVICE_CREDENTIAL alone
* 4. BIOMETRIC_STRONG alone
* 5. BIOMETRIC_WEAK alone
* Returns 0 if nothing is available.
*/
private fun resolveAuthenticators(): Int {
val candidates = listOf(
BIOMETRIC_STRONG or DEVICE_CREDENTIAL,
BIOMETRIC_WEAK or DEVICE_CREDENTIAL,
DEVICE_CREDENTIAL,
BIOMETRIC_STRONG,
BIOMETRIC_WEAK
)
for (auth in candidates) {
if (biometricManager.canAuthenticate(auth) == BiometricManager.BIOMETRIC_SUCCESS) {
return auth
}
}
return 0
}
fun canAuthenticate(): BiometricAvailability {
if (resolveAuthenticators() != 0) return BiometricAvailability.AVAILABLE
val result = biometricManager.canAuthenticate(BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
return when (result) {
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> BiometricAvailability.NO_HARDWARE
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> BiometricAvailability.NOT_ENROLLED
else -> BiometricAvailability.UNAVAILABLE
}
}
fun authenticate(
activity: FragmentActivity,
onSuccess: () -> Unit,
onError: (String) -> Unit
) {
val authenticators = resolveAuthenticators()
if (authenticators == 0) {
onError("Aucune méthode d'authentification disponible")
return
}
val executor = ContextCompat.getMainExecutor(activity)
val builder = BiometricPrompt.PromptInfo.Builder()
.setTitle("Déverrouiller ShaarIt")
.setSubtitle("Utilisez votre empreinte, visage ou code PIN")
.setAllowedAuthenticators(authenticators)
// setNegativeButtonText is required when DEVICE_CREDENTIAL is NOT included
if (authenticators and DEVICE_CREDENTIAL == 0) {
builder.setNegativeButtonText("Annuler")
}
val promptInfo = builder.build()
val biometricPrompt = BiometricPrompt(activity, executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
onSuccess()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString)
if (errorCode != BiometricPrompt.ERROR_USER_CANCELED &&
errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON &&
errorCode != BiometricPrompt.ERROR_CANCELED
) {
onError(errString.toString())
}
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
// Individual attempt failed, prompt stays open for retry
}
}
)
biometricPrompt.authenticate(promptInfo)
}
}

View File

@ -0,0 +1,79 @@
package com.shaarit.core.storage
import android.content.Context
import android.content.SharedPreferences
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
enum class LockTimeout(val displayName: String, val delayMs: Long) {
IMMEDIATE("Immédiat", 0),
AFTER_1_MIN("Après 1 minute", 60_000),
AFTER_5_MIN("Après 5 minutes", 300_000),
AFTER_15_MIN("Après 15 minutes", 900_000)
}
@Singleton
class SecurityPreferences @Inject constructor(
@ApplicationContext private val context: Context
) {
private val prefs: SharedPreferences =
context.getSharedPreferences("security_prefs", Context.MODE_PRIVATE)
private val _isBiometricEnabled = MutableStateFlow(loadBiometricEnabled())
val isBiometricEnabled: StateFlow<Boolean> = _isBiometricEnabled.asStateFlow()
private val _lockTimeout = MutableStateFlow(loadLockTimeout())
val lockTimeout: StateFlow<LockTimeout> = _lockTimeout.asStateFlow()
private val _requireOnStartup = MutableStateFlow(loadRequireOnStartup())
val requireOnStartup: StateFlow<Boolean> = _requireOnStartup.asStateFlow()
private val _requireOnResume = MutableStateFlow(loadRequireOnResume())
val requireOnResume: StateFlow<Boolean> = _requireOnResume.asStateFlow()
private fun loadBiometricEnabled(): Boolean =
prefs.getBoolean(KEY_BIOMETRIC_ENABLED, false)
private fun loadLockTimeout(): LockTimeout {
val name = prefs.getString(KEY_LOCK_TIMEOUT, LockTimeout.IMMEDIATE.name)
?: LockTimeout.IMMEDIATE.name
return try { LockTimeout.valueOf(name) } catch (_: Exception) { LockTimeout.IMMEDIATE }
}
private fun loadRequireOnStartup(): Boolean =
prefs.getBoolean(KEY_REQUIRE_ON_STARTUP, true)
private fun loadRequireOnResume(): Boolean =
prefs.getBoolean(KEY_REQUIRE_ON_RESUME, false)
fun setBiometricEnabled(enabled: Boolean) {
prefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED, enabled).apply()
_isBiometricEnabled.value = enabled
}
fun setLockTimeout(timeout: LockTimeout) {
prefs.edit().putString(KEY_LOCK_TIMEOUT, timeout.name).apply()
_lockTimeout.value = timeout
}
fun setRequireOnStartup(require: Boolean) {
prefs.edit().putBoolean(KEY_REQUIRE_ON_STARTUP, require).apply()
_requireOnStartup.value = require
}
fun setRequireOnResume(require: Boolean) {
prefs.edit().putBoolean(KEY_REQUIRE_ON_RESUME, require).apply()
_requireOnResume.value = require
}
companion object {
private const val KEY_BIOMETRIC_ENABLED = "biometric_enabled"
private const val KEY_LOCK_TIMEOUT = "lock_timeout"
private const val KEY_REQUIRE_ON_STARTUP = "require_on_startup"
private const val KEY_REQUIRE_ON_RESUME = "require_on_resume"
}
}

View File

@ -28,6 +28,9 @@ interface TokenManager {
fun saveGeminiApiKey(apiKey: String)
fun getGeminiApiKey(): String?
fun clearGeminiApiKey()
fun saveLastSyncTimestamp(timestamp: Long)
fun getLastSyncTimestamp(): Long
}
@Singleton
@ -128,6 +131,14 @@ class TokenManagerImpl @Inject constructor(@ApplicationContext private val conte
sharedPreferences.edit().remove(KEY_GEMINI_API_KEY).apply()
}
override fun saveLastSyncTimestamp(timestamp: Long) {
sharedPreferences.edit().putLong(KEY_LAST_SYNC_TIMESTAMP, timestamp).apply()
}
override fun getLastSyncTimestamp(): Long {
return sharedPreferences.getLong(KEY_LAST_SYNC_TIMESTAMP, 0L)
}
companion object {
private const val KEY_TOKEN = "jwt_token"
private const val KEY_BASE_URL = "base_url"
@ -136,5 +147,6 @@ class TokenManagerImpl @Inject constructor(@ApplicationContext private val conte
private const val KEY_COLLECTIONS_DIRTY = "collections_config_dirty"
private const val KEY_COLLECTIONS_BOOKMARK_ID = "collections_config_bookmark_id"
private const val KEY_GEMINI_API_KEY = "gemini_api_key"
private const val KEY_LAST_SYNC_TIMESTAMP = "last_sync_timestamp"
}
}

View File

@ -73,6 +73,9 @@ interface LinkDao {
@RawQuery(observedEntities = [LinkEntity::class])
fun getLinksByTags(query: SupportSQLiteQuery): PagingSource<Int, LinkEntity>
@RawQuery(observedEntities = [LinkEntity::class])
suspend fun getLinksRawQuery(query: SupportSQLiteQuery): List<LinkEntity>
@Query("""
SELECT links.* FROM links
INNER JOIN collection_links ON links.id = collection_links.link_id

View File

@ -5,6 +5,8 @@ import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.shaarit.data.local.converter.Converters
import com.shaarit.data.local.dao.CollectionDao
import com.shaarit.data.local.dao.LinkDao
@ -28,8 +30,8 @@ import com.shaarit.data.local.entity.TagEntity
CollectionEntity::class,
CollectionLinkCrossRef::class
],
version = 4,
exportSchema = false
version = 5,
exportSchema = true
)
@TypeConverters(Converters::class)
abstract class ShaarliDatabase : RoomDatabase() {
@ -41,6 +43,22 @@ abstract class ShaarliDatabase : RoomDatabase() {
companion object {
private const val DATABASE_NAME = "shaarli.db"
/**
* Migration v4 v5 : Ajout des index manquants pour les colonnes fréquemment filtrées
* - content_type : utilisé par getContentTypeDistribution()
* - site_name : utilisé par getTopSites() et getAllSites()
* - link_check_status : utilisé par getDeadLinks(), getDeadLinksCount(), getPendingLinksCount()
* - last_health_check : utilisé par getLinksForHealthCheck()
*/
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE INDEX IF NOT EXISTS `index_links_content_type` ON `links` (`content_type`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_links_site_name` ON `links` (`site_name`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_links_link_check_status` ON `links` (`link_check_status`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_links_last_health_check` ON `links` (`last_health_check`)")
}
}
@Volatile
private var instance: ShaarliDatabase? = null
@ -56,7 +74,8 @@ abstract class ShaarliDatabase : RoomDatabase() {
ShaarliDatabase::class.java,
DATABASE_NAME
)
.fallbackToDestructiveMigration()
.addMigrations(MIGRATION_4_5)
.fallbackToDestructiveMigrationFrom(1, 2, 3)
.build()
}
}

View File

@ -17,7 +17,11 @@ import androidx.room.PrimaryKey
Index(value = ["is_private"]),
Index(value = ["created_at"]),
Index(value = ["is_pinned"]),
Index(value = ["url"], unique = true)
Index(value = ["url"], unique = true),
Index(value = ["content_type"]),
Index(value = ["site_name"]),
Index(value = ["link_check_status"]),
Index(value = ["last_health_check"])
]
)
data class LinkEntity(

View File

@ -21,6 +21,16 @@ class LinkMetadataExtractor @Inject constructor() {
private const val TAG = "LinkMetadataExtractor"
private const val TIMEOUT_MS = 10000
private const val MAX_DESCRIPTION_LENGTH = 300
private val VIDEO_PATTERN = Regex("youtube\\.com|vimeo\\.com|dailymotion|youtu\\.be")
private val REPO_PATTERN = Regex("github\\.com|gitlab\\.com|gitlab|bitbucket")
private val DOCUMENT_PATTERN = Regex("docs\\.google\\.com|docs\\.google|notion\\.so|confluence")
private val SOCIAL_PATTERN = Regex("facebook\\.com|instagram\\.com|tiktok\\.com|twitter\\.com|x\\.com|mastodon|linkedin\\.com|reddit\\.com|snapchat\\.com|pinterest\\.com")
private val SHOPPING_PATTERN = Regex("amazon|ebay|shopify")
private val NEWSLETTER_PATTERN = Regex("substack|revue|mailchimp")
private val MUSIC_PATTERN = Regex("spotify|deezer|soundcloud|bandcamp")
private val NEWS_PATTERN = Regex("news|nytimes|lemonde|bbc|cnn|reuters|theguardian|lefigaro")
private val PODCAST_PATTERN = Regex("podcast|anchor")
}
/**
@ -172,16 +182,16 @@ class LinkMetadataExtractor @Inject constructor() {
val ogType = doc.select("meta[property=og:type]").attr("content")
return when {
ogType == "video" || url.contains(Regex("youtube\\.com|vimeo\\.com|dailymotion")) -> ContentType.VIDEO
ogType == "video" || url.contains(VIDEO_PATTERN) -> ContentType.VIDEO
ogType == "article" || doc.select("article").isNotEmpty() -> ContentType.ARTICLE
url.contains(Regex("github\\.com|gitlab\\.com|bitbucket")) -> ContentType.REPOSITORY
url.contains(Regex("docs\\.google\\.com|notion\\.so|confluence")) -> ContentType.DOCUMENT
url.contains(Regex("facebook\\.com|instagram\\.com|tiktok\\.com|twitter\\.com|x\\.com|mastodon|linkedin\\.com|reddit\\.com|snapchat\\.com|pinterest\\.com")) -> ContentType.SOCIAL
url.contains(Regex("amazon|ebay|shopify")) -> ContentType.SHOPPING
url.contains(Regex("substack|revue|mailchimp")) -> ContentType.NEWSLETTER
url.contains(Regex("spotify|deezer|soundcloud|bandcamp")) -> ContentType.MUSIC
url.contains(Regex("news|nytimes|lemonde|bbc|cnn|reuters|theguardian|lefigaro")) -> ContentType.NEWS
doc.select("audio").isNotEmpty() || url.contains(Regex("podcast|anchor")) -> ContentType.PODCAST
url.contains(REPO_PATTERN) -> ContentType.REPOSITORY
url.contains(DOCUMENT_PATTERN) -> ContentType.DOCUMENT
url.contains(SOCIAL_PATTERN) -> ContentType.SOCIAL
url.contains(SHOPPING_PATTERN) -> ContentType.SHOPPING
url.contains(NEWSLETTER_PATTERN) -> ContentType.NEWSLETTER
url.contains(MUSIC_PATTERN) -> ContentType.MUSIC
url.contains(NEWS_PATTERN) -> ContentType.NEWS
doc.select("audio").isNotEmpty() || url.contains(PODCAST_PATTERN) -> ContentType.PODCAST
url.endsWith(".pdf") -> ContentType.PDF
else -> ContentType.UNKNOWN
}
@ -192,14 +202,14 @@ class LinkMetadataExtractor @Inject constructor() {
*/
private fun detectContentTypeFromUrl(url: String): ContentType {
return when {
url.contains(Regex("youtube\\.com|youtu\\.be|vimeo|dailymotion")) -> ContentType.VIDEO
url.contains(Regex("github\\.com|gitlab|bitbucket")) -> ContentType.REPOSITORY
url.contains(Regex("docs\\.google|notion\\.so|confluence")) -> ContentType.DOCUMENT
url.contains(Regex("facebook\\.com|instagram\\.com|tiktok\\.com|twitter\\.com|x\\.com|mastodon|linkedin\\.com|reddit\\.com|snapchat\\.com|pinterest\\.com")) -> ContentType.SOCIAL
url.contains(Regex("amazon|ebay|shopify")) -> ContentType.SHOPPING
url.contains(Regex("substack|revue|mailchimp")) -> ContentType.NEWSLETTER
url.contains(Regex("spotify|deezer|soundcloud|bandcamp")) -> ContentType.MUSIC
url.contains(Regex("news|nytimes|lemonde|bbc|cnn|reuters|theguardian|lefigaro")) -> ContentType.NEWS
url.contains(VIDEO_PATTERN) -> ContentType.VIDEO
url.contains(REPO_PATTERN) -> ContentType.REPOSITORY
url.contains(DOCUMENT_PATTERN) -> ContentType.DOCUMENT
url.contains(SOCIAL_PATTERN) -> ContentType.SOCIAL
url.contains(SHOPPING_PATTERN) -> ContentType.SHOPPING
url.contains(NEWSLETTER_PATTERN) -> ContentType.NEWSLETTER
url.contains(MUSIC_PATTERN) -> ContentType.MUSIC
url.contains(NEWS_PATTERN) -> ContentType.NEWS
url.endsWith(".pdf") -> ContentType.PDF
else -> ContentType.UNKNOWN
}

View File

@ -1,68 +0,0 @@
package com.shaarit.data.paging
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.shaarit.data.api.ShaarliApi
import com.shaarit.data.mapper.LinkMapper
import com.shaarit.domain.model.ShaarliLink
import java.io.IOException
import retrofit2.HttpException
class LinkPagingSource(
private val api: ShaarliApi,
private val searchTerm: String? = null,
private val searchTags: String? = null
) : PagingSource<Int, ShaarliLink>() {
override fun getRefreshKey(state: PagingState<Int, ShaarliLink>): Int? {
return state.anchorPosition?.let { anchorPosition ->
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
}
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, ShaarliLink> {
val position = params.key ?: 0
// Shaarli V1 API uses offset and limit
// Assuming we pass page index as key? No, offset is better for generic APIs, but here we
// can manage offset.
// Wait, params.key is usually page index if we set it so.
// Or we can use offset as the key.
// Let's use Offset as Key. Initial key = 0.
val offset = position
val limit = params.loadSize
return try {
val dtos =
api.getLinks(
offset = offset,
limit = limit,
searchTerm = searchTerm,
searchTags = searchTags
)
val links = dtos.mapNotNull { LinkMapper.toDomain(it) }
val nextKey =
if (links.isEmpty()) {
null
} else {
// If we got less than requested, we are at the end?
// Shaarli doesn't return total count easily in v1 (maybe headers).
// If detailed list is empty or < limit, next is null.
if (links.size < limit) null else offset + limit
}
LoadResult.Page(
data = links,
prevKey = if (offset == 0) null else offset - limit,
nextKey = nextKey
)
} catch (exception: IOException) {
LoadResult.Error(exception)
} catch (exception: HttpException) {
LoadResult.Error(exception)
}
}
}

View File

@ -13,6 +13,7 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.json.JSONObject
import android.util.LruCache
import javax.inject.Inject
import javax.inject.Singleton
@ -22,11 +23,11 @@ class GeminiRepositoryImpl @Inject constructor(
private val okHttpClient: OkHttpClient
) : GeminiRepository {
// Cache pour les tags
private val tagsCache = mutableMapOf<String, List<String>>()
// Cache pour les tags (borné à 50 entrées max)
private val tagsCache = LruCache<String, List<String>>(50)
// Cache en mémoire pour éviter de rappeler l'API pour la même URL durant la session
private val analysisCache = mutableMapOf<String, AiEnrichmentResult>()
// Cache en mémoire pour éviter de rappeler l'API pour la même URL durant la session (borné à 50 entrées max)
private val analysisCache = LruCache<String, AiEnrichmentResult>(50)
override fun isApiKeyConfigured(): Boolean {
return !tokenManager.getGeminiApiKey().isNullOrBlank()
@ -34,8 +35,8 @@ class GeminiRepositoryImpl @Inject constructor(
override suspend fun generateTags(title: String, description: String): Result<List<String>> = withContext(Dispatchers.IO) {
val cacheKey = "$title|$description"
if (tagsCache.containsKey(cacheKey)) {
return@withContext Result.success(tagsCache[cacheKey]!!)
tagsCache.get(cacheKey)?.let {
return@withContext Result.success(it)
}
try {
@ -56,7 +57,7 @@ class GeminiRepositoryImpl @Inject constructor(
for (modelName in modelsToTry) {
try {
val tags = generateTagsWithModel(apiKey, modelName, title, description)
tagsCache[cacheKey] = tags
tagsCache.put(cacheKey, tags)
return@withContext Result.success(tags)
} catch (e: Exception) {
lastException = e
@ -114,8 +115,8 @@ class GeminiRepositoryImpl @Inject constructor(
override suspend fun analyzeUrl(url: String): Result<AiEnrichmentResult> = withContext(Dispatchers.IO) {
// Vérifier le cache d'abord
if (analysisCache.containsKey(url)) {
return@withContext Result.success(analysisCache[url]!!)
analysisCache.get(url)?.let {
return@withContext Result.success(it)
}
try {
@ -146,7 +147,7 @@ class GeminiRepositoryImpl @Inject constructor(
try {
val result = generateWithModel(apiKey, modelName, url)
// Mettre en cache le résultat réussi
analysisCache[url] = result
analysisCache.put(url, result)
return@withContext Result.success(result)
} catch (e: Exception) {
lastException = e

View File

@ -55,11 +55,16 @@ constructor(
): Flow<PagingData<ShaarliLink>> {
// Utiliser Room pour la pagination locale
return Pager(
config = PagingConfig(pageSize = 20, enablePlaceholders = false),
config = PagingConfig(
pageSize = 20,
prefetchDistance = 10,
initialLoadSize = 40,
enablePlaceholders = false
),
pagingSourceFactory = {
when {
collectionId != null -> linkDao.getLinksInCollectionPaged(collectionId)
!searchTerm.isNullOrBlank() -> linkDao.searchLinks(searchTerm)
!searchTerm.isNullOrBlank() -> linkDao.searchLinksFullText(searchTerm)
!searchTags.isNullOrBlank() -> {
val tags =
searchTags
@ -114,18 +119,15 @@ constructor(
}
override suspend fun getLinksByTag(tag: String): Result<List<ShaarliLink>> {
// Lire depuis le cache local
return try {
// Si nous avons des données locales, les retourner
val localLinks = linkDao.getAllLinks().firstOrNull() ?: emptyList()
// Requête SQL directe via Room au lieu de charger toute la base en mémoire
val sql = "SELECT * FROM links WHERE tags LIKE ? AND sync_status != 'PENDING_DELETE' ORDER BY is_pinned DESC, created_at DESC LIMIT 100"
val localLinks = linkDao.getLinksRawQuery(SimpleSQLiteQuery(sql, arrayOf("%\"$tag\"%")))
if (localLinks.isNotEmpty()) {
val filtered = localLinks.filter { it.tags.contains(tag) }
if (filtered.isNotEmpty()) {
return Result.success(filtered.map { it.toDomainModel() })
}
return Result.success(localLinks.map { it.toDomainModel() })
}
// Fallback vers l'API
// Fallback vers l'API si aucun résultat local
val links = api.getLinksByTag(tag = tag, offset = 0, limit = 100)
Result.success(links.mapNotNull { LinkMapper.toDomain(it) })
} catch (e: Exception) {
@ -507,7 +509,12 @@ constructor(
override fun getDeadLinksStream(): Flow<PagingData<ShaarliLink>> {
return Pager(
config = PagingConfig(pageSize = 20, enablePlaceholders = false),
config = PagingConfig(
pageSize = 20,
prefetchDistance = 10,
initialLoadSize = 40,
enablePlaceholders = false
),
pagingSourceFactory = { linkDao.getDeadLinks() }
).flow.map { pagingData ->
pagingData.map { it.toDomainModel() }
@ -516,7 +523,12 @@ constructor(
override fun getPinnedLinksStream(): Flow<PagingData<ShaarliLink>> {
return Pager(
config = PagingConfig(pageSize = 20, enablePlaceholders = false),
config = PagingConfig(
pageSize = 20,
prefetchDistance = 10,
initialLoadSize = 40,
enablePlaceholders = false
),
pagingSourceFactory = { linkDao.getPinnedLinksPaged() }
).flow.map { pagingData ->
pagingData.map { it.toDomainModel() }
@ -533,6 +545,10 @@ constructor(
}
}
companion object {
private val DATE_FORMAT = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.US)
}
private fun LinkEntity.toDomainModel(): ShaarliLink {
return ShaarliLink(
id = id,
@ -541,7 +557,7 @@ constructor(
description = description,
tags = tags,
isPrivate = isPrivate,
date = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.getDefault()).format(java.util.Date(createdAt)),
date = DATE_FORMAT.format(java.util.Date(createdAt)),
isPinned = isPinned,
thumbnailUrl = thumbnailUrl,
readingTime = readingTimeMinutes,
@ -580,8 +596,7 @@ constructor(
private fun parseDate(dateString: String?): Long {
if (dateString.isNullOrBlank()) return System.currentTimeMillis()
return try {
val format = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.getDefault())
format.parse(dateString)?.time ?: System.currentTimeMillis()
DATE_FORMAT.parse(dateString)?.time ?: System.currentTimeMillis()
} catch (e: Exception) {
System.currentTimeMillis()
}

View File

@ -370,12 +370,20 @@ class SyncManager @Inject constructor(
}
/**
* Récupère les données depuis le serveur
* Récupère les données depuis le serveur (sync incrémentale)
* S'arrête quand on rencontre des liens déjà synchronisés (non modifiés depuis la dernière sync)
*/
private suspend fun pullFromServer() {
var offset = 0
val limit = 100
var hasMore = true
val lastSyncTimestamp = tokenManager.getLastSyncTimestamp()
val syncStartTime = System.currentTimeMillis()
val isFirstSync = lastSyncTimestamp == 0L
var unchangedStreakCount = 0
val unchangedStreakThreshold = 2 // Stop after 2 consecutive pages of unchanged links
Log.d(TAG, "Sync incrémentale: lastSync=${if (isFirstSync) "jamais" else java.time.Instant.ofEpochMilli(lastSyncTimestamp)}")
while (hasMore) {
try {
@ -395,9 +403,21 @@ class SyncManager @Inject constructor(
}
Log.d(TAG, "${validLinks.size}/${links.size} liens valides")
var newOrUpdatedCount = 0
val entities = validLinks.mapNotNull { dto ->
try {
val existing = linkDao.getLinkById(dto.id!!)
val serverUpdatedAt = parseDate(dto.updated)
// Check if this link has been modified since last sync
if (!isFirstSync && existing != null && existing.updatedAt >= serverUpdatedAt && existing.syncStatus == SyncStatus.SYNCED) {
// Link unchanged since last sync — skip
return@mapNotNull null
}
newOrUpdatedCount++
LinkEntity(
id = dto.id,
url = dto.url!!,
@ -407,7 +427,7 @@ class SyncManager @Inject constructor(
isPrivate = dto.isPrivate ?: false,
isPinned = existing?.isPinned ?: false,
createdAt = parseDate(dto.created),
updatedAt = parseDate(dto.updated),
updatedAt = serverUpdatedAt,
syncStatus = SyncStatus.SYNCED,
thumbnailUrl = existing?.thumbnailUrl,
readingTimeMinutes = existing?.readingTimeMinutes,
@ -428,6 +448,20 @@ class SyncManager @Inject constructor(
if (entities.isNotEmpty()) {
linkDao.insertLinks(entities)
}
Log.d(TAG, "Page offset=$offset: $newOrUpdatedCount nouveaux/modifiés sur ${validLinks.size} valides")
// Incremental sync: stop early if we encounter pages with no changes
if (!isFirstSync && newOrUpdatedCount == 0) {
unchangedStreakCount++
if (unchangedStreakCount >= unchangedStreakThreshold) {
Log.d(TAG, "Sync incrémentale: $unchangedStreakThreshold pages consécutives sans changement, arrêt anticipé")
hasMore = false
}
} else {
unchangedStreakCount = 0
}
offset += links.size
}
} catch (e: Exception) {
@ -436,6 +470,9 @@ class SyncManager @Inject constructor(
}
}
// Save sync timestamp on success
tokenManager.saveLastSyncTimestamp(syncStartTime)
// Synchroniser les tags
try {
val tags = api.getTags(limit = 1000)

View File

@ -34,6 +34,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
import com.shaarit.ui.components.*
import com.shaarit.ui.components.VoiceInputButton
import com.shaarit.ui.theme.Typography
import kotlinx.coroutines.launch
@ -60,6 +61,7 @@ fun AddLinkScreen(
val snackbarHostState = remember { SnackbarHostState() }
val focusManager = LocalFocusManager.current
val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
var showMarkdownPreview by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
val coroutineScope = rememberCoroutineScope()
@ -75,6 +77,7 @@ fun AddLinkScreen(
LaunchedEffect(uiState) {
when (val state = uiState) {
is AddLinkUiState.Success -> {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
if (onShareSuccess != null) {
onShareSuccess()
} else {
@ -335,7 +338,13 @@ fun AddLinkScreen(
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
colors = compactTextFieldColors(),
shape = RoundedCornerShape(8.dp),
textStyle = MaterialTheme.typography.bodyMedium
textStyle = MaterialTheme.typography.bodyMedium,
trailingIcon = {
VoiceInputButton(
onResult = { viewModel.title.value = it },
contentDescription = "Dicter le titre"
)
}
)
}

View File

@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import java.net.URLDecoder
import javax.inject.Inject
@ -122,29 +123,47 @@ constructor(
private fun extractMetadata(urlString: String) {
viewModelScope.launch {
_isExtractingMetadata.value = true
try {
val metadata = metadataExtractor.extract(urlString)
// Auto-remplir si les champs sont vides
// Launch JSoup metadata extraction and AI analysis in parallel
val metadataDeferred = async {
try {
metadataExtractor.extract(urlString)
} catch (e: Exception) {
null
}
}
val aiDeferred = if (analyzeUrlWithAiUseCase.isApiKeyConfigured() && _aiEnrichmentState.value != AiEnrichmentState.Loading) {
_aiEnrichmentState.value = AiEnrichmentState.Loading
async {
analyzeUrlWithAiUseCase(urlString)
}
} else null
// Apply JSoup metadata as soon as it arrives
val metadata = metadataDeferred.await()
if (metadata != null) {
if (title.value.isBlank() && !metadata.title.isNullOrBlank()) {
title.value = metadata.title
}
if (description.value.isBlank() && !metadata.description.isNullOrBlank()) {
description.value = metadata.description
}
_extractedThumbnail.value = metadata.thumbnailUrl
_contentType.value = metadata.contentType.name
// Suggérer des tags basés sur le site
metadata.siteName?.let { site ->
suggestTagFromSite(site)
}
} catch (e: Exception) {
// Ignorer silencieusement les erreurs d'extraction
} finally {
_isExtractingMetadata.value = false
metadata.siteName?.let { site -> suggestTagFromSite(site) }
}
_isExtractingMetadata.value = false
// Complete with AI enrichment
aiDeferred?.await()
?.onSuccess { result ->
applyAiEnrichment(result)
_aiEnrichmentState.value = AiEnrichmentState.Success
}
?.onFailure {
_aiEnrichmentState.value = AiEnrichmentState.Idle
}
}
}

View File

@ -0,0 +1,155 @@
package com.shaarit.presentation.auth
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Fingerprint
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import com.shaarit.core.storage.BiometricAuthManager
import com.shaarit.core.storage.BiometricAvailability
@Composable
fun LockScreen(
activity: FragmentActivity,
biometricAuthManager: BiometricAuthManager,
onAuthenticated: () -> Unit
) {
var errorMessage by remember { mutableStateOf<String?>(null) }
var hasTriedOnce by remember { mutableStateOf(false) }
// Auto-trigger biometric prompt only when activity lifecycle reaches RESUMED
DisposableEffect(activity) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME && !hasTriedOnce) {
hasTriedOnce = true
if (biometricAuthManager.canAuthenticate() == BiometricAvailability.AVAILABLE) {
try {
biometricAuthManager.authenticate(
activity = activity,
onSuccess = onAuthenticated,
onError = { errorMessage = it }
)
} catch (e: Exception) {
errorMessage = e.message ?: "Erreur d'authentification"
}
}
}
}
activity.lifecycle.addObserver(observer)
onDispose { activity.lifecycle.removeObserver(observer) }
}
// Pulsing animation for the lock icon
val infiniteTransition = rememberInfiniteTransition(label = "lock_pulse")
val pulseAlpha by infiniteTransition.animateFloat(
initialValue = 0.6f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(1200, easing = EaseInOutCubic),
repeatMode = RepeatMode.Reverse
),
label = "pulse_alpha"
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
brush = Brush.verticalGradient(
colors = listOf(
MaterialTheme.colorScheme.background,
MaterialTheme.colorScheme.surface
)
)
),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier.padding(32.dp)
) {
Icon(
imageVector = Icons.Default.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.size(72.dp)
.alpha(pulseAlpha)
)
Text(
text = "ShaarIt est verrouillé",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center
)
Text(
text = "Authentifiez-vous pour accéder à vos favoris",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
errorMessage?.let { msg ->
Text(
text = msg,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
errorMessage = null
if (biometricAuthManager.canAuthenticate() == BiometricAvailability.AVAILABLE) {
biometricAuthManager.authenticate(
activity = activity,
onSuccess = onAuthenticated,
onError = { errorMessage = it }
)
} else {
errorMessage = "Authentification biométrique non disponible"
}
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
Icon(
imageVector = Icons.Default.Fingerprint,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Déverrouiller",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
}
}
}

View File

@ -43,6 +43,7 @@ import com.shaarit.domain.model.TagFilter
import com.shaarit.domain.model.ViewStyle
import com.shaarit.ui.components.PremiumTextField
import com.shaarit.ui.components.TagChip
import com.shaarit.ui.components.VoiceInputButton
import com.shaarit.ui.theme.Typography
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
@ -1314,16 +1315,21 @@ fun FeedScreen(
)
},
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(
onClick = { viewModel.onSearchQueryChanged("") }
) {
Icon(
Icons.Default.Close,
contentDescription = "Clear",
tint = MaterialTheme.colorScheme.outline
)
Row {
if (searchQuery.isNotEmpty()) {
IconButton(
onClick = { viewModel.onSearchQueryChanged("") }
) {
Icon(
Icons.Default.Close,
contentDescription = "Clear",
tint = MaterialTheme.colorScheme.outline
)
}
}
VoiceInputButton(
onResult = { viewModel.onSearchQueryChanged(it) }
)
}
}
)
@ -1444,7 +1450,8 @@ fun FeedScreen(
},
onViewClick = { selectedLink = link },
onEditClick = onNavigateToEdit,
onDeleteClick = { viewModel.deleteLink(link.id) }
onDeleteClick = { viewModel.deleteLink(link.id) },
onTogglePin = { id -> viewModel.togglePin(id) }
)
}
}
@ -1504,7 +1511,8 @@ fun FeedScreen(
},
onViewClick = { selectedLink = link },
onEditClick = onNavigateToEdit,
onDeleteClick = { viewModel.deleteLink(link.id) }
onDeleteClick = { viewModel.deleteLink(link.id) },
onTogglePin = { id -> viewModel.togglePin(id) }
)
}
}
@ -1565,7 +1573,8 @@ fun FeedScreen(
},
onViewClick = { selectedLink = link },
onEditClick = onNavigateToEdit,
onDeleteClick = { viewModel.deleteLink(link.id) }
onDeleteClick = { viewModel.deleteLink(link.id) },
onTogglePin = { id -> viewModel.togglePin(id) }
)
}
}

View File

@ -16,6 +16,7 @@ import com.shaarit.domain.model.TimeFilter
import com.shaarit.domain.model.VisibilityFilter
import com.shaarit.domain.model.TagFilter
import com.shaarit.domain.model.ViewStyle
import com.shaarit.data.local.dao.LinkDao
import com.shaarit.domain.repository.LinkRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@ -44,7 +45,8 @@ class FeedViewModel @Inject constructor(
private val syncManager: SyncManager,
private val collectionDao: CollectionDao,
private val tagDao: TagDao,
private val tokenManager: TokenManager
private val tokenManager: TokenManager,
private val linkDao: LinkDao
) : ViewModel() {
private val _searchQuery = MutableStateFlow("")
@ -75,20 +77,24 @@ class FeedViewModel @Inject constructor(
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
val pagedLinks: Flow<PagingData<ShaarliLink>> =
combine(_searchQuery, _searchTags, _collectionId, _bookmarkFilter, _refreshTrigger) { query, tags, collectionId, bookmarkFilter, _ ->
Quadruple(query, tags, collectionId, bookmarkFilter)
}
.debounce(300) // Debounce for 300ms
.flatMapLatest { (query, tags, collectionId, bookmarkFilter) ->
linkRepository.getLinksStream(
searchTerm = if (query.isBlank()) null else query,
searchTags = tags,
collectionId = collectionId,
bookmarkFilter = bookmarkFilter
)
}
.cachedIn(viewModelScope)
val pagedLinks: Flow<PagingData<ShaarliLink>> = run {
val debouncedSearch = _searchQuery.debounce(300)
val instantFilters = combine(_searchTags, _collectionId, _bookmarkFilter, _refreshTrigger) { tags, collectionId, bookmarkFilter, _ ->
Triple(tags, collectionId, bookmarkFilter)
}
combine(debouncedSearch, instantFilters) { query, (tags, collectionId, bookmarkFilter) ->
Quadruple(query, tags, collectionId, bookmarkFilter)
}
.flatMapLatest { (query, tags, collectionId, bookmarkFilter) ->
linkRepository.getLinksStream(
searchTerm = if (query.isBlank()) null else query,
searchTags = tags,
collectionId = collectionId,
bookmarkFilter = bookmarkFilter
)
}
.cachedIn(viewModelScope)
}
fun setTagFilter(tags: String?) {
_collectionId.value = null
@ -185,8 +191,19 @@ class FeedViewModel @Inject constructor(
_bookmarkFilter.value = filter
}
fun togglePin(id: Int) {
viewModelScope.launch {
linkDao.getLinkById(id)?.let { link ->
linkDao.updatePinStatus(id, !link.isPinned)
_refreshTrigger.value++
}
}
}
fun refresh() {
syncManager.syncNow()
_refreshTrigger.value++
viewModelScope.launch {
syncManager.performFullSync()
_refreshTrigger.value++
}
}
}

View File

@ -29,7 +29,9 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@ -40,6 +42,7 @@ import com.shaarit.ui.components.TagChip
import com.shaarit.ui.theme.Typography
import dev.jeziellago.compose.markdowntext.MarkdownText
import coil.compose.AsyncImage
import coil.request.ImageRequest
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.Color
@ -61,12 +64,14 @@ fun ListViewItem(
onDeleteClick: () -> Unit,
onTogglePin: (Int) -> Unit = {}
) {
val haptic = LocalHapticFeedback.current
var showDeleteDialog by remember { mutableStateOf(false) }
if (showDeleteDialog) {
DeleteConfirmationDialog(
linkTitle = link.displayTitle,
onConfirm = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onDeleteClick()
showDeleteDialog = false
},
@ -89,7 +94,12 @@ fun ListViewItem(
// Thumbnail (List View)
if (!link.thumbnailUrl.isNullOrBlank()) {
AsyncImage(
model = link.thumbnailUrl,
model = ImageRequest.Builder(LocalContext.current)
.data(link.thumbnailUrl)
.size(200)
.crossfade(true)
.memoryCacheKey(link.thumbnailUrl)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
@ -137,7 +147,10 @@ fun ListViewItem(
}
// Pin button
IconButton(
onClick = { onTogglePin(link.id) },
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onTogglePin(link.id)
},
modifier = Modifier.size(32.dp)
) {
Icon(
@ -176,10 +189,12 @@ fun ListViewItem(
if (link.description.isNotBlank()) {
Spacer(modifier = Modifier.height(8.dp))
MarkdownText(
markdown = link.description,
style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurfaceVariant),
Text(
text = link.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
}
@ -245,12 +260,14 @@ fun GridViewItem(
onDeleteClick: () -> Unit,
onTogglePin: (Int) -> Unit = {}
) {
val haptic = LocalHapticFeedback.current
var showDeleteDialog by remember { mutableStateOf(false) }
if (showDeleteDialog) {
DeleteConfirmationDialog(
linkTitle = link.displayTitle,
onConfirm = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onDeleteClick()
showDeleteDialog = false
},
@ -274,7 +291,12 @@ fun GridViewItem(
// Thumbnail (Grid View)
if (!link.thumbnailUrl.isNullOrBlank()) {
AsyncImage(
model = link.thumbnailUrl,
model = ImageRequest.Builder(LocalContext.current)
.data(link.thumbnailUrl)
.size(400, 280)
.crossfade(true)
.memoryCacheKey(link.thumbnailUrl)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
@ -322,12 +344,14 @@ fun GridViewItem(
Spacer(modifier = Modifier.height(4.dp))
// Description with Markdown
// Description (plain text for scroll performance)
if (link.description.isNotBlank()) {
MarkdownText(
markdown = link.description,
style = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurfaceVariant),
Text(
text = link.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth()
)
}
@ -393,7 +417,10 @@ fun GridViewItem(
Row {
// Pin button
IconButton(
onClick = { onTogglePin(link.id) },
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onTogglePin(link.id)
},
modifier = Modifier.size(24.dp)
) {
Icon(
@ -460,12 +487,14 @@ fun CompactViewItem(
onDeleteClick: () -> Unit,
onTogglePin: (Int) -> Unit = {}
) {
val haptic = LocalHapticFeedback.current
var showDeleteDialog by remember { mutableStateOf(false) }
if (showDeleteDialog) {
DeleteConfirmationDialog(
linkTitle = link.displayTitle,
onConfirm = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onDeleteClick()
showDeleteDialog = false
},
@ -565,7 +594,10 @@ fun CompactViewItem(
Row {
IconButton(
onClick = { onTogglePin(link.id) },
onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onTogglePin(link.id)
},
modifier = Modifier.size(28.dp)
) {
Icon(
@ -713,7 +745,12 @@ fun LinkDetailsView(
// Hero Image in Details
if (!link.thumbnailUrl.isNullOrBlank()) {
AsyncImage(
model = link.thumbnailUrl,
model = ImageRequest.Builder(LocalContext.current)
.data(link.thumbnailUrl)
.size(800, 400)
.crossfade(true)
.memoryCacheKey(link.thumbnailUrl)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier

View File

@ -58,6 +58,29 @@ fun AppNavGraph(
) {
val navController = rememberNavController()
val context = LocalContext.current
// If user is already logged in and has share data, navigate directly to Add screen
val hasShareData = shareUrl != null || (isFileShare && shareTitle != null)
androidx.compose.runtime.LaunchedEffect(hasShareData) {
if (hasShareData && startDestination != Screen.Login.route) {
val route = if (isFileShare && shareTitle != null) {
val encodedTitle = URLEncoder.encode(shareTitle, "UTF-8")
val encodedDesc = if (shareDescription != null) URLEncoder.encode(shareDescription, "UTF-8") else ""
val encodedTags = shareTags?.joinToString(",") { URLEncoder.encode(it, "UTF-8") } ?: ""
"add?url=&title=$encodedTitle&isShare=true&isFileShare=true&description=$encodedDesc&tags=$encodedTags"
} else if (shareUrl != null) {
val encodedUrl = URLEncoder.encode(shareUrl, "UTF-8")
val encodedTitle = if (shareTitle != null) URLEncoder.encode(shareTitle, "UTF-8") else ""
"add?url=$encodedUrl&title=$encodedTitle&isShare=true&isFileShare=false&description=&tags="
} else null
route?.let {
navController.navigate(it) {
popUpTo(startDestination) { inclusive = true }
}
}
}
}
NavHost(navController = navController, startDestination = startDestination) {
composable(Screen.Login.route) {

View File

@ -8,7 +8,10 @@ import com.shaarit.data.local.dao.LinkDao
import com.shaarit.domain.model.ShaarliLink
import com.shaarit.domain.repository.LinkRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -18,14 +21,19 @@ class PinnedViewModel @Inject constructor(
private val linkDao: LinkDao
) : ViewModel() {
private val _refreshTrigger = MutableStateFlow(0)
@OptIn(ExperimentalCoroutinesApi::class)
val pagedPinnedLinks: Flow<PagingData<ShaarliLink>> =
linkRepository.getPinnedLinksStream()
.cachedIn(viewModelScope)
_refreshTrigger.flatMapLatest {
linkRepository.getPinnedLinksStream()
}.cachedIn(viewModelScope)
fun togglePin(id: Int) {
viewModelScope.launch {
linkDao.getLinkById(id)?.let { link ->
linkDao.updatePinStatus(id, !link.isPinned)
_refreshTrigger.value++
}
}
}

View File

@ -33,10 +33,16 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.shaarit.core.storage.BiometricAuthManager
import com.shaarit.core.storage.BiometricAvailability
import com.shaarit.core.storage.LockTimeout
import com.shaarit.core.storage.SecurityPreferences
import com.shaarit.data.export.BookmarkImporter
import com.shaarit.ui.theme.AppTheme
import com.shaarit.ui.theme.ThemeMode
import com.shaarit.ui.theme.ThemePreferences
import com.shaarit.ui.theme.getColorSchemeForTheme
import com.shaarit.ui.theme.getLightColorSchemeForTheme
import java.text.SimpleDateFormat
import java.util.*
@ -51,6 +57,7 @@ fun SettingsScreen(
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsState()
val currentTheme by themePreferences.currentTheme.collectAsState()
val currentThemeMode by themePreferences.themeMode.collectAsState()
// Export JSON
val exportJsonLauncher = rememberLauncherForActivityResult(
@ -122,7 +129,22 @@ fun SettingsScreen(
item {
ThemePickerItem(
currentTheme = currentTheme,
onThemeSelected = { themePreferences.setTheme(it) }
currentThemeMode = currentThemeMode,
onThemeSelected = { themePreferences.setTheme(it) },
onThemeModeSelected = { themePreferences.setThemeMode(it) }
)
}
// Security Section
item {
Spacer(modifier = Modifier.height(16.dp))
SettingsSection(title = "Sécurité")
}
item {
SecuritySettingsItem(
securityPreferences = viewModel.securityPreferences,
biometricAuthManager = viewModel.biometricAuthManager
)
}
@ -695,11 +717,19 @@ private fun HealthCheckStatusItem(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ThemePickerItem(
currentTheme: AppTheme,
onThemeSelected: (AppTheme) -> Unit
currentThemeMode: ThemeMode,
onThemeSelected: (AppTheme) -> Unit,
onThemeModeSelected: (ThemeMode) -> Unit
) {
val isDynamic = currentThemeMode in listOf(
ThemeMode.DYNAMIC_DARK, ThemeMode.DYNAMIC_LIGHT, ThemeMode.DYNAMIC_SYSTEM
)
val isDynamicAvailable = android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S
Card(
modifier = Modifier.fillMaxWidth()
) {
@ -724,7 +754,7 @@ private fun ThemePickerItem(
style = MaterialTheme.typography.bodyLarge
)
Text(
text = currentTheme.displayName,
text = "${currentTheme.displayName} \u2022 ${currentThemeMode.displayName}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@ -733,17 +763,129 @@ private fun ThemePickerItem(
Spacer(modifier = Modifier.height(16.dp))
LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp)
// Theme Mode selector (Dark / Light / System)
Text(
text = "Mode",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
modifier = Modifier.padding(bottom = 8.dp)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(AppTheme.entries.toList()) { theme ->
ThemePreviewCard(
theme = theme,
isSelected = theme == currentTheme,
onClick = { onThemeSelected(theme) }
val baseMode = if (isDynamic) {
when (currentThemeMode) {
ThemeMode.DYNAMIC_DARK -> ThemeMode.DARK
ThemeMode.DYNAMIC_LIGHT -> ThemeMode.LIGHT
ThemeMode.DYNAMIC_SYSTEM -> ThemeMode.SYSTEM
else -> currentThemeMode
}
} else currentThemeMode
listOf(
ThemeMode.DARK to Icons.Default.DarkMode,
ThemeMode.LIGHT to Icons.Default.LightMode,
ThemeMode.SYSTEM to Icons.Default.BrightnessAuto
).forEach { (mode, icon) ->
val isSelected = baseMode == mode
FilterChip(
selected = isSelected,
onClick = {
val newMode = if (isDynamic) {
when (mode) {
ThemeMode.DARK -> ThemeMode.DYNAMIC_DARK
ThemeMode.LIGHT -> ThemeMode.DYNAMIC_LIGHT
ThemeMode.SYSTEM -> ThemeMode.DYNAMIC_SYSTEM
else -> mode
}
} else mode
onThemeModeSelected(newMode)
},
label = { Text(mode.displayName, style = MaterialTheme.typography.labelSmall) },
leadingIcon = {
Icon(icon, contentDescription = null, modifier = Modifier.size(16.dp))
},
modifier = Modifier.weight(1f)
)
}
}
// Material You toggle (Android 12+)
if (isDynamicAvailable) {
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.AutoAwesome,
contentDescription = null,
tint = if (isDynamic) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Material You",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "Couleurs extraites du fond d'\u00e9cran",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = isDynamic,
onCheckedChange = { enabled ->
val baseMode2 = when (currentThemeMode) {
ThemeMode.DYNAMIC_DARK -> ThemeMode.DARK
ThemeMode.DYNAMIC_LIGHT -> ThemeMode.LIGHT
ThemeMode.DYNAMIC_SYSTEM -> ThemeMode.SYSTEM
ThemeMode.DARK -> ThemeMode.DARK
ThemeMode.LIGHT -> ThemeMode.LIGHT
ThemeMode.SYSTEM -> ThemeMode.SYSTEM
}
val newMode = if (enabled) {
when (baseMode2) {
ThemeMode.DARK -> ThemeMode.DYNAMIC_DARK
ThemeMode.LIGHT -> ThemeMode.DYNAMIC_LIGHT
ThemeMode.SYSTEM -> ThemeMode.DYNAMIC_SYSTEM
else -> ThemeMode.DYNAMIC_SYSTEM
}
} else baseMode2
onThemeModeSelected(newMode)
}
)
}
}
// Theme color picker (hidden when Material You is active)
if (!isDynamic) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Palette de couleurs",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
modifier = Modifier.padding(bottom = 8.dp)
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
items(AppTheme.entries.toList()) { theme ->
ThemePreviewCard(
theme = theme,
isSelected = theme == currentTheme,
isDarkPreview = currentThemeMode != ThemeMode.LIGHT,
onClick = { onThemeSelected(theme) }
)
}
}
}
}
}
}
@ -752,9 +894,10 @@ private fun ThemePickerItem(
private fun ThemePreviewCard(
theme: AppTheme,
isSelected: Boolean,
isDarkPreview: Boolean = true,
onClick: () -> Unit
) {
val colors = getColorSchemeForTheme(theme)
val colors = if (isDarkPreview) getColorSchemeForTheme(theme) else getLightColorSchemeForTheme(theme)
val borderColor = if (isSelected) MaterialTheme.colorScheme.primary else colors.outline
Column(
@ -844,3 +987,170 @@ private fun ThemePreviewCard(
)
}
}
@Composable
private fun SecuritySettingsItem(
securityPreferences: SecurityPreferences,
biometricAuthManager: BiometricAuthManager
) {
val isBiometricEnabled by securityPreferences.isBiometricEnabled.collectAsState()
val lockTimeout by securityPreferences.lockTimeout.collectAsState()
val requireOnStartup by securityPreferences.requireOnStartup.collectAsState()
val requireOnResume by securityPreferences.requireOnResume.collectAsState()
val biometricAvailability = remember { biometricAuthManager.canAuthenticate() }
var showTimeoutMenu by remember { mutableStateOf(false) }
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Fingerprint,
contentDescription = null,
tint = if (isBiometricEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Verrouillage biométrique",
style = MaterialTheme.typography.bodyLarge
)
Text(
text = when (biometricAvailability) {
BiometricAvailability.AVAILABLE -> if (isBiometricEnabled) "Activé" else "Désactivé"
BiometricAvailability.NO_HARDWARE -> "Matériel non disponible"
BiometricAvailability.NOT_ENROLLED -> "Aucune empreinte enregistrée"
BiometricAvailability.UNAVAILABLE -> "Non disponible"
},
style = MaterialTheme.typography.bodySmall,
color = if (isBiometricEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = isBiometricEnabled,
onCheckedChange = { securityPreferences.setBiometricEnabled(it) },
enabled = biometricAvailability == BiometricAvailability.AVAILABLE
)
}
if (isBiometricEnabled) {
Spacer(modifier = Modifier.height(16.dp))
Divider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(12.dp))
// Lock timeout
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { showTimeoutMenu = true },
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Timer,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Délai de verrouillage",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = lockTimeout.displayName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
Box {
Icon(
imageVector = Icons.Default.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
DropdownMenu(
expanded = showTimeoutMenu,
onDismissRequest = { showTimeoutMenu = false }
) {
LockTimeout.entries.forEach { timeout ->
DropdownMenuItem(
text = { Text(timeout.displayName) },
onClick = {
securityPreferences.setLockTimeout(timeout)
showTimeoutMenu = false
},
leadingIcon = {
if (timeout == lockTimeout) {
Icon(
Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp)
)
}
}
)
}
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// Require on startup
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.PowerSettingsNew,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = "Verrouiller au démarrage",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
Checkbox(
checked = requireOnStartup,
onCheckedChange = { securityPreferences.setRequireOnStartup(it) }
)
}
// Require on resume
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.PhonelinkLock,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = "Verrouiller en arrière-plan",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
Checkbox(
checked = requireOnResume,
onCheckedChange = { securityPreferences.setRequireOnResume(it) }
)
}
}
}
}
}

View File

@ -3,6 +3,8 @@ package com.shaarit.presentation.settings
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.shaarit.core.storage.BiometricAuthManager
import com.shaarit.core.storage.SecurityPreferences
import com.shaarit.core.storage.TokenManager
import com.shaarit.data.export.BookmarkExporter
import com.shaarit.data.export.BookmarkImporter
@ -31,7 +33,9 @@ class SettingsViewModel @Inject constructor(
private val classifyBookmarksUseCase: ClassifyBookmarksUseCase,
private val tokenManager: TokenManager,
private val workManager: WorkManager,
val themePreferences: ThemePreferences
val themePreferences: ThemePreferences,
val securityPreferences: SecurityPreferences,
val biometricAuthManager: BiometricAuthManager
) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())

View File

@ -0,0 +1,87 @@
package com.shaarit.ui.components
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
import android.speech.RecognizerIntent
import android.speech.SpeechRecognizer
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
@Composable
fun VoiceInputButton(
onResult: (String) -> Unit,
modifier: Modifier = Modifier,
language: String = "fr-FR",
contentDescription: String = "Recherche vocale"
) {
val context = LocalContext.current
val isAvailable = remember { SpeechRecognizer.isRecognitionAvailable(context) }
val speechLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val matches = result.data
?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
matches?.firstOrNull()?.let { onResult(it) }
}
}
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) {
speechLauncher.launch(createRecognitionIntent(language))
} else {
Toast.makeText(context, "Permission micro requise pour la saisie vocale", Toast.LENGTH_SHORT).show()
}
}
IconButton(
onClick = {
if (!isAvailable) {
Toast.makeText(context, "Reconnaissance vocale non disponible", Toast.LENGTH_SHORT).show()
return@IconButton
}
val hasPermission = ContextCompat.checkSelfPermission(
context, Manifest.permission.RECORD_AUDIO
) == PackageManager.PERMISSION_GRANTED
if (hasPermission) {
speechLauncher.launch(createRecognitionIntent(language))
} else {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
},
modifier = modifier,
enabled = isAvailable
) {
Icon(
imageVector = if (isAvailable) Icons.Default.Mic else Icons.Default.MicOff,
contentDescription = contentDescription,
tint = if (isAvailable) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
)
}
}
private fun createRecognitionIntent(language: String): Intent {
return Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
putExtra(RecognizerIntent.EXTRA_LANGUAGE, language)
putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 3)
putExtra(RecognizerIntent.EXTRA_PROMPT, "Parlez maintenant...")
}
}

View File

@ -36,6 +36,21 @@ enum class AppTheme(
}
}
enum class ThemeMode(val displayName: String) {
DARK("Sombre"),
LIGHT("Clair"),
SYSTEM("Système"),
DYNAMIC_DARK("Material You Sombre"),
DYNAMIC_LIGHT("Material You Clair"),
DYNAMIC_SYSTEM("Material You Auto");
companion object {
fun fromName(name: String): ThemeMode {
return entries.find { it.name == name } ?: DARK
}
}
}
@Singleton
class ThemePreferences @Inject constructor(
@ApplicationContext private val context: Context
@ -46,17 +61,31 @@ class ThemePreferences @Inject constructor(
private val _currentTheme = MutableStateFlow(loadTheme())
val currentTheme: StateFlow<AppTheme> = _currentTheme.asStateFlow()
private val _themeMode = MutableStateFlow(loadThemeMode())
val themeMode: StateFlow<ThemeMode> = _themeMode.asStateFlow()
private fun loadTheme(): AppTheme {
val name = prefs.getString(KEY_THEME, AppTheme.DEFAULT.name) ?: AppTheme.DEFAULT.name
return AppTheme.fromName(name)
}
private fun loadThemeMode(): ThemeMode {
val name = prefs.getString(KEY_THEME_MODE, ThemeMode.DARK.name) ?: ThemeMode.DARK.name
return ThemeMode.fromName(name)
}
fun setTheme(theme: AppTheme) {
prefs.edit().putString(KEY_THEME, theme.name).apply()
_currentTheme.value = theme
}
fun setThemeMode(mode: ThemeMode) {
prefs.edit().putString(KEY_THEME_MODE, mode.name).apply()
_themeMode.value = mode
}
companion object {
private const val KEY_THEME = "selected_theme"
private const val KEY_THEME_MODE = "theme_mode"
}
}

View File

@ -1,12 +1,18 @@
package com.shaarit.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
@ -466,24 +472,437 @@ fun getColorSchemeForTheme(appTheme: AppTheme): androidx.compose.material3.Color
}
}
// ══════════════════════════════════════════════════════════
// ══════════════════ LIGHT COLOR SCHEMES ══════════════════
// ══════════════════════════════════════════════════════════
private val DefaultLightColorScheme = lightColorScheme(
primary = Color(0xFF006B5A),
onPrimary = Color.White,
primaryContainer = Color(0xFFB2F5E6),
onPrimaryContainer = Color(0xFF00201A),
secondary = Color(0xFF0077B6),
onSecondary = Color.White,
secondaryContainer = Color(0xFFD0E8FF),
onSecondaryContainer = Color(0xFF001E36),
tertiary = Color(0xFF00897B),
onTertiary = Color.White,
background = Color(0xFFF8FAFA),
onBackground = Color(0xFF1A1C1E),
surface = Color(0xFFFFFFFF),
onSurface = Color(0xFF1A1C1E),
surfaceVariant = Color(0xFFE7F0EE),
onSurfaceVariant = Color(0xFF404944),
outline = Color(0xFF707974),
outlineVariant = Color(0xFFC0C9C4),
error = Color(0xFFBA1A1A),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF410002)
)
private val GitHubLightColorScheme = lightColorScheme(
primary = Color(0xFF0969DA),
onPrimary = Color.White,
primaryContainer = Color(0xFFDDF4FF),
onPrimaryContainer = Color(0xFF0A3069),
secondary = Color(0xFF1A7F37),
onSecondary = Color.White,
secondaryContainer = Color(0xFFDAFBE1),
onSecondaryContainer = Color(0xFF0E4F1F),
tertiary = Color(0xFF8250DF),
onTertiary = Color.White,
background = Color(0xFFFFFFFF),
onBackground = Color(0xFF1F2328),
surface = Color(0xFFF6F8FA),
onSurface = Color(0xFF1F2328),
surfaceVariant = Color(0xFFEAEEF2),
onSurfaceVariant = Color(0xFF656D76),
outline = Color(0xFFD0D7DE),
outlineVariant = Color(0xFFE1E4E8),
error = Color(0xFFCF222E),
onError = Color.White,
errorContainer = Color(0xFFFFEBE9),
onErrorContainer = Color(0xFF82071E)
)
private val LinearLightColorScheme = lightColorScheme(
primary = Color(0xFF5E6AD2),
onPrimary = Color.White,
primaryContainer = Color(0xFFE0E1FF),
onPrimaryContainer = Color(0xFF1B1F5E),
secondary = Color(0xFF2B6CB0),
onSecondary = Color.White,
secondaryContainer = Color(0xFFD0E4FF),
onSecondaryContainer = Color(0xFF0A2E52),
tertiary = Color(0xFFBF7B3F),
onTertiary = Color.White,
background = Color(0xFFFCFCFD),
onBackground = Color(0xFF1B1C24),
surface = Color(0xFFF5F5F7),
onSurface = Color(0xFF1B1C24),
surfaceVariant = Color(0xFFECECF0),
onSurfaceVariant = Color(0xFF5A5B66),
outline = Color(0xFFCCCDD4),
outlineVariant = Color(0xFFDDDDE2),
error = Color(0xFFD32F2F),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF690005)
)
private val SpotifyLightColorScheme = lightColorScheme(
primary = Color(0xFF1DB954),
onPrimary = Color.White,
primaryContainer = Color(0xFFD4F5E0),
onPrimaryContainer = Color(0xFF0B3D1E),
secondary = Color(0xFF1DB954),
onSecondary = Color.White,
secondaryContainer = Color(0xFFD4F5E0),
onSecondaryContainer = Color(0xFF0B3D1E),
tertiary = Color(0xFF535353),
onTertiary = Color.White,
background = Color(0xFFF8F8F8),
onBackground = Color(0xFF191414),
surface = Color(0xFFFFFFFF),
onSurface = Color(0xFF191414),
surfaceVariant = Color(0xFFF0F0F0),
onSurfaceVariant = Color(0xFF535353),
outline = Color(0xFFB3B3B3),
outlineVariant = Color(0xFFD9D9D9),
error = Color(0xFFE22134),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF690005)
)
private val NotionLightColorScheme = lightColorScheme(
primary = Color(0xFF2F7ABA),
onPrimary = Color.White,
primaryContainer = Color(0xFFD6EAFF),
onPrimaryContainer = Color(0xFF0A2E52),
secondary = Color(0xFFCB5A3C),
onSecondary = Color.White,
secondaryContainer = Color(0xFFFFDDD3),
onSecondaryContainer = Color(0xFF4D1A0D),
tertiary = Color(0xFF4D8B6F),
onTertiary = Color.White,
background = Color(0xFFFFFFFF),
onBackground = Color(0xFF37352F),
surface = Color(0xFFF7F6F3),
onSurface = Color(0xFF37352F),
surfaceVariant = Color(0xFFEFEEEB),
onSurfaceVariant = Color(0xFF787774),
outline = Color(0xFFD3D1CB),
outlineVariant = Color(0xFFE3E2DE),
error = Color(0xFFEB5757),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF690005)
)
private val DiscordLightColorScheme = lightColorScheme(
primary = Color(0xFF5865F2),
onPrimary = Color.White,
primaryContainer = Color(0xFFE0E3FF),
onPrimaryContainer = Color(0xFF1E2175),
secondary = Color(0xFF248046),
onSecondary = Color.White,
secondaryContainer = Color(0xFFD4F5E0),
onSecondaryContainer = Color(0xFF0B3D1E),
tertiary = Color(0xFFC4960C),
onTertiary = Color.White,
background = Color(0xFFFFFFFF),
onBackground = Color(0xFF313338),
surface = Color(0xFFF2F3F5),
onSurface = Color(0xFF313338),
surfaceVariant = Color(0xFFEBEDEF),
onSurfaceVariant = Color(0xFF5C5E66),
outline = Color(0xFFD1D3D8),
outlineVariant = Color(0xFFE1E3E8),
error = Color(0xFFED4245),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF690005)
)
private val DraculaLightColorScheme = lightColorScheme(
primary = Color(0xFF7C3AED),
onPrimary = Color.White,
primaryContainer = Color(0xFFEDE0FF),
onPrimaryContainer = Color(0xFF2D0A6E),
secondary = Color(0xFF16A34A),
onSecondary = Color.White,
secondaryContainer = Color(0xFFD4F5E0),
onSecondaryContainer = Color(0xFF0B3D1E),
tertiary = Color(0xFFDB2777),
onTertiary = Color.White,
background = Color(0xFFFCFAFF),
onBackground = Color(0xFF1E1E2E),
surface = Color(0xFFF5F3FA),
onSurface = Color(0xFF1E1E2E),
surfaceVariant = Color(0xFFEDE9F5),
onSurfaceVariant = Color(0xFF5C5670),
outline = Color(0xFFCBC4D8),
outlineVariant = Color(0xFFDDD8E8),
error = Color(0xFFDC2626),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF690005)
)
private val OneDarkProLightColorScheme = lightColorScheme(
primary = Color(0xFF2563EB),
onPrimary = Color.White,
primaryContainer = Color(0xFFDBEAFE),
onPrimaryContainer = Color(0xFF0A2E6E),
secondary = Color(0xFF4D7C0F),
onSecondary = Color.White,
secondaryContainer = Color(0xFFE2F5C5),
onSecondaryContainer = Color(0xFF1A3305),
tertiary = Color(0xFFCA8A04),
onTertiary = Color.White,
background = Color(0xFFFAFAFB),
onBackground = Color(0xFF282C34),
surface = Color(0xFFF3F4F6),
onSurface = Color(0xFF282C34),
surfaceVariant = Color(0xFFECEDF0),
onSurfaceVariant = Color(0xFF5A5D66),
outline = Color(0xFFCCCED4),
outlineVariant = Color(0xFFDDDEE2),
error = Color(0xFFDC2626),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF690005)
)
private val TokyoNightLightColorScheme = lightColorScheme(
primary = Color(0xFF3B5FD9),
onPrimary = Color.White,
primaryContainer = Color(0xFFDDE3FF),
onPrimaryContainer = Color(0xFF0F1E5E),
secondary = Color(0xFF5A8A1E),
onSecondary = Color.White,
secondaryContainer = Color(0xFFE2F5C5),
onSecondaryContainer = Color(0xFF1A3305),
tertiary = Color(0xFF7C3AED),
onTertiary = Color.White,
background = Color(0xFFF8F9FC),
onBackground = Color(0xFF1A1B26),
surface = Color(0xFFF0F2F8),
onSurface = Color(0xFF1A1B26),
surfaceVariant = Color(0xFFE8EAF2),
onSurfaceVariant = Color(0xFF545770),
outline = Color(0xFFC4C7D8),
outlineVariant = Color(0xFFD8DAE8),
error = Color(0xFFDC2626),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF690005)
)
private val NordLightColorScheme = lightColorScheme(
primary = Color(0xFF5E81AC),
onPrimary = Color.White,
primaryContainer = Color(0xFFD8E6F3),
onPrimaryContainer = Color(0xFF1E3450),
secondary = Color(0xFF6B8F71),
onSecondary = Color.White,
secondaryContainer = Color(0xFFDAEEDC),
onSecondaryContainer = Color(0xFF233828),
tertiary = Color(0xFFBF8B2E),
onTertiary = Color.White,
background = Color(0xFFF5F7FA),
onBackground = Color(0xFF2E3440),
surface = Color(0xFFECEFF4),
onSurface = Color(0xFF2E3440),
surfaceVariant = Color(0xFFE5E9F0),
onSurfaceVariant = Color(0xFF4C566A),
outline = Color(0xFFBCC3CE),
outlineVariant = Color(0xFFD8DEE9),
error = Color(0xFFBF616A),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF690005)
)
private val NightOwlLightColorScheme = lightColorScheme(
primary = Color(0xFF0C7B93),
onPrimary = Color.White,
primaryContainer = Color(0xFFCCEFF5),
onPrimaryContainer = Color(0xFF003540),
secondary = Color(0xFF4D7C0F),
onSecondary = Color.White,
secondaryContainer = Color(0xFFE2F5C5),
onSecondaryContainer = Color(0xFF1A3305),
tertiary = Color(0xFF9333EA),
onTertiary = Color.White,
background = Color(0xFFFBFCFD),
onBackground = Color(0xFF011627),
surface = Color(0xFFF0F4F8),
onSurface = Color(0xFF011627),
surfaceVariant = Color(0xFFE5ECF2),
onSurfaceVariant = Color(0xFF4A5568),
outline = Color(0xFFBCC8D6),
outlineVariant = Color(0xFFD4DEE8),
error = Color(0xFFDC2626),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF690005)
)
private val AnthraciteLightColorScheme = lightColorScheme(
primary = Color(0xFF6750A4),
onPrimary = Color.White,
primaryContainer = Color(0xFFEADDFF),
onPrimaryContainer = Color(0xFF21005D),
secondary = Color(0xFF00897B),
onSecondary = Color.White,
secondaryContainer = Color(0xFFB2F5E6),
onSecondaryContainer = Color(0xFF00201A),
tertiary = Color(0xFFC2185B),
onTertiary = Color.White,
background = Color(0xFFFFFBFE),
onBackground = Color(0xFF1C1B1F),
surface = Color(0xFFF4F0F5),
onSurface = Color(0xFF1C1B1F),
surfaceVariant = Color(0xFFE7E0EC),
onSurfaceVariant = Color(0xFF49454F),
outline = Color(0xFF79747E),
outlineVariant = Color(0xFFCAC4D0),
error = Color(0xFFB3261E),
onError = Color.White,
errorContainer = Color(0xFFF9DEDC),
onErrorContainer = Color(0xFF410E0B)
)
private val CyberpunkLightColorScheme = lightColorScheme(
primary = Color(0xFF0097A7),
onPrimary = Color.White,
primaryContainer = Color(0xFFCCF2F6),
onPrimaryContainer = Color(0xFF003840),
secondary = Color(0xFFC2185B),
onSecondary = Color.White,
secondaryContainer = Color(0xFFFFD9E3),
onSecondaryContainer = Color(0xFF4D0620),
tertiary = Color(0xFFC6A700),
onTertiary = Color.White,
background = Color(0xFFF8F8FC),
onBackground = Color(0xFF0A0A14),
surface = Color(0xFFF0F0F8),
onSurface = Color(0xFF0A0A14),
surfaceVariant = Color(0xFFE8E8F2),
onSurfaceVariant = Color(0xFF4A4A60),
outline = Color(0xFFB8B8CC),
outlineVariant = Color(0xFFD4D4E2),
error = Color(0xFFDC2626),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF690005)
)
private val NavyEleganceLightColorScheme = lightColorScheme(
primary = Color(0xFFB8860B),
onPrimary = Color.White,
primaryContainer = Color(0xFFFFF0C8),
onPrimaryContainer = Color(0xFF3D2E00),
secondary = Color(0xFF6B7280),
onSecondary = Color.White,
secondaryContainer = Color(0xFFE5E7EB),
onSecondaryContainer = Color(0xFF1F2937),
tertiary = Color(0xFF3B82F6),
onTertiary = Color.White,
background = Color(0xFFFAFBFD),
onBackground = Color(0xFF0B1929),
surface = Color(0xFFF0F3F8),
onSurface = Color(0xFF0B1929),
surfaceVariant = Color(0xFFE8ECF2),
onSurfaceVariant = Color(0xFF4A5568),
outline = Color(0xFFBCC5D2),
outlineVariant = Color(0xFFD4DAE4),
error = Color(0xFFDC2626),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF690005)
)
private val EarthyLightColorScheme = lightColorScheme(
primary = Color(0xFF2E7D32),
onPrimary = Color.White,
primaryContainer = Color(0xFFD4EDDA),
onPrimaryContainer = Color(0xFF0B3D0E),
secondary = Color(0xFF8D6E4C),
onSecondary = Color.White,
secondaryContainer = Color(0xFFF5E6D3),
onSecondaryContainer = Color(0xFF3D2B18),
tertiary = Color(0xFF558B2F),
onTertiary = Color.White,
background = Color(0xFFFAF8F5),
onBackground = Color(0xFF1A1510),
surface = Color(0xFFF5F2ED),
onSurface = Color(0xFF1A1510),
surfaceVariant = Color(0xFFEDE8E0),
onSurfaceVariant = Color(0xFF5A5248),
outline = Color(0xFFC4BAA8),
outlineVariant = Color(0xFFD8D0C2),
error = Color(0xFFDC2626),
onError = Color.White,
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF690005)
)
fun getLightColorSchemeForTheme(appTheme: AppTheme): androidx.compose.material3.ColorScheme {
return when (appTheme) {
AppTheme.DEFAULT -> DefaultLightColorScheme
AppTheme.GITHUB -> GitHubLightColorScheme
AppTheme.LINEAR -> LinearLightColorScheme
AppTheme.SPOTIFY -> SpotifyLightColorScheme
AppTheme.NOTION -> NotionLightColorScheme
AppTheme.DISCORD -> DiscordLightColorScheme
AppTheme.DRACULA -> DraculaLightColorScheme
AppTheme.ONE_DARK_PRO -> OneDarkProLightColorScheme
AppTheme.TOKYO_NIGHT -> TokyoNightLightColorScheme
AppTheme.NORD -> NordLightColorScheme
AppTheme.NIGHT_OWL -> NightOwlLightColorScheme
AppTheme.ANTHRACITE -> AnthraciteLightColorScheme
AppTheme.CYBERPUNK -> CyberpunkLightColorScheme
AppTheme.NAVY_ELEGANCE -> NavyEleganceLightColorScheme
AppTheme.EARTHY -> EarthyLightColorScheme
}
}
@Composable
fun ShaarItTheme(
appTheme: AppTheme = AppTheme.DEFAULT,
themeMode: ThemeMode = ThemeMode.DARK,
content: @Composable () -> Unit
) {
// Always use the explicit color scheme for the selected theme
val colorScheme = getColorSchemeForTheme(appTheme)
// All custom themes are dark
val isEffectivelyDark = true
val isDarkTheme = when (themeMode) {
ThemeMode.DARK, ThemeMode.DYNAMIC_DARK -> true
ThemeMode.LIGHT, ThemeMode.DYNAMIC_LIGHT -> false
ThemeMode.SYSTEM, ThemeMode.DYNAMIC_SYSTEM -> isSystemInDarkTheme()
}
val useDynamicColors = themeMode in listOf(
ThemeMode.DYNAMIC_DARK, ThemeMode.DYNAMIC_LIGHT, ThemeMode.DYNAMIC_SYSTEM
)
val colorScheme = when {
useDynamicColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (isDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
isDarkTheme -> getColorSchemeForTheme(appTheme)
else -> getLightColorSchemeForTheme(appTheme)
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.background.toArgb()
window.navigationBarColor = colorScheme.background.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !isEffectivelyDark
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !isDarkTheme
}
}

View File

@ -0,0 +1,48 @@
plugins {
alias(libs.plugins.android.application) apply false
id("com.android.test")
alias(libs.plugins.jetbrains.kotlin.android)
}
android {
namespace = "com.shaarit.benchmark"
compileSdk = 34
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
defaultConfig {
minSdk = 24
targetSdk = 34
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
create("benchmark") {
isDebuggable = true
signingConfig = signingConfigs.getByName("debug")
matchingFallbacks += listOf("release")
}
}
targetProjectPath = ":app"
experimentalProperties["android.experimental.self-instrumenting"] = true
}
dependencies {
implementation("androidx.test.ext:junit:1.1.5")
implementation("androidx.test.espresso:espresso-core:3.5.1")
implementation("androidx.test.uiautomator:uiautomator:2.2.0")
implementation("androidx.benchmark:benchmark-macro-junit4:1.2.3")
}
androidComponents {
beforeVariants(selector().all()) {
it.enable = it.buildType == "benchmark"
}
}

View File

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View File

@ -0,0 +1,54 @@
package com.shaarit.benchmark
import androidx.benchmark.macro.junit4.BaselineProfileRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Generates Baseline Profiles for ShaarIt.
*
* Baseline Profiles pre-compile critical user journeys (cold start, navigation, scroll)
* using AOT compilation, avoiding JIT compilation at runtime.
*
* Typical gain: 30-50% reduction in cold start time for Compose apps.
*
* To generate the profile, run:
* ./gradlew :benchmark:pixel6Api31BenchmarkAndroidTest -Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.enabledRules=BaselineProfile
*
* Then copy the generated baseline-prof.txt to app/src/main/baseline-prof.txt
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
class BaselineProfileGenerator {
@get:Rule
val rule = BaselineProfileRule()
@Test
fun generateProfile() {
rule.collect(
packageName = "com.shaarit",
includeInStartupProfile = true
) {
// Cold start — launch the main activity
startActivityAndWait()
// Wait for the feed to load
device.waitForIdle()
// Scroll the feed to capture scroll-related code paths
val feedList = device.findObject(
androidx.test.uiautomator.By.scrollable(true)
)
feedList?.let {
it.scroll(androidx.test.uiautomator.Direction.DOWN, 2.0f)
device.waitForIdle()
it.scroll(androidx.test.uiautomator.Direction.UP, 2.0f)
device.waitForIdle()
}
}
}
}

View File

@ -0,0 +1,869 @@
# 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)*

File diff suppressed because it is too large Load Diff

View File

@ -24,6 +24,7 @@ workManager = "2.9.0"
dataStore = "1.0.0"
kotlinxSerialization = "1.6.2"
coil = "2.6.0"
biometric = "1.1.0"
[libraries]
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
@ -83,6 +84,9 @@ coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coi
# Kotlin Serialization
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
# Biometric
androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }

View File

@ -18,3 +18,4 @@ dependencyResolutionManagement {
rootProject.name = "ShaarIt"
include(":app")
include(":benchmark")