From ec0931134c1f613e66da2d0f3310802ffb596e97 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Tue, 10 Feb 2026 21:15:30 -0500 Subject: [PATCH] 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 --- README.md | 203 ++- app/build.gradle.kts | 16 +- app/proguard-rules.pro | 8 +- .../5.json | 514 +++++++ app/src/main/AndroidManifest.xml | 1 + app/src/main/baseline-prof.txt | 15 + app/src/main/java/com/shaarit/MainActivity.kt | 176 ++- .../java/com/shaarit/core/di/NetworkModule.kt | 20 +- .../core/storage/BiometricAuthManager.kt | 115 ++ .../core/storage/SecurityPreferences.kt | 79 + .../com/shaarit/core/storage/TokenManager.kt | 12 + .../com/shaarit/data/local/dao/LinkDao.kt | 3 + .../data/local/database/ShaarliDatabase.kt | 25 +- .../shaarit/data/local/entity/LinkEntity.kt | 6 +- .../data/metadata/LinkMetadataExtractor.kt | 44 +- .../shaarit/data/paging/LinkPagingSource.kt | 68 - .../data/repository/GeminiRepositoryImpl.kt | 21 +- .../data/repository/LinkRepositoryImpl.kt | 45 +- .../java/com/shaarit/data/sync/SyncManager.kt | 41 +- .../shaarit/presentation/add/AddLinkScreen.kt | 11 +- .../presentation/add/AddLinkViewModel.kt | 47 +- .../shaarit/presentation/auth/LockScreen.kt | 155 ++ .../shaarit/presentation/feed/FeedScreen.kt | 33 +- .../presentation/feed/FeedViewModel.kt | 51 +- .../presentation/feed/LinkItemViews.kt | 63 +- .../com/shaarit/presentation/nav/NavGraph.kt | 23 + .../presentation/pinned/PinnedViewModel.kt | 12 +- .../presentation/settings/SettingsScreen.kt | 332 +++- .../settings/SettingsViewModel.kt | 6 +- .../shaarit/ui/components/VoiceInputButton.kt | 87 ++ .../java/com/shaarit/ui/theme/AppTheme.kt | 29 + .../main/java/com/shaarit/ui/theme/Theme.kt | 433 +++++- benchmark/build.gradle.kts | 48 + benchmark/src/main/AndroidManifest.xml | 3 + .../benchmark/BaselineProfileGenerator.kt | 54 + docs/PERFORMANCE_ET_UX_OPTIMALE.md | 869 +++++++++++ docs/ROADMAP_PROCHAINES_FONCTIONNALITES.md | 1352 +++++++++++++++++ gradle/libs.versions.toml | 4 + settings.gradle.kts | 1 + 39 files changed, 4717 insertions(+), 308 deletions(-) create mode 100644 app/schemas/com.shaarit.data.local.database.ShaarliDatabase/5.json create mode 100644 app/src/main/baseline-prof.txt create mode 100644 app/src/main/java/com/shaarit/core/storage/BiometricAuthManager.kt create mode 100644 app/src/main/java/com/shaarit/core/storage/SecurityPreferences.kt delete mode 100644 app/src/main/java/com/shaarit/data/paging/LinkPagingSource.kt create mode 100644 app/src/main/java/com/shaarit/presentation/auth/LockScreen.kt create mode 100644 app/src/main/java/com/shaarit/ui/components/VoiceInputButton.kt create mode 100644 benchmark/build.gradle.kts create mode 100644 benchmark/src/main/AndroidManifest.xml create mode 100644 benchmark/src/main/java/com/shaarit/benchmark/BaselineProfileGenerator.kt create mode 100644 docs/PERFORMANCE_ET_UX_OPTIMALE.md create mode 100644 docs/ROADMAP_PROCHAINES_FONCTIONNALITES.md diff --git a/README.md b/README.md index dfe6c08..30a9b39 100644 --- a/README.md +++ b/README.md @@ -15,16 +15,19 @@ - Connexion sécurisée à votre instance Shaarli auto-hébergée (API v1) - Stockage chiffré des tokens JWT et secrets API via `EncryptedSharedPreferences` - Génération automatique de tokens JWT avec algorithme HS512 +- Vérification automatique du token au démarrage (skip login si valide) ### 📚 Gestion des Favoris - **Flux infini** : Défilement continu avec chargement progressif (Paging 3) - **Recherche avancée** : Recherche locale avec FTS4 (full-text search) et filtrage par tags - **Mode hors-ligne** : Consultation et modification des liens même sans connexion - **Ajout rapide** : Création de liens privés/publics avec description, tags et extraction automatique de métadonnées +- **Notes Markdown** : Création de notes enrichies (pas uniquement des URLs) avec le préfixe `note://` - **Édition** : Modification complète des liens existants avec éditeur Markdown - **Suppression** : Gestion facile des favoris avec file d'attente de sync - **Détection des doublons** : Alerte lors de l'ajout d'un lien existant avec option de mise à jour -- **Liens épinglés** : Mise en avant des liens importants +- **Liens épinglés** : Mise en avant des liens importants avec écran dédié +- **Vérification santé des liens** : Détection automatique des liens morts (Dead Links) en arrière-plan ### 🏷️ Gestion des Tags - Vue dédiée pour parcourir tous les tags @@ -33,8 +36,9 @@ - Tags favoris pour accès rapide ### 📁 Collections -- Organisation des liens en collections -- Collections intelligentes avec filtres automatiques +- Organisation des liens en collections manuelles ou intelligentes +- Collections intelligentes avec filtres automatiques (basées sur les tags) +- Synchronisation des collections via le serveur Shaarli - Vue grille adaptative pour les collections ### 📝 Éditeur Markdown @@ -43,11 +47,19 @@ - Mode lecture focus sans distraction - Barre d'outils de formatage +### 🤖 Intelligence Artificielle (Google Gemini) +- **Analyse IA d'URL** : Extraction intelligente du titre, description et tags via Gemini +- **Suggestions de tags IA** : Génération automatique de tags pertinents +- **Classification de contenu** : Détection automatique du type (Article, Vidéo, Tutorial, Repository) +- **Fallback multi-modèles** : Essai automatique de plusieurs modèles Gemini (2.5 Flash Lite → 1.5 Flash) +- **Classification en lot** : Scan et classification de tous les bookmarks existants + ### 🌐 Extraction de Métadonnées -- Extraction automatique des OpenGraph (titre, description, image) -- Détection du type de contenu (article, vidéo, image, audio, code, etc.) +- Extraction automatique des OpenGraph (titre, description, image) via JSoup +- Détection du type de contenu (article, vidéo, image, audio, code, repository, social, etc.) - Estimation du temps de lecture -- Extraction du nom du site +- Extraction du nom du site et du favicon +- Suggestion automatique de tags basée sur le domaine ### 📊 Tableau de Bord - Statistiques d'utilisation (liens totaux, cette semaine, ce mois) @@ -56,7 +68,15 @@ - Tags les plus utilisés - Graphique d'activité sur 30 jours -### 💾 Import/Export +### � 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 + +### �💾 Import/Export - Export JSON (format complet avec métadonnées) - Export CSV (compatible Excel) - Export HTML (format Netscape/Chrome bookmarks) @@ -70,20 +90,21 @@ - File d'attente des opérations ### 🔗 Intégration système -- **Share Intent Android** : Sauvegarde rapide depuis n'importe quelle app +- **Share Intent Android** : Sauvegarde rapide depuis n'importe quelle app (URLs et fichiers texte/Markdown) +- **Partage de fichiers** : Import de fichiers `.md`, `.txt` directement comme notes Shaarli - **App Shortcuts** : Accès rapide via appui long sur l'icône (Ajouter, Aléatoire, Rechercher, Collections) - **Quick Settings Tile** : Tuile pour ajouter rapidement un lien -- **Widget** : Widget d'accueil affichant les liens récents +- **Deep Links** : Navigation directe via `shaarit://feed`, `shaarit://add`, `shaarit://search`, etc. - Ouverture des liens dans le navigateur par défaut ### 🎨 Interface Utilisateur -- **Material You (Monet)** : Couleurs dynamiques basées sur le fond d'écran (Android 12+) -- **Mode OLED** : Noir pur pour les écrans AMOLED -- **Design premium** : Thème sombre moderne avec dégradés -- **Material Design 3** : Composants UI natifs Android -- **Animations fluides** : Transitions et effets visuels +- **15 thèmes sombres** : ShaarIt, GitHub, Linear, Spotify, Notion, Discord, Dracula, One Dark Pro, Tokyo Night, Nord, Night Owl, Anthracite, Cyberpunk, Navy Élégance, Tons Terreux +- **Design premium** : Composants glassmorphism avec effets visuels +- **Material Design 3** : Composants UI natifs Jetpack Compose +- **Skeleton Loading** : Chargement élégant avec shimmer effect - **Trois modes d'affichage** : Liste détaillée, grille compacte, ou vue compacte - **Pull-to-refresh** : Actualisation du flux par glissement +- **Edge-to-Edge** : Support des insets système (barre de statut, navigation) --- @@ -91,24 +112,30 @@ | Catégorie | Technologie | |-----------|-------------| -| **Langage** | Kotlin 2.0.0 | -| **UI** | Jetpack Compose + Material Design 3 | +| **Langage** | Kotlin 1.9.20 | +| **UI** | Jetpack Compose (BOM 2023.08) + Material Design 3 | | **Architecture** | Clean Architecture + MVVM | -| **Injection de dépendances** | Dagger Hilt 2.51.1 | -| **Réseau** | Retrofit 2.11.0 + Moshi 1.15.1 + OkHttp 4.12.0 | -| **Base de données locale** | Room 2.6.1 | -| **Pagination** | Paging 3 | +| **Injection de dépendances** | Dagger Hilt 2.48.1 | +| **Réseau** | Retrofit 2.9.0 + Moshi 1.15.0 + OkHttp 4.12.0 | +| **Base de données locale** | Room 2.6.1 (avec FTS4) | +| **Pagination** | Paging 3 (3.2.1) | | **Concurrence** | Coroutines & Flow | | **Background work** | WorkManager 2.9.0 | | **Stockage sécurisé** | AndroidX Security Crypto | -| **Navigation** | Navigation Compose | -| **Compilation** | Gradle 8.0+ avec KSP | +| **Navigation** | Navigation Compose 2.7.6 | +| **Images** | Coil 2.6.0 | +| **Markdown** | compose-markdown 0.4.1 | +| **HTML Parsing** | JSoup 1.17.1 | +| **IA** | Google Gemini AI SDK 0.9.0 | +| **Sérialisation** | Kotlin Serialization 1.6.2 | +| **Compilation** | AGP 8.13.2 + KSP 1.9.20 | ### Compatibilité -- **minSdk**: 24 (Android 7.0) -- **targetSdk**: 34 (Android 14) -- **compileSdk**: 34 -- **JDK requis**: 17+ +- **minSdk** : 24 (Android 7.0) +- **targetSdk** : 34 (Android 14) +- **compileSdk** : 34 +- **JDK requis** : 17+ +- **Compose Compiler** : 1.5.4 --- @@ -149,14 +176,27 @@ L'APK sera généré dans `app/build/outputs/apk/debug/` ### Première configuration 1. Ouvrez l'application -2. Entrez l'URL de votre instance Shaarli +2. Entrez l'URL de votre instance Shaarli (ex: `https://monserveur.com/shaarli`) 3. Entrez votre nom d'utilisateur et mot de passe 4. L'application générera automatiquement les tokens API nécessaires +5. *(Optionnel)* Configurez votre clé API Gemini dans Paramètres pour l'auto-tagging IA ### Ajouter un lien - **Via l'app** : Appuyez sur le bouton + et entrez l'URL - **Via le partage Android** : Partagez n'importe quelle URL vers ShaarIt depuis n'importe quelle app - **Via Quick Settings** : Ajoutez la tuile ShaarIt dans vos paramètres rapides +- **Fichier Markdown** : Partagez un fichier `.md` ou `.txt` pour l'importer comme note + +### Créer une note +- Sur l'écran d'ajout, basculez en mode **Note** (au lieu de Bookmark) +- Rédigez le titre et le contenu en Markdown +- La note sera sauvegardée sur votre instance Shaarli avec un identifiant unique + +### Intelligence Artificielle +1. Obtenez une clé API sur [Google AI Studio](https://aistudio.google.com/) +2. Allez dans **Paramètres** → **Clé API Gemini** et entrez votre clé +3. Lors de l'ajout d'un lien, appuyez sur le bouton IA pour analyser automatiquement l'URL +4. L'IA remplira le titre, la description et suggérera des tags pertinents ### Organiser vos liens - Utilisez les tags pour catégoriser vos liens @@ -176,52 +216,98 @@ Le projet suit les principes de la **Clean Architecture** avec une séparation c ``` ├── data/ # Couche de données -│ ├── api/ # API Retrofit +│ ├── api/ # API Retrofit (ShaarliApi) +│ ├── dto/ # Data Transfer Objects (Moshi) │ ├── local/ # Base de données Room -│ │ ├── dao/ # Data Access Objects -│ │ ├── entity/ # Entités Room +│ │ ├── dao/ # Data Access Objects (Link, Tag, Collection) +│ │ ├── entity/ # Entités Room + FTS4 +│ │ ├── converter/ # Type converters Room │ │ └── database/ # Configuration de la DB -│ ├── sync/ # Synchronisation -│ ├── export/ # Import/Export -│ └── repository/ # Implémentations des repositories +│ ├── mapper/ # Mappers DTO ↔ Entity ↔ Domain +│ ├── metadata/ # Extraction métadonnées (JSoup/OpenGraph) +│ ├── paging/ # PagingSource réseau +│ ├── sync/ # SyncManager + ConflictResolver + SyncWorker +│ ├── worker/ # LinkHealthCheckWorker +│ ├── export/ # BookmarkExporter (JSON, CSV, HTML) +│ └── repository/ # Implémentations (Link, Auth, Gemini) ├── domain/ # Couche domaine -│ ├── model/ # Modèles métier -│ └── repository/ # Interfaces des repositories +│ ├── model/ # Modèles métier (ShaarliLink, ShaarliTag, etc.) +│ ├── repository/ # Interfaces des repositories +│ └── usecase/ # Use cases (AnalyzeUrl, GenerateTags, Classify, Login) ├── presentation/ # Couche présentation -│ ├── feed/ # Écran principal -│ ├── add/ # Ajout de liens +│ ├── feed/ # Écran principal + vues (List, Grid, Compact) +│ ├── add/ # Ajout de liens / notes │ ├── edit/ # Édition de liens +│ ├── auth/ # Écran de connexion │ ├── tags/ # Gestion des tags │ ├── collections/ # Collections -│ ├── dashboard/ # Tableau de bord -│ ├── settings/ # Paramètres -│ └── nav/ # Navigation +│ ├── dashboard/ # Tableau de bord / statistiques +│ ├── deadlinks/ # Gestion des liens morts +│ ├── pinned/ # Liens épinglés +│ ├── settings/ # Paramètres (sync, export, IA, thèmes) +│ ├── help/ # Écran d'aide +│ └── nav/ # Navigation (NavGraph + Deep Links) +├── ui/ # Composants UI réutilisables +│ ├── components/ # GlassCard, TagChip, MarkdownEditor, SkeletonLoader +│ └── theme/ # 15 thèmes + typographie + préférences +├── service/ # AddLinkTileService (Quick Settings) └── core/ # Utilitaires - ├── di/ # Injection de dépendances - ├── network/ # Configuration réseau - └── storage/ # Stockage local + ├── di/ # Modules Hilt (App, Database, Network, Repository) + ├── network/ # AuthInterceptor + HostSelectionInterceptor + ├── storage/ # TokenManager (EncryptedSharedPreferences) + └── util/ # JwtGenerator ``` --- ## 🚀 Roadmap +### Complété ✅ - [x] Synchronisation en arrière-plan avec WorkManager -- [x] Mode hors-ligne avec Room -- [x] Éditeur Markdown -- [x] Extraction de métadonnées OpenGraph -- [x] Collections d'organisation -- [x] Liens épinglés -- [x] Widget d'accueil -- [x] App Shortcuts +- [x] Mode hors-ligne avec Room + architecture offline-first +- [x] Éditeur Markdown (édition + prévisualisation + split) +- [x] Extraction de métadonnées OpenGraph (JSoup) +- [x] Collections manuelles et intelligentes +- [x] Liens épinglés avec écran dédié +- [x] App Shortcuts + Deep Links (`shaarit://`) - [x] Quick Settings Tile -- [x] Tableau de bord analytique -- [x] Import/Export -- [x] Material You (Monet) -- [ ] Recherche avancée avec filtres multiples -- [ ] Suggestions de tags par IA -- [ ] Mode lecture sans distraction pour les articles -- [ ] Partage de collections +- [x] Tableau de bord analytique (statistiques, graphiques) +- [x] Import/Export (JSON, CSV, HTML Netscape) +- [x] 15 thèmes sombres premium +- [x] Intelligence Artificielle (Google Gemini) — auto-tagging, analyse d'URL, classification +- [x] Recherche FTS4 (full-text search) + filtres multi-tags +- [x] Détection et gestion des liens morts (Health Check) +- [x] Support des notes Markdown (pas uniquement des URLs) +- [x] Partage de fichiers `.md` / `.txt` via Share Intent +- [x] Skeleton Loading (shimmer effect) +- [x] Résolution de conflits de synchronisation +- [x] Synchronisation des collections via le serveur Shaarli +- [x] Optimisations de performance — R8/ProGuard, Baseline Profiles, sync incrémentale +- [x] Recherche FTS4 activée dans le repository — Moteur FTS4 intégré dans `LinkRepositoryImpl` +- [x] Migrations Room explicites — `MIGRATION_4_5` explicite + fallback ciblé pour v1-v3 + +### Prochaines étapes 🔜 +- [ ] Mode lecture sans distraction (Reader Mode) pour les articles +- [ ] Widget d'accueil interactif (Glance) +- [ ] Partage de collections entre utilisateurs +- [ ] Support multi-instances Shaarli +- [ ] Verrouillage biométrique +- [ ] Rappels de lecture (« Lire plus tard » avec notifications) +- [ ] Voice Input dans la recherche et l'ajout de lien +- [ ] Adaptive Layouts pour tablettes et foldables +- [ ] Thème clair et Material You (Monet) dynamique sur Android 12+ + +--- + +## 📄 Documentation + +| Document | Description | +|----------|-------------| +| [Performance & UX Optimale](docs/PERFORMANCE_ET_UX_OPTIMALE.md) | Analyse de performance complète, 24 optimisations identifiées, plan d'action priorisé | +| [Analyse et Améliorations](docs/ANALYSE_ET_AMELIORATIONS.md) | Propositions d'améliorations fonctionnelles et architecturales | +| [Rapport d'Audit v2](docs/RAPPORT_AMELIORATION_v2.md) | Audit UX « Next Level » — micro-interactions, IA, ergonomie | +| [Instructions de Build Release](docs/RELEASE_BUILD.md) | Guide de création du keystore, signature et build release | +| [Roadmap Prochaines Fonctionnalités](docs/ROADMAP_PROCHAINES_FONCTIONNALITES.md) | Analyse détaillée des 9 fonctionnalités à implémenter (Reader Mode, Widget, Biométrie, Material You…) | --- @@ -253,6 +339,9 @@ Ce projet est sous licence MIT. Voir le fichier [LICENSE](LICENSE) pour plus de - [Shaarli](https://github.com/shaarli/Shaarli) - Le projet original de gestionnaire de favoris - [Jetpack Compose](https://developer.android.com/jetpack/compose) - Framework UI moderne d'Android +- [Google Gemini](https://ai.google.dev/) - IA générative pour l'enrichissement intelligent +- [JSoup](https://jsoup.org/) - Parsing HTML pour l'extraction de métadonnées +- [Coil](https://coil-kt.github.io/coil/) - Chargement d'images optimisé pour Compose - La communauté open source pour les excellentes bibliothèques utilisées --- diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c603fc4..9211b00 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -44,8 +44,8 @@ android { buildTypes { release { - isMinifyEnabled = false - isShrinkResources = false + isMinifyEnabled = true + isShrinkResources = true signingConfig = signingConfigs.getByName("release") proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), @@ -67,6 +67,7 @@ android { } buildFeatures { compose = true + buildConfig = true } composeOptions { kotlinCompilerExtensionVersion = "1.5.4" @@ -78,6 +79,10 @@ android { } } +ksp { + arg("room.schemaLocation", "$projectDir/schemas") +} + dependencies { implementation(libs.androidx.core.ktx) @@ -145,9 +150,15 @@ dependencies { // JSoup for HTML parsing (metadata extraction) implementation(libs.jsoup) + // Biometric + implementation(libs.androidx.biometric) + // Google Gemini AI SDK implementation("com.google.ai.client.generativeai:generativeai:0.9.0") + // Baseline Profiles + implementation("androidx.profileinstaller:profileinstaller:1.3.1") + testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) @@ -155,4 +166,5 @@ dependencies { androidTestImplementation(libs.androidx.ui.test.junit4) debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) + debugImplementation("com.squareup.leakcanary:leakcanary-android:2.13") } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index d8d8dd9..0939e15 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -104,9 +104,11 @@ } -dontwarn kotlinx.coroutines.** -# Keep ALL Application classes to prevent runtime crashes --keep class com.shaarit.** { *; } --keepclassmembers class com.shaarit.** { *; } +# Keep only what is strictly needed for serialization and Room +-keep class com.shaarit.data.dto.** { *; } +-keep class com.shaarit.data.local.entity.** { *; } +-keep class com.shaarit.domain.model.** { *; } +-keep class com.shaarit.data.export.Exported* { *; } # Keep Hilt generated classes -keep class com.shaarit.Hilt_* { *; } diff --git a/app/schemas/com.shaarit.data.local.database.ShaarliDatabase/5.json b/app/schemas/com.shaarit.data.local.database.ShaarliDatabase/5.json new file mode 100644 index 0000000..826e7e2 --- /dev/null +++ b/app/schemas/com.shaarit.data.local.database.ShaarliDatabase/5.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6cd5a1b..8011937 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> + 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/**;->**(**)** diff --git a/app/src/main/java/com/shaarit/MainActivity.kt b/app/src/main/java/com/shaarit/MainActivity.kt index d238743..7e191c1 100644 --- a/app/src/main/java/com/shaarit/MainActivity.kt +++ b/app/src/main/java/com/shaarit/MainActivity.kt @@ -2,18 +2,23 @@ package com.shaarit import android.net.Uri import android.os.Bundle -import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.fragment.app.FragmentActivity import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.WindowCompat +import com.shaarit.core.storage.BiometricAuthManager +import com.shaarit.core.storage.SecurityPreferences +import com.shaarit.presentation.auth.LockScreen import com.shaarit.presentation.nav.AppNavGraph import com.shaarit.ui.theme.ShaarItTheme import com.shaarit.ui.theme.ThemePreferences @@ -23,9 +28,17 @@ import java.io.InputStreamReader import javax.inject.Inject @AndroidEntryPoint -class MainActivity : ComponentActivity() { +class MainActivity : FragmentActivity() { @Inject lateinit var themePreferences: ThemePreferences + @Inject lateinit var tokenManager: com.shaarit.core.storage.TokenManager + @Inject lateinit var securityPreferences: SecurityPreferences + @Inject lateinit var biometricAuthManager: BiometricAuthManager + + // Start as authenticated — lock only triggers after app goes to background + private var isAuthenticated by mutableStateOf(true) + private var lastBackgroundTime: Long = 0L + private var hasBeenBackgrounded = false override fun onCreate(savedInstanceState: Bundle?) { // Install splash screen before super.onCreate @@ -36,67 +49,128 @@ class MainActivity : ComponentActivity() { // Enable edge-to-edge mode for proper keyboard (IME) insets detection enableEdgeToEdge() WindowCompat.setDecorFitsSystemWindows(window, false) + + // On cold start, require auth if biometric + requireOnStartup are enabled + if (securityPreferences.isBiometricEnabled.value && securityPreferences.requireOnStartup.value) { + isAuthenticated = false + } + + // Parse intent ONCE, before composition (avoids re-execution on recomposition) + val shareData = parseShareIntent(intent) + + // Check if user is already logged in to skip login screen for share intents + val hasValidToken = tokenManager.getToken() != null && tokenManager.getBaseUrl() != null + val startDestination = if (hasValidToken) { + com.shaarit.presentation.nav.Screen.Feed.createRoute() + } else { + com.shaarit.presentation.nav.Screen.Login.route + } setContent { val currentTheme by themePreferences.currentTheme.collectAsState() - ShaarItTheme(appTheme = currentTheme) { - // A surface container using the 'background' color from the theme - val context = LocalContext.current - var shareUrl: String? = null - var shareTitle: String? = null - var shareDescription: String? = null - var shareTags: List? = null - var deepLink: String? = null - var isFileShare = false + val currentThemeMode by themePreferences.themeMode.collectAsState() + val biometricEnabled by securityPreferences.isBiometricEnabled.collectAsState() - val activity = context as? androidx.activity.ComponentActivity - val intent = activity?.intent - - // Handle share intent - if (intent?.action == android.content.Intent.ACTION_SEND) { - val mimeType = intent.type ?: "" - - // Check if this is a file share (markdown or text file) - val fileUri = intent.getParcelableExtra(android.content.Intent.EXTRA_STREAM) - - if (fileUri != null && isTextOrMarkdownFile(mimeType, fileUri)) { - // Handle file sharing - use filename as title, content as description - isFileShare = true - val fileInfo = readFileContent(fileUri) - shareTitle = fileInfo.first // filename without extension - shareDescription = fileInfo.second // file content - shareTags = listOf("note", "fichier") - shareUrl = null // No URL for file shares - } else if (mimeType == "text/plain") { - // Regular text share (URL) - shareUrl = intent.getStringExtra(android.content.Intent.EXTRA_TEXT) - shareTitle = intent.getStringExtra(android.content.Intent.EXTRA_SUBJECT) - } - } - - // Handle deep links from App Shortcuts - intent?.data?.let { uri -> - if (uri.scheme == "shaarit") { - deepLink = uri.toString() - } - } + val needsAuth = biometricEnabled && !isAuthenticated + ShaarItTheme(appTheme = currentTheme, themeMode = currentThemeMode) { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background - ) { - AppNavGraph( - shareUrl = shareUrl, - shareTitle = shareTitle, - shareDescription = shareDescription, - shareTags = shareTags, - isFileShare = isFileShare, - initialDeepLink = deepLink - ) + ) { + if (needsAuth) { + LockScreen( + activity = this@MainActivity, + biometricAuthManager = biometricAuthManager, + onAuthenticated = { isAuthenticated = true } + ) + } else { + AppNavGraph( + startDestination = startDestination, + shareUrl = shareData.url, + shareTitle = shareData.title, + shareDescription = shareData.description, + shareTags = shareData.tags, + isFileShare = shareData.isFileShare, + initialDeepLink = shareData.deepLink + ) + } } } } } + + override fun onStop() { + super.onStop() + lastBackgroundTime = System.currentTimeMillis() + hasBeenBackgrounded = true + } + + override fun onResume() { + super.onResume() + if (!hasBeenBackgrounded) return + if (securityPreferences.isBiometricEnabled.value) { + val elapsed = System.currentTimeMillis() - lastBackgroundTime + val timeout = securityPreferences.lockTimeout.value.delayMs + val shouldLock = if (securityPreferences.requireOnResume.value) { + lastBackgroundTime > 0 && elapsed > timeout + } else { + false + } + if (shouldLock) { + isAuthenticated = false + } + } + } + + private data class ShareData( + val url: String? = null, + val title: String? = null, + val description: String? = null, + val tags: List? = null, + val isFileShare: Boolean = false, + val deepLink: String? = null + ) + + private fun parseShareIntent(intent: android.content.Intent?): ShareData { + if (intent == null) return ShareData() + + var shareUrl: String? = null + var shareTitle: String? = null + var shareDescription: String? = null + var shareTags: List? = null + var isFileShare = false + var deepLink: String? = null + + // Handle share intent + if (intent.action == android.content.Intent.ACTION_SEND) { + val mimeType = intent.type ?: "" + + // Check if this is a file share (markdown or text file) + val fileUri = intent.getParcelableExtra(android.content.Intent.EXTRA_STREAM) + + if (fileUri != null && isTextOrMarkdownFile(mimeType, fileUri)) { + isFileShare = true + val fileInfo = readFileContent(fileUri) + shareTitle = fileInfo.first + shareDescription = fileInfo.second + shareTags = listOf("note", "fichier") + shareUrl = null + } else if (mimeType == "text/plain") { + shareUrl = intent.getStringExtra(android.content.Intent.EXTRA_TEXT) + shareTitle = intent.getStringExtra(android.content.Intent.EXTRA_SUBJECT) + } + } + + // Handle deep links from App Shortcuts + intent.data?.let { uri -> + if (uri.scheme == "shaarit") { + deepLink = uri.toString() + } + } + + return ShareData(shareUrl, shareTitle, shareDescription, shareTags, isFileShare, deepLink) + } /** * Checks if the shared content is a text or markdown file diff --git a/app/src/main/java/com/shaarit/core/di/NetworkModule.kt b/app/src/main/java/com/shaarit/core/di/NetworkModule.kt index 4e58445..8da9a91 100644 --- a/app/src/main/java/com/shaarit/core/di/NetworkModule.kt +++ b/app/src/main/java/com/shaarit/core/di/NetworkModule.kt @@ -1,5 +1,6 @@ package com.shaarit.core.di +import android.content.Context import com.shaarit.core.network.AuthInterceptor import com.shaarit.core.network.HostSelectionInterceptor import com.shaarit.data.api.ShaarliApi @@ -7,8 +8,12 @@ import com.squareup.moshi.Moshi import dagger.Module import dagger.Provides import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import java.io.File +import java.util.concurrent.TimeUnit import javax.inject.Singleton +import okhttp3.Cache import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit @@ -23,11 +28,24 @@ object NetworkModule { @Provides @Singleton fun provideOkHttpClient( + @ApplicationContext context: Context, authInterceptor: AuthInterceptor, hostSelectionInterceptor: HostSelectionInterceptor ): OkHttpClient { - val logging = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY } + val logging = HttpLoggingInterceptor().apply { + level = if (com.shaarit.BuildConfig.DEBUG) { + HttpLoggingInterceptor.Level.BODY + } else { + HttpLoggingInterceptor.Level.NONE + } + } + val cacheSize = 10L * 1024 * 1024 // 10 MB + val cache = Cache(File(context.cacheDir, "http_cache"), cacheSize) return OkHttpClient.Builder() + .cache(cache) + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) .addInterceptor(hostSelectionInterceptor) // Host selection first .addInterceptor(authInterceptor) // Auth header second .addInterceptor(logging) diff --git a/app/src/main/java/com/shaarit/core/storage/BiometricAuthManager.kt b/app/src/main/java/com/shaarit/core/storage/BiometricAuthManager.kt new file mode 100644 index 0000000..58ad7a4 --- /dev/null +++ b/app/src/main/java/com/shaarit/core/storage/BiometricAuthManager.kt @@ -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) + } +} diff --git a/app/src/main/java/com/shaarit/core/storage/SecurityPreferences.kt b/app/src/main/java/com/shaarit/core/storage/SecurityPreferences.kt new file mode 100644 index 0000000..f727723 --- /dev/null +++ b/app/src/main/java/com/shaarit/core/storage/SecurityPreferences.kt @@ -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 = _isBiometricEnabled.asStateFlow() + + private val _lockTimeout = MutableStateFlow(loadLockTimeout()) + val lockTimeout: StateFlow = _lockTimeout.asStateFlow() + + private val _requireOnStartup = MutableStateFlow(loadRequireOnStartup()) + val requireOnStartup: StateFlow = _requireOnStartup.asStateFlow() + + private val _requireOnResume = MutableStateFlow(loadRequireOnResume()) + val requireOnResume: StateFlow = _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" + } +} diff --git a/app/src/main/java/com/shaarit/core/storage/TokenManager.kt b/app/src/main/java/com/shaarit/core/storage/TokenManager.kt index 2553234..021d88a 100644 --- a/app/src/main/java/com/shaarit/core/storage/TokenManager.kt +++ b/app/src/main/java/com/shaarit/core/storage/TokenManager.kt @@ -28,6 +28,9 @@ interface TokenManager { fun saveGeminiApiKey(apiKey: String) fun getGeminiApiKey(): String? fun clearGeminiApiKey() + + fun saveLastSyncTimestamp(timestamp: Long) + fun getLastSyncTimestamp(): Long } @Singleton @@ -128,6 +131,14 @@ class TokenManagerImpl @Inject constructor(@ApplicationContext private val conte sharedPreferences.edit().remove(KEY_GEMINI_API_KEY).apply() } + override fun saveLastSyncTimestamp(timestamp: Long) { + sharedPreferences.edit().putLong(KEY_LAST_SYNC_TIMESTAMP, timestamp).apply() + } + + override fun getLastSyncTimestamp(): Long { + return sharedPreferences.getLong(KEY_LAST_SYNC_TIMESTAMP, 0L) + } + companion object { private const val KEY_TOKEN = "jwt_token" private const val KEY_BASE_URL = "base_url" @@ -136,5 +147,6 @@ class TokenManagerImpl @Inject constructor(@ApplicationContext private val conte private const val KEY_COLLECTIONS_DIRTY = "collections_config_dirty" private const val KEY_COLLECTIONS_BOOKMARK_ID = "collections_config_bookmark_id" private const val KEY_GEMINI_API_KEY = "gemini_api_key" + private const val KEY_LAST_SYNC_TIMESTAMP = "last_sync_timestamp" } } diff --git a/app/src/main/java/com/shaarit/data/local/dao/LinkDao.kt b/app/src/main/java/com/shaarit/data/local/dao/LinkDao.kt index a2440c5..6456fcb 100644 --- a/app/src/main/java/com/shaarit/data/local/dao/LinkDao.kt +++ b/app/src/main/java/com/shaarit/data/local/dao/LinkDao.kt @@ -73,6 +73,9 @@ interface LinkDao { @RawQuery(observedEntities = [LinkEntity::class]) fun getLinksByTags(query: SupportSQLiteQuery): PagingSource + @RawQuery(observedEntities = [LinkEntity::class]) + suspend fun getLinksRawQuery(query: SupportSQLiteQuery): List + @Query(""" SELECT links.* FROM links INNER JOIN collection_links ON links.id = collection_links.link_id diff --git a/app/src/main/java/com/shaarit/data/local/database/ShaarliDatabase.kt b/app/src/main/java/com/shaarit/data/local/database/ShaarliDatabase.kt index 55db148..b50db97 100644 --- a/app/src/main/java/com/shaarit/data/local/database/ShaarliDatabase.kt +++ b/app/src/main/java/com/shaarit/data/local/database/ShaarliDatabase.kt @@ -5,6 +5,8 @@ import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase import com.shaarit.data.local.converter.Converters import com.shaarit.data.local.dao.CollectionDao import com.shaarit.data.local.dao.LinkDao @@ -28,8 +30,8 @@ import com.shaarit.data.local.entity.TagEntity CollectionEntity::class, CollectionLinkCrossRef::class ], - version = 4, - exportSchema = false + version = 5, + exportSchema = true ) @TypeConverters(Converters::class) abstract class ShaarliDatabase : RoomDatabase() { @@ -41,6 +43,22 @@ abstract class ShaarliDatabase : RoomDatabase() { companion object { private const val DATABASE_NAME = "shaarli.db" + /** + * Migration v4 → v5 : Ajout des index manquants pour les colonnes fréquemment filtrées + * - content_type : utilisé par getContentTypeDistribution() + * - site_name : utilisé par getTopSites() et getAllSites() + * - link_check_status : utilisé par getDeadLinks(), getDeadLinksCount(), getPendingLinksCount() + * - last_health_check : utilisé par getLinksForHealthCheck() + */ + val MIGRATION_4_5 = object : Migration(4, 5) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("CREATE INDEX IF NOT EXISTS `index_links_content_type` ON `links` (`content_type`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_links_site_name` ON `links` (`site_name`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_links_link_check_status` ON `links` (`link_check_status`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_links_last_health_check` ON `links` (`last_health_check`)") + } + } + @Volatile private var instance: ShaarliDatabase? = null @@ -56,7 +74,8 @@ abstract class ShaarliDatabase : RoomDatabase() { ShaarliDatabase::class.java, DATABASE_NAME ) - .fallbackToDestructiveMigration() + .addMigrations(MIGRATION_4_5) + .fallbackToDestructiveMigrationFrom(1, 2, 3) .build() } } diff --git a/app/src/main/java/com/shaarit/data/local/entity/LinkEntity.kt b/app/src/main/java/com/shaarit/data/local/entity/LinkEntity.kt index 0c181f5..1054579 100644 --- a/app/src/main/java/com/shaarit/data/local/entity/LinkEntity.kt +++ b/app/src/main/java/com/shaarit/data/local/entity/LinkEntity.kt @@ -17,7 +17,11 @@ import androidx.room.PrimaryKey Index(value = ["is_private"]), Index(value = ["created_at"]), Index(value = ["is_pinned"]), - Index(value = ["url"], unique = true) + Index(value = ["url"], unique = true), + Index(value = ["content_type"]), + Index(value = ["site_name"]), + Index(value = ["link_check_status"]), + Index(value = ["last_health_check"]) ] ) data class LinkEntity( diff --git a/app/src/main/java/com/shaarit/data/metadata/LinkMetadataExtractor.kt b/app/src/main/java/com/shaarit/data/metadata/LinkMetadataExtractor.kt index e91469c..22ac45e 100644 --- a/app/src/main/java/com/shaarit/data/metadata/LinkMetadataExtractor.kt +++ b/app/src/main/java/com/shaarit/data/metadata/LinkMetadataExtractor.kt @@ -21,6 +21,16 @@ class LinkMetadataExtractor @Inject constructor() { private const val TAG = "LinkMetadataExtractor" private const val TIMEOUT_MS = 10000 private const val MAX_DESCRIPTION_LENGTH = 300 + + private val VIDEO_PATTERN = Regex("youtube\\.com|vimeo\\.com|dailymotion|youtu\\.be") + private val REPO_PATTERN = Regex("github\\.com|gitlab\\.com|gitlab|bitbucket") + private val DOCUMENT_PATTERN = Regex("docs\\.google\\.com|docs\\.google|notion\\.so|confluence") + private val SOCIAL_PATTERN = Regex("facebook\\.com|instagram\\.com|tiktok\\.com|twitter\\.com|x\\.com|mastodon|linkedin\\.com|reddit\\.com|snapchat\\.com|pinterest\\.com") + private val SHOPPING_PATTERN = Regex("amazon|ebay|shopify") + private val NEWSLETTER_PATTERN = Regex("substack|revue|mailchimp") + private val MUSIC_PATTERN = Regex("spotify|deezer|soundcloud|bandcamp") + private val NEWS_PATTERN = Regex("news|nytimes|lemonde|bbc|cnn|reuters|theguardian|lefigaro") + private val PODCAST_PATTERN = Regex("podcast|anchor") } /** @@ -172,16 +182,16 @@ class LinkMetadataExtractor @Inject constructor() { val ogType = doc.select("meta[property=og:type]").attr("content") return when { - ogType == "video" || url.contains(Regex("youtube\\.com|vimeo\\.com|dailymotion")) -> ContentType.VIDEO + ogType == "video" || url.contains(VIDEO_PATTERN) -> ContentType.VIDEO ogType == "article" || doc.select("article").isNotEmpty() -> ContentType.ARTICLE - url.contains(Regex("github\\.com|gitlab\\.com|bitbucket")) -> ContentType.REPOSITORY - url.contains(Regex("docs\\.google\\.com|notion\\.so|confluence")) -> ContentType.DOCUMENT - url.contains(Regex("facebook\\.com|instagram\\.com|tiktok\\.com|twitter\\.com|x\\.com|mastodon|linkedin\\.com|reddit\\.com|snapchat\\.com|pinterest\\.com")) -> ContentType.SOCIAL - url.contains(Regex("amazon|ebay|shopify")) -> ContentType.SHOPPING - url.contains(Regex("substack|revue|mailchimp")) -> ContentType.NEWSLETTER - url.contains(Regex("spotify|deezer|soundcloud|bandcamp")) -> ContentType.MUSIC - url.contains(Regex("news|nytimes|lemonde|bbc|cnn|reuters|theguardian|lefigaro")) -> ContentType.NEWS - doc.select("audio").isNotEmpty() || url.contains(Regex("podcast|anchor")) -> ContentType.PODCAST + url.contains(REPO_PATTERN) -> ContentType.REPOSITORY + url.contains(DOCUMENT_PATTERN) -> ContentType.DOCUMENT + url.contains(SOCIAL_PATTERN) -> ContentType.SOCIAL + url.contains(SHOPPING_PATTERN) -> ContentType.SHOPPING + url.contains(NEWSLETTER_PATTERN) -> ContentType.NEWSLETTER + url.contains(MUSIC_PATTERN) -> ContentType.MUSIC + url.contains(NEWS_PATTERN) -> ContentType.NEWS + doc.select("audio").isNotEmpty() || url.contains(PODCAST_PATTERN) -> ContentType.PODCAST url.endsWith(".pdf") -> ContentType.PDF else -> ContentType.UNKNOWN } @@ -192,14 +202,14 @@ class LinkMetadataExtractor @Inject constructor() { */ private fun detectContentTypeFromUrl(url: String): ContentType { return when { - url.contains(Regex("youtube\\.com|youtu\\.be|vimeo|dailymotion")) -> ContentType.VIDEO - url.contains(Regex("github\\.com|gitlab|bitbucket")) -> ContentType.REPOSITORY - url.contains(Regex("docs\\.google|notion\\.so|confluence")) -> ContentType.DOCUMENT - url.contains(Regex("facebook\\.com|instagram\\.com|tiktok\\.com|twitter\\.com|x\\.com|mastodon|linkedin\\.com|reddit\\.com|snapchat\\.com|pinterest\\.com")) -> ContentType.SOCIAL - url.contains(Regex("amazon|ebay|shopify")) -> ContentType.SHOPPING - url.contains(Regex("substack|revue|mailchimp")) -> ContentType.NEWSLETTER - url.contains(Regex("spotify|deezer|soundcloud|bandcamp")) -> ContentType.MUSIC - url.contains(Regex("news|nytimes|lemonde|bbc|cnn|reuters|theguardian|lefigaro")) -> ContentType.NEWS + url.contains(VIDEO_PATTERN) -> ContentType.VIDEO + url.contains(REPO_PATTERN) -> ContentType.REPOSITORY + url.contains(DOCUMENT_PATTERN) -> ContentType.DOCUMENT + url.contains(SOCIAL_PATTERN) -> ContentType.SOCIAL + url.contains(SHOPPING_PATTERN) -> ContentType.SHOPPING + url.contains(NEWSLETTER_PATTERN) -> ContentType.NEWSLETTER + url.contains(MUSIC_PATTERN) -> ContentType.MUSIC + url.contains(NEWS_PATTERN) -> ContentType.NEWS url.endsWith(".pdf") -> ContentType.PDF else -> ContentType.UNKNOWN } diff --git a/app/src/main/java/com/shaarit/data/paging/LinkPagingSource.kt b/app/src/main/java/com/shaarit/data/paging/LinkPagingSource.kt deleted file mode 100644 index 0dd4afc..0000000 --- a/app/src/main/java/com/shaarit/data/paging/LinkPagingSource.kt +++ /dev/null @@ -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() { - - override fun getRefreshKey(state: PagingState): Int? { - return state.anchorPosition?.let { anchorPosition -> - state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) - ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) - } - } - - override suspend fun load(params: LoadParams): LoadResult { - 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) - } - } -} diff --git a/app/src/main/java/com/shaarit/data/repository/GeminiRepositoryImpl.kt b/app/src/main/java/com/shaarit/data/repository/GeminiRepositoryImpl.kt index 1f6a821..ecd596b 100644 --- a/app/src/main/java/com/shaarit/data/repository/GeminiRepositoryImpl.kt +++ b/app/src/main/java/com/shaarit/data/repository/GeminiRepositoryImpl.kt @@ -13,6 +13,7 @@ import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import org.json.JSONObject +import android.util.LruCache import javax.inject.Inject import javax.inject.Singleton @@ -22,11 +23,11 @@ class GeminiRepositoryImpl @Inject constructor( private val okHttpClient: OkHttpClient ) : GeminiRepository { - // Cache pour les tags - private val tagsCache = mutableMapOf>() + // Cache pour les tags (borné à 50 entrées max) + private val tagsCache = LruCache>(50) - // Cache en mémoire pour éviter de rappeler l'API pour la même URL durant la session - private val analysisCache = mutableMapOf() + // Cache en mémoire pour éviter de rappeler l'API pour la même URL durant la session (borné à 50 entrées max) + private val analysisCache = LruCache(50) override fun isApiKeyConfigured(): Boolean { return !tokenManager.getGeminiApiKey().isNullOrBlank() @@ -34,8 +35,8 @@ class GeminiRepositoryImpl @Inject constructor( override suspend fun generateTags(title: String, description: String): Result> = withContext(Dispatchers.IO) { val cacheKey = "$title|$description" - if (tagsCache.containsKey(cacheKey)) { - return@withContext Result.success(tagsCache[cacheKey]!!) + tagsCache.get(cacheKey)?.let { + return@withContext Result.success(it) } try { @@ -56,7 +57,7 @@ class GeminiRepositoryImpl @Inject constructor( for (modelName in modelsToTry) { try { val tags = generateTagsWithModel(apiKey, modelName, title, description) - tagsCache[cacheKey] = tags + tagsCache.put(cacheKey, tags) return@withContext Result.success(tags) } catch (e: Exception) { lastException = e @@ -114,8 +115,8 @@ class GeminiRepositoryImpl @Inject constructor( override suspend fun analyzeUrl(url: String): Result = withContext(Dispatchers.IO) { // Vérifier le cache d'abord - if (analysisCache.containsKey(url)) { - return@withContext Result.success(analysisCache[url]!!) + analysisCache.get(url)?.let { + return@withContext Result.success(it) } try { @@ -146,7 +147,7 @@ class GeminiRepositoryImpl @Inject constructor( try { val result = generateWithModel(apiKey, modelName, url) // Mettre en cache le résultat réussi - analysisCache[url] = result + analysisCache.put(url, result) return@withContext Result.success(result) } catch (e: Exception) { lastException = e diff --git a/app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt b/app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt index 5f4fe4f..916c4cf 100644 --- a/app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt +++ b/app/src/main/java/com/shaarit/data/repository/LinkRepositoryImpl.kt @@ -55,11 +55,16 @@ constructor( ): Flow> { // Utiliser Room pour la pagination locale return Pager( - config = PagingConfig(pageSize = 20, enablePlaceholders = false), + config = PagingConfig( + pageSize = 20, + prefetchDistance = 10, + initialLoadSize = 40, + enablePlaceholders = false + ), pagingSourceFactory = { when { collectionId != null -> linkDao.getLinksInCollectionPaged(collectionId) - !searchTerm.isNullOrBlank() -> linkDao.searchLinks(searchTerm) + !searchTerm.isNullOrBlank() -> linkDao.searchLinksFullText(searchTerm) !searchTags.isNullOrBlank() -> { val tags = searchTags @@ -114,18 +119,15 @@ constructor( } override suspend fun getLinksByTag(tag: String): Result> { - // Lire depuis le cache local return try { - // Si nous avons des données locales, les retourner - val localLinks = linkDao.getAllLinks().firstOrNull() ?: emptyList() + // Requête SQL directe via Room au lieu de charger toute la base en mémoire + val sql = "SELECT * FROM links WHERE tags LIKE ? AND sync_status != 'PENDING_DELETE' ORDER BY is_pinned DESC, created_at DESC LIMIT 100" + val localLinks = linkDao.getLinksRawQuery(SimpleSQLiteQuery(sql, arrayOf("%\"$tag\"%"))) if (localLinks.isNotEmpty()) { - val filtered = localLinks.filter { it.tags.contains(tag) } - if (filtered.isNotEmpty()) { - return Result.success(filtered.map { it.toDomainModel() }) - } + return Result.success(localLinks.map { it.toDomainModel() }) } - // Fallback vers l'API + // Fallback vers l'API si aucun résultat local val links = api.getLinksByTag(tag = tag, offset = 0, limit = 100) Result.success(links.mapNotNull { LinkMapper.toDomain(it) }) } catch (e: Exception) { @@ -507,7 +509,12 @@ constructor( override fun getDeadLinksStream(): Flow> { return Pager( - config = PagingConfig(pageSize = 20, enablePlaceholders = false), + config = PagingConfig( + pageSize = 20, + prefetchDistance = 10, + initialLoadSize = 40, + enablePlaceholders = false + ), pagingSourceFactory = { linkDao.getDeadLinks() } ).flow.map { pagingData -> pagingData.map { it.toDomainModel() } @@ -516,7 +523,12 @@ constructor( override fun getPinnedLinksStream(): Flow> { return Pager( - config = PagingConfig(pageSize = 20, enablePlaceholders = false), + config = PagingConfig( + pageSize = 20, + prefetchDistance = 10, + initialLoadSize = 40, + enablePlaceholders = false + ), pagingSourceFactory = { linkDao.getPinnedLinksPaged() } ).flow.map { pagingData -> pagingData.map { it.toDomainModel() } @@ -533,6 +545,10 @@ constructor( } } + companion object { + private val DATE_FORMAT = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.US) + } + private fun LinkEntity.toDomainModel(): ShaarliLink { return ShaarliLink( id = id, @@ -541,7 +557,7 @@ constructor( description = description, tags = tags, isPrivate = isPrivate, - date = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.getDefault()).format(java.util.Date(createdAt)), + date = DATE_FORMAT.format(java.util.Date(createdAt)), isPinned = isPinned, thumbnailUrl = thumbnailUrl, readingTime = readingTimeMinutes, @@ -580,8 +596,7 @@ constructor( private fun parseDate(dateString: String?): Long { if (dateString.isNullOrBlank()) return System.currentTimeMillis() return try { - val format = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.getDefault()) - format.parse(dateString)?.time ?: System.currentTimeMillis() + DATE_FORMAT.parse(dateString)?.time ?: System.currentTimeMillis() } catch (e: Exception) { System.currentTimeMillis() } diff --git a/app/src/main/java/com/shaarit/data/sync/SyncManager.kt b/app/src/main/java/com/shaarit/data/sync/SyncManager.kt index 974e879..736c99e 100644 --- a/app/src/main/java/com/shaarit/data/sync/SyncManager.kt +++ b/app/src/main/java/com/shaarit/data/sync/SyncManager.kt @@ -370,12 +370,20 @@ class SyncManager @Inject constructor( } /** - * Récupère les données depuis le serveur + * Récupère les données depuis le serveur (sync incrémentale) + * S'arrête quand on rencontre des liens déjà synchronisés (non modifiés depuis la dernière sync) */ private suspend fun pullFromServer() { var offset = 0 val limit = 100 var hasMore = true + val lastSyncTimestamp = tokenManager.getLastSyncTimestamp() + val syncStartTime = System.currentTimeMillis() + val isFirstSync = lastSyncTimestamp == 0L + var unchangedStreakCount = 0 + val unchangedStreakThreshold = 2 // Stop after 2 consecutive pages of unchanged links + + Log.d(TAG, "Sync incrémentale: lastSync=${if (isFirstSync) "jamais" else java.time.Instant.ofEpochMilli(lastSyncTimestamp)}") while (hasMore) { try { @@ -395,9 +403,21 @@ class SyncManager @Inject constructor( } Log.d(TAG, "${validLinks.size}/${links.size} liens valides") + var newOrUpdatedCount = 0 + val entities = validLinks.mapNotNull { dto -> try { val existing = linkDao.getLinkById(dto.id!!) + val serverUpdatedAt = parseDate(dto.updated) + + // Check if this link has been modified since last sync + if (!isFirstSync && existing != null && existing.updatedAt >= serverUpdatedAt && existing.syncStatus == SyncStatus.SYNCED) { + // Link unchanged since last sync — skip + return@mapNotNull null + } + + newOrUpdatedCount++ + LinkEntity( id = dto.id, url = dto.url!!, @@ -407,7 +427,7 @@ class SyncManager @Inject constructor( isPrivate = dto.isPrivate ?: false, isPinned = existing?.isPinned ?: false, createdAt = parseDate(dto.created), - updatedAt = parseDate(dto.updated), + updatedAt = serverUpdatedAt, syncStatus = SyncStatus.SYNCED, thumbnailUrl = existing?.thumbnailUrl, readingTimeMinutes = existing?.readingTimeMinutes, @@ -428,6 +448,20 @@ class SyncManager @Inject constructor( if (entities.isNotEmpty()) { linkDao.insertLinks(entities) } + + Log.d(TAG, "Page offset=$offset: $newOrUpdatedCount nouveaux/modifiés sur ${validLinks.size} valides") + + // Incremental sync: stop early if we encounter pages with no changes + if (!isFirstSync && newOrUpdatedCount == 0) { + unchangedStreakCount++ + if (unchangedStreakCount >= unchangedStreakThreshold) { + Log.d(TAG, "Sync incrémentale: $unchangedStreakThreshold pages consécutives sans changement, arrêt anticipé") + hasMore = false + } + } else { + unchangedStreakCount = 0 + } + offset += links.size } } catch (e: Exception) { @@ -436,6 +470,9 @@ class SyncManager @Inject constructor( } } + // Save sync timestamp on success + tokenManager.saveLastSyncTimestamp(syncStartTime) + // Synchroniser les tags try { val tags = api.getTags(limit = 1000) diff --git a/app/src/main/java/com/shaarit/presentation/add/AddLinkScreen.kt b/app/src/main/java/com/shaarit/presentation/add/AddLinkScreen.kt index 834ee20..cca40f4 100644 --- a/app/src/main/java/com/shaarit/presentation/add/AddLinkScreen.kt +++ b/app/src/main/java/com/shaarit/presentation/add/AddLinkScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import coil.compose.AsyncImage import com.shaarit.ui.components.* +import com.shaarit.ui.components.VoiceInputButton import com.shaarit.ui.theme.Typography import kotlinx.coroutines.launch @@ -60,6 +61,7 @@ fun AddLinkScreen( val snackbarHostState = remember { SnackbarHostState() } val focusManager = LocalFocusManager.current + val haptic = androidx.compose.ui.platform.LocalHapticFeedback.current var showMarkdownPreview by remember { mutableStateOf(false) } val scrollState = rememberScrollState() val coroutineScope = rememberCoroutineScope() @@ -75,6 +77,7 @@ fun AddLinkScreen( LaunchedEffect(uiState) { when (val state = uiState) { is AddLinkUiState.Success -> { + haptic.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress) if (onShareSuccess != null) { onShareSuccess() } else { @@ -335,7 +338,13 @@ fun AddLinkScreen( keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), colors = compactTextFieldColors(), shape = RoundedCornerShape(8.dp), - textStyle = MaterialTheme.typography.bodyMedium + textStyle = MaterialTheme.typography.bodyMedium, + trailingIcon = { + VoiceInputButton( + onResult = { viewModel.title.value = it }, + contentDescription = "Dicter le titre" + ) + } ) } diff --git a/app/src/main/java/com/shaarit/presentation/add/AddLinkViewModel.kt b/app/src/main/java/com/shaarit/presentation/add/AddLinkViewModel.kt index 53c2a48..caa5604 100644 --- a/app/src/main/java/com/shaarit/presentation/add/AddLinkViewModel.kt +++ b/app/src/main/java/com/shaarit/presentation/add/AddLinkViewModel.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.async import kotlinx.coroutines.launch import java.net.URLDecoder import javax.inject.Inject @@ -122,29 +123,47 @@ constructor( private fun extractMetadata(urlString: String) { viewModelScope.launch { _isExtractingMetadata.value = true - try { - val metadata = metadataExtractor.extract(urlString) - - // Auto-remplir si les champs sont vides + + // Launch JSoup metadata extraction and AI analysis in parallel + val metadataDeferred = async { + try { + metadataExtractor.extract(urlString) + } catch (e: Exception) { + null + } + } + + val aiDeferred = if (analyzeUrlWithAiUseCase.isApiKeyConfigured() && _aiEnrichmentState.value != AiEnrichmentState.Loading) { + _aiEnrichmentState.value = AiEnrichmentState.Loading + async { + analyzeUrlWithAiUseCase(urlString) + } + } else null + + // Apply JSoup metadata as soon as it arrives + val metadata = metadataDeferred.await() + if (metadata != null) { if (title.value.isBlank() && !metadata.title.isNullOrBlank()) { title.value = metadata.title } if (description.value.isBlank() && !metadata.description.isNullOrBlank()) { description.value = metadata.description } - _extractedThumbnail.value = metadata.thumbnailUrl _contentType.value = metadata.contentType.name - - // Suggérer des tags basés sur le site - metadata.siteName?.let { site -> - suggestTagFromSite(site) - } - } catch (e: Exception) { - // Ignorer silencieusement les erreurs d'extraction - } finally { - _isExtractingMetadata.value = false + metadata.siteName?.let { site -> suggestTagFromSite(site) } } + _isExtractingMetadata.value = false + + // Complete with AI enrichment + aiDeferred?.await() + ?.onSuccess { result -> + applyAiEnrichment(result) + _aiEnrichmentState.value = AiEnrichmentState.Success + } + ?.onFailure { + _aiEnrichmentState.value = AiEnrichmentState.Idle + } } } diff --git a/app/src/main/java/com/shaarit/presentation/auth/LockScreen.kt b/app/src/main/java/com/shaarit/presentation/auth/LockScreen.kt new file mode 100644 index 0000000..438001e --- /dev/null +++ b/app/src/main/java/com/shaarit/presentation/auth/LockScreen.kt @@ -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(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 + ) + } + } + } +} diff --git a/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt b/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt index f3d59c4..5701313 100644 --- a/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt +++ b/app/src/main/java/com/shaarit/presentation/feed/FeedScreen.kt @@ -43,6 +43,7 @@ import com.shaarit.domain.model.TagFilter import com.shaarit.domain.model.ViewStyle import com.shaarit.ui.components.PremiumTextField import com.shaarit.ui.components.TagChip +import com.shaarit.ui.components.VoiceInputButton import com.shaarit.ui.theme.Typography import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.hapticfeedback.HapticFeedbackType @@ -1314,16 +1315,21 @@ fun FeedScreen( ) }, trailingIcon = { - if (searchQuery.isNotEmpty()) { - IconButton( - onClick = { viewModel.onSearchQueryChanged("") } - ) { - Icon( - Icons.Default.Close, - contentDescription = "Clear", - tint = MaterialTheme.colorScheme.outline - ) + Row { + if (searchQuery.isNotEmpty()) { + IconButton( + onClick = { viewModel.onSearchQueryChanged("") } + ) { + Icon( + Icons.Default.Close, + contentDescription = "Clear", + tint = MaterialTheme.colorScheme.outline + ) + } } + VoiceInputButton( + onResult = { viewModel.onSearchQueryChanged(it) } + ) } } ) @@ -1444,7 +1450,8 @@ fun FeedScreen( }, onViewClick = { selectedLink = link }, onEditClick = onNavigateToEdit, - onDeleteClick = { viewModel.deleteLink(link.id) } + onDeleteClick = { viewModel.deleteLink(link.id) }, + onTogglePin = { id -> viewModel.togglePin(id) } ) } } @@ -1504,7 +1511,8 @@ fun FeedScreen( }, onViewClick = { selectedLink = link }, onEditClick = onNavigateToEdit, - onDeleteClick = { viewModel.deleteLink(link.id) } + onDeleteClick = { viewModel.deleteLink(link.id) }, + onTogglePin = { id -> viewModel.togglePin(id) } ) } } @@ -1565,7 +1573,8 @@ fun FeedScreen( }, onViewClick = { selectedLink = link }, onEditClick = onNavigateToEdit, - onDeleteClick = { viewModel.deleteLink(link.id) } + onDeleteClick = { viewModel.deleteLink(link.id) }, + onTogglePin = { id -> viewModel.togglePin(id) } ) } } diff --git a/app/src/main/java/com/shaarit/presentation/feed/FeedViewModel.kt b/app/src/main/java/com/shaarit/presentation/feed/FeedViewModel.kt index a60d9a5..1fc8f82 100644 --- a/app/src/main/java/com/shaarit/presentation/feed/FeedViewModel.kt +++ b/app/src/main/java/com/shaarit/presentation/feed/FeedViewModel.kt @@ -16,6 +16,7 @@ import com.shaarit.domain.model.TimeFilter import com.shaarit.domain.model.VisibilityFilter import com.shaarit.domain.model.TagFilter import com.shaarit.domain.model.ViewStyle +import com.shaarit.data.local.dao.LinkDao import com.shaarit.domain.repository.LinkRepository import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -44,7 +45,8 @@ class FeedViewModel @Inject constructor( private val syncManager: SyncManager, private val collectionDao: CollectionDao, private val tagDao: TagDao, - private val tokenManager: TokenManager + private val tokenManager: TokenManager, + private val linkDao: LinkDao ) : ViewModel() { private val _searchQuery = MutableStateFlow("") @@ -75,20 +77,24 @@ class FeedViewModel @Inject constructor( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) - val pagedLinks: Flow> = - combine(_searchQuery, _searchTags, _collectionId, _bookmarkFilter, _refreshTrigger) { query, tags, collectionId, bookmarkFilter, _ -> - Quadruple(query, tags, collectionId, bookmarkFilter) - } - .debounce(300) // Debounce for 300ms - .flatMapLatest { (query, tags, collectionId, bookmarkFilter) -> - linkRepository.getLinksStream( - searchTerm = if (query.isBlank()) null else query, - searchTags = tags, - collectionId = collectionId, - bookmarkFilter = bookmarkFilter - ) - } - .cachedIn(viewModelScope) + val pagedLinks: Flow> = run { + val debouncedSearch = _searchQuery.debounce(300) + val instantFilters = combine(_searchTags, _collectionId, _bookmarkFilter, _refreshTrigger) { tags, collectionId, bookmarkFilter, _ -> + Triple(tags, collectionId, bookmarkFilter) + } + combine(debouncedSearch, instantFilters) { query, (tags, collectionId, bookmarkFilter) -> + Quadruple(query, tags, collectionId, bookmarkFilter) + } + .flatMapLatest { (query, tags, collectionId, bookmarkFilter) -> + linkRepository.getLinksStream( + searchTerm = if (query.isBlank()) null else query, + searchTags = tags, + collectionId = collectionId, + bookmarkFilter = bookmarkFilter + ) + } + .cachedIn(viewModelScope) + } fun setTagFilter(tags: String?) { _collectionId.value = null @@ -185,8 +191,19 @@ class FeedViewModel @Inject constructor( _bookmarkFilter.value = filter } + fun togglePin(id: Int) { + viewModelScope.launch { + linkDao.getLinkById(id)?.let { link -> + linkDao.updatePinStatus(id, !link.isPinned) + _refreshTrigger.value++ + } + } + } + fun refresh() { - syncManager.syncNow() - _refreshTrigger.value++ + viewModelScope.launch { + syncManager.performFullSync() + _refreshTrigger.value++ + } } } diff --git a/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt b/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt index d4691bd..19414b0 100644 --- a/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt +++ b/app/src/main/java/com/shaarit/presentation/feed/LinkItemViews.kt @@ -29,7 +29,9 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -40,6 +42,7 @@ import com.shaarit.ui.components.TagChip import com.shaarit.ui.theme.Typography import dev.jeziellago.compose.markdowntext.MarkdownText import coil.compose.AsyncImage +import coil.request.ImageRequest import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.graphics.Color @@ -61,12 +64,14 @@ fun ListViewItem( onDeleteClick: () -> Unit, onTogglePin: (Int) -> Unit = {} ) { + val haptic = LocalHapticFeedback.current var showDeleteDialog by remember { mutableStateOf(false) } if (showDeleteDialog) { DeleteConfirmationDialog( linkTitle = link.displayTitle, onConfirm = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) onDeleteClick() showDeleteDialog = false }, @@ -89,7 +94,12 @@ fun ListViewItem( // Thumbnail (List View) if (!link.thumbnailUrl.isNullOrBlank()) { AsyncImage( - model = link.thumbnailUrl, + model = ImageRequest.Builder(LocalContext.current) + .data(link.thumbnailUrl) + .size(200) + .crossfade(true) + .memoryCacheKey(link.thumbnailUrl) + .build(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier @@ -137,7 +147,10 @@ fun ListViewItem( } // Pin button IconButton( - onClick = { onTogglePin(link.id) }, + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onTogglePin(link.id) + }, modifier = Modifier.size(32.dp) ) { Icon( @@ -176,10 +189,12 @@ fun ListViewItem( if (link.description.isNotBlank()) { Spacer(modifier = Modifier.height(8.dp)) - MarkdownText( - markdown = link.description, - style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurfaceVariant), + Text( + text = link.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 5, + overflow = TextOverflow.Ellipsis, modifier = Modifier.fillMaxWidth() ) } @@ -245,12 +260,14 @@ fun GridViewItem( onDeleteClick: () -> Unit, onTogglePin: (Int) -> Unit = {} ) { + val haptic = LocalHapticFeedback.current var showDeleteDialog by remember { mutableStateOf(false) } if (showDeleteDialog) { DeleteConfirmationDialog( linkTitle = link.displayTitle, onConfirm = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) onDeleteClick() showDeleteDialog = false }, @@ -274,7 +291,12 @@ fun GridViewItem( // Thumbnail (Grid View) if (!link.thumbnailUrl.isNullOrBlank()) { AsyncImage( - model = link.thumbnailUrl, + model = ImageRequest.Builder(LocalContext.current) + .data(link.thumbnailUrl) + .size(400, 280) + .crossfade(true) + .memoryCacheKey(link.thumbnailUrl) + .build(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier @@ -322,12 +344,14 @@ fun GridViewItem( Spacer(modifier = Modifier.height(4.dp)) - // Description with Markdown + // Description (plain text for scroll performance) if (link.description.isNotBlank()) { - MarkdownText( - markdown = link.description, - style = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurfaceVariant), + Text( + text = link.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 3, + overflow = TextOverflow.Ellipsis, modifier = Modifier.fillMaxWidth() ) } @@ -393,7 +417,10 @@ fun GridViewItem( Row { // Pin button IconButton( - onClick = { onTogglePin(link.id) }, + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onTogglePin(link.id) + }, modifier = Modifier.size(24.dp) ) { Icon( @@ -460,12 +487,14 @@ fun CompactViewItem( onDeleteClick: () -> Unit, onTogglePin: (Int) -> Unit = {} ) { + val haptic = LocalHapticFeedback.current var showDeleteDialog by remember { mutableStateOf(false) } if (showDeleteDialog) { DeleteConfirmationDialog( linkTitle = link.displayTitle, onConfirm = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) onDeleteClick() showDeleteDialog = false }, @@ -565,7 +594,10 @@ fun CompactViewItem( Row { IconButton( - onClick = { onTogglePin(link.id) }, + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onTogglePin(link.id) + }, modifier = Modifier.size(28.dp) ) { Icon( @@ -713,7 +745,12 @@ fun LinkDetailsView( // Hero Image in Details if (!link.thumbnailUrl.isNullOrBlank()) { AsyncImage( - model = link.thumbnailUrl, + model = ImageRequest.Builder(LocalContext.current) + .data(link.thumbnailUrl) + .size(800, 400) + .crossfade(true) + .memoryCacheKey(link.thumbnailUrl) + .build(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier diff --git a/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt b/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt index 3b7b71d..b0316a3 100644 --- a/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt +++ b/app/src/main/java/com/shaarit/presentation/nav/NavGraph.kt @@ -58,6 +58,29 @@ fun AppNavGraph( ) { val navController = rememberNavController() val context = LocalContext.current + + // If user is already logged in and has share data, navigate directly to Add screen + val hasShareData = shareUrl != null || (isFileShare && shareTitle != null) + androidx.compose.runtime.LaunchedEffect(hasShareData) { + if (hasShareData && startDestination != Screen.Login.route) { + val route = if (isFileShare && shareTitle != null) { + val encodedTitle = URLEncoder.encode(shareTitle, "UTF-8") + val encodedDesc = if (shareDescription != null) URLEncoder.encode(shareDescription, "UTF-8") else "" + val encodedTags = shareTags?.joinToString(",") { URLEncoder.encode(it, "UTF-8") } ?: "" + "add?url=&title=$encodedTitle&isShare=true&isFileShare=true&description=$encodedDesc&tags=$encodedTags" + } else if (shareUrl != null) { + val encodedUrl = URLEncoder.encode(shareUrl, "UTF-8") + val encodedTitle = if (shareTitle != null) URLEncoder.encode(shareTitle, "UTF-8") else "" + "add?url=$encodedUrl&title=$encodedTitle&isShare=true&isFileShare=false&description=&tags=" + } else null + + route?.let { + navController.navigate(it) { + popUpTo(startDestination) { inclusive = true } + } + } + } + } NavHost(navController = navController, startDestination = startDestination) { composable(Screen.Login.route) { diff --git a/app/src/main/java/com/shaarit/presentation/pinned/PinnedViewModel.kt b/app/src/main/java/com/shaarit/presentation/pinned/PinnedViewModel.kt index fa7bdab..942252d 100644 --- a/app/src/main/java/com/shaarit/presentation/pinned/PinnedViewModel.kt +++ b/app/src/main/java/com/shaarit/presentation/pinned/PinnedViewModel.kt @@ -8,7 +8,10 @@ import com.shaarit.data.local.dao.LinkDao import com.shaarit.domain.model.ShaarliLink import com.shaarit.domain.repository.LinkRepository import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch import javax.inject.Inject @@ -18,14 +21,19 @@ class PinnedViewModel @Inject constructor( private val linkDao: LinkDao ) : ViewModel() { + private val _refreshTrigger = MutableStateFlow(0) + + @OptIn(ExperimentalCoroutinesApi::class) val pagedPinnedLinks: Flow> = - linkRepository.getPinnedLinksStream() - .cachedIn(viewModelScope) + _refreshTrigger.flatMapLatest { + linkRepository.getPinnedLinksStream() + }.cachedIn(viewModelScope) fun togglePin(id: Int) { viewModelScope.launch { linkDao.getLinkById(id)?.let { link -> linkDao.updatePinStatus(id, !link.isPinned) + _refreshTrigger.value++ } } } diff --git a/app/src/main/java/com/shaarit/presentation/settings/SettingsScreen.kt b/app/src/main/java/com/shaarit/presentation/settings/SettingsScreen.kt index c4ce213..6fbb9d2 100644 --- a/app/src/main/java/com/shaarit/presentation/settings/SettingsScreen.kt +++ b/app/src/main/java/com/shaarit/presentation/settings/SettingsScreen.kt @@ -33,10 +33,16 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import com.shaarit.core.storage.BiometricAuthManager +import com.shaarit.core.storage.BiometricAvailability +import com.shaarit.core.storage.LockTimeout +import com.shaarit.core.storage.SecurityPreferences import com.shaarit.data.export.BookmarkImporter import com.shaarit.ui.theme.AppTheme +import com.shaarit.ui.theme.ThemeMode import com.shaarit.ui.theme.ThemePreferences import com.shaarit.ui.theme.getColorSchemeForTheme +import com.shaarit.ui.theme.getLightColorSchemeForTheme import java.text.SimpleDateFormat import java.util.* @@ -51,6 +57,7 @@ fun SettingsScreen( val context = LocalContext.current val uiState by viewModel.uiState.collectAsState() val currentTheme by themePreferences.currentTheme.collectAsState() + val currentThemeMode by themePreferences.themeMode.collectAsState() // Export JSON val exportJsonLauncher = rememberLauncherForActivityResult( @@ -122,7 +129,22 @@ fun SettingsScreen( item { ThemePickerItem( currentTheme = currentTheme, - onThemeSelected = { themePreferences.setTheme(it) } + currentThemeMode = currentThemeMode, + onThemeSelected = { themePreferences.setTheme(it) }, + onThemeModeSelected = { themePreferences.setThemeMode(it) } + ) + } + + // Security Section + item { + Spacer(modifier = Modifier.height(16.dp)) + SettingsSection(title = "Sécurité") + } + + item { + SecuritySettingsItem( + securityPreferences = viewModel.securityPreferences, + biometricAuthManager = viewModel.biometricAuthManager ) } @@ -695,11 +717,19 @@ private fun HealthCheckStatusItem( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun ThemePickerItem( currentTheme: AppTheme, - onThemeSelected: (AppTheme) -> Unit + currentThemeMode: ThemeMode, + onThemeSelected: (AppTheme) -> Unit, + onThemeModeSelected: (ThemeMode) -> Unit ) { + val isDynamic = currentThemeMode in listOf( + ThemeMode.DYNAMIC_DARK, ThemeMode.DYNAMIC_LIGHT, ThemeMode.DYNAMIC_SYSTEM + ) + val isDynamicAvailable = android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S + Card( modifier = Modifier.fillMaxWidth() ) { @@ -724,7 +754,7 @@ private fun ThemePickerItem( style = MaterialTheme.typography.bodyLarge ) Text( - text = currentTheme.displayName, + text = "${currentTheme.displayName} \u2022 ${currentThemeMode.displayName}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -733,17 +763,129 @@ private fun ThemePickerItem( Spacer(modifier = Modifier.height(16.dp)) - LazyRow( - horizontalArrangement = Arrangement.spacedBy(12.dp) + // Theme Mode selector (Dark / Light / System) + Text( + text = "Mode", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.padding(bottom = 8.dp) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - items(AppTheme.entries.toList()) { theme -> - ThemePreviewCard( - theme = theme, - isSelected = theme == currentTheme, - onClick = { onThemeSelected(theme) } + val baseMode = if (isDynamic) { + when (currentThemeMode) { + ThemeMode.DYNAMIC_DARK -> ThemeMode.DARK + ThemeMode.DYNAMIC_LIGHT -> ThemeMode.LIGHT + ThemeMode.DYNAMIC_SYSTEM -> ThemeMode.SYSTEM + else -> currentThemeMode + } + } else currentThemeMode + + listOf( + ThemeMode.DARK to Icons.Default.DarkMode, + ThemeMode.LIGHT to Icons.Default.LightMode, + ThemeMode.SYSTEM to Icons.Default.BrightnessAuto + ).forEach { (mode, icon) -> + val isSelected = baseMode == mode + FilterChip( + selected = isSelected, + onClick = { + val newMode = if (isDynamic) { + when (mode) { + ThemeMode.DARK -> ThemeMode.DYNAMIC_DARK + ThemeMode.LIGHT -> ThemeMode.DYNAMIC_LIGHT + ThemeMode.SYSTEM -> ThemeMode.DYNAMIC_SYSTEM + else -> mode + } + } else mode + onThemeModeSelected(newMode) + }, + label = { Text(mode.displayName, style = MaterialTheme.typography.labelSmall) }, + leadingIcon = { + Icon(icon, contentDescription = null, modifier = Modifier.size(16.dp)) + }, + modifier = Modifier.weight(1f) ) } } + + // Material You toggle (Android 12+) + if (isDynamicAvailable) { + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.AutoAwesome, + contentDescription = null, + tint = if (isDynamic) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Material You", + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = "Couleurs extraites du fond d'\u00e9cran", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = isDynamic, + onCheckedChange = { enabled -> + val baseMode2 = when (currentThemeMode) { + ThemeMode.DYNAMIC_DARK -> ThemeMode.DARK + ThemeMode.DYNAMIC_LIGHT -> ThemeMode.LIGHT + ThemeMode.DYNAMIC_SYSTEM -> ThemeMode.SYSTEM + ThemeMode.DARK -> ThemeMode.DARK + ThemeMode.LIGHT -> ThemeMode.LIGHT + ThemeMode.SYSTEM -> ThemeMode.SYSTEM + } + val newMode = if (enabled) { + when (baseMode2) { + ThemeMode.DARK -> ThemeMode.DYNAMIC_DARK + ThemeMode.LIGHT -> ThemeMode.DYNAMIC_LIGHT + ThemeMode.SYSTEM -> ThemeMode.DYNAMIC_SYSTEM + else -> ThemeMode.DYNAMIC_SYSTEM + } + } else baseMode2 + onThemeModeSelected(newMode) + } + ) + } + } + + // Theme color picker (hidden when Material You is active) + if (!isDynamic) { + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Palette de couleurs", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.padding(bottom = 8.dp) + ) + + LazyRow( + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(AppTheme.entries.toList()) { theme -> + ThemePreviewCard( + theme = theme, + isSelected = theme == currentTheme, + isDarkPreview = currentThemeMode != ThemeMode.LIGHT, + onClick = { onThemeSelected(theme) } + ) + } + } + } } } } @@ -752,9 +894,10 @@ private fun ThemePickerItem( private fun ThemePreviewCard( theme: AppTheme, isSelected: Boolean, + isDarkPreview: Boolean = true, onClick: () -> Unit ) { - val colors = getColorSchemeForTheme(theme) + val colors = if (isDarkPreview) getColorSchemeForTheme(theme) else getLightColorSchemeForTheme(theme) val borderColor = if (isSelected) MaterialTheme.colorScheme.primary else colors.outline Column( @@ -844,3 +987,170 @@ private fun ThemePreviewCard( ) } } + +@Composable +private fun SecuritySettingsItem( + securityPreferences: SecurityPreferences, + biometricAuthManager: BiometricAuthManager +) { + val isBiometricEnabled by securityPreferences.isBiometricEnabled.collectAsState() + val lockTimeout by securityPreferences.lockTimeout.collectAsState() + val requireOnStartup by securityPreferences.requireOnStartup.collectAsState() + val requireOnResume by securityPreferences.requireOnResume.collectAsState() + val biometricAvailability = remember { biometricAuthManager.canAuthenticate() } + var showTimeoutMenu by remember { mutableStateOf(false) } + + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Fingerprint, + contentDescription = null, + tint = if (isBiometricEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Verrouillage biométrique", + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = when (biometricAvailability) { + BiometricAvailability.AVAILABLE -> if (isBiometricEnabled) "Activé" else "Désactivé" + BiometricAvailability.NO_HARDWARE -> "Matériel non disponible" + BiometricAvailability.NOT_ENROLLED -> "Aucune empreinte enregistrée" + BiometricAvailability.UNAVAILABLE -> "Non disponible" + }, + style = MaterialTheme.typography.bodySmall, + color = if (isBiometricEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = isBiometricEnabled, + onCheckedChange = { securityPreferences.setBiometricEnabled(it) }, + enabled = biometricAvailability == BiometricAvailability.AVAILABLE + ) + } + + if (isBiometricEnabled) { + Spacer(modifier = Modifier.height(16.dp)) + Divider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(modifier = Modifier.height(12.dp)) + + // Lock timeout + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { showTimeoutMenu = true }, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Timer, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Délai de verrouillage", + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = lockTimeout.displayName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + } + + Box { + Icon( + imageVector = Icons.Default.ExpandMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + DropdownMenu( + expanded = showTimeoutMenu, + onDismissRequest = { showTimeoutMenu = false } + ) { + LockTimeout.entries.forEach { timeout -> + DropdownMenuItem( + text = { Text(timeout.displayName) }, + onClick = { + securityPreferences.setLockTimeout(timeout) + showTimeoutMenu = false + }, + leadingIcon = { + if (timeout == lockTimeout) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp) + ) + } + } + ) + } + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Require on startup + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.PowerSettingsNew, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = "Verrouiller au démarrage", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + Checkbox( + checked = requireOnStartup, + onCheckedChange = { securityPreferences.setRequireOnStartup(it) } + ) + } + + // Require on resume + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.PhonelinkLock, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = "Verrouiller en arrière-plan", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) + ) + Checkbox( + checked = requireOnResume, + onCheckedChange = { securityPreferences.setRequireOnResume(it) } + ) + } + } + } + } +} diff --git a/app/src/main/java/com/shaarit/presentation/settings/SettingsViewModel.kt b/app/src/main/java/com/shaarit/presentation/settings/SettingsViewModel.kt index 01f69ad..ebe737a 100644 --- a/app/src/main/java/com/shaarit/presentation/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/shaarit/presentation/settings/SettingsViewModel.kt @@ -3,6 +3,8 @@ package com.shaarit.presentation.settings import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.shaarit.core.storage.BiometricAuthManager +import com.shaarit.core.storage.SecurityPreferences import com.shaarit.core.storage.TokenManager import com.shaarit.data.export.BookmarkExporter import com.shaarit.data.export.BookmarkImporter @@ -31,7 +33,9 @@ class SettingsViewModel @Inject constructor( private val classifyBookmarksUseCase: ClassifyBookmarksUseCase, private val tokenManager: TokenManager, private val workManager: WorkManager, - val themePreferences: ThemePreferences + val themePreferences: ThemePreferences, + val securityPreferences: SecurityPreferences, + val biometricAuthManager: BiometricAuthManager ) : ViewModel() { private val _uiState = MutableStateFlow(SettingsUiState()) diff --git a/app/src/main/java/com/shaarit/ui/components/VoiceInputButton.kt b/app/src/main/java/com/shaarit/ui/components/VoiceInputButton.kt new file mode 100644 index 0000000..5ff444f --- /dev/null +++ b/app/src/main/java/com/shaarit/ui/components/VoiceInputButton.kt @@ -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...") + } +} diff --git a/app/src/main/java/com/shaarit/ui/theme/AppTheme.kt b/app/src/main/java/com/shaarit/ui/theme/AppTheme.kt index 5868c3f..c842305 100644 --- a/app/src/main/java/com/shaarit/ui/theme/AppTheme.kt +++ b/app/src/main/java/com/shaarit/ui/theme/AppTheme.kt @@ -36,6 +36,21 @@ enum class AppTheme( } } +enum class ThemeMode(val displayName: String) { + DARK("Sombre"), + LIGHT("Clair"), + SYSTEM("Système"), + DYNAMIC_DARK("Material You Sombre"), + DYNAMIC_LIGHT("Material You Clair"), + DYNAMIC_SYSTEM("Material You Auto"); + + companion object { + fun fromName(name: String): ThemeMode { + return entries.find { it.name == name } ?: DARK + } + } +} + @Singleton class ThemePreferences @Inject constructor( @ApplicationContext private val context: Context @@ -46,17 +61,31 @@ class ThemePreferences @Inject constructor( private val _currentTheme = MutableStateFlow(loadTheme()) val currentTheme: StateFlow = _currentTheme.asStateFlow() + private val _themeMode = MutableStateFlow(loadThemeMode()) + val themeMode: StateFlow = _themeMode.asStateFlow() + private fun loadTheme(): AppTheme { val name = prefs.getString(KEY_THEME, AppTheme.DEFAULT.name) ?: AppTheme.DEFAULT.name return AppTheme.fromName(name) } + private fun loadThemeMode(): ThemeMode { + val name = prefs.getString(KEY_THEME_MODE, ThemeMode.DARK.name) ?: ThemeMode.DARK.name + return ThemeMode.fromName(name) + } + fun setTheme(theme: AppTheme) { prefs.edit().putString(KEY_THEME, theme.name).apply() _currentTheme.value = theme } + fun setThemeMode(mode: ThemeMode) { + prefs.edit().putString(KEY_THEME_MODE, mode.name).apply() + _themeMode.value = mode + } + companion object { private const val KEY_THEME = "selected_theme" + private const val KEY_THEME_MODE = "theme_mode" } } diff --git a/app/src/main/java/com/shaarit/ui/theme/Theme.kt b/app/src/main/java/com/shaarit/ui/theme/Theme.kt index e4933de..8ef9859 100644 --- a/app/src/main/java/com/shaarit/ui/theme/Theme.kt +++ b/app/src/main/java/com/shaarit/ui/theme/Theme.kt @@ -1,12 +1,18 @@ package com.shaarit.ui.theme import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat @@ -466,24 +472,437 @@ fun getColorSchemeForTheme(appTheme: AppTheme): androidx.compose.material3.Color } } +// ══════════════════════════════════════════════════════════ +// ══════════════════ LIGHT COLOR SCHEMES ══════════════════ +// ══════════════════════════════════════════════════════════ + +private val DefaultLightColorScheme = lightColorScheme( + primary = Color(0xFF006B5A), + onPrimary = Color.White, + primaryContainer = Color(0xFFB2F5E6), + onPrimaryContainer = Color(0xFF00201A), + secondary = Color(0xFF0077B6), + onSecondary = Color.White, + secondaryContainer = Color(0xFFD0E8FF), + onSecondaryContainer = Color(0xFF001E36), + tertiary = Color(0xFF00897B), + onTertiary = Color.White, + background = Color(0xFFF8FAFA), + onBackground = Color(0xFF1A1C1E), + surface = Color(0xFFFFFFFF), + onSurface = Color(0xFF1A1C1E), + surfaceVariant = Color(0xFFE7F0EE), + onSurfaceVariant = Color(0xFF404944), + outline = Color(0xFF707974), + outlineVariant = Color(0xFFC0C9C4), + error = Color(0xFFBA1A1A), + onError = Color.White, + errorContainer = Color(0xFFFFDAD6), + onErrorContainer = Color(0xFF410002) +) + +private val GitHubLightColorScheme = lightColorScheme( + primary = Color(0xFF0969DA), + onPrimary = Color.White, + primaryContainer = Color(0xFFDDF4FF), + onPrimaryContainer = Color(0xFF0A3069), + secondary = Color(0xFF1A7F37), + onSecondary = Color.White, + secondaryContainer = Color(0xFFDAFBE1), + onSecondaryContainer = Color(0xFF0E4F1F), + tertiary = Color(0xFF8250DF), + onTertiary = Color.White, + background = Color(0xFFFFFFFF), + onBackground = Color(0xFF1F2328), + surface = Color(0xFFF6F8FA), + onSurface = Color(0xFF1F2328), + surfaceVariant = Color(0xFFEAEEF2), + onSurfaceVariant = Color(0xFF656D76), + outline = Color(0xFFD0D7DE), + outlineVariant = Color(0xFFE1E4E8), + error = Color(0xFFCF222E), + onError = Color.White, + errorContainer = Color(0xFFFFEBE9), + onErrorContainer = Color(0xFF82071E) +) + +private val LinearLightColorScheme = lightColorScheme( + primary = Color(0xFF5E6AD2), + onPrimary = Color.White, + primaryContainer = Color(0xFFE0E1FF), + onPrimaryContainer = Color(0xFF1B1F5E), + secondary = Color(0xFF2B6CB0), + onSecondary = Color.White, + secondaryContainer = Color(0xFFD0E4FF), + onSecondaryContainer = Color(0xFF0A2E52), + tertiary = Color(0xFFBF7B3F), + onTertiary = Color.White, + background = Color(0xFFFCFCFD), + onBackground = Color(0xFF1B1C24), + surface = Color(0xFFF5F5F7), + onSurface = Color(0xFF1B1C24), + surfaceVariant = Color(0xFFECECF0), + onSurfaceVariant = Color(0xFF5A5B66), + outline = Color(0xFFCCCDD4), + outlineVariant = Color(0xFFDDDDE2), + error = Color(0xFFD32F2F), + onError = Color.White, + errorContainer = Color(0xFFFFDAD6), + onErrorContainer = Color(0xFF690005) +) + +private val SpotifyLightColorScheme = lightColorScheme( + primary = Color(0xFF1DB954), + onPrimary = Color.White, + primaryContainer = Color(0xFFD4F5E0), + onPrimaryContainer = Color(0xFF0B3D1E), + secondary = Color(0xFF1DB954), + onSecondary = Color.White, + secondaryContainer = Color(0xFFD4F5E0), + onSecondaryContainer = Color(0xFF0B3D1E), + tertiary = Color(0xFF535353), + onTertiary = Color.White, + background = Color(0xFFF8F8F8), + onBackground = Color(0xFF191414), + surface = Color(0xFFFFFFFF), + onSurface = Color(0xFF191414), + surfaceVariant = Color(0xFFF0F0F0), + onSurfaceVariant = Color(0xFF535353), + outline = Color(0xFFB3B3B3), + outlineVariant = Color(0xFFD9D9D9), + error = Color(0xFFE22134), + onError = Color.White, + errorContainer = Color(0xFFFFDAD6), + onErrorContainer = Color(0xFF690005) +) + +private val NotionLightColorScheme = lightColorScheme( + primary = Color(0xFF2F7ABA), + onPrimary = Color.White, + primaryContainer = Color(0xFFD6EAFF), + onPrimaryContainer = Color(0xFF0A2E52), + secondary = Color(0xFFCB5A3C), + onSecondary = Color.White, + secondaryContainer = Color(0xFFFFDDD3), + onSecondaryContainer = Color(0xFF4D1A0D), + tertiary = Color(0xFF4D8B6F), + onTertiary = Color.White, + background = Color(0xFFFFFFFF), + onBackground = Color(0xFF37352F), + surface = Color(0xFFF7F6F3), + onSurface = Color(0xFF37352F), + surfaceVariant = Color(0xFFEFEEEB), + onSurfaceVariant = Color(0xFF787774), + outline = Color(0xFFD3D1CB), + outlineVariant = Color(0xFFE3E2DE), + error = Color(0xFFEB5757), + onError = Color.White, + errorContainer = Color(0xFFFFDAD6), + onErrorContainer = Color(0xFF690005) +) + +private val DiscordLightColorScheme = lightColorScheme( + primary = Color(0xFF5865F2), + onPrimary = Color.White, + primaryContainer = Color(0xFFE0E3FF), + onPrimaryContainer = Color(0xFF1E2175), + secondary = Color(0xFF248046), + onSecondary = Color.White, + secondaryContainer = Color(0xFFD4F5E0), + onSecondaryContainer = Color(0xFF0B3D1E), + tertiary = Color(0xFFC4960C), + onTertiary = Color.White, + background = Color(0xFFFFFFFF), + onBackground = Color(0xFF313338), + surface = Color(0xFFF2F3F5), + onSurface = Color(0xFF313338), + surfaceVariant = Color(0xFFEBEDEF), + onSurfaceVariant = Color(0xFF5C5E66), + outline = Color(0xFFD1D3D8), + outlineVariant = Color(0xFFE1E3E8), + error = Color(0xFFED4245), + onError = Color.White, + errorContainer = Color(0xFFFFDAD6), + onErrorContainer = Color(0xFF690005) +) + +private val DraculaLightColorScheme = lightColorScheme( + primary = Color(0xFF7C3AED), + onPrimary = Color.White, + primaryContainer = Color(0xFFEDE0FF), + onPrimaryContainer = Color(0xFF2D0A6E), + secondary = Color(0xFF16A34A), + onSecondary = Color.White, + secondaryContainer = Color(0xFFD4F5E0), + onSecondaryContainer = Color(0xFF0B3D1E), + tertiary = Color(0xFFDB2777), + onTertiary = Color.White, + background = Color(0xFFFCFAFF), + onBackground = Color(0xFF1E1E2E), + surface = Color(0xFFF5F3FA), + onSurface = Color(0xFF1E1E2E), + surfaceVariant = Color(0xFFEDE9F5), + onSurfaceVariant = Color(0xFF5C5670), + outline = Color(0xFFCBC4D8), + outlineVariant = Color(0xFFDDD8E8), + error = Color(0xFFDC2626), + onError = Color.White, + errorContainer = Color(0xFFFFDAD6), + onErrorContainer = Color(0xFF690005) +) + +private val OneDarkProLightColorScheme = lightColorScheme( + primary = Color(0xFF2563EB), + onPrimary = Color.White, + primaryContainer = Color(0xFFDBEAFE), + onPrimaryContainer = Color(0xFF0A2E6E), + secondary = Color(0xFF4D7C0F), + onSecondary = Color.White, + secondaryContainer = Color(0xFFE2F5C5), + onSecondaryContainer = Color(0xFF1A3305), + tertiary = Color(0xFFCA8A04), + onTertiary = Color.White, + background = Color(0xFFFAFAFB), + onBackground = Color(0xFF282C34), + surface = Color(0xFFF3F4F6), + onSurface = Color(0xFF282C34), + surfaceVariant = Color(0xFFECEDF0), + onSurfaceVariant = Color(0xFF5A5D66), + outline = Color(0xFFCCCED4), + outlineVariant = Color(0xFFDDDEE2), + error = Color(0xFFDC2626), + onError = Color.White, + errorContainer = Color(0xFFFFDAD6), + onErrorContainer = Color(0xFF690005) +) + +private val TokyoNightLightColorScheme = lightColorScheme( + primary = Color(0xFF3B5FD9), + onPrimary = Color.White, + primaryContainer = Color(0xFFDDE3FF), + onPrimaryContainer = Color(0xFF0F1E5E), + secondary = Color(0xFF5A8A1E), + onSecondary = Color.White, + secondaryContainer = Color(0xFFE2F5C5), + onSecondaryContainer = Color(0xFF1A3305), + tertiary = Color(0xFF7C3AED), + onTertiary = Color.White, + background = Color(0xFFF8F9FC), + onBackground = Color(0xFF1A1B26), + surface = Color(0xFFF0F2F8), + onSurface = Color(0xFF1A1B26), + surfaceVariant = Color(0xFFE8EAF2), + onSurfaceVariant = Color(0xFF545770), + outline = Color(0xFFC4C7D8), + outlineVariant = Color(0xFFD8DAE8), + error = Color(0xFFDC2626), + onError = Color.White, + errorContainer = Color(0xFFFFDAD6), + onErrorContainer = Color(0xFF690005) +) + +private val NordLightColorScheme = lightColorScheme( + primary = Color(0xFF5E81AC), + onPrimary = Color.White, + primaryContainer = Color(0xFFD8E6F3), + onPrimaryContainer = Color(0xFF1E3450), + secondary = Color(0xFF6B8F71), + onSecondary = Color.White, + secondaryContainer = Color(0xFFDAEEDC), + onSecondaryContainer = Color(0xFF233828), + tertiary = Color(0xFFBF8B2E), + onTertiary = Color.White, + background = Color(0xFFF5F7FA), + onBackground = Color(0xFF2E3440), + surface = Color(0xFFECEFF4), + onSurface = Color(0xFF2E3440), + surfaceVariant = Color(0xFFE5E9F0), + onSurfaceVariant = Color(0xFF4C566A), + outline = Color(0xFFBCC3CE), + outlineVariant = Color(0xFFD8DEE9), + error = Color(0xFFBF616A), + onError = Color.White, + errorContainer = Color(0xFFFFDAD6), + onErrorContainer = Color(0xFF690005) +) + +private val NightOwlLightColorScheme = lightColorScheme( + primary = Color(0xFF0C7B93), + onPrimary = Color.White, + primaryContainer = Color(0xFFCCEFF5), + onPrimaryContainer = Color(0xFF003540), + secondary = Color(0xFF4D7C0F), + onSecondary = Color.White, + secondaryContainer = Color(0xFFE2F5C5), + onSecondaryContainer = Color(0xFF1A3305), + tertiary = Color(0xFF9333EA), + onTertiary = Color.White, + background = Color(0xFFFBFCFD), + onBackground = Color(0xFF011627), + surface = Color(0xFFF0F4F8), + onSurface = Color(0xFF011627), + surfaceVariant = Color(0xFFE5ECF2), + onSurfaceVariant = Color(0xFF4A5568), + outline = Color(0xFFBCC8D6), + outlineVariant = Color(0xFFD4DEE8), + error = Color(0xFFDC2626), + onError = Color.White, + errorContainer = Color(0xFFFFDAD6), + onErrorContainer = Color(0xFF690005) +) + +private val AnthraciteLightColorScheme = lightColorScheme( + primary = Color(0xFF6750A4), + onPrimary = Color.White, + primaryContainer = Color(0xFFEADDFF), + onPrimaryContainer = Color(0xFF21005D), + secondary = Color(0xFF00897B), + onSecondary = Color.White, + secondaryContainer = Color(0xFFB2F5E6), + onSecondaryContainer = Color(0xFF00201A), + tertiary = Color(0xFFC2185B), + onTertiary = Color.White, + background = Color(0xFFFFFBFE), + onBackground = Color(0xFF1C1B1F), + surface = Color(0xFFF4F0F5), + onSurface = Color(0xFF1C1B1F), + surfaceVariant = Color(0xFFE7E0EC), + onSurfaceVariant = Color(0xFF49454F), + outline = Color(0xFF79747E), + outlineVariant = Color(0xFFCAC4D0), + error = Color(0xFFB3261E), + onError = Color.White, + errorContainer = Color(0xFFF9DEDC), + onErrorContainer = Color(0xFF410E0B) +) + +private val CyberpunkLightColorScheme = lightColorScheme( + primary = Color(0xFF0097A7), + onPrimary = Color.White, + primaryContainer = Color(0xFFCCF2F6), + onPrimaryContainer = Color(0xFF003840), + secondary = Color(0xFFC2185B), + onSecondary = Color.White, + secondaryContainer = Color(0xFFFFD9E3), + onSecondaryContainer = Color(0xFF4D0620), + tertiary = Color(0xFFC6A700), + onTertiary = Color.White, + background = Color(0xFFF8F8FC), + onBackground = Color(0xFF0A0A14), + surface = Color(0xFFF0F0F8), + onSurface = Color(0xFF0A0A14), + surfaceVariant = Color(0xFFE8E8F2), + onSurfaceVariant = Color(0xFF4A4A60), + outline = Color(0xFFB8B8CC), + outlineVariant = Color(0xFFD4D4E2), + error = Color(0xFFDC2626), + onError = Color.White, + errorContainer = Color(0xFFFFDAD6), + onErrorContainer = Color(0xFF690005) +) + +private val NavyEleganceLightColorScheme = lightColorScheme( + primary = Color(0xFFB8860B), + onPrimary = Color.White, + primaryContainer = Color(0xFFFFF0C8), + onPrimaryContainer = Color(0xFF3D2E00), + secondary = Color(0xFF6B7280), + onSecondary = Color.White, + secondaryContainer = Color(0xFFE5E7EB), + onSecondaryContainer = Color(0xFF1F2937), + tertiary = Color(0xFF3B82F6), + onTertiary = Color.White, + background = Color(0xFFFAFBFD), + onBackground = Color(0xFF0B1929), + surface = Color(0xFFF0F3F8), + onSurface = Color(0xFF0B1929), + surfaceVariant = Color(0xFFE8ECF2), + onSurfaceVariant = Color(0xFF4A5568), + outline = Color(0xFFBCC5D2), + outlineVariant = Color(0xFFD4DAE4), + error = Color(0xFFDC2626), + onError = Color.White, + errorContainer = Color(0xFFFFDAD6), + onErrorContainer = Color(0xFF690005) +) + +private val EarthyLightColorScheme = lightColorScheme( + primary = Color(0xFF2E7D32), + onPrimary = Color.White, + primaryContainer = Color(0xFFD4EDDA), + onPrimaryContainer = Color(0xFF0B3D0E), + secondary = Color(0xFF8D6E4C), + onSecondary = Color.White, + secondaryContainer = Color(0xFFF5E6D3), + onSecondaryContainer = Color(0xFF3D2B18), + tertiary = Color(0xFF558B2F), + onTertiary = Color.White, + background = Color(0xFFFAF8F5), + onBackground = Color(0xFF1A1510), + surface = Color(0xFFF5F2ED), + onSurface = Color(0xFF1A1510), + surfaceVariant = Color(0xFFEDE8E0), + onSurfaceVariant = Color(0xFF5A5248), + outline = Color(0xFFC4BAA8), + outlineVariant = Color(0xFFD8D0C2), + error = Color(0xFFDC2626), + onError = Color.White, + errorContainer = Color(0xFFFFDAD6), + onErrorContainer = Color(0xFF690005) +) + +fun getLightColorSchemeForTheme(appTheme: AppTheme): androidx.compose.material3.ColorScheme { + return when (appTheme) { + AppTheme.DEFAULT -> DefaultLightColorScheme + AppTheme.GITHUB -> GitHubLightColorScheme + AppTheme.LINEAR -> LinearLightColorScheme + AppTheme.SPOTIFY -> SpotifyLightColorScheme + AppTheme.NOTION -> NotionLightColorScheme + AppTheme.DISCORD -> DiscordLightColorScheme + AppTheme.DRACULA -> DraculaLightColorScheme + AppTheme.ONE_DARK_PRO -> OneDarkProLightColorScheme + AppTheme.TOKYO_NIGHT -> TokyoNightLightColorScheme + AppTheme.NORD -> NordLightColorScheme + AppTheme.NIGHT_OWL -> NightOwlLightColorScheme + AppTheme.ANTHRACITE -> AnthraciteLightColorScheme + AppTheme.CYBERPUNK -> CyberpunkLightColorScheme + AppTheme.NAVY_ELEGANCE -> NavyEleganceLightColorScheme + AppTheme.EARTHY -> EarthyLightColorScheme + } +} + @Composable fun ShaarItTheme( appTheme: AppTheme = AppTheme.DEFAULT, + themeMode: ThemeMode = ThemeMode.DARK, content: @Composable () -> Unit ) { - // Always use the explicit color scheme for the selected theme - val colorScheme = getColorSchemeForTheme(appTheme) - - // All custom themes are dark - val isEffectivelyDark = true - + val isDarkTheme = when (themeMode) { + ThemeMode.DARK, ThemeMode.DYNAMIC_DARK -> true + ThemeMode.LIGHT, ThemeMode.DYNAMIC_LIGHT -> false + ThemeMode.SYSTEM, ThemeMode.DYNAMIC_SYSTEM -> isSystemInDarkTheme() + } + + val useDynamicColors = themeMode in listOf( + ThemeMode.DYNAMIC_DARK, ThemeMode.DYNAMIC_LIGHT, ThemeMode.DYNAMIC_SYSTEM + ) + + val colorScheme = when { + useDynamicColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (isDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + isDarkTheme -> getColorSchemeForTheme(appTheme) + else -> getLightColorSchemeForTheme(appTheme) + } + val view = LocalView.current if (!view.isInEditMode) { SideEffect { val window = (view.context as Activity).window window.statusBarColor = colorScheme.background.toArgb() window.navigationBarColor = colorScheme.background.toArgb() - WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !isEffectivelyDark + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !isDarkTheme } } diff --git a/benchmark/build.gradle.kts b/benchmark/build.gradle.kts new file mode 100644 index 0000000..c7dc6bb --- /dev/null +++ b/benchmark/build.gradle.kts @@ -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" + } +} diff --git a/benchmark/src/main/AndroidManifest.xml b/benchmark/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9a40236 --- /dev/null +++ b/benchmark/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/benchmark/src/main/java/com/shaarit/benchmark/BaselineProfileGenerator.kt b/benchmark/src/main/java/com/shaarit/benchmark/BaselineProfileGenerator.kt new file mode 100644 index 0000000..ddd2ca6 --- /dev/null +++ b/benchmark/src/main/java/com/shaarit/benchmark/BaselineProfileGenerator.kt @@ -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() + } + } + } +} diff --git a/docs/PERFORMANCE_ET_UX_OPTIMALE.md b/docs/PERFORMANCE_ET_UX_OPTIMALE.md new file mode 100644 index 0000000..abbb820 --- /dev/null +++ b/docs/PERFORMANCE_ET_UX_OPTIMALE.md @@ -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> { + 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> { + 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 +// WHERE title LIKE '%query%' OR description LIKE '%query%' OR url LIKE '%query%' + +// Disponible mais NON UTILISÉ (RAPIDE) : +fun searchLinksFullText(query: String): PagingSource +// 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 +``` + +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>` 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>() +private val analysisCache = mutableMapOf() +``` + +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(50, 0.75f, true) { + override fun removeEldestEntry(eldest: Map.Entry): Boolean { + return size > 50 + } +} +``` + +Ou mieux, utiliser `LruCache` d'Android : +```kotlin +private val analysisCache = LruCache(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() + +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)* diff --git a/docs/ROADMAP_PROCHAINES_FONCTIONNALITES.md b/docs/ROADMAP_PROCHAINES_FONCTIONNALITES.md new file mode 100644 index 0000000..e7321e7 --- /dev/null +++ b/docs/ROADMAP_PROCHAINES_FONCTIONNALITES.md @@ -0,0 +1,1352 @@ +# ShaarIt — Analyse Détaillée des Prochaines Fonctionnalités + +**Date** : 9 février 2026 +**Version de référence** : v1.0 (47 fichiers Kotlin, ~8 500 lignes) +**Objectif** : Spécification technique et fonctionnelle des 9 fonctionnalités de la roadmap « Prochaines étapes » + +--- + +## Table des Matières + +1. [Mode Lecture sans Distraction (Reader Mode)](#1-mode-lecture-sans-distraction-reader-mode) +2. [Widget d'Accueil Interactif (Glance)](#2-widget-daccueil-interactif-glance) +3. [Partage de Collections entre Utilisateurs](#3-partage-de-collections-entre-utilisateurs) +4. [Support Multi-Instances Shaarli](#4-support-multi-instances-shaarli) +5. [Verrouillage Biométrique](#5-verrouillage-biométrique) +6. [Rappels de Lecture (« Lire plus tard »)](#6-rappels-de-lecture--lire-plus-tard-) +7. [Voice Input (Recherche et Ajout)](#7-voice-input-recherche-et-ajout) +8. [Adaptive Layouts (Tablettes et Foldables)](#8-adaptive-layouts-tablettes-et-foldables) +9. [Thème Clair et Material You (Monet)](#9-thème-clair-et-material-you-monet) +10. [Roadmap Priorisée et Dépendances](#10-roadmap-priorisée-et-dépendances) + +--- + +## 1. Mode Lecture sans Distraction (Reader Mode) + +### 1.1 Objectif + +Proposer un mode lecture immersif qui extrait et affiche le contenu principal d'un article web (texte, images, code) dans une vue Compose épurée, sans publicités, menus ni distractions. Similaire à Firefox Reader View ou Safari Reader Mode. + +### 1.2 Contexte Existant + +- L'app dispose déjà d'un éditeur/lecteur Markdown (`MarkdownEditor`, bibliothèque `compose-markdown 0.4.1`) +- JSoup est déjà intégré pour l'extraction de métadonnées (`LinkMetadataExtractor`) +- Le champ `excerpt` existe dans `LinkEntity` mais ne contient qu'un résumé court +- Un `readingTimeMinutes` est déjà calculé et stocké + +### 1.3 Architecture Proposée + +``` +┌──────────────────────────────────────────────────┐ +│ ReaderModeScreen │ +│ ┌────────────────────────────────────────────┐ │ +│ │ TopBar : titre, source, temps de lecture │ │ +│ ├────────────────────────────────────────────┤ │ +│ │ │ │ +│ │ Contenu article (Markdown/HTML rendu) │ │ +│ │ - Typographie optimisée lecture │ │ +│ │ - Images inline redimensionnées │ │ +│ │ - Blocs de code avec coloration │ │ +│ │ - Scroll fluide sans jank │ │ +│ │ │ │ +│ ├────────────────────────────────────────────┤ │ +│ │ BottomBar : police, taille, thème lecture │ │ +│ └────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────┘ +``` + +### 1.4 Composants Techniques + +#### A. Extraction du contenu (Readability) + +```kotlin +// Nouveau service dans data/reader/ +interface ArticleExtractor { + suspend fun extract(url: String): ReadableArticle? +} + +data class ReadableArticle( + val title: String, + val author: String?, + val siteName: String?, + val content: String, // HTML nettoyé ou Markdown + val leadImage: String?, // Image principale + val readingTimeMinutes: Int, + val wordCount: Int +) +``` + +**Options d'implémentation** : +| Option | Bibliothèque | Avantage | Inconvénient | +|--------|-------------|----------|--------------| +| **A1** | JSoup + heuristiques maison | Déjà intégré, pas de dépendance | Qualité variable | +| **A2** | [Readability4J](https://github.com/nicola-moro/readability4j) | Port Java de Mozilla Readability | Dépendance supplémentaire (~50KB) | +| **A3** | API serveur Shaarli (si disponible) | Extraction côté serveur | Nécessite connexion | + +**Recommandation** : Option A2 (Readability4J) pour la meilleure qualité d'extraction. Fallback vers JSoup si l'article est déjà en Markdown (notes Shaarli). + +#### B. Cache offline du contenu + +```kotlin +// Nouvelle colonne dans LinkEntity (migration v5 → v6) +@ColumnInfo(name = "reader_content") +val readerContent: String? = null, + +@ColumnInfo(name = "reader_content_fetched_at") +val readerContentFetchedAt: Long = 0 +``` + +- Contenu extrait et stocké localement pour consultation offline +- TTL configurable (ex : 7 jours) avant re-extraction +- WorkManager pour pré-extraction en arrière-plan des liens non lus + +#### C. Personnalisation de la lecture + +```kotlin +data class ReaderPreferences( + val fontFamily: ReaderFont, // SANS_SERIF, SERIF, MONOSPACE + val fontSize: TextUnit, // 14sp → 24sp + val lineSpacing: Float, // 1.2 → 2.0 + val theme: ReaderTheme, // DARK, SEPIA, LIGHT, AUTO + val textAlign: TextAlign // START, JUSTIFY +) + +enum class ReaderFont(val displayName: String) { + SANS_SERIF("Sans-serif"), + SERIF("Serif (lecture longue)"), + MONOSPACE("Monospace (code)") +} +``` + +### 1.5 Navigation + +- Nouveau `Screen.Reader` dans `NavGraph.kt` : `reader/{linkId}` +- Accessible depuis le bouton « Lire » sur chaque lien dans le feed, l'écran d'édition, et l'écran épinglés +- Deep link : `shaarit://reader/{linkId}` + +### 1.6 Estimation + +| Tâche | Effort | Priorité | +|-------|--------|----------| +| `ArticleExtractor` + Readability4J | 4h | Haute | +| `ReaderModeScreen` (Compose) | 6h | Haute | +| Personnalisation (police, taille, thème) | 3h | Moyenne | +| Cache offline + migration Room v6 | 2h | Moyenne | +| Pré-extraction WorkManager | 2h | Basse | +| **Total** | **~17h** | | + +--- + +## 2. Widget d'Accueil Interactif (Glance) + +### 2.1 Objectif + +Offrir un widget Android sur l'écran d'accueil permettant de : +- Voir les derniers liens ajoutés +- Ajouter rapidement un lien (quick-add) +- Accéder à un lien aléatoire +- Voir les statistiques rapides + +### 2.2 Contexte Existant + +- Quick Settings Tile (`AddLinkTileService`) déjà implémenté +- App Shortcuts déjà configurés (Ajouter, Aléatoire, Rechercher, Collections) +- Deep links fonctionnels (`shaarit://add`, `shaarit://feed`, etc.) + +### 2.3 Architecture Technique + +**Framework** : Jetpack Glance (basé sur Compose pour widgets) + +```kotlin +// Nouvelle dépendance +implementation("androidx.glance:glance-appwidget:1.1.0") +implementation("androidx.glance:glance-material3:1.1.0") +``` + +#### A. Widget « Derniers Liens » (4×2) + +```kotlin +class RecentLinksWidget : GlanceAppWidget() { + override suspend fun provideGlance(context: Context, id: GlanceId) { + val links = getRecentLinks(context) // Room query directe + + provideContent { + GlanceTheme { + RecentLinksContent(links) + } + } + } +} + +@Composable +private fun RecentLinksContent(links: List) { + LazyColumn { + items(links) { link -> + Row( + modifier = GlanceModifier + .fillMaxWidth() + .clickable(actionStartActivity(/* deep link */)) + ) { + Text(link.title, style = TextStyle(fontSize = 14.sp)) + Text(link.siteName ?: "", style = TextStyle(color = ColorProvider(Color.Gray))) + } + } + } +} +``` + +**Mockup** : +``` +┌─────────────────────────────────────────────┐ +│ 🔖 ShaarIt — Récents [+] [🔀] │ +├─────────────────────────────────────────────┤ +│ 📄 Kotlin Coroutines Deep Dive │ +│ medium.com · il y a 2h │ +├─────────────────────────────────────────────┤ +│ 📹 Understanding Compose State │ +│ youtube.com · il y a 5h │ +├─────────────────────────────────────────────┤ +│ 🛠️ Architecture hexagonale en pratique │ +│ blog.octo.com · hier │ +└─────────────────────────────────────────────┘ +``` + +#### B. Widget « Quick Stats » (2×1) + +``` +┌─────────────────────┐ +│ 📊 ShaarIt │ +│ 1,247 liens │ +│ 42 cette semaine │ +│ 📚 3h de lecture │ +└─────────────────────┘ +``` + +#### C. Mise à jour du widget + +```kotlin +class WidgetUpdateWorker( + context: Context, + params: WorkerParameters +) : CoroutineWorker(context, params) { + override suspend fun doWork(): Result { + RecentLinksWidget().updateAll(applicationContext) + return Result.success() + } +} +``` + +- Mise à jour automatique toutes les 30 min via WorkManager +- Mise à jour immédiate après chaque ajout/sync via `SyncManager` + +### 2.4 Fichiers à Créer + +``` +presentation/widget/ +├── RecentLinksWidget.kt # Widget liens récents +├── QuickStatsWidget.kt # Widget statistiques +├── WidgetReceiver.kt # GlanceAppWidgetReceiver +├── WidgetDataProvider.kt # Accès Room pour le widget +└── WidgetUpdateWorker.kt # Mise à jour périodique +``` + +- Déclaration dans `AndroidManifest.xml` : `` + `` + +### 2.5 Estimation + +| Tâche | Effort | Priorité | +|-------|--------|----------| +| Setup Glance + widget receiver | 2h | Haute | +| Widget « Derniers Liens » (4×2) | 4h | Haute | +| Widget « Quick Stats » (2×1) | 2h | Moyenne | +| Mise à jour auto (WorkManager) | 1h | Haute | +| Configurations multiples (tailles) | 2h | Basse | +| **Total** | **~11h** | | + +--- + +## 3. Partage de Collections entre Utilisateurs + +### 3.1 Objectif + +Permettre de partager une collection (liste de liens organisée) avec d'autres utilisateurs Shaarli ou via un lien public. + +### 3.2 Contexte Existant + +- Collections manuelles et intelligentes déjà implémentées (`CollectionEntity`, `CollectionDao`, `CollectionLinkCrossRef`) +- Synchronisation des collections via un bookmark serveur (format JSON dans le champ `description` d'un lien Shaarli privé, tag `shaarit_config`) +- Import/Export déjà supporté (JSON, CSV, HTML) + +### 3.3 Modes de Partage + +#### A. Partage via Lien Public (sans authentification) + +```kotlin +data class SharedCollection( + val id: String, // UUID court (ex: "abc123") + val name: String, + val description: String?, + val links: List, + val createdBy: String, // URL de l'instance Shaarli + val sharedAt: Long, + val expiresAt: Long? // Optionnel : expiration +) +``` + +**Mécanisme** : Stocker la collection partagée comme un bookmark Shaarli **public** avec un tag spécial : + +```kotlin +// URL unique pour le partage +val shareUrl = "https://shaarit.app/shared/$collectionUuid" + +// Bookmark Shaarli public contenant le JSON de la collection +val sharedBookmark = CreateLinkDto( + url = shareUrl, + title = "[Shared] $collectionName", + description = sharedCollectionJson, // JSON sérialisé + tags = listOf("shaarit_shared"), + isPrivate = false // Public pour être accessible +) +``` + +#### B. Partage Inter-Instances (via API Shaarli) + +``` +Instance A Instance B +┌──────────┐ ┌──────────┐ +│ Exporter │ ──── JSON ────▶ │ Importer │ +│ Collection│ │ Collection│ +└──────────┘ └──────────┘ + │ │ + └── Bookmark public ──── API fetch ──┘ +``` + +#### C. Partage Local (Share Intent Android) + +- Export de la collection en JSON/HTML via le système de partage Android +- Import depuis un fichier JSON reçu (déjà partiellement supporté par `BookmarkImporter`) + +### 3.4 Interface Utilisateur + +``` +┌──────────────────────────────────────────┐ +│ 📁 Ma Collection "Dev Android" │ +│ │ +│ [🔗 Copier le lien] [📤 Exporter] │ +│ [👥 Partager via…] [⏰ Expiration: 7j] │ +│ │ +│ ── Liens partagés (12) ── │ +│ ✅ Kotlin Coroutines Guide │ +│ ✅ Compose Navigation Deep Dive │ +│ ✅ Room Database Best Practices │ +│ ... │ +└──────────────────────────────────────────┘ +``` + +### 3.5 Fichiers à Créer / Modifier + +``` +data/share/ +├── CollectionShareManager.kt # Logique de création/lecture de partages +├── SharedCollectionDto.kt # DTOs pour sérialisation +domain/model/ +├── SharedCollection.kt # Modèle domaine +presentation/collections/ +├── ShareCollectionSheet.kt # Bottom sheet de partage +├── ImportCollectionScreen.kt # Écran d'import +``` + +- Modifier `CollectionDao` pour ajouter des requêtes liées au partage +- Modifier `SyncManager` pour synchroniser les collections partagées + +### 3.6 Estimation + +| Tâche | Effort | Priorité | +|-------|--------|----------| +| `CollectionShareManager` | 4h | Haute | +| DTOs et sérialisation | 2h | Haute | +| UI partage (bottom sheet) | 3h | Haute | +| Import depuis lien/fichier | 3h | Moyenne | +| Expiration des partages | 2h | Basse | +| **Total** | **~14h** | | + +### 3.7 Limitations + +- L'API Shaarli v1 ne propose pas de mécanisme de partage natif entre instances +- Le partage repose donc sur des bookmarks publics comme vecteur de transport +- La collaboration temps réel n'est pas possible sans serveur intermédiaire + +--- + +## 4. Support Multi-Instances Shaarli + +### 4.1 Objectif + +Permettre de gérer plusieurs instances Shaarli (perso, travail, projet) avec switch rapide et vue unifiée optionnelle. + +### 4.2 Contexte Existant + +- `TokenManager` stocke un seul jeu de credentials (token, baseUrl, apiSecret) +- `HostSelectionInterceptor` permet déjà de changer dynamiquement le host de l'API +- `AuthInterceptor` gère le JWT automatiquement +- La base Room est unique (une seule `ShaarliDatabase`) + +### 4.3 Architecture Proposée + +#### A. Stockage Multi-Comptes + +```kotlin +// Nouvelle entité pour stocker les instances +data class ShaarliInstance( + val id: String, // UUID + val name: String, // Ex: "Perso", "Travail" + val baseUrl: String, + val username: String, + val icon: String?, // Emoji ou URL + val color: Long?, // Couleur d'identification + val isActive: Boolean, + val lastSyncAt: Long +) + +// Extension de TokenManager +interface MultiInstanceTokenManager { + fun getInstances(): List + fun getActiveInstance(): ShaarliInstance? + fun switchToInstance(instanceId: String) + fun addInstance(instance: ShaarliInstance, token: String, apiSecret: String) + fun removeInstance(instanceId: String) + + // Credentials par instance (chiffrées séparément) + fun getTokenForInstance(instanceId: String): String? + fun getApiSecretForInstance(instanceId: String): String? +} +``` + +#### B. Isolation des Données + +**Option 1 — Base de données séparée par instance** (recommandé) : +```kotlin +fun getDatabaseForInstance(context: Context, instanceId: String): ShaarliDatabase { + return Room.databaseBuilder( + context, + ShaarliDatabase::class.java, + "shaarli_$instanceId.db" // DB unique par instance + ) + .addMigrations(MIGRATION_4_5) + .fallbackToDestructiveMigrationFrom(1, 2, 3) + .build() +} +``` + +**Option 2 — Colonne `instance_id` dans chaque table** : +```kotlin +// Plus simple mais requiert migration lourde de toutes les tables +@ColumnInfo(name = "instance_id") +val instanceId: String +``` + +**Recommandation** : Option 1 — DB séparées. Plus propre, pas de migration complexe, isolation parfaite des données. + +#### C. Switch Rapide + +``` +┌──────────────────────────────────┐ +│ 🏠 Perso (shaarli.maison.fr) │ ← Active +│ 💼 Travail (links.corp.com) │ +│ 🧪 Lab (test.shaarli.org) │ +│ │ +│ [+ Ajouter une instance] │ +└──────────────────────────────────┘ +``` + +- Drawer ou bottom sheet accessible depuis le header du feed +- Indicateur visuel permanent de l'instance active (couleur, icône) +- Switch = changement de DB + mise à jour du `HostSelectionInterceptor` + +#### D. Vue Unifiée (optionnelle) + +```kotlin +// Agrégation cross-DB en lecture seule +suspend fun getUnifiedFeed(): Flow> { + val instances = tokenManager.getInstances() + return instances.map { instance -> + getDatabaseForInstance(context, instance.id) + .linkDao() + .getRecentLinks(limit = 50) + .map { links -> links.map { it.toDomainModel(instance) } } + }.merge() // kotlinx.coroutines.flow.merge +} +``` + +### 4.4 Impact sur le Code Existant + +| Fichier | Modification | +|---------|-------------| +| `TokenManager.kt` | Refactoring vers `MultiInstanceTokenManager` | +| `DatabaseModule.kt` | Fournir la DB basée sur l'instance active | +| `NetworkModule.kt` | `HostSelectionInterceptor` piloté par l'instance active | +| `SyncManager.kt` | Sync par instance, pas global | +| `NavGraph.kt` | Nouveau `Screen.InstancePicker` | +| `FeedScreen.kt` | Afficher l'instance active + switch | + +### 4.5 Estimation + +| Tâche | Effort | Priorité | +|-------|--------|----------| +| `MultiInstanceTokenManager` | 4h | Haute | +| DB par instance (`DatabaseModule` refactoring) | 4h | Haute | +| UI picker d'instances | 3h | Haute | +| Switch dynamique (network + DB) | 3h | Haute | +| Vue unifiée cross-DB | 4h | Basse | +| Migration des données existantes | 2h | Haute | +| **Total** | **~20h** | | + +--- + +## 5. Verrouillage Biométrique + +### 5.1 Objectif + +Protéger l'accès à l'application par empreinte digitale, reconnaissance faciale ou code PIN, en utilisant l'API BiometricPrompt d'AndroidX. + +### 5.2 Contexte Existant + +- L'app utilise déjà `EncryptedSharedPreferences` pour stocker les tokens +- `TokenManager` gère les secrets sensibles +- Le minSdk est 24 (Android 7.0) — BiometricPrompt requiert SDK 28+ mais AndroidX fournit un compat + +### 5.3 Architecture Proposée + +#### A. Dépendances + +```kotlin +implementation("androidx.biometric:biometric:1.2.0-alpha05") +``` + +#### B. Gestion des Préférences + +```kotlin +data class SecurityPreferences( + val isBiometricEnabled: Boolean = false, + val lockTimeout: LockTimeout = LockTimeout.IMMEDIATE, + val requireOnStartup: Boolean = true, + val requireOnResume: Boolean = false // Après mise en arrière-plan +) + +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) +} +``` + +#### C. Service Biométrique + +```kotlin +@Singleton +class BiometricAuthManager @Inject constructor( + @ApplicationContext private val context: Context +) { + private val biometricManager = BiometricManager.from(context) + + fun canAuthenticate(): BiometricAvailability { + return when (biometricManager.canAuthenticate(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)) { + BiometricManager.BIOMETRIC_SUCCESS -> BiometricAvailability.AVAILABLE + 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 promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle("Déverrouiller ShaarIt") + .setSubtitle("Utilisez votre empreinte ou votre visage") + .setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL) + .build() + + val biometricPrompt = BiometricPrompt(activity, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + onSuccess() + } + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + onError(errString.toString()) + } + } + ) + biometricPrompt.authenticate(promptInfo) + } +} +``` + +#### D. Flux d'Authentification + +``` +App Launch + │ + ├── Biometric disabled? ──▶ Normal flow (Login/Feed) + │ + └── Biometric enabled? + │ + ├── Show LockScreen + │ │ + │ ├── Auth success ──▶ Normal flow + │ ├── Auth failed ──▶ Retry ou message d'erreur + │ └── Fallback ──▶ PIN/Pattern système + │ + └── Check timeout (si reprise après background) + ├── Timeout pas expiré ──▶ Normal flow + └── Timeout expiré ──▶ Show LockScreen +``` + +#### E. Intégration dans MainActivity + +```kotlin +// Dans MainActivity.kt +override fun onCreate(savedInstanceState: Bundle?) { + installSplashScreen() + super.onCreate(savedInstanceState) + + val needsAuth = securityPreferences.isBiometricEnabled && !isAuthenticated + + setContent { + if (needsAuth) { + LockScreen(onAuthenticated = { isAuthenticated = true }) + } else { + // AppNavGraph normal... + } + } +} + +override fun onStop() { + super.onStop() + lastBackgroundTime = System.currentTimeMillis() +} + +override fun onResume() { + super.onResume() + if (securityPreferences.isBiometricEnabled) { + val elapsed = System.currentTimeMillis() - lastBackgroundTime + if (elapsed > securityPreferences.lockTimeout.delayMs) { + isAuthenticated = false + } + } +} +``` + +### 5.4 UI Paramètres + +``` +┌──────────────────────────────────────────┐ +│ 🔒 Sécurité │ +├──────────────────────────────────────────┤ +│ Verrouillage biométrique [Toggle ON] │ +│ Délai de verrouillage [Immédiat ▾]│ +│ Verrouiller au démarrage [✓] │ +│ Verrouiller en arrière-plan [✓] │ +└──────────────────────────────────────────┘ +``` + +### 5.5 Estimation + +| Tâche | Effort | Priorité | +|-------|--------|----------| +| `BiometricAuthManager` | 2h | Haute | +| `LockScreen` (Compose) | 2h | Haute | +| Intégration `MainActivity` (lifecycle) | 2h | Haute | +| Préférences de sécurité (Settings) | 2h | Moyenne | +| Gestion du timeout background | 1h | Moyenne | +| **Total** | **~9h** | | + +--- + +## 6. Rappels de Lecture (« Lire plus tard ») + +### 6.1 Objectif + +Permettre à l'utilisateur de programmer des rappels pour relire des liens sauvegardés, avec notifications push à l'heure souhaitée. + +### 6.2 Contexte Existant + +- WorkManager déjà intégré et utilisé (sync, health check) +- Notifications non implémentées actuellement +- Le champ `is_pinned` sur `LinkEntity` offre un mécanisme rudimentaire de « favoris » + +### 6.3 Architecture Proposée + +#### A. Modèle de Données + +```kotlin +// Nouvelle entité Room +@Entity( + tableName = "reading_reminders", + foreignKeys = [ForeignKey( + entity = LinkEntity::class, + parentColumns = ["id"], + childColumns = ["link_id"], + onDelete = ForeignKey.CASCADE + )] +) +data class ReadingReminderEntity( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + + @ColumnInfo(name = "link_id") + val linkId: Int, + + @ColumnInfo(name = "remind_at") + val remindAt: Long, // Timestamp du rappel + + @ColumnInfo(name = "repeat_interval") + val repeatInterval: RepeatInterval = RepeatInterval.NONE, + + @ColumnInfo(name = "is_dismissed") + val isDismissed: Boolean = false, + + @ColumnInfo(name = "created_at") + val createdAt: Long = System.currentTimeMillis() +) + +enum class RepeatInterval { + NONE, + DAILY, + WEEKLY, + MONTHLY +} +``` + +- Migration Room v6 (ou v7 selon la fonctionnalité Reader Mode) pour ajouter la table + +#### B. Notification Worker + +```kotlin +@HiltWorker +class ReminderNotificationWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted params: WorkerParameters, + private val linkDao: LinkDao, + private val reminderDao: ReminderDao +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + val reminderId = inputData.getLong("reminder_id", -1) + val reminder = reminderDao.getById(reminderId) ?: return Result.failure() + val link = linkDao.getLinkById(reminder.linkId) ?: return Result.failure() + + showNotification(link, reminder) + + if (reminder.repeatInterval != RepeatInterval.NONE) { + scheduleNextReminder(reminder) + } else { + reminderDao.markDismissed(reminderId) + } + + return Result.success() + } + + private fun showNotification(link: LinkEntity, reminder: ReadingReminderEntity) { + val notification = NotificationCompat.Builder(applicationContext, CHANNEL_REMINDERS) + .setSmallIcon(R.drawable.ic_bookmark) + .setContentTitle("📖 Rappel de lecture") + .setContentText(link.title) + .setContentIntent(createDeepLinkPendingIntent(link.id)) + .addAction(R.drawable.ic_check, "Lu", createDismissIntent(reminder.id)) + .addAction(R.drawable.ic_snooze, "Rappeler dans 1h", createSnoozeIntent(reminder.id)) + .setAutoCancel(true) + .build() + + NotificationManagerCompat.from(applicationContext).notify(reminder.id.toInt(), notification) + } +} +``` + +#### C. Raccourcis Rapides de Rappel + +```kotlin +enum class QuickReminder(val displayName: String, val delayMs: Long) { + IN_1_HOUR("Dans 1 heure", 3_600_000), + TONIGHT("Ce soir (20h)", /* calculé */), + TOMORROW("Demain matin (9h)", /* calculé */), + THIS_WEEKEND("Ce week-end", /* calculé */), + NEXT_WEEK("La semaine prochaine", /* calculé */), + CUSTOM("Date personnalisée…", 0) +} +``` + +### 6.4 Interface Utilisateur + +``` +Long press sur un lien dans le feed : +┌──────────────────────────────────────┐ +│ ⏰ Rappeler de lire │ +├──────────────────────────────────────┤ +│ 🕐 Dans 1 heure │ +│ 🌙 Ce soir (20h00) │ +│ ☀️ Demain matin (9h00) │ +│ 📅 Ce week-end │ +│ 📆 La semaine prochaine │ +│ 📝 Date personnalisée… │ +└──────────────────────────────────────┘ +``` + +- Indicateur visuel sur les liens avec rappel actif (icône ⏰ dans le feed) +- Section dédiée dans Settings : « Mes rappels » avec liste des rappels planifiés + +### 6.5 Permissions + +```xml + + + +``` + +- Demander la permission `POST_NOTIFICATIONS` au runtime sur Android 13+ + +### 6.6 Estimation + +| Tâche | Effort | Priorité | +|-------|--------|----------| +| Entité `ReadingReminderEntity` + DAO + migration | 2h | Haute | +| `ReminderNotificationWorker` | 3h | Haute | +| Notification channel + permissions | 1h | Haute | +| Bottom sheet de sélection rapide | 2h | Haute | +| Date picker personnalisé | 2h | Moyenne | +| Écran « Mes rappels » | 3h | Moyenne | +| Rappels récurrents | 2h | Basse | +| **Total** | **~15h** | | + +--- + +## 7. Voice Input (Recherche et Ajout) + +### 7.1 Objectif + +Permettre l'ajout de liens et la recherche vocale via `SpeechRecognizer` ou l'API Material Search avec entrée vocale. + +### 7.2 Contexte Existant + +- Barre de recherche dans `FeedScreen` (champ texte avec debounce 300ms) +- Écran `AddLinkScreen` avec champ URL et titres +- Aucune intégration vocale actuelle + +### 7.3 Architecture Proposée + +#### A. Service de Reconnaissance Vocale + +```kotlin +@Singleton +class VoiceInputManager @Inject constructor( + @ApplicationContext private val context: Context +) { + fun isAvailable(): Boolean { + return SpeechRecognizer.isRecognitionAvailable(context) + } + + fun createRecognitionIntent(language: String = "fr-FR"): 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) + } + } +} +``` + +#### B. Composable Réutilisable + +```kotlin +@Composable +fun VoiceInputButton( + onResult: (String) -> Unit, + modifier: Modifier = Modifier +) { + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val matches = result.data + ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS) + matches?.firstOrNull()?.let { onResult(it) } + } + } + + IconButton( + onClick = { launcher.launch(voiceInputManager.createRecognitionIntent()) }, + modifier = modifier + ) { + Icon(Icons.Default.Mic, contentDescription = "Recherche vocale") + } +} +``` + +#### C. Points d'Intégration + +| Écran | Usage | Détail | +|-------|-------|--------| +| **FeedScreen** | Recherche vocale | Bouton 🎤 à côté de la barre de recherche | +| **AddLinkScreen** | Dictée du titre | Bouton 🎤 à côté du champ titre | +| **AddLinkScreen** | Dictée des tags | Bouton 🎤 — reconnaissance + split par virgule | +| **AddLinkScreen** | Dictée de la description | Mode dictée pour le champ description | + +#### D. Intelligence de Parsing + +```kotlin +fun parseVoiceInput(text: String): VoiceParsedInput { + val urlPattern = Regex("https?://\\S+") + val url = urlPattern.find(text)?.value + + return if (url != null) { + VoiceParsedInput.Url(url) + } else { + VoiceParsedInput.SearchQuery(text) + } +} + +sealed class VoiceParsedInput { + data class Url(val url: String) : VoiceParsedInput() + data class SearchQuery(val query: String) : VoiceParsedInput() +} +``` + +### 7.4 Permissions + +```xml + +``` + +- Demander la permission au runtime avant la première utilisation +- Afficher un message explicatif si refusée + +### 7.5 Estimation + +| Tâche | Effort | Priorité | +|-------|--------|----------| +| `VoiceInputManager` | 1h | Haute | +| `VoiceInputButton` composable | 2h | Haute | +| Intégration FeedScreen (recherche) | 1h | Haute | +| Intégration AddLinkScreen (titre, tags) | 2h | Moyenne | +| Parsing intelligent (URL vs texte) | 1h | Moyenne | +| Gestion des permissions audio | 1h | Haute | +| **Total** | **~8h** | | + +--- + +## 8. Adaptive Layouts (Tablettes et Foldables) + +### 8.1 Objectif + +Adapter l'interface de ShaarIt pour les tablettes, foldables et écrans larges en utilisant les WindowSizeClass de Material 3 et la navigation adaptative. + +### 8.2 Contexte Existant + +- UI actuellement optimisée pour téléphone uniquement (single-pane) +- Trois modes d'affichage existants : Liste, Grille, Compact (via `ViewStyle`) +- Navigation Compose avec `NavHost` mono-pane +- Edge-to-Edge déjà activé + +### 8.3 Architecture Proposée + +#### A. Dépendances + +```kotlin +implementation("androidx.compose.material3:material3-window-size-class:1.2.0") +implementation("androidx.compose.material3:material3-adaptive-navigation-suite:1.0.0") +implementation("androidx.window:window:1.2.0") +``` + +#### B. Détection de la Taille d'Écran + +```kotlin +@Composable +fun ShaarItAdaptiveLayout() { + val windowSizeClass = calculateWindowSizeClass(LocalContext.current as Activity) + + when (windowSizeClass.widthSizeClass) { + WindowWidthSizeClass.Compact -> { + // Téléphone : navigation bottom/drawer actuelle + SinglePaneLayout() + } + WindowWidthSizeClass.Medium -> { + // Tablette portrait / foldable déplié : list-detail + ListDetailLayout() + } + WindowWidthSizeClass.Expanded -> { + // Tablette paysage / grand écran : three-pane + ThreePaneLayout() + } + } +} +``` + +#### C. Layout List-Detail (Medium) + +``` +┌─────────────────┬──────────────────────────────┐ +│ │ │ +│ Feed (liste) │ Détail du lien sélectionné │ +│ │ (édition ou Reader Mode) │ +│ ┌────────────┐ │ │ +│ │ Lien 1 │ │ Titre: Kotlin Coroutines │ +│ │ Lien 2 ◀──┼─┤ URL: medium.com/... │ +│ │ Lien 3 │ │ Description: ... │ +│ │ Lien 4 │ │ Tags: [kotlin] [coroutines]│ +│ └────────────┘ │ │ +│ │ │ +└─────────────────┴──────────────────────────────┘ +``` + +#### D. Navigation Adaptative + +```kotlin +@Composable +fun AdaptiveNavigation( + windowSizeClass: WindowSizeClass, + content: @Composable () -> Unit +) { + when (windowSizeClass.widthSizeClass) { + WindowWidthSizeClass.Compact -> { + // Bottom navigation bar + Scaffold(bottomBar = { BottomNavBar() }) { content() } + } + WindowWidthSizeClass.Medium -> { + // Navigation rail (barre latérale fine) + Row { + NavigationRail { /* Feed, Tags, Collections, Settings */ } + content() + } + } + WindowWidthSizeClass.Expanded -> { + // Navigation drawer permanent + PermanentNavigationDrawer( + drawerContent = { PermanentDrawerSheet { /* Menu complet */ } } + ) { content() } + } + } +} +``` + +#### E. Grille Adaptative + +```kotlin +@Composable +fun AdaptiveGrid(links: LazyPagingItems) { + val windowSizeClass = calculateWindowSizeClass(LocalContext.current as Activity) + val columns = when (windowSizeClass.widthSizeClass) { + WindowWidthSizeClass.Compact -> 1 // ou 2 en grille + WindowWidthSizeClass.Medium -> 2 + WindowWidthSizeClass.Expanded -> 3 + else -> 1 + } + + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Fixed(columns), + contentPadding = PaddingValues(16.dp) + ) { + items(links.itemCount) { index -> + links[index]?.let { LinkCard(it) } + } + } +} +``` + +### 8.4 Support Foldables + +```kotlin +// Détection de la posture du foldable +val foldingFeatures = WindowInfoTracker.getOrCreate(context) + .windowLayoutInfo(context) + .collectAsState() + +// Adapter le layout selon la charnière +foldingFeatures.value.displayFeatures.forEach { feature -> + if (feature is FoldingFeature) { + when (feature.state) { + FoldingFeature.State.HALF_OPENED -> { + // Mode table-top : contenu en haut, contrôles en bas + TableTopLayout() + } + FoldingFeature.State.FLAT -> { + // Déplié : utiliser tout l'espace + ExpandedLayout() + } + } + } +} +``` + +### 8.5 Estimation + +| Tâche | Effort | Priorité | +|-------|--------|----------| +| WindowSizeClass + layout adaptatif | 3h | Haute | +| Navigation adaptative (rail/drawer) | 4h | Haute | +| Layout List-Detail (tablettes) | 6h | Haute | +| Grille adaptative multi-colonnes | 2h | Moyenne | +| Support foldables (posture) | 3h | Basse | +| Tests sur émulateurs (tablette, fold) | 2h | Haute | +| **Total** | **~20h** | | + +--- + +## 9. Thème Clair et Material You (Monet) + +### 9.1 Objectif + +Ajouter un thème clair et le support de Material You (couleurs dynamiques basées sur le fond d'écran) sur Android 12+, tout en conservant les 15 thèmes sombres existants. + +### 9.2 Contexte Existant + +- 15 thèmes sombres définis dans `Theme.kt` (492 lignes, chacun un `darkColorScheme`) +- `AppTheme` enum dans `AppTheme.kt` avec `ThemePreferences` (SharedPreferences) +- `ShaarItTheme` composable applique le thème sélectionné +- La variable `isEffectivelyDark` est hardcodée à `true` +- Aucun `lightColorScheme` défini + +### 9.3 Architecture Proposée + +#### A. Extension de l'Enum AppTheme + +```kotlin +enum class ThemeMode(val displayName: String) { + DARK("Sombre"), + LIGHT("Clair"), + SYSTEM("Système"), // Suit le paramètre Android + DYNAMIC_DARK("Material You Sombre"), // Android 12+ dynamic dark + DYNAMIC_LIGHT("Material You Clair"), // Android 12+ dynamic light + DYNAMIC_SYSTEM("Material You Auto") // Android 12+ dynamic + suit système +} +``` + +```kotlin +// Extension de ThemePreferences +@Singleton +class ThemePreferences @Inject constructor( + @ApplicationContext private val context: Context +) { + // Existant + private val _currentTheme = MutableStateFlow(loadTheme()) + val currentTheme: StateFlow = _currentTheme.asStateFlow() + + // Nouveau + private val _themeMode = MutableStateFlow(loadThemeMode()) + val themeMode: StateFlow = _themeMode.asStateFlow() + + fun setThemeMode(mode: ThemeMode) { + prefs.edit().putString(KEY_THEME_MODE, mode.name).apply() + _themeMode.value = mode + } +} +``` + +#### B. Schémas de Couleurs Claires + +Pour chaque thème sombre existant, créer un pendant clair : + +```kotlin +// ── Default Light (ShaarIt) ── +private val DefaultLightColorScheme = lightColorScheme( + primary = Color(0xFF006B5A), // CyanPrimary assombri + onPrimary = Color.White, + primaryContainer = Color(0xFFB2F5E6), + onPrimaryContainer = Color(0xFF00201A), + secondary = Color(0xFF0077B6), // TealSecondary assombri + onSecondary = Color.White, + background = Color(0xFFF8FAFA), + onBackground = Color(0xFF1A1C1E), + surface = Color(0xFFFFFFFF), + onSurface = Color(0xFF1A1C1E), + surfaceVariant = Color(0xFFE7F0EE), + onSurfaceVariant = Color(0xFF404944), + outline = Color(0xFF707974), + error = Color(0xFFBA1A1A), + onError = Color.White +) + +// Générer pour : GitHub Light, Linear Light, Nord Light, etc. +``` + +#### C. Material You (Dynamic Colors) + +```kotlin +@Composable +fun ShaarItTheme( + appTheme: AppTheme = AppTheme.DEFAULT, + themeMode: ThemeMode = ThemeMode.DARK, + content: @Composable () -> Unit +) { + val isDarkTheme = when (themeMode) { + ThemeMode.DARK, ThemeMode.DYNAMIC_DARK -> true + ThemeMode.LIGHT, ThemeMode.DYNAMIC_LIGHT -> false + ThemeMode.SYSTEM, ThemeMode.DYNAMIC_SYSTEM -> isSystemInDarkTheme() + } + + val useDynamicColors = themeMode in listOf( + ThemeMode.DYNAMIC_DARK, ThemeMode.DYNAMIC_LIGHT, ThemeMode.DYNAMIC_SYSTEM + ) + + val colorScheme = when { + useDynamicColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + if (isDarkTheme) { + dynamicDarkColorScheme(LocalContext.current) + } else { + dynamicLightColorScheme(LocalContext.current) + } + } + isDarkTheme -> getColorSchemeForTheme(appTheme) // Existant + else -> getLightColorSchemeForTheme(appTheme) // Nouveau + } + + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.background.toArgb() + window.navigationBarColor = colorScheme.background.toArgb() + WindowCompat.getInsetsController(window, view) + .isAppearanceLightStatusBars = !isDarkTheme + } + } + + MaterialTheme(colorScheme = colorScheme, typography = Typography, content = content) +} +``` + +#### D. UI Paramètres (mise à jour) + +``` +┌──────────────────────────────────────────┐ +│ 🎨 Apparence │ +├──────────────────────────────────────────┤ +│ Mode │ +│ ○ Sombre ○ Clair ○ Système │ +│ │ +│ Material You (Android 12+) [Toggle] │ +│ ↳ Couleurs extraites du fond d'écran │ +│ │ +│ Thème (si Material You désactivé) │ +│ [ShaarIt] [GitHub] [Spotify] [Dracula]… │ +│ │ +│ Prévisualisation │ +│ ┌──────────────────────────────────┐ │ +│ │ Aperçu du thème sélectionné │ │ +│ └──────────────────────────────────┘ │ +└──────────────────────────────────────────┘ +``` + +### 9.4 Impact sur les Composants Existants + +- **GlassCard** et composants glassmorphism : adapter les effets de transparence pour le mode clair +- **Skeleton Loading** : les couleurs shimmer doivent être adaptées +- **TagChip** : les couleurs de fond et texte doivent s'inverser +- **Icônes de statut** : certaines couleurs (SuccessGreen, WarningAmber) nécessitent un ajustement en mode clair pour le contraste + +### 9.5 Estimation + +| Tâche | Effort | Priorité | +|-------|--------|----------| +| Créer `lightColorScheme` pour les 15 thèmes | 4h | Haute | +| `ThemeMode` enum + préférences | 1h | Haute | +| Material You (dynamic colors API 31+) | 2h | Haute | +| Modifier `ShaarItTheme` composable | 2h | Haute | +| Adapter les composants custom (glass, skeleton, etc.) | 4h | Haute | +| UI paramètres (sélecteur mode + preview) | 3h | Moyenne | +| Tests visuels (clair/sombre/dynamic × 15 thèmes) | 3h | Haute | +| **Total** | **~19h** | | + +--- + +## 10. Roadmap Priorisée et Dépendances + +### 10.1 Matrice Effort / Impact + +``` +Impact Utilisateur ▲ + │ + Élevé │ 🏆 Reader Mode 🏆 Material You + │ 🏆 Biométrique 🏆 Widget Glance + │ + Moyen │ ★ Rappels Lecture ★ Adaptive Layout + │ ★ Voice Input + │ + Faible │ ◆ Multi-Instances ◆ Partage Collections + │ + └──────────────────────────────────────▶ + Faible Moyen Élevé + Effort de Dev +``` + +### 10.2 Graphe de Dépendances + +```mermaid +graph TD + A[Thème Clair / Material You] --> |"Indépendant"| Z[Prêt] + B[Verrouillage Biométrique] --> |"Indépendant"| Z + C[Voice Input] --> |"Indépendant"| Z + D[Widget Glance] --> |"Indépendant"| Z + E[Mode Lecture] --> |"Migration Room"| F[Rappels de Lecture] + G[Multi-Instances] --> |"Requis pour"| H[Partage Collections] + I[Adaptive Layouts] --> |"Améliore"| E + + style A fill:#81C784 + style B fill:#81C784 + style C fill:#81C784 + style D fill:#81C784 + style E fill:#FFB74D + style F fill:#FFB74D + style G fill:#EF5350 + style H fill:#EF5350 + style I fill:#FFB74D +``` + +### 10.3 Plan de Livraison Recommandé + +#### Phase 1 — Quick Wins Indépendants (2-3 semaines) + +| # | Fonctionnalité | Effort | Justification | +|---|----------------|--------|---------------| +| 1 | **Verrouillage Biométrique** | ~9h | Indépendant, forte demande sécurité, impact immédiat | +| 2 | **Voice Input** | ~8h | Indépendant, facile à implémenter, améliore l'accessibilité | +| 3 | **Thème Clair + Material You** | ~19h | Indépendant, impact visuel majeur, demande fréquente | + +#### Phase 2 — Expérience Enrichie (3-4 semaines) + +| # | Fonctionnalité | Effort | Justification | +|---|----------------|--------|---------------| +| 4 | **Widget Glance** | ~11h | Visibilité app sur l'écran d'accueil, engagement quotidien | +| 5 | **Mode Lecture** | ~17h | Différenciateur clé, exploite JSoup/Markdown existants | +| 6 | **Rappels de Lecture** | ~15h | Complète le Reader Mode, utilise WorkManager existant | + +#### Phase 3 — Adaptation et Collaboration (4-5 semaines) + +| # | Fonctionnalité | Effort | Justification | +|---|----------------|--------|---------------| +| 7 | **Adaptive Layouts** | ~20h | Ouvre le marché tablettes/foldables, améliore le Reader Mode | +| 8 | **Multi-Instances** | ~20h | Refactoring profond, prérequis au partage | +| 9 | **Partage de Collections** | ~14h | Nécessite multi-instances, fonctionnalité sociale | + +### 10.4 Résumé Global + +| Fonctionnalité | Effort | Priorité | Dépendances | Migration Room | +|----------------|--------|----------|-------------|----------------| +| Verrouillage Biométrique | ~9h | 🔴 Haute | Aucune | Non | +| Voice Input | ~8h | 🔴 Haute | Aucune | Non | +| Thème Clair + Material You | ~19h | 🔴 Haute | Aucune | Non | +| Widget Glance | ~11h | 🔴 Haute | Aucune | Non | +| Mode Lecture | ~17h | 🟡 Moyenne | Aucune | Oui (v6) | +| Rappels de Lecture | ~15h | 🟡 Moyenne | Reader Mode (migration) | Oui (v6/v7) | +| Adaptive Layouts | ~20h | 🟡 Moyenne | Aucune | Non | +| Multi-Instances | ~20h | 🟠 Élevé | Aucune | Non (DB séparées) | +| Partage Collections | ~14h | 🟠 Élevé | Multi-Instances | Non | + +**Effort total estimé** : **~133 heures** (~3.5 semaines à temps plein) + +--- + +*Document généré le 9 février 2026 — Analyse basée sur le code source ShaarIt v1.0* diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index eea5bd7..0be0fbd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ workManager = "2.9.0" dataStore = "1.0.0" kotlinxSerialization = "1.6.2" coil = "2.6.0" +biometric = "1.1.0" [libraries] material = { group = "com.google.android.material", name = "material", version.ref = "material" } @@ -83,6 +84,9 @@ coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coi # Kotlin Serialization kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } +# Biometric +androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 2b63356..735d892 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,3 +18,4 @@ dependencyResolutionManagement { rootProject.name = "ShaarIt" include(":app") +include(":benchmark")