diff --git a/.idea/misc.xml b/.idea/misc.xml index b2c751a..991a888 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,6 +1,7 @@ + - + diff --git a/app/src/main/java/com/example/scanwich/AuthWrapper.kt b/app/src/main/java/com/example/scanwich/AuthWrapper.kt index fcbc8f4..bb03a6d 100644 --- a/app/src/main/java/com/example/scanwich/AuthWrapper.kt +++ b/app/src/main/java/com/example/scanwich/AuthWrapper.kt @@ -59,6 +59,9 @@ fun AuthWrapper(dao: AppDao) { var firebaseUser by remember { mutableStateOf(auth.currentUser) } var isAuthorized by remember { mutableStateOf(null) } + + // État pour savoir si la synchro initiale est terminée + var syncCompleted by remember { mutableStateOf(false) } val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> val task = GoogleSignIn.getSignedInAccountFromIntent(result.data) @@ -77,8 +80,8 @@ fun AuthWrapper(dao: AppDao) { } catch (e: ApiException) { Log.e("Auth", "Erreur Google Sign-In : ${e.statusCode}") val msg = when (e.statusCode) { - 10 -> "Erreur 10 : SHA-1 non reconnu. Assurez-vous d'avoir ajouté le SHA-1 de VOS clés." - 7 -> "Erreur 7 : Problème de réseau." + 10 -> "Erreur 10 : SHA-1 non reconnu." + 7 -> "Erreur 7 : Réseau." else -> "Erreur Google (Code ${e.statusCode})." } Toast.makeText(context, msg, Toast.LENGTH_LONG).show() @@ -90,6 +93,7 @@ fun AuthWrapper(dao: AppDao) { googleSignInClient.signOut().addOnCompleteListener { firebaseUser = null isAuthorized = null + syncCompleted = false } } @@ -98,23 +102,20 @@ fun AuthWrapper(dao: AppDao) { val email = firebaseUser?.email?.trim()?.lowercase() if (email != null && email.isNotEmpty()) { - Log.d("Auth", "Vérification de l'autorisation pour l'email: '$email'") try { val db = FirebaseFirestore.getInstance("scan-wich") val docRef = db.collection("authorized_users").document(email) val document = docRef.get().await() if (document.exists()) { - Log.d("Auth", "Accès AUTORISÉ pour '$email'.") - - // --- NOUVEAU : Rapatriement des données --- - coroutineScope.launch(Dispatchers.IO) { - FirebaseUtils.fetchAllDataFromFirestore(dao) - } + Log.d("Auth", "Accès autorisé. Lancement de la synchronisation...") + val prefs = ApiClient.getEncryptedPrefs(context) + // On attend que la synchro soit finie pour afficher l'app + FirebaseUtils.fetchAllDataFromFirestore(dao, prefs) + syncCompleted = true isAuthorized = true } else { - Log.w("Auth", "Accès REFUSÉ pour '$email'.") isAuthorized = false } } catch (e: Exception) { @@ -130,15 +131,26 @@ fun AuthWrapper(dao: AppDao) { LoginScreen { launcher.launch(googleSignInClient.signInIntent) } } else { when (isAuthorized) { - true -> MainApp(dao = dao, onLogout = onLogout, userId = firebaseUser!!.uid) - false -> AccessDeniedScreen(onLogout) - null -> { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - Spacer(modifier = Modifier.height(8.dp)) - Text("Vérification et synchronisation...") + true -> { + if (syncCompleted) { + MainApp(dao = dao, onLogout = onLogout, userId = firebaseUser!!.uid) + } else { + LoadingBox("Synchronisation de votre profil...") } } + false -> AccessDeniedScreen(onLogout) + null -> LoadingBox("Vérification de l'accès...") + } + } +} + +@Composable +fun LoadingBox(text: String) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(16.dp)) + Text(text) } } } diff --git a/app/src/main/java/com/example/scanwich/Database.kt b/app/src/main/java/com/example/scanwich/Database.kt index cd860cc..eebb474 100644 --- a/app/src/main/java/com/example/scanwich/Database.kt +++ b/app/src/main/java/com/example/scanwich/Database.kt @@ -8,50 +8,49 @@ import kotlinx.coroutines.flow.Flow @Entity(tableName = "meals") data class Meal( - @PrimaryKey(autoGenerate = true) val id: Int = 0, - val date: Long, + @PrimaryKey val date: Long = 0, // La date devient la clé unique pour éviter les doublons val name: String = "Repas", - val analysisText: String, - val totalCalories: Int, + val analysisText: String = "", + val totalCalories: Int = 0, val carbs: Int = 0, val protein: Int = 0, val fat: Int = 0, - val type: String = "Collation" + val type: String = "Collation", + val id: Int = 0 // On garde le champ id pour la compatibilité mais il n'est plus PrimaryKey ) @Entity(tableName = "glycemia") data class Glycemia( - @PrimaryKey(autoGenerate = true) val id: Int = 0, - val date: Long, - val value: Double, - val moment: String + @PrimaryKey val date: Long = 0, // La date devient la clé unique + val value: Double = 0.0, + val moment: String = "", + val id: Int = 0 ) @Entity(tableName = "sports") data class SportActivity( - @PrimaryKey val id: Long, - val name: String, - val type: String, - val distance: Float, - val movingTime: Int, - val calories: Float?, - val date: Long // timestamp + @PrimaryKey val id: Long = 0, // Strava fournit déjà un ID unique + val name: String = "", + val type: String = "", + val distance: Float = 0f, + val movingTime: Int = 0, + val calories: Float? = null, + val date: Long = 0 ) @Entity(tableName = "favorite_meals") data class FavoriteMeal( @PrimaryKey(autoGenerate = true) val id: Int = 0, - val name: String, - val analysisText: String, - val calories: Int, - val carbs: Int, - val protein: Int, - val fat: Int + val name: String = "", + val analysisText: String = "", + val calories: Int = 0, + val carbs: Int = 0, + val protein: Int = 0, + val fat: Int = 0 ) @Dao interface AppDao { - // On ajoute OnConflictStrategy.REPLACE pour la synchronisation @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertMeal(meal: Meal): Long @@ -61,7 +60,6 @@ interface AppDao { @Query("SELECT * FROM meals WHERE date >= :start AND date <= :end ORDER BY date ASC") suspend fun getMealsInRangeSync(start: Long, end: Long): List - // On ajoute OnConflictStrategy.REPLACE pour la synchronisation @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertGlycemia(glycemia: Glycemia): Long @@ -73,7 +71,7 @@ interface AppDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertSports(sports: List) - + @Query("SELECT * FROM sports ORDER BY date DESC") fun getAllSports(): Flow> @Query("SELECT * FROM sports WHERE date >= :startOfDay AND date < :endOfDay ORDER BY date DESC") fun getSportsForDay(startOfDay: Long, endOfDay: Long): Flow> @@ -87,23 +85,16 @@ interface AppDao { fun getAllDatesWithData(): Flow> } -@Database(entities = [Meal::class, Glycemia::class, SportActivity::class, FavoriteMeal::class], version = 7) +@Database(entities = [Meal::class, Glycemia::class, SportActivity::class, FavoriteMeal::class], version = 8) // Version incrémentée abstract class AppDatabase : RoomDatabase() { abstract fun appDao(): AppDao companion object { @Volatile private var INSTANCE: AppDatabase? = null - private val MIGRATION_6_7 = object : Migration(6, 7) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("CREATE TABLE IF NOT EXISTS `favorite_meals` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `analysisText` TEXT NOT NULL, `calories` INTEGER NOT NULL, `carbs` INTEGER NOT NULL, `protein` INTEGER NOT NULL, `fat` INTEGER NOT NULL)") - } - } - fun getDatabase(context: Context): AppDatabase = INSTANCE ?: synchronized(this) { Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "app_db") - .addMigrations(MIGRATION_6_7) - .fallbackToDestructiveMigration(dropAllTables = false) + .fallbackToDestructiveMigration() // Ceci va vider la base locale une seule fois pour appliquer le changement .build().also { INSTANCE = it } } } diff --git a/app/src/main/java/com/example/scanwich/FirebaseUtils.kt b/app/src/main/java/com/example/scanwich/FirebaseUtils.kt index c4a864b..e443c09 100644 --- a/app/src/main/java/com/example/scanwich/FirebaseUtils.kt +++ b/app/src/main/java/com/example/scanwich/FirebaseUtils.kt @@ -1,6 +1,8 @@ package com.example.scanwich +import android.content.SharedPreferences import android.util.Log +import androidx.core.content.edit import com.google.firebase.auth.FirebaseAuth import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.SetOptions @@ -48,33 +50,60 @@ object FirebaseUtils { } } - // NOUVELLE FONCTION : Rapatrie toutes les données depuis le Cloud - suspend fun fetchAllDataFromFirestore(dao: AppDao) { + suspend fun fetchAllDataFromFirestore(dao: AppDao, prefs: SharedPreferences) { val user = FirebaseAuth.getInstance().currentUser ?: return val db = getDb() val userDoc = db.collection("users").document(user.uid) try { - // 1. Récupérer les repas + Log.d("FirestoreSync", "Début de la récupération pour l'UID: ${user.uid}") + + // 1. Profil + val profileSnapshot = userDoc.get().await() + if (profileSnapshot.exists()) { + prefs.edit { + profileSnapshot.get("target_calories")?.let { putString("target_calories", it.toString()) } + profileSnapshot.get("target_carbs")?.let { putString("target_carbs", it.toString()) } + profileSnapshot.get("target_protein")?.let { putString("target_protein", it.toString()) } + profileSnapshot.get("target_fat")?.let { putString("target_fat", it.toString()) } + profileSnapshot.get("weight_kg")?.let { putString("weight_kg", it.toString()) } + profileSnapshot.getBoolean("is_lbs")?.let { putBoolean("is_lbs", it) } + profileSnapshot.get("height_cm")?.let { putString("height_cm", it.toString()) } + profileSnapshot.getBoolean("is_diabetic")?.let { putBoolean("is_diabetic", it) } + (profileSnapshot.get("age") as? Number)?.let { putInt("age", it.toInt()) } + profileSnapshot.getString("gender")?.let { putString("gender", it) } + profileSnapshot.getString("activity_level")?.let { putString("activity_level", it) } + profileSnapshot.getString("goal")?.let { putString("goal", it) } + } + Log.d("FirestoreSync", "Profil récupéré avec succès") + } + + // 2. Repas val mealsSnapshot = userDoc.collection("meals").get().await() - val meals = mealsSnapshot.toObjects(Meal::class.java) - meals.forEach { dao.insertMeal(it) } - Log.d("FirestoreSync", "${meals.size} repas récupérés") + if (!mealsSnapshot.isEmpty) { + val meals = mealsSnapshot.toObjects(Meal::class.java) + meals.forEach { dao.insertMeal(it) } + Log.d("FirestoreSync", "${meals.size} repas insérés dans la base locale") + } - // 2. Récupérer la glycémie + // 3. Glycémie val glycemiaSnapshot = userDoc.collection("glycemia").get().await() - val glycemia = glycemiaSnapshot.toObjects(Glycemia::class.java) - glycemia.forEach { dao.insertGlycemia(it) } - Log.d("FirestoreSync", "${glycemia.size} glycémies récupérées") + if (!glycemiaSnapshot.isEmpty) { + val glycemia = glycemiaSnapshot.toObjects(Glycemia::class.java) + glycemia.forEach { dao.insertGlycemia(it) } + Log.d("FirestoreSync", "${glycemia.size} relevés de glycémie insérés") + } - // 3. Récupérer le sport + // 4. Sport val sportsSnapshot = userDoc.collection("sports").get().await() - val sports = sportsSnapshot.toObjects(SportActivity::class.java) - dao.insertSports(sports) - Log.d("FirestoreSync", "${sports.size} activités sportives récupérées") + if (!sportsSnapshot.isEmpty) { + val sports = sportsSnapshot.toObjects(SportActivity::class.java) + dao.insertSports(sports) + Log.d("FirestoreSync", "${sports.size} activités sportives insérées") + } } catch (e: Exception) { - Log.e("FirestoreSync", "Erreur lors du rapatriement des données: ${e.message}") + Log.e("FirestoreSync", "ERREUR CRITIQUE lors du rapatriement: ${e.message}", e) } } } diff --git a/app/src/main/java/com/example/scanwich/Networking.kt b/app/src/main/java/com/example/scanwich/Networking.kt index a14e6b6..b67a0c6 100644 --- a/app/src/main/java/com/example/scanwich/Networking.kt +++ b/app/src/main/java/com/example/scanwich/Networking.kt @@ -20,6 +20,7 @@ import java.text.SimpleDateFormat import java.util.* import java.util.concurrent.TimeUnit import kotlinx.coroutines.tasks.await +import java.io.File // --- OPEN FOOD FACTS API --- data class OffProductResponse(val status: Int, val product: OffProduct?) @@ -71,7 +72,6 @@ object ApiClient { .create(StravaApi::class.java) val offApi: OffApi = Retrofit.Builder() - // On force l'utilisation de l'API française pour les codes-barres .baseUrl("https://fr.openfoodfacts.org/api/v2/") .client(okHttpClient) .addConverterFactory(GsonConverterFactory.create()) @@ -79,12 +79,31 @@ object ApiClient { .create(OffApi::class.java) private const val STRAVA_CLIENT_ID = "203805" + private const val PREFS_FILENAME = "secure_user_prefs" fun getEncryptedPrefs(context: Context): SharedPreferences { + return try { + createPrefs(context) + } catch (e: Exception) { + Log.e("Security", "Erreur Keystore: ${e.message}. Réinitialisation...") + try { + // Version plus sûre pour effacer les prefs corrompues + val prefsFile = File(context.filesDir.parent, "shared_prefs/$PREFS_FILENAME.xml") + if (prefsFile.exists()) { + prefsFile.delete() + } + createPrefs(context) + } catch (e2: Exception) { + context.getSharedPreferences(PREFS_FILENAME, Context.MODE_PRIVATE) + } + } + } + + private fun createPrefs(context: Context): SharedPreferences { val masterKey = MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build() return EncryptedSharedPreferences.create( context, - "secure_user_prefs", + PREFS_FILENAME, masterKey, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM diff --git a/release-notes.txt b/release-notes.txt index 78b5fbb..05b3847 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -2,16 +2,17 @@ **Nouveautés de la version actuelle :** +☁️ **Synchronisation Cloud Totale (Zéro Re-saisie) :** +- **Mémoire Cloud :** Votre profil (poids, objectifs, calories cibles) est désormais entièrement sauvegardé dans le Cloud. Plus besoin de retaper vos informations lors d'une mise à jour ou d'une réinstallation ! +- **Rapatriement Automatique :** L'application récupère instantanément tout votre historique (repas, sport, glycémie) et vos paramètres dès la connexion. +- **Modèles de données optimisés :** Mise à jour des structures internes pour garantir une compatibilité parfaite avec Firebase lors de la récupération des données. +- **Résilience Keystore :** Ajout d'un système d'auto-réparation en cas de corruption des clés de sécurité locales, évitant ainsi les fermetures inattendues de l'application. + 🍲 **Focus Nourriture Pur (IA) :** - **Analyse sélective :** L'intelligence artificielle se concentre désormais exclusivement sur la nourriture. Les qualificatifs, les descriptions de l'environnement ou les éléments de décor sont ignorés pour ne garder que l'essentiel nutritionnel. 🔔 **Rappels de Repas :** - **Notifications intelligentes :** Ne manquez plus un enregistrement ! Des rappels automatiques ont été ajoutés pour le déjeuner (08h30), le dîner (12h30) et le souper (19h30). -- **Relance automatique :** Les notifications se réactivent automatiquement même après un redémarrage du téléphone. - -📊 **Résumé Calorique Avancé :** -- **Bilan Net :** L'historique affiche maintenant le calcul "Mangé - Dépensé (Sport) = Total Net" pour une vision précise de votre équilibre journalier. -- **Alertes visuelles :** Le total net passe en rouge si vous dépassez vos objectifs quotidiens. --- @@ -27,11 +28,9 @@ 🚀 **Connexion Strava 100% Automatique :** - **Simplicité totale :** Connexion en un seul clic sans saisie d'identifiants techniques. -🎨 **Améliorations UI/UX :** -- **Correction du Thème :** Lisibilité parfaite sur l'écran de profil. - -🛡️ **Architecture Cloud :** -- **Profils Cloud :** Synchronisation automatique de vos données personnelles. +🛡️ **Architecture Cloud & Sécurité :** +- **Gestion des accès :** Contrôle dynamique des utilisateurs autorisés via Firestore. +- **Secret Manager :** Protection maximale des clés API via Google Cloud. ---