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) - Connexion sécurisée à votre instance Shaarli auto-hébergée (API v1)
- Stockage chiffré des tokens JWT et secrets API via `EncryptedSharedPreferences` - Stockage chiffré des tokens JWT et secrets API via `EncryptedSharedPreferences`
- Génération automatique de tokens JWT avec algorithme HS512 - 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 ### 📚 Gestion des Favoris
- **Flux infini** : Défilement continu avec chargement progressif (Paging 3) - **Flux infini** : Défilement continu avec chargement progressif (Paging 3)
- **Recherche avancée** : Recherche locale avec FTS4 (full-text search) et filtrage par tags - **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 - **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 - **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 - **Édition** : Modification complète des liens existants avec éditeur Markdown
- **Suppression** : Gestion facile des favoris avec file d'attente de sync - **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 - **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 ### 🏷️ Gestion des Tags
- Vue dédiée pour parcourir tous les tags - Vue dédiée pour parcourir tous les tags
@ -33,8 +36,9 @@
- Tags favoris pour accès rapide - Tags favoris pour accès rapide
### 📁 Collections ### 📁 Collections
- Organisation des liens en collections - Organisation des liens en collections manuelles ou intelligentes
- Collections intelligentes avec filtres automatiques - Collections intelligentes avec filtres automatiques (basées sur les tags)
- Synchronisation des collections via le serveur Shaarli
- Vue grille adaptative pour les collections - Vue grille adaptative pour les collections
### 📝 Éditeur Markdown ### 📝 Éditeur Markdown
@ -43,11 +47,19 @@
- Mode lecture focus sans distraction - Mode lecture focus sans distraction
- Barre d'outils de formatage - 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 de Métadonnées
- Extraction automatique des OpenGraph (titre, description, image) - Extraction automatique des OpenGraph (titre, description, image) via JSoup
- Détection du type de contenu (article, vidéo, image, audio, code, etc.) - Détection du type de contenu (article, vidéo, image, audio, code, repository, social, etc.)
- Estimation du temps de lecture - 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 ### 📊 Tableau de Bord
- Statistiques d'utilisation (liens totaux, cette semaine, ce mois) - Statistiques d'utilisation (liens totaux, cette semaine, ce mois)
@ -56,7 +68,15 @@
- Tags les plus utilisés - Tags les plus utilisés
- Graphique d'activité sur 30 jours - 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 JSON (format complet avec métadonnées)
- Export CSV (compatible Excel) - Export CSV (compatible Excel)
- Export HTML (format Netscape/Chrome bookmarks) - Export HTML (format Netscape/Chrome bookmarks)
@ -70,20 +90,21 @@
- File d'attente des opérations - File d'attente des opérations
### 🔗 Intégration système ### 🔗 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) - **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 - **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 - Ouverture des liens dans le navigateur par défaut
### 🎨 Interface Utilisateur ### 🎨 Interface Utilisateur
- **Material You (Monet)** : Couleurs dynamiques basées sur le fond d'écran (Android 12+) - **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
- **Mode OLED** : Noir pur pour les écrans AMOLED - **Design premium** : Composants glassmorphism avec effets visuels
- **Design premium** : Thème sombre moderne avec dégradés - **Material Design 3** : Composants UI natifs Jetpack Compose
- **Material Design 3** : Composants UI natifs Android - **Skeleton Loading** : Chargement élégant avec shimmer effect
- **Animations fluides** : Transitions et effets visuels
- **Trois modes d'affichage** : Liste détaillée, grille compacte, ou vue compacte - **Trois modes d'affichage** : Liste détaillée, grille compacte, ou vue compacte
- **Pull-to-refresh** : Actualisation du flux par glissement - **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 | | Catégorie | Technologie |
|-----------|-------------| |-----------|-------------|
| **Langage** | Kotlin 2.0.0 | | **Langage** | Kotlin 1.9.20 |
| **UI** | Jetpack Compose + Material Design 3 | | **UI** | Jetpack Compose (BOM 2023.08) + Material Design 3 |
| **Architecture** | Clean Architecture + MVVM | | **Architecture** | Clean Architecture + MVVM |
| **Injection de dépendances** | Dagger Hilt 2.51.1 | | **Injection de dépendances** | Dagger Hilt 2.48.1 |
| **Réseau** | Retrofit 2.11.0 + Moshi 1.15.1 + OkHttp 4.12.0 | | **Réseau** | Retrofit 2.9.0 + Moshi 1.15.0 + OkHttp 4.12.0 |
| **Base de données locale** | Room 2.6.1 | | **Base de données locale** | Room 2.6.1 (avec FTS4) |
| **Pagination** | Paging 3 | | **Pagination** | Paging 3 (3.2.1) |
| **Concurrence** | Coroutines & Flow | | **Concurrence** | Coroutines & Flow |
| **Background work** | WorkManager 2.9.0 | | **Background work** | WorkManager 2.9.0 |
| **Stockage sécurisé** | AndroidX Security Crypto | | **Stockage sécurisé** | AndroidX Security Crypto |
| **Navigation** | Navigation Compose | | **Navigation** | Navigation Compose 2.7.6 |
| **Compilation** | Gradle 8.0+ avec KSP | | **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é ### Compatibilité
- **minSdk**: 24 (Android 7.0) - **minSdk** : 24 (Android 7.0)
- **targetSdk**: 34 (Android 14) - **targetSdk** : 34 (Android 14)
- **compileSdk**: 34 - **compileSdk** : 34
- **JDK requis**: 17+ - **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 ### Première configuration
1. Ouvrez l'application 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 3. Entrez votre nom d'utilisateur et mot de passe
4. L'application générera automatiquement les tokens API nécessaires 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 ### Ajouter un lien
- **Via l'app** : Appuyez sur le bouton + et entrez l'URL - **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 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 - **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 ### Organiser vos liens
- Utilisez les tags pour catégoriser 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 ├── data/ # Couche de données
│ ├── api/ # API Retrofit │ ├── api/ # API Retrofit (ShaarliApi)
│ ├── dto/ # Data Transfer Objects (Moshi)
│ ├── local/ # Base de données Room │ ├── local/ # Base de données Room
│ │ ├── dao/ # Data Access Objects │ │ ├── dao/ # Data Access Objects (Link, Tag, Collection)
│ │ ├── entity/ # Entités Room │ │ ├── entity/ # Entités Room + FTS4
│ │ ├── converter/ # Type converters Room
│ │ └── database/ # Configuration de la DB │ │ └── database/ # Configuration de la DB
│ ├── sync/ # Synchronisation │ ├── mapper/ # Mappers DTO ↔ Entity ↔ Domain
│ ├── export/ # Import/Export │ ├── metadata/ # Extraction métadonnées (JSoup/OpenGraph)
│ └── repository/ # Implémentations des repositories │ ├── paging/ # PagingSource réseau
│ ├── sync/ # SyncManager + ConflictResolver + SyncWorker
│ ├── worker/ # LinkHealthCheckWorker
│ ├── export/ # BookmarkExporter (JSON, CSV, HTML)
│ └── repository/ # Implémentations (Link, Auth, Gemini)
├── domain/ # Couche domaine ├── domain/ # Couche domaine
│ ├── model/ # Modèles métier │ ├── model/ # Modèles métier (ShaarliLink, ShaarliTag, etc.)
│ └── repository/ # Interfaces des repositories │ ├── repository/ # Interfaces des repositories
│ └── usecase/ # Use cases (AnalyzeUrl, GenerateTags, Classify, Login)
├── presentation/ # Couche présentation ├── presentation/ # Couche présentation
│ ├── feed/ # Écran principal │ ├── feed/ # Écran principal + vues (List, Grid, Compact)
│ ├── add/ # Ajout de liens │ ├── add/ # Ajout de liens / notes
│ ├── edit/ # Édition de liens │ ├── edit/ # Édition de liens
│ ├── auth/ # Écran de connexion
│ ├── tags/ # Gestion des tags │ ├── tags/ # Gestion des tags
│ ├── collections/ # Collections │ ├── collections/ # Collections
│ ├── dashboard/ # Tableau de bord │ ├── dashboard/ # Tableau de bord / statistiques
│ ├── settings/ # Paramètres │ ├── deadlinks/ # Gestion des liens morts
│ └── nav/ # Navigation │ ├── 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 └── core/ # Utilitaires
├── di/ # Injection de dépendances ├── di/ # Modules Hilt (App, Database, Network, Repository)
├── network/ # Configuration réseau ├── network/ # AuthInterceptor + HostSelectionInterceptor
└── storage/ # Stockage local ├── storage/ # TokenManager (EncryptedSharedPreferences)
└── util/ # JwtGenerator
``` ```
--- ---
## 🚀 Roadmap ## 🚀 Roadmap
### Complété ✅
- [x] Synchronisation en arrière-plan avec WorkManager - [x] Synchronisation en arrière-plan avec WorkManager
- [x] Mode hors-ligne avec Room - [x] Mode hors-ligne avec Room + architecture offline-first
- [x] Éditeur Markdown - [x] Éditeur Markdown (édition + prévisualisation + split)
- [x] Extraction de métadonnées OpenGraph - [x] Extraction de métadonnées OpenGraph (JSoup)
- [x] Collections d'organisation - [x] Collections manuelles et intelligentes
- [x] Liens épinglés - [x] Liens épinglés avec écran dédié
- [x] Widget d'accueil - [x] App Shortcuts + Deep Links (`shaarit://`)
- [x] App Shortcuts
- [x] Quick Settings Tile - [x] Quick Settings Tile
- [x] Tableau de bord analytique - [x] Tableau de bord analytique (statistiques, graphiques)
- [x] Import/Export - [x] Import/Export (JSON, CSV, HTML Netscape)
- [x] Material You (Monet) - [x] 15 thèmes sombres premium
- [ ] Recherche avancée avec filtres multiples - [x] Intelligence Artificielle (Google Gemini) — auto-tagging, analyse d'URL, classification
- [ ] Suggestions de tags par IA - [x] Recherche FTS4 (full-text search) + filtres multi-tags
- [ ] Mode lecture sans distraction pour les articles - [x] Détection et gestion des liens morts (Health Check)
- [ ] Partage de collections - [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 - [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 - [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 - La communauté open source pour les excellentes bibliothèques utilisées
--- ---

View File

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

View File

@ -104,9 +104,11 @@
} }
-dontwarn kotlinx.coroutines.** -dontwarn kotlinx.coroutines.**
# Keep ALL Application classes to prevent runtime crashes # Keep only what is strictly needed for serialization and Room
-keep class com.shaarit.** { *; } -keep class com.shaarit.data.dto.** { *; }
-keepclassmembers class com.shaarit.** { *; } -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 Hilt generated classes
-keep class com.shaarit.Hilt_* { *; } -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"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application <application
android:name=".ShaarItApp" 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.net.Uri
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.fragment.app.FragmentActivity
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat 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.presentation.nav.AppNavGraph
import com.shaarit.ui.theme.ShaarItTheme import com.shaarit.ui.theme.ShaarItTheme
import com.shaarit.ui.theme.ThemePreferences import com.shaarit.ui.theme.ThemePreferences
@ -23,9 +28,17 @@ import java.io.InputStreamReader
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : FragmentActivity() {
@Inject lateinit var themePreferences: ThemePreferences @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?) { override fun onCreate(savedInstanceState: Bundle?) {
// Install splash screen before super.onCreate // Install splash screen before super.onCreate
@ -37,65 +50,126 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
WindowCompat.setDecorFitsSystemWindows(window, false) 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 { setContent {
val currentTheme by themePreferences.currentTheme.collectAsState() val currentTheme by themePreferences.currentTheme.collectAsState()
ShaarItTheme(appTheme = currentTheme) { val currentThemeMode by themePreferences.themeMode.collectAsState()
// A surface container using the 'background' color from the theme val biometricEnabled by securityPreferences.isBiometricEnabled.collectAsState()
val context = LocalContext.current
val needsAuth = biometricEnabled && !isAuthenticated
ShaarItTheme(appTheme = currentTheme, themeMode = currentThemeMode) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
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 shareUrl: String? = null
var shareTitle: String? = null var shareTitle: String? = null
var shareDescription: String? = null var shareDescription: String? = null
var shareTags: List<String>? = null var shareTags: List<String>? = null
var deepLink: String? = null
var isFileShare = false var isFileShare = false
var deepLink: String? = null
val activity = context as? androidx.activity.ComponentActivity
val intent = activity?.intent
// Handle share intent // Handle share intent
if (intent?.action == android.content.Intent.ACTION_SEND) { if (intent.action == android.content.Intent.ACTION_SEND) {
val mimeType = intent.type ?: "" val mimeType = intent.type ?: ""
// Check if this is a file share (markdown or text file) // Check if this is a file share (markdown or text file)
val fileUri = intent.getParcelableExtra<Uri>(android.content.Intent.EXTRA_STREAM) val fileUri = intent.getParcelableExtra<Uri>(android.content.Intent.EXTRA_STREAM)
if (fileUri != null && isTextOrMarkdownFile(mimeType, fileUri)) { if (fileUri != null && isTextOrMarkdownFile(mimeType, fileUri)) {
// Handle file sharing - use filename as title, content as description
isFileShare = true isFileShare = true
val fileInfo = readFileContent(fileUri) val fileInfo = readFileContent(fileUri)
shareTitle = fileInfo.first // filename without extension shareTitle = fileInfo.first
shareDescription = fileInfo.second // file content shareDescription = fileInfo.second
shareTags = listOf("note", "fichier") shareTags = listOf("note", "fichier")
shareUrl = null // No URL for file shares shareUrl = null
} else if (mimeType == "text/plain") { } else if (mimeType == "text/plain") {
// Regular text share (URL)
shareUrl = intent.getStringExtra(android.content.Intent.EXTRA_TEXT) shareUrl = intent.getStringExtra(android.content.Intent.EXTRA_TEXT)
shareTitle = intent.getStringExtra(android.content.Intent.EXTRA_SUBJECT) shareTitle = intent.getStringExtra(android.content.Intent.EXTRA_SUBJECT)
} }
} }
// Handle deep links from App Shortcuts // Handle deep links from App Shortcuts
intent?.data?.let { uri -> intent.data?.let { uri ->
if (uri.scheme == "shaarit") { if (uri.scheme == "shaarit") {
deepLink = uri.toString() deepLink = uri.toString()
} }
} }
Surface( return ShareData(shareUrl, shareTitle, shareDescription, shareTags, isFileShare, deepLink)
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
AppNavGraph(
shareUrl = shareUrl,
shareTitle = shareTitle,
shareDescription = shareDescription,
shareTags = shareTags,
isFileShare = isFileShare,
initialDeepLink = deepLink
)
}
}
}
} }
/** /**

View File

@ -1,5 +1,6 @@
package com.shaarit.core.di package com.shaarit.core.di
import android.content.Context
import com.shaarit.core.network.AuthInterceptor import com.shaarit.core.network.AuthInterceptor
import com.shaarit.core.network.HostSelectionInterceptor import com.shaarit.core.network.HostSelectionInterceptor
import com.shaarit.data.api.ShaarliApi import com.shaarit.data.api.ShaarliApi
@ -7,8 +8,12 @@ import com.squareup.moshi.Moshi
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import java.io.File
import java.util.concurrent.TimeUnit
import javax.inject.Singleton import javax.inject.Singleton
import okhttp3.Cache
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit import retrofit2.Retrofit
@ -23,11 +28,24 @@ object NetworkModule {
@Provides @Provides
@Singleton @Singleton
fun provideOkHttpClient( fun provideOkHttpClient(
@ApplicationContext context: Context,
authInterceptor: AuthInterceptor, authInterceptor: AuthInterceptor,
hostSelectionInterceptor: HostSelectionInterceptor hostSelectionInterceptor: HostSelectionInterceptor
): OkHttpClient { ): 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() return OkHttpClient.Builder()
.cache(cache)
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.addInterceptor(hostSelectionInterceptor) // Host selection first .addInterceptor(hostSelectionInterceptor) // Host selection first
.addInterceptor(authInterceptor) // Auth header second .addInterceptor(authInterceptor) // Auth header second
.addInterceptor(logging) .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 saveGeminiApiKey(apiKey: String)
fun getGeminiApiKey(): String? fun getGeminiApiKey(): String?
fun clearGeminiApiKey() fun clearGeminiApiKey()
fun saveLastSyncTimestamp(timestamp: Long)
fun getLastSyncTimestamp(): Long
} }
@Singleton @Singleton
@ -128,6 +131,14 @@ class TokenManagerImpl @Inject constructor(@ApplicationContext private val conte
sharedPreferences.edit().remove(KEY_GEMINI_API_KEY).apply() 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 { companion object {
private const val KEY_TOKEN = "jwt_token" private const val KEY_TOKEN = "jwt_token"
private const val KEY_BASE_URL = "base_url" 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_DIRTY = "collections_config_dirty"
private const val KEY_COLLECTIONS_BOOKMARK_ID = "collections_config_bookmark_id" 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_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]) @RawQuery(observedEntities = [LinkEntity::class])
fun getLinksByTags(query: SupportSQLiteQuery): PagingSource<Int, LinkEntity> fun getLinksByTags(query: SupportSQLiteQuery): PagingSource<Int, LinkEntity>
@RawQuery(observedEntities = [LinkEntity::class])
suspend fun getLinksRawQuery(query: SupportSQLiteQuery): List<LinkEntity>
@Query(""" @Query("""
SELECT links.* FROM links SELECT links.* FROM links
INNER JOIN collection_links ON links.id = collection_links.link_id 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.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters 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.converter.Converters
import com.shaarit.data.local.dao.CollectionDao import com.shaarit.data.local.dao.CollectionDao
import com.shaarit.data.local.dao.LinkDao import com.shaarit.data.local.dao.LinkDao
@ -28,8 +30,8 @@ import com.shaarit.data.local.entity.TagEntity
CollectionEntity::class, CollectionEntity::class,
CollectionLinkCrossRef::class CollectionLinkCrossRef::class
], ],
version = 4, version = 5,
exportSchema = false exportSchema = true
) )
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
abstract class ShaarliDatabase : RoomDatabase() { abstract class ShaarliDatabase : RoomDatabase() {
@ -41,6 +43,22 @@ abstract class ShaarliDatabase : RoomDatabase() {
companion object { companion object {
private const val DATABASE_NAME = "shaarli.db" 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 @Volatile
private var instance: ShaarliDatabase? = null private var instance: ShaarliDatabase? = null
@ -56,7 +74,8 @@ abstract class ShaarliDatabase : RoomDatabase() {
ShaarliDatabase::class.java, ShaarliDatabase::class.java,
DATABASE_NAME DATABASE_NAME
) )
.fallbackToDestructiveMigration() .addMigrations(MIGRATION_4_5)
.fallbackToDestructiveMigrationFrom(1, 2, 3)
.build() .build()
} }
} }

View File

@ -17,7 +17,11 @@ import androidx.room.PrimaryKey
Index(value = ["is_private"]), Index(value = ["is_private"]),
Index(value = ["created_at"]), Index(value = ["created_at"]),
Index(value = ["is_pinned"]), 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( data class LinkEntity(

View File

@ -21,6 +21,16 @@ class LinkMetadataExtractor @Inject constructor() {
private const val TAG = "LinkMetadataExtractor" private const val TAG = "LinkMetadataExtractor"
private const val TIMEOUT_MS = 10000 private const val TIMEOUT_MS = 10000
private const val MAX_DESCRIPTION_LENGTH = 300 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") val ogType = doc.select("meta[property=og:type]").attr("content")
return when { 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 ogType == "article" || doc.select("article").isNotEmpty() -> ContentType.ARTICLE
url.contains(Regex("github\\.com|gitlab\\.com|bitbucket")) -> ContentType.REPOSITORY url.contains(REPO_PATTERN) -> ContentType.REPOSITORY
url.contains(Regex("docs\\.google\\.com|notion\\.so|confluence")) -> ContentType.DOCUMENT url.contains(DOCUMENT_PATTERN) -> 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(SOCIAL_PATTERN) -> ContentType.SOCIAL
url.contains(Regex("amazon|ebay|shopify")) -> ContentType.SHOPPING url.contains(SHOPPING_PATTERN) -> ContentType.SHOPPING
url.contains(Regex("substack|revue|mailchimp")) -> ContentType.NEWSLETTER url.contains(NEWSLETTER_PATTERN) -> ContentType.NEWSLETTER
url.contains(Regex("spotify|deezer|soundcloud|bandcamp")) -> ContentType.MUSIC url.contains(MUSIC_PATTERN) -> ContentType.MUSIC
url.contains(Regex("news|nytimes|lemonde|bbc|cnn|reuters|theguardian|lefigaro")) -> ContentType.NEWS url.contains(NEWS_PATTERN) -> ContentType.NEWS
doc.select("audio").isNotEmpty() || url.contains(Regex("podcast|anchor")) -> ContentType.PODCAST doc.select("audio").isNotEmpty() || url.contains(PODCAST_PATTERN) -> ContentType.PODCAST
url.endsWith(".pdf") -> ContentType.PDF url.endsWith(".pdf") -> ContentType.PDF
else -> ContentType.UNKNOWN else -> ContentType.UNKNOWN
} }
@ -192,14 +202,14 @@ class LinkMetadataExtractor @Inject constructor() {
*/ */
private fun detectContentTypeFromUrl(url: String): ContentType { private fun detectContentTypeFromUrl(url: String): ContentType {
return when { return when {
url.contains(Regex("youtube\\.com|youtu\\.be|vimeo|dailymotion")) -> ContentType.VIDEO url.contains(VIDEO_PATTERN) -> ContentType.VIDEO
url.contains(Regex("github\\.com|gitlab|bitbucket")) -> ContentType.REPOSITORY url.contains(REPO_PATTERN) -> ContentType.REPOSITORY
url.contains(Regex("docs\\.google|notion\\.so|confluence")) -> ContentType.DOCUMENT url.contains(DOCUMENT_PATTERN) -> 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(SOCIAL_PATTERN) -> ContentType.SOCIAL
url.contains(Regex("amazon|ebay|shopify")) -> ContentType.SHOPPING url.contains(SHOPPING_PATTERN) -> ContentType.SHOPPING
url.contains(Regex("substack|revue|mailchimp")) -> ContentType.NEWSLETTER url.contains(NEWSLETTER_PATTERN) -> ContentType.NEWSLETTER
url.contains(Regex("spotify|deezer|soundcloud|bandcamp")) -> ContentType.MUSIC url.contains(MUSIC_PATTERN) -> ContentType.MUSIC
url.contains(Regex("news|nytimes|lemonde|bbc|cnn|reuters|theguardian|lefigaro")) -> ContentType.NEWS url.contains(NEWS_PATTERN) -> ContentType.NEWS
url.endsWith(".pdf") -> ContentType.PDF url.endsWith(".pdf") -> ContentType.PDF
else -> ContentType.UNKNOWN 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.Request
import okhttp3.Response import okhttp3.Response
import org.json.JSONObject import org.json.JSONObject
import android.util.LruCache
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -22,11 +23,11 @@ class GeminiRepositoryImpl @Inject constructor(
private val okHttpClient: OkHttpClient private val okHttpClient: OkHttpClient
) : GeminiRepository { ) : GeminiRepository {
// Cache pour les tags // Cache pour les tags (borné à 50 entrées max)
private val tagsCache = mutableMapOf<String, List<String>>() 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 // 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 = mutableMapOf<String, AiEnrichmentResult>() private val analysisCache = LruCache<String, AiEnrichmentResult>(50)
override fun isApiKeyConfigured(): Boolean { override fun isApiKeyConfigured(): Boolean {
return !tokenManager.getGeminiApiKey().isNullOrBlank() 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) { override suspend fun generateTags(title: String, description: String): Result<List<String>> = withContext(Dispatchers.IO) {
val cacheKey = "$title|$description" val cacheKey = "$title|$description"
if (tagsCache.containsKey(cacheKey)) { tagsCache.get(cacheKey)?.let {
return@withContext Result.success(tagsCache[cacheKey]!!) return@withContext Result.success(it)
} }
try { try {
@ -56,7 +57,7 @@ class GeminiRepositoryImpl @Inject constructor(
for (modelName in modelsToTry) { for (modelName in modelsToTry) {
try { try {
val tags = generateTagsWithModel(apiKey, modelName, title, description) val tags = generateTagsWithModel(apiKey, modelName, title, description)
tagsCache[cacheKey] = tags tagsCache.put(cacheKey, tags)
return@withContext Result.success(tags) return@withContext Result.success(tags)
} catch (e: Exception) { } catch (e: Exception) {
lastException = e lastException = e
@ -114,8 +115,8 @@ class GeminiRepositoryImpl @Inject constructor(
override suspend fun analyzeUrl(url: String): Result<AiEnrichmentResult> = withContext(Dispatchers.IO) { override suspend fun analyzeUrl(url: String): Result<AiEnrichmentResult> = withContext(Dispatchers.IO) {
// Vérifier le cache d'abord // Vérifier le cache d'abord
if (analysisCache.containsKey(url)) { analysisCache.get(url)?.let {
return@withContext Result.success(analysisCache[url]!!) return@withContext Result.success(it)
} }
try { try {
@ -146,7 +147,7 @@ class GeminiRepositoryImpl @Inject constructor(
try { try {
val result = generateWithModel(apiKey, modelName, url) val result = generateWithModel(apiKey, modelName, url)
// Mettre en cache le résultat réussi // Mettre en cache le résultat réussi
analysisCache[url] = result analysisCache.put(url, result)
return@withContext Result.success(result) return@withContext Result.success(result)
} catch (e: Exception) { } catch (e: Exception) {
lastException = e lastException = e

View File

@ -55,11 +55,16 @@ constructor(
): Flow<PagingData<ShaarliLink>> { ): Flow<PagingData<ShaarliLink>> {
// Utiliser Room pour la pagination locale // Utiliser Room pour la pagination locale
return Pager( return Pager(
config = PagingConfig(pageSize = 20, enablePlaceholders = false), config = PagingConfig(
pageSize = 20,
prefetchDistance = 10,
initialLoadSize = 40,
enablePlaceholders = false
),
pagingSourceFactory = { pagingSourceFactory = {
when { when {
collectionId != null -> linkDao.getLinksInCollectionPaged(collectionId) collectionId != null -> linkDao.getLinksInCollectionPaged(collectionId)
!searchTerm.isNullOrBlank() -> linkDao.searchLinks(searchTerm) !searchTerm.isNullOrBlank() -> linkDao.searchLinksFullText(searchTerm)
!searchTags.isNullOrBlank() -> { !searchTags.isNullOrBlank() -> {
val tags = val tags =
searchTags searchTags
@ -114,18 +119,15 @@ constructor(
} }
override suspend fun getLinksByTag(tag: String): Result<List<ShaarliLink>> { override suspend fun getLinksByTag(tag: String): Result<List<ShaarliLink>> {
// Lire depuis le cache local
return try { return try {
// Si nous avons des données locales, les retourner // Requête SQL directe via Room au lieu de charger toute la base en mémoire
val localLinks = linkDao.getAllLinks().firstOrNull() ?: emptyList() 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()) { if (localLinks.isNotEmpty()) {
val filtered = localLinks.filter { it.tags.contains(tag) } return Result.success(localLinks.map { it.toDomainModel() })
if (filtered.isNotEmpty()) {
return Result.success(filtered.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) val links = api.getLinksByTag(tag = tag, offset = 0, limit = 100)
Result.success(links.mapNotNull { LinkMapper.toDomain(it) }) Result.success(links.mapNotNull { LinkMapper.toDomain(it) })
} catch (e: Exception) { } catch (e: Exception) {
@ -507,7 +509,12 @@ constructor(
override fun getDeadLinksStream(): Flow<PagingData<ShaarliLink>> { override fun getDeadLinksStream(): Flow<PagingData<ShaarliLink>> {
return Pager( return Pager(
config = PagingConfig(pageSize = 20, enablePlaceholders = false), config = PagingConfig(
pageSize = 20,
prefetchDistance = 10,
initialLoadSize = 40,
enablePlaceholders = false
),
pagingSourceFactory = { linkDao.getDeadLinks() } pagingSourceFactory = { linkDao.getDeadLinks() }
).flow.map { pagingData -> ).flow.map { pagingData ->
pagingData.map { it.toDomainModel() } pagingData.map { it.toDomainModel() }
@ -516,7 +523,12 @@ constructor(
override fun getPinnedLinksStream(): Flow<PagingData<ShaarliLink>> { override fun getPinnedLinksStream(): Flow<PagingData<ShaarliLink>> {
return Pager( return Pager(
config = PagingConfig(pageSize = 20, enablePlaceholders = false), config = PagingConfig(
pageSize = 20,
prefetchDistance = 10,
initialLoadSize = 40,
enablePlaceholders = false
),
pagingSourceFactory = { linkDao.getPinnedLinksPaged() } pagingSourceFactory = { linkDao.getPinnedLinksPaged() }
).flow.map { pagingData -> ).flow.map { pagingData ->
pagingData.map { it.toDomainModel() } 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 { private fun LinkEntity.toDomainModel(): ShaarliLink {
return ShaarliLink( return ShaarliLink(
id = id, id = id,
@ -541,7 +557,7 @@ constructor(
description = description, description = description,
tags = tags, tags = tags,
isPrivate = isPrivate, 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, isPinned = isPinned,
thumbnailUrl = thumbnailUrl, thumbnailUrl = thumbnailUrl,
readingTime = readingTimeMinutes, readingTime = readingTimeMinutes,
@ -580,8 +596,7 @@ constructor(
private fun parseDate(dateString: String?): Long { private fun parseDate(dateString: String?): Long {
if (dateString.isNullOrBlank()) return System.currentTimeMillis() if (dateString.isNullOrBlank()) return System.currentTimeMillis()
return try { return try {
val format = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.getDefault()) DATE_FORMAT.parse(dateString)?.time ?: System.currentTimeMillis()
format.parse(dateString)?.time ?: System.currentTimeMillis()
} catch (e: Exception) { } catch (e: Exception) {
System.currentTimeMillis() 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() { private suspend fun pullFromServer() {
var offset = 0 var offset = 0
val limit = 100 val limit = 100
var hasMore = true 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) { while (hasMore) {
try { try {
@ -395,9 +403,21 @@ class SyncManager @Inject constructor(
} }
Log.d(TAG, "${validLinks.size}/${links.size} liens valides") Log.d(TAG, "${validLinks.size}/${links.size} liens valides")
var newOrUpdatedCount = 0
val entities = validLinks.mapNotNull { dto -> val entities = validLinks.mapNotNull { dto ->
try { try {
val existing = linkDao.getLinkById(dto.id!!) 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( LinkEntity(
id = dto.id, id = dto.id,
url = dto.url!!, url = dto.url!!,
@ -407,7 +427,7 @@ class SyncManager @Inject constructor(
isPrivate = dto.isPrivate ?: false, isPrivate = dto.isPrivate ?: false,
isPinned = existing?.isPinned ?: false, isPinned = existing?.isPinned ?: false,
createdAt = parseDate(dto.created), createdAt = parseDate(dto.created),
updatedAt = parseDate(dto.updated), updatedAt = serverUpdatedAt,
syncStatus = SyncStatus.SYNCED, syncStatus = SyncStatus.SYNCED,
thumbnailUrl = existing?.thumbnailUrl, thumbnailUrl = existing?.thumbnailUrl,
readingTimeMinutes = existing?.readingTimeMinutes, readingTimeMinutes = existing?.readingTimeMinutes,
@ -428,6 +448,20 @@ class SyncManager @Inject constructor(
if (entities.isNotEmpty()) { if (entities.isNotEmpty()) {
linkDao.insertLinks(entities) 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 offset += links.size
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -436,6 +470,9 @@ class SyncManager @Inject constructor(
} }
} }
// Save sync timestamp on success
tokenManager.saveLastSyncTimestamp(syncStartTime)
// Synchroniser les tags // Synchroniser les tags
try { try {
val tags = api.getTags(limit = 1000) 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 androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.shaarit.ui.components.* import com.shaarit.ui.components.*
import com.shaarit.ui.components.VoiceInputButton
import com.shaarit.ui.theme.Typography import com.shaarit.ui.theme.Typography
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -60,6 +61,7 @@ fun AddLinkScreen(
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current
var showMarkdownPreview by remember { mutableStateOf(false) } var showMarkdownPreview by remember { mutableStateOf(false) }
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@ -75,6 +77,7 @@ fun AddLinkScreen(
LaunchedEffect(uiState) { LaunchedEffect(uiState) {
when (val state = uiState) { when (val state = uiState) {
is AddLinkUiState.Success -> { is AddLinkUiState.Success -> {
haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
if (onShareSuccess != null) { if (onShareSuccess != null) {
onShareSuccess() onShareSuccess()
} else { } else {
@ -335,7 +338,13 @@ fun AddLinkScreen(
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
colors = compactTextFieldColors(), colors = compactTextFieldColors(),
shape = RoundedCornerShape(8.dp), 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.debounce
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.async
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.net.URLDecoder import java.net.URLDecoder
import javax.inject.Inject import javax.inject.Inject
@ -122,28 +123,46 @@ constructor(
private fun extractMetadata(urlString: String) { private fun extractMetadata(urlString: String) {
viewModelScope.launch { viewModelScope.launch {
_isExtractingMetadata.value = true _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()) { if (title.value.isBlank() && !metadata.title.isNullOrBlank()) {
title.value = metadata.title title.value = metadata.title
} }
if (description.value.isBlank() && !metadata.description.isNullOrBlank()) { if (description.value.isBlank() && !metadata.description.isNullOrBlank()) {
description.value = metadata.description description.value = metadata.description
} }
_extractedThumbnail.value = metadata.thumbnailUrl _extractedThumbnail.value = metadata.thumbnailUrl
_contentType.value = metadata.contentType.name _contentType.value = metadata.contentType.name
metadata.siteName?.let { site -> suggestTagFromSite(site) }
// 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 _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.domain.model.ViewStyle
import com.shaarit.ui.components.PremiumTextField import com.shaarit.ui.components.PremiumTextField
import com.shaarit.ui.components.TagChip import com.shaarit.ui.components.TagChip
import com.shaarit.ui.components.VoiceInputButton
import com.shaarit.ui.theme.Typography import com.shaarit.ui.theme.Typography
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
@ -1314,6 +1315,7 @@ fun FeedScreen(
) )
}, },
trailingIcon = { trailingIcon = {
Row {
if (searchQuery.isNotEmpty()) { if (searchQuery.isNotEmpty()) {
IconButton( IconButton(
onClick = { viewModel.onSearchQueryChanged("") } onClick = { viewModel.onSearchQueryChanged("") }
@ -1325,6 +1327,10 @@ fun FeedScreen(
) )
} }
} }
VoiceInputButton(
onResult = { viewModel.onSearchQueryChanged(it) }
)
}
} }
) )
} }
@ -1444,7 +1450,8 @@ fun FeedScreen(
}, },
onViewClick = { selectedLink = link }, onViewClick = { selectedLink = link },
onEditClick = onNavigateToEdit, 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 }, onViewClick = { selectedLink = link },
onEditClick = onNavigateToEdit, 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 }, onViewClick = { selectedLink = link },
onEditClick = onNavigateToEdit, 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.VisibilityFilter
import com.shaarit.domain.model.TagFilter import com.shaarit.domain.model.TagFilter
import com.shaarit.domain.model.ViewStyle import com.shaarit.domain.model.ViewStyle
import com.shaarit.data.local.dao.LinkDao
import com.shaarit.domain.repository.LinkRepository import com.shaarit.domain.repository.LinkRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
@ -44,7 +45,8 @@ class FeedViewModel @Inject constructor(
private val syncManager: SyncManager, private val syncManager: SyncManager,
private val collectionDao: CollectionDao, private val collectionDao: CollectionDao,
private val tagDao: TagDao, private val tagDao: TagDao,
private val tokenManager: TokenManager private val tokenManager: TokenManager,
private val linkDao: LinkDao
) : ViewModel() { ) : ViewModel() {
private val _searchQuery = MutableStateFlow("") private val _searchQuery = MutableStateFlow("")
@ -75,11 +77,14 @@ class FeedViewModel @Inject constructor(
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
val pagedLinks: Flow<PagingData<ShaarliLink>> = val pagedLinks: Flow<PagingData<ShaarliLink>> = run {
combine(_searchQuery, _searchTags, _collectionId, _bookmarkFilter, _refreshTrigger) { query, tags, collectionId, bookmarkFilter, _ -> 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) Quadruple(query, tags, collectionId, bookmarkFilter)
} }
.debounce(300) // Debounce for 300ms
.flatMapLatest { (query, tags, collectionId, bookmarkFilter) -> .flatMapLatest { (query, tags, collectionId, bookmarkFilter) ->
linkRepository.getLinksStream( linkRepository.getLinksStream(
searchTerm = if (query.isBlank()) null else query, searchTerm = if (query.isBlank()) null else query,
@ -89,6 +94,7 @@ class FeedViewModel @Inject constructor(
) )
} }
.cachedIn(viewModelScope) .cachedIn(viewModelScope)
}
fun setTagFilter(tags: String?) { fun setTagFilter(tags: String?) {
_collectionId.value = null _collectionId.value = null
@ -185,8 +191,19 @@ class FeedViewModel @Inject constructor(
_bookmarkFilter.value = filter _bookmarkFilter.value = filter
} }
fun refresh() { fun togglePin(id: Int) {
syncManager.syncNow() viewModelScope.launch {
linkDao.getLinkById(id)?.let { link ->
linkDao.updatePinStatus(id, !link.isPinned)
_refreshTrigger.value++ _refreshTrigger.value++
} }
}
}
fun refresh() {
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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -40,6 +42,7 @@ import com.shaarit.ui.components.TagChip
import com.shaarit.ui.theme.Typography import com.shaarit.ui.theme.Typography
import dev.jeziellago.compose.markdowntext.MarkdownText import dev.jeziellago.compose.markdowntext.MarkdownText
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.ImageRequest
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -61,12 +64,14 @@ fun ListViewItem(
onDeleteClick: () -> Unit, onDeleteClick: () -> Unit,
onTogglePin: (Int) -> Unit = {} onTogglePin: (Int) -> Unit = {}
) { ) {
val haptic = LocalHapticFeedback.current
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
if (showDeleteDialog) { if (showDeleteDialog) {
DeleteConfirmationDialog( DeleteConfirmationDialog(
linkTitle = link.displayTitle, linkTitle = link.displayTitle,
onConfirm = { onConfirm = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onDeleteClick() onDeleteClick()
showDeleteDialog = false showDeleteDialog = false
}, },
@ -89,7 +94,12 @@ fun ListViewItem(
// Thumbnail (List View) // Thumbnail (List View)
if (!link.thumbnailUrl.isNullOrBlank()) { if (!link.thumbnailUrl.isNullOrBlank()) {
AsyncImage( AsyncImage(
model = link.thumbnailUrl, model = ImageRequest.Builder(LocalContext.current)
.data(link.thumbnailUrl)
.size(200)
.crossfade(true)
.memoryCacheKey(link.thumbnailUrl)
.build(),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier
@ -137,7 +147,10 @@ fun ListViewItem(
} }
// Pin button // Pin button
IconButton( IconButton(
onClick = { onTogglePin(link.id) }, onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onTogglePin(link.id)
},
modifier = Modifier.size(32.dp) modifier = Modifier.size(32.dp)
) { ) {
Icon( Icon(
@ -176,10 +189,12 @@ fun ListViewItem(
if (link.description.isNotBlank()) { if (link.description.isNotBlank()) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
MarkdownText( Text(
markdown = link.description, text = link.description,
style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurfaceVariant), style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 5, maxLines = 5,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
} }
@ -245,12 +260,14 @@ fun GridViewItem(
onDeleteClick: () -> Unit, onDeleteClick: () -> Unit,
onTogglePin: (Int) -> Unit = {} onTogglePin: (Int) -> Unit = {}
) { ) {
val haptic = LocalHapticFeedback.current
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
if (showDeleteDialog) { if (showDeleteDialog) {
DeleteConfirmationDialog( DeleteConfirmationDialog(
linkTitle = link.displayTitle, linkTitle = link.displayTitle,
onConfirm = { onConfirm = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onDeleteClick() onDeleteClick()
showDeleteDialog = false showDeleteDialog = false
}, },
@ -274,7 +291,12 @@ fun GridViewItem(
// Thumbnail (Grid View) // Thumbnail (Grid View)
if (!link.thumbnailUrl.isNullOrBlank()) { if (!link.thumbnailUrl.isNullOrBlank()) {
AsyncImage( AsyncImage(
model = link.thumbnailUrl, model = ImageRequest.Builder(LocalContext.current)
.data(link.thumbnailUrl)
.size(400, 280)
.crossfade(true)
.memoryCacheKey(link.thumbnailUrl)
.build(),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier
@ -322,12 +344,14 @@ fun GridViewItem(
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
// Description with Markdown // Description (plain text for scroll performance)
if (link.description.isNotBlank()) { if (link.description.isNotBlank()) {
MarkdownText( Text(
markdown = link.description, text = link.description,
style = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurfaceVariant), style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3, maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
} }
@ -393,7 +417,10 @@ fun GridViewItem(
Row { Row {
// Pin button // Pin button
IconButton( IconButton(
onClick = { onTogglePin(link.id) }, onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onTogglePin(link.id)
},
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp)
) { ) {
Icon( Icon(
@ -460,12 +487,14 @@ fun CompactViewItem(
onDeleteClick: () -> Unit, onDeleteClick: () -> Unit,
onTogglePin: (Int) -> Unit = {} onTogglePin: (Int) -> Unit = {}
) { ) {
val haptic = LocalHapticFeedback.current
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
if (showDeleteDialog) { if (showDeleteDialog) {
DeleteConfirmationDialog( DeleteConfirmationDialog(
linkTitle = link.displayTitle, linkTitle = link.displayTitle,
onConfirm = { onConfirm = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onDeleteClick() onDeleteClick()
showDeleteDialog = false showDeleteDialog = false
}, },
@ -565,7 +594,10 @@ fun CompactViewItem(
Row { Row {
IconButton( IconButton(
onClick = { onTogglePin(link.id) }, onClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onTogglePin(link.id)
},
modifier = Modifier.size(28.dp) modifier = Modifier.size(28.dp)
) { ) {
Icon( Icon(
@ -713,7 +745,12 @@ fun LinkDetailsView(
// Hero Image in Details // Hero Image in Details
if (!link.thumbnailUrl.isNullOrBlank()) { if (!link.thumbnailUrl.isNullOrBlank()) {
AsyncImage( AsyncImage(
model = link.thumbnailUrl, model = ImageRequest.Builder(LocalContext.current)
.data(link.thumbnailUrl)
.size(800, 400)
.crossfade(true)
.memoryCacheKey(link.thumbnailUrl)
.build(),
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier

View File

@ -59,6 +59,29 @@ fun AppNavGraph(
val navController = rememberNavController() val navController = rememberNavController()
val context = LocalContext.current 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) { NavHost(navController = navController, startDestination = startDestination) {
composable(Screen.Login.route) { composable(Screen.Login.route) {
com.shaarit.presentation.auth.LoginScreen( com.shaarit.presentation.auth.LoginScreen(

View File

@ -8,7 +8,10 @@ import com.shaarit.data.local.dao.LinkDao
import com.shaarit.domain.model.ShaarliLink import com.shaarit.domain.model.ShaarliLink
import com.shaarit.domain.repository.LinkRepository import com.shaarit.domain.repository.LinkRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -18,14 +21,19 @@ class PinnedViewModel @Inject constructor(
private val linkDao: LinkDao private val linkDao: LinkDao
) : ViewModel() { ) : ViewModel() {
private val _refreshTrigger = MutableStateFlow(0)
@OptIn(ExperimentalCoroutinesApi::class)
val pagedPinnedLinks: Flow<PagingData<ShaarliLink>> = val pagedPinnedLinks: Flow<PagingData<ShaarliLink>> =
_refreshTrigger.flatMapLatest {
linkRepository.getPinnedLinksStream() linkRepository.getPinnedLinksStream()
.cachedIn(viewModelScope) }.cachedIn(viewModelScope)
fun togglePin(id: Int) { fun togglePin(id: Int) {
viewModelScope.launch { viewModelScope.launch {
linkDao.getLinkById(id)?.let { link -> linkDao.getLinkById(id)?.let { link ->
linkDao.updatePinStatus(id, !link.isPinned) 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel 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.data.export.BookmarkImporter
import com.shaarit.ui.theme.AppTheme import com.shaarit.ui.theme.AppTheme
import com.shaarit.ui.theme.ThemeMode
import com.shaarit.ui.theme.ThemePreferences import com.shaarit.ui.theme.ThemePreferences
import com.shaarit.ui.theme.getColorSchemeForTheme import com.shaarit.ui.theme.getColorSchemeForTheme
import com.shaarit.ui.theme.getLightColorSchemeForTheme
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@ -51,6 +57,7 @@ fun SettingsScreen(
val context = LocalContext.current val context = LocalContext.current
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val currentTheme by themePreferences.currentTheme.collectAsState() val currentTheme by themePreferences.currentTheme.collectAsState()
val currentThemeMode by themePreferences.themeMode.collectAsState()
// Export JSON // Export JSON
val exportJsonLauncher = rememberLauncherForActivityResult( val exportJsonLauncher = rememberLauncherForActivityResult(
@ -122,7 +129,22 @@ fun SettingsScreen(
item { item {
ThemePickerItem( ThemePickerItem(
currentTheme = currentTheme, 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 @Composable
private fun ThemePickerItem( private fun ThemePickerItem(
currentTheme: AppTheme, 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( Card(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
@ -724,7 +754,7 @@ private fun ThemePickerItem(
style = MaterialTheme.typography.bodyLarge style = MaterialTheme.typography.bodyLarge
) )
Text( Text(
text = currentTheme.displayName, text = "${currentTheme.displayName} \u2022 ${currentThemeMode.displayName}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@ -733,6 +763,116 @@ private fun ThemePickerItem(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.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)
) {
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( LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
@ -740,21 +880,24 @@ private fun ThemePickerItem(
ThemePreviewCard( ThemePreviewCard(
theme = theme, theme = theme,
isSelected = theme == currentTheme, isSelected = theme == currentTheme,
isDarkPreview = currentThemeMode != ThemeMode.LIGHT,
onClick = { onThemeSelected(theme) } onClick = { onThemeSelected(theme) }
) )
} }
} }
} }
} }
}
} }
@Composable @Composable
private fun ThemePreviewCard( private fun ThemePreviewCard(
theme: AppTheme, theme: AppTheme,
isSelected: Boolean, isSelected: Boolean,
isDarkPreview: Boolean = true,
onClick: () -> Unit 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 val borderColor = if (isSelected) MaterialTheme.colorScheme.primary else colors.outline
Column( 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 android.net.Uri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.core.storage.TokenManager
import com.shaarit.data.export.BookmarkExporter import com.shaarit.data.export.BookmarkExporter
import com.shaarit.data.export.BookmarkImporter import com.shaarit.data.export.BookmarkImporter
@ -31,7 +33,9 @@ class SettingsViewModel @Inject constructor(
private val classifyBookmarksUseCase: ClassifyBookmarksUseCase, private val classifyBookmarksUseCase: ClassifyBookmarksUseCase,
private val tokenManager: TokenManager, private val tokenManager: TokenManager,
private val workManager: WorkManager, private val workManager: WorkManager,
val themePreferences: ThemePreferences val themePreferences: ThemePreferences,
val securityPreferences: SecurityPreferences,
val biometricAuthManager: BiometricAuthManager
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState()) 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 @Singleton
class ThemePreferences @Inject constructor( class ThemePreferences @Inject constructor(
@ApplicationContext private val context: Context @ApplicationContext private val context: Context
@ -46,17 +61,31 @@ class ThemePreferences @Inject constructor(
private val _currentTheme = MutableStateFlow(loadTheme()) private val _currentTheme = MutableStateFlow(loadTheme())
val currentTheme: StateFlow<AppTheme> = _currentTheme.asStateFlow() val currentTheme: StateFlow<AppTheme> = _currentTheme.asStateFlow()
private val _themeMode = MutableStateFlow(loadThemeMode())
val themeMode: StateFlow<ThemeMode> = _themeMode.asStateFlow()
private fun loadTheme(): AppTheme { private fun loadTheme(): AppTheme {
val name = prefs.getString(KEY_THEME, AppTheme.DEFAULT.name) ?: AppTheme.DEFAULT.name val name = prefs.getString(KEY_THEME, AppTheme.DEFAULT.name) ?: AppTheme.DEFAULT.name
return AppTheme.fromName(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) { fun setTheme(theme: AppTheme) {
prefs.edit().putString(KEY_THEME, theme.name).apply() prefs.edit().putString(KEY_THEME, theme.name).apply()
_currentTheme.value = theme _currentTheme.value = theme
} }
fun setThemeMode(mode: ThemeMode) {
prefs.edit().putString(KEY_THEME_MODE, mode.name).apply()
_themeMode.value = mode
}
companion object { companion object {
private const val KEY_THEME = "selected_theme" 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 package com.shaarit.ui.theme
import android.app.Activity import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme 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.Composable
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
@ -466,16 +472,429 @@ 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 @Composable
fun ShaarItTheme( fun ShaarItTheme(
appTheme: AppTheme = AppTheme.DEFAULT, appTheme: AppTheme = AppTheme.DEFAULT,
themeMode: ThemeMode = ThemeMode.DARK,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
// Always use the explicit color scheme for the selected theme val isDarkTheme = when (themeMode) {
val colorScheme = getColorSchemeForTheme(appTheme) ThemeMode.DARK, ThemeMode.DYNAMIC_DARK -> true
ThemeMode.LIGHT, ThemeMode.DYNAMIC_LIGHT -> false
ThemeMode.SYSTEM, ThemeMode.DYNAMIC_SYSTEM -> isSystemInDarkTheme()
}
// All custom themes are dark val useDynamicColors = themeMode in listOf(
val isEffectivelyDark = true 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 val view = LocalView.current
if (!view.isInEditMode) { if (!view.isInEditMode) {
@ -483,7 +902,7 @@ fun ShaarItTheme(
val window = (view.context as Activity).window val window = (view.context as Activity).window
window.statusBarColor = colorScheme.background.toArgb() window.statusBarColor = colorScheme.background.toArgb()
window.navigationBarColor = 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" dataStore = "1.0.0"
kotlinxSerialization = "1.6.2" kotlinxSerialization = "1.6.2"
coil = "2.6.0" coil = "2.6.0"
biometric = "1.1.0"
[libraries] [libraries]
material = { group = "com.google.android.material", name = "material", version.ref = "material" } 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 # Kotlin Serialization
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }

View File

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