Add initial project
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
# HabitForge Android Companion App
|
||||
|
||||
Application Android permettant de synchroniser les données de Health Connect avec votre serveur HabitForge.
|
||||
|
||||
## 🆕 Nouvelles Fonctionnalités
|
||||
|
||||
### Suivi des Synchronisations
|
||||
- **Historique complet** : Visualisez l'historique de toutes les synchronisations
|
||||
- **Statistiques** : Nombre de syncs réussies/échouées, métriques totales synchronisées
|
||||
- **Détails** : Chaque sync affiche le temps de traitement, les données transférées et les erreurs
|
||||
|
||||
### Connectivité Serveur
|
||||
- **Indicateur en temps réel** : Voyez immédiatement si le serveur est en ligne
|
||||
- **Test de connexion** : Bouton dédié pour tester la connexion et l'authentification
|
||||
- **Temps de réponse** : Affichage du temps de réponse du serveur
|
||||
|
||||
### Validation des Données
|
||||
- **Vérification de compatibilité** : Validez que vos données Health Connect sont compatibles avec le schéma HabitForge
|
||||
- **Aperçu des données** : Prévisualisation des données avant synchronisation
|
||||
- **Diagnostic** : Identification des problèmes potentiels de mapping
|
||||
|
||||
### Configuration Avancée
|
||||
- **Période de sync configurable** : Choisissez la fréquence de synchronisation automatique
|
||||
- Toutes les heures
|
||||
- Toutes les 3 heures
|
||||
- Toutes les 6 heures (par défaut)
|
||||
- Toutes les 12 heures
|
||||
- Une fois par jour
|
||||
|
||||
## Prérequis
|
||||
|
||||
- Android Studio (version 2024.2.1 ou plus récente)
|
||||
- JDK 17
|
||||
- Un appareil Android avec Android 9+ (API 28+)
|
||||
- Health Connect installé sur l'appareil
|
||||
|
||||
## Structure du projet
|
||||
|
||||
```
|
||||
android-companion/
|
||||
├── app/
|
||||
│ ├── src/main/
|
||||
│ │ ├── java/com/habitforge/companion/
|
||||
│ │ │ ├── HabitForgeApp.kt # Application class
|
||||
│ │ │ ├── MainActivity.kt # UI principale
|
||||
│ │ │ ├── HealthConnectManager.kt # Lecture des données Health Connect
|
||||
│ │ │ ├── SyncService.kt # Service de sync en arrière-plan
|
||||
│ │ │ ├── SyncHistoryManager.kt # Gestion de l'historique des syncs
|
||||
│ │ │ ├── ServerConnectionManager.kt # Test de connectivité serveur
|
||||
│ │ │ ├── DataCompatibilityValidator.kt # Validation schéma de données
|
||||
│ │ │ ├── HabitForgeApi.kt # API Retrofit pour le backend
|
||||
│ │ │ └── PrivacyPolicyActivity.kt # Page de politique de confidentialité
|
||||
│ │ ├── res/
|
||||
│ │ │ ├── layout/ # Layouts XML
|
||||
│ │ │ ├── values/ # Couleurs, strings, thèmes
|
||||
│ │ │ └── drawable/ # Icônes et indicateurs de status
|
||||
│ │ └── AndroidManifest.xml # Permissions Health Connect
|
||||
│ └── build.gradle.kts # Dépendances
|
||||
├── build.gradle.kts # Configuration root
|
||||
├── settings.gradle.kts # Settings Gradle
|
||||
└── gradle.properties # Propriétés Gradle
|
||||
```
|
||||
|
||||
## Données synchronisées
|
||||
|
||||
L'application lit les données suivantes depuis Health Connect :
|
||||
|
||||
### Métriques quotidiennes
|
||||
- Pas (Steps)
|
||||
- Distance parcourue
|
||||
- Calories brûlées
|
||||
- Fréquence cardiaque (moyenne, min, max, repos)
|
||||
- Sommeil (durée, phases : profond, léger, REM, éveillé)
|
||||
- Poids
|
||||
- SpO2
|
||||
|
||||
### Sessions d'exercice
|
||||
- Type d'exercice (Running, Walking, Cycling, Swimming, Weight Training, etc.)
|
||||
- Durée
|
||||
- Heure de début/fin
|
||||
- Notes
|
||||
|
||||
## Compilation
|
||||
|
||||
### Option 1 : Via Android Studio
|
||||
|
||||
1. Ouvrir Android Studio
|
||||
2. `File` > `Open` > Sélectionner le dossier `android-companion`
|
||||
3. Attendre que Gradle synchronise
|
||||
4. `Build` > `Build Bundle(s) / APK(s)` > `Build APK(s)`
|
||||
|
||||
### Option 2 : Via ligne de commande
|
||||
|
||||
```powershell
|
||||
# Se placer dans le dossier du projet
|
||||
cd c:\dev\git\python\HabitForge\android-companion
|
||||
|
||||
# Compiler en mode debug
|
||||
.\gradlew assembleDebug
|
||||
|
||||
# Le fichier APK sera dans :
|
||||
# app\build\outputs\apk\debug\app-debug.apk
|
||||
|
||||
# Compiler en mode release (nécessite une signature)
|
||||
.\gradlew assembleRelease
|
||||
```
|
||||
|
||||
## Installation sur téléphone
|
||||
|
||||
### Via ADB
|
||||
```powershell
|
||||
adb install app\build\outputs\apk\debug\app-debug.apk
|
||||
```
|
||||
|
||||
### Via Android Studio
|
||||
- Connecter le téléphone en USB (Mode débogage activé)
|
||||
- Cliquer sur "Run" (bouton vert)
|
||||
|
||||
## Configuration
|
||||
|
||||
1. **Installer Health Connect** sur le téléphone (Play Store)
|
||||
2. **Ouvrir HabitForge Companion**
|
||||
3. **Accorder les permissions** quand demandé
|
||||
4. **Se connecter à votre compte** :
|
||||
- Cliquez sur "Login"
|
||||
- Entrez l'URL du serveur (ex: `http://192.168.x.x:8000`)
|
||||
- Utilisez les **mêmes identifiants** que l'interface web HabitForge
|
||||
5. **Tester la connexion** : Utilisez le bouton "Test Connection" pour vérifier
|
||||
6. **Valider les données** : Cliquez sur "Validate Data Schema"
|
||||
7. **Configurer la fréquence** : Choisissez l'intervalle de sync souhaité
|
||||
8. **Synchroniser** manuellement ou laisser l'auto-sync
|
||||
|
||||
## Interface Utilisateur
|
||||
|
||||
### Cartes de l'écran principal
|
||||
|
||||
| Carte | Description |
|
||||
|-------|-------------|
|
||||
| **Health Connect** | Statut de Health Connect, bouton pour l'ouvrir |
|
||||
| **Permissions** | Permissions accordées/requises, bouton pour les demander |
|
||||
| **Server** | Configuration serveur, indicateur de disponibilité, test de connexion |
|
||||
| **Sync Settings** | Fréquence de sync, dernière sync, bouton Sync Now |
|
||||
| **Data Compatibility** | Validation du schéma, aperçu des données |
|
||||
| **Sync History** | Historique récent, statistiques, bouton historique complet |
|
||||
|
||||
### Indicateurs de Status Serveur
|
||||
|
||||
| Indicateur | Signification |
|
||||
|------------|---------------|
|
||||
| 🟢 Online | Serveur accessible et connecté |
|
||||
| 🟠 Auth needed | Serveur accessible mais non connecté |
|
||||
| 🟠 Timeout | Serveur lent à répondre |
|
||||
| 🔴 Offline | Serveur inaccessible |
|
||||
| ⚫ Unknown | Statut non vérifié |
|
||||
|
||||
## Dépannage
|
||||
|
||||
### "Health Connect not available"
|
||||
- L'appareil doit être sous Android 9+
|
||||
- Installer Health Connect depuis le Play Store
|
||||
|
||||
### "Permissions not granted"
|
||||
- Réouvrir l'app et cliquer sur "Grant Permissions"
|
||||
- S'assurer que Health Connect a des données (d'autres apps comme Google Fit)
|
||||
|
||||
### "Login failed" / "Invalid username or password"
|
||||
- Vérifier que le serveur HabitForge est accessible (utilisez "Test Connection")
|
||||
- Vérifier que les identifiants sont corrects (mêmes que l'interface web)
|
||||
- Consulter les logs via `adb logcat | grep HabitForge`
|
||||
|
||||
### "Sync failed"
|
||||
- Vérifier que vous êtes bien connecté
|
||||
- Vérifier l'historique des syncs pour voir le message d'erreur
|
||||
|
||||
### "Connection timeout"
|
||||
- Vérifier que le serveur est démarré
|
||||
- Vérifier la connexion réseau
|
||||
- Si sur le même réseau local, utiliser l'IP locale (ex: 192.168.x.x)
|
||||
|
||||
### "Data validation issues"
|
||||
- Cliquez sur "Validate Data Schema" pour diagnostiquer
|
||||
- Vérifiez que Health Connect contient des données récentes
|
||||
- Accordez toutes les permissions demandées
|
||||
|
||||
## API Backend requise
|
||||
|
||||
L'application nécessite les endpoints suivants sur votre serveur HabitForge :
|
||||
|
||||
| Endpoint | Méthode | Description |
|
||||
|----------|---------|-------------|
|
||||
| `/api/health` | GET | Health check (retourne `{"status": "healthy"}`) |
|
||||
| `/api/auth/token` | POST | Authentification (OAuth2 form: username, password) |
|
||||
| `/api/auth/me` | GET | Récupérer les infos utilisateur connecté |
|
||||
| `/api/health-connect/sync` | POST | Réception des données synchronisées |
|
||||
|
||||
## Sécurité
|
||||
|
||||
- L'authentification utilise les mêmes identifiants que l'interface web
|
||||
- Les tokens JWT sont stockés dans SharedPreferences (mode privé)
|
||||
- Toutes les connexions utilisent HTTPS en production
|
||||
- Les données ne quittent jamais l'appareil sauf vers votre serveur configuré
|
||||
- L'historique des syncs est stocké localement uniquement
|
||||
- Déconnexion possible via le bouton "Logout"
|
||||
@@ -0,0 +1,115 @@
|
||||
import java.util.Properties
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
fun getBuildNumber(): Int {
|
||||
val versionFile = file("version.properties")
|
||||
val properties = Properties()
|
||||
|
||||
if (versionFile.exists()) {
|
||||
properties.load(FileInputStream(versionFile))
|
||||
}
|
||||
|
||||
val buildNumber = (properties.getProperty("build.number") ?: "0").toInt() + 1
|
||||
|
||||
properties.setProperty("build.number", buildNumber.toString())
|
||||
properties.store(FileOutputStream(versionFile), "Auto-incremented Build Number")
|
||||
|
||||
return buildNumber
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.habitforge.companion"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.habitforge.companion"
|
||||
minSdk = 28 // Health Connect requires Android 9+
|
||||
targetSdk = 34
|
||||
|
||||
val buildNum = getBuildNumber()
|
||||
versionCode = buildNum
|
||||
versionName = "1.0.0.$buildNum"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
// Backend URL - change this to your server address
|
||||
buildConfigField("String", "BACKEND_URL", "\"http://10.0.2.2:8000\"") // 10.0.2.2 = localhost from emulator
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
// For production, change this to your actual server URL
|
||||
buildConfigField("String", "BACKEND_URL", "\"https://your-habitforge-server.com\"")
|
||||
}
|
||||
debug {
|
||||
buildConfigField("String", "BACKEND_URL", "\"http://10.0.2.2:8000\"")
|
||||
}
|
||||
}
|
||||
|
||||
applicationVariants.all {
|
||||
val variant = this
|
||||
outputs.all {
|
||||
(this as? com.android.build.gradle.internal.api.BaseVariantOutputImpl)?.outputFileName = "HabitForge-companion-v${variant.versionName}.apk"
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Core Android
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
implementation("com.google.android.material:material:1.11.0")
|
||||
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
|
||||
implementation("androidx.activity:activity-ktx:1.8.2")
|
||||
|
||||
// Health Connect SDK
|
||||
implementation("androidx.health.connect:connect-client:1.1.0-alpha10")
|
||||
|
||||
// WorkManager for background sync
|
||||
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
||||
|
||||
// Networking
|
||||
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||
|
||||
// Coroutines
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
|
||||
|
||||
// DataStore for preferences
|
||||
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||
|
||||
// Testing
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# Keep Retrofit and Gson
|
||||
-keepattributes Signature
|
||||
-keepattributes *Annotation*
|
||||
|
||||
-keep class com.habitforge.companion.** { *; }
|
||||
-keep class retrofit2.** { *; }
|
||||
-keep class com.google.gson.** { *; }
|
||||
|
||||
# Keep Health Connect classes
|
||||
-keep class androidx.health.connect.** { *; }
|
||||
@@ -0,0 +1,119 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Internet permission for syncing with backend -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<!-- Foreground service permission for reliable syncing -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- Health Connect Permissions -->
|
||||
<!-- Activity Data -->
|
||||
<uses-permission android:name="android.permission.health.READ_STEPS" />
|
||||
<uses-permission android:name="android.permission.health.READ_DISTANCE" />
|
||||
<uses-permission android:name="android.permission.health.READ_TOTAL_CALORIES_BURNED" />
|
||||
<uses-permission android:name="android.permission.health.READ_ACTIVE_CALORIES_BURNED" />
|
||||
|
||||
<!-- Heart & Vitals -->
|
||||
<uses-permission android:name="android.permission.health.READ_HEART_RATE" />
|
||||
<uses-permission android:name="android.permission.health.READ_RESTING_HEART_RATE" />
|
||||
<uses-permission android:name="android.permission.health.READ_HEART_RATE_VARIABILITY" />
|
||||
<uses-permission android:name="android.permission.health.READ_OXYGEN_SATURATION" />
|
||||
<uses-permission android:name="android.permission.health.READ_RESPIRATORY_RATE" />
|
||||
<uses-permission android:name="android.permission.health.READ_BLOOD_PRESSURE" />
|
||||
|
||||
<!-- Body Measurements -->
|
||||
<uses-permission android:name="android.permission.health.READ_WEIGHT" />
|
||||
<uses-permission android:name="android.permission.health.READ_HEIGHT" />
|
||||
<uses-permission android:name="android.permission.health.READ_BODY_FAT" />
|
||||
<uses-permission android:name="android.permission.health.READ_BODY_WATER_MASS" />
|
||||
<uses-permission android:name="android.permission.health.READ_LEAN_BODY_MASS" />
|
||||
<uses-permission android:name="android.permission.health.READ_BONE_MASS" />
|
||||
<uses-permission android:name="android.permission.health.READ_BASAL_METABOLIC_RATE" />
|
||||
|
||||
<!-- Sleep -->
|
||||
<uses-permission android:name="android.permission.health.READ_SLEEP" />
|
||||
|
||||
<!-- Exercise / Workouts -->
|
||||
<uses-permission android:name="android.permission.health.READ_EXERCISE" />
|
||||
<uses-permission android:name="android.permission.health.READ_POWER" />
|
||||
<uses-permission android:name="android.permission.health.READ_SPEED" />
|
||||
|
||||
<!-- Nutrition (optional) -->
|
||||
<uses-permission android:name="android.permission.health.READ_NUTRITION" />
|
||||
<uses-permission android:name="android.permission.health.READ_HYDRATION" />
|
||||
|
||||
<!-- Historical Data Access (for data older than 30 days) -->
|
||||
<uses-permission android:name="android.permission.health.READ_HEALTH_DATA_HISTORY" />
|
||||
|
||||
<application
|
||||
android:name=".HabitForgeApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.HabitForgeCompanion"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="34">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.HabitForgeCompanion">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Receive Health Connect permission results -->
|
||||
<intent-filter>
|
||||
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Privacy Policy Activity (Required by Health Connect) -->
|
||||
<activity
|
||||
android:name=".PrivacyPolicyActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
|
||||
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Raw Data Viewer Activity -->
|
||||
<activity
|
||||
android:name=".RawDataViewerActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.HabitForgeCompanion" />
|
||||
|
||||
<!-- Sync Configuration Activity -->
|
||||
<activity
|
||||
android:name=".SyncConfigActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.HabitForgeCompanion" />
|
||||
|
||||
<!-- WorkManager initializer -->
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
android:exported="false"
|
||||
tools:node="merge">
|
||||
<meta-data
|
||||
android:name="androidx.work.WorkManagerInitializer"
|
||||
android:value="androidx.startup" />
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
|
||||
<!-- Declare that this app uses Health Connect -->
|
||||
<queries>
|
||||
<package android:name="com.google.android.apps.healthdata" />
|
||||
</queries>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,282 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Manages user authentication and session state.
|
||||
* Handles login, logout, and secure token storage.
|
||||
*/
|
||||
class AuthManager(private val context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
companion object {
|
||||
private const val PREFS_NAME = "habitforge_auth"
|
||||
private const val KEY_ACCESS_TOKEN = "access_token"
|
||||
private const val KEY_REFRESH_TOKEN = "refresh_token"
|
||||
private const val KEY_USERNAME = "username"
|
||||
private const val KEY_USER_ID = "user_id"
|
||||
private const val KEY_SERVER_URL = "server_url"
|
||||
private const val KEY_IS_LOGGED_IN = "is_logged_in"
|
||||
|
||||
// Default server URL (can be changed)
|
||||
const val DEFAULT_SERVER_URL = "http://192.168.1.100:8000"
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is currently logged in
|
||||
*/
|
||||
fun isLoggedIn(): Boolean {
|
||||
return prefs.getBoolean(KEY_IS_LOGGED_IN, false) &&
|
||||
!prefs.getString(KEY_ACCESS_TOKEN, null).isNullOrEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored access token with Bearer prefix
|
||||
*/
|
||||
fun getAuthorizationHeader(): String? {
|
||||
val token = prefs.getString(KEY_ACCESS_TOKEN, null)
|
||||
return if (token != null) "Bearer $token" else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get raw access token (without Bearer prefix)
|
||||
*/
|
||||
fun getAccessToken(): String? {
|
||||
return prefs.getString(KEY_ACCESS_TOKEN, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get raw refresh token
|
||||
*/
|
||||
fun getRefreshToken(): String? {
|
||||
return prefs.getString(KEY_REFRESH_TOKEN, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current username
|
||||
*/
|
||||
fun getUsername(): String? {
|
||||
return prefs.getString(KEY_USERNAME, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user ID
|
||||
*/
|
||||
fun getUserId(): Int {
|
||||
return prefs.getInt(KEY_USER_ID, -1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configured server URL
|
||||
*/
|
||||
fun getServerUrl(): String {
|
||||
return prefs.getString(KEY_SERVER_URL, BuildConfig.BACKEND_URL) ?: BuildConfig.BACKEND_URL
|
||||
}
|
||||
|
||||
/**
|
||||
* Set server URL
|
||||
*/
|
||||
fun setServerUrl(url: String) {
|
||||
prefs.edit().putString(KEY_SERVER_URL, url).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to login with username and password
|
||||
* @return LoginResult with success/failure and message
|
||||
*/
|
||||
suspend fun login(serverUrl: String, username: String, password: String): LoginResult {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// Ensure URL has proper format
|
||||
val formattedUrl = formatServerUrl(serverUrl)
|
||||
|
||||
// Create API client
|
||||
val api = HabitForgeApi.create(formattedUrl)
|
||||
|
||||
// Attempt login
|
||||
val response = api.login(username, password)
|
||||
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val loginResponse = response.body()!!
|
||||
val token = loginResponse.access_token
|
||||
|
||||
// Verify token by getting user info
|
||||
val userResponse = api.getCurrentUser("Bearer $token")
|
||||
|
||||
if (userResponse.isSuccessful && userResponse.body() != null) {
|
||||
val user = userResponse.body()!!
|
||||
|
||||
// Save credentials
|
||||
saveSession(
|
||||
serverUrl = formattedUrl,
|
||||
token = token,
|
||||
refreshToken = loginResponse.refresh_token,
|
||||
username = user.username,
|
||||
userId = user.id
|
||||
)
|
||||
|
||||
LoginResult(
|
||||
success = true,
|
||||
message = "Welcome back, ${user.username}!",
|
||||
username = user.username
|
||||
)
|
||||
} else {
|
||||
LoginResult(
|
||||
success = false,
|
||||
message = "Login successful but failed to get user info"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val errorBody = response.errorBody()?.string()
|
||||
val errorMessage = when (response.code()) {
|
||||
401 -> "Invalid username or password"
|
||||
404 -> "Server not found. Check URL."
|
||||
500 -> "Server error. Please try again."
|
||||
else -> errorBody ?: "Login failed (${response.code()})"
|
||||
}
|
||||
LoginResult(success = false, message = errorMessage)
|
||||
}
|
||||
} catch (e: java.net.UnknownHostException) {
|
||||
LoginResult(success = false, message = "Cannot reach server. Check URL and network.")
|
||||
} catch (e: java.net.ConnectException) {
|
||||
LoginResult(success = false, message = "Connection refused. Is the server running?")
|
||||
} catch (e: java.net.SocketTimeoutException) {
|
||||
LoginResult(success = false, message = "Connection timeout. Server may be slow.")
|
||||
} catch (e: Exception) {
|
||||
LoginResult(success = false, message = "Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save session data after successful login
|
||||
*/
|
||||
private fun saveSession(serverUrl: String, token: String, refreshToken: String, username: String, userId: Int) {
|
||||
prefs.edit()
|
||||
.putString(KEY_SERVER_URL, serverUrl)
|
||||
.putString(KEY_ACCESS_TOKEN, token)
|
||||
.putString(KEY_REFRESH_TOKEN, refreshToken)
|
||||
.putString(KEY_USERNAME, username)
|
||||
.putInt(KEY_USER_ID, userId)
|
||||
.putBoolean(KEY_IS_LOGGED_IN, true)
|
||||
.apply()
|
||||
|
||||
// Also update the old prefs for backward compatibility with SyncService
|
||||
context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString("server_url", serverUrl)
|
||||
.putString("auth_token", token)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear session (logout)
|
||||
*/
|
||||
fun logout() {
|
||||
prefs.edit()
|
||||
.remove(KEY_ACCESS_TOKEN)
|
||||
.remove(KEY_REFRESH_TOKEN)
|
||||
.remove(KEY_USERNAME)
|
||||
.remove(KEY_USER_ID)
|
||||
.putBoolean(KEY_IS_LOGGED_IN, false)
|
||||
.apply()
|
||||
|
||||
// Clear old prefs too
|
||||
context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.remove("auth_token")
|
||||
.apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh token if needed (for future token refresh implementation)
|
||||
*/
|
||||
/**
|
||||
* Refresh token if needed
|
||||
*/
|
||||
suspend fun refreshTokenIfNeeded(): Boolean {
|
||||
val token = getAuthorizationHeader() ?: return false
|
||||
val serverUrl = getServerUrl()
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
val api = HabitForgeApi.create(serverUrl)
|
||||
|
||||
// 1. Try to use current token
|
||||
try {
|
||||
val response = api.getCurrentUser(token)
|
||||
if (response.isSuccessful) {
|
||||
return@withContext true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// If it's a network error unrelated to auth, we might want to fail?
|
||||
// But let's try refresh anyway if we have a refresh token
|
||||
}
|
||||
|
||||
// 2. Token invalid, try to refresh
|
||||
val refreshToken = getRefreshToken() ?: return@withContext false
|
||||
try {
|
||||
val refreshResponse = api.refreshToken(RefreshTokenRequest(refreshToken))
|
||||
if (refreshResponse.isSuccessful && refreshResponse.body() != null) {
|
||||
val newTokens = refreshResponse.body()!!
|
||||
updateTokens(newTokens.access_token, newTokens.refresh_token)
|
||||
return@withContext true
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Refresh failed
|
||||
}
|
||||
|
||||
// 3. Keep logged in locally? No, if we can't refresh, caller handles it.
|
||||
return@withContext false
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateTokens(accessToken: String, refreshToken: String) {
|
||||
prefs.edit()
|
||||
.putString(KEY_ACCESS_TOKEN, accessToken)
|
||||
.putString(KEY_REFRESH_TOKEN, refreshToken)
|
||||
.apply()
|
||||
|
||||
// Update legacy preference for sync service compatibility if needed
|
||||
context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString("auth_token", accessToken)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Format server URL to ensure it has proper protocol and no trailing slash
|
||||
*/
|
||||
private fun formatServerUrl(url: String): String {
|
||||
var formatted = url.trim()
|
||||
|
||||
// Add protocol if missing
|
||||
if (!formatted.startsWith("http://") && !formatted.startsWith("https://")) {
|
||||
formatted = "http://$formatted"
|
||||
}
|
||||
|
||||
// Remove trailing slash
|
||||
if (formatted.endsWith("/")) {
|
||||
formatted = formatted.dropLast(1)
|
||||
}
|
||||
|
||||
// Ensure ends with proper format for Retrofit baseUrl
|
||||
if (!formatted.endsWith("/")) {
|
||||
formatted = "$formatted/"
|
||||
}
|
||||
|
||||
return formatted
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a login attempt
|
||||
*/
|
||||
data class LoginResult(
|
||||
val success: Boolean,
|
||||
val message: String,
|
||||
val username: String? = null
|
||||
)
|
||||
+354
@@ -0,0 +1,354 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.LocalDate
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Validates that Health Connect data can be properly mapped to HabitForge schema
|
||||
*/
|
||||
class DataCompatibilityValidator(
|
||||
private val context: Context,
|
||||
private val healthConnectManager: HealthConnectManager
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "DataCompatibilityValidator"
|
||||
}
|
||||
|
||||
/**
|
||||
* Run full compatibility validation
|
||||
*/
|
||||
suspend fun validateCompatibility(): ValidationResult = withContext(Dispatchers.IO) {
|
||||
val issues = mutableListOf<ValidationIssue>()
|
||||
val validations = mutableListOf<ValidationItem>()
|
||||
|
||||
Log.i(TAG, "Starting data compatibility validation...")
|
||||
|
||||
// Check Health Connect availability
|
||||
val availability = healthConnectManager.isAvailable()
|
||||
validations.add(
|
||||
ValidationItem(
|
||||
name = "Health Connect Availability",
|
||||
passed = availability == HealthConnectAvailability.AVAILABLE,
|
||||
message = when (availability) {
|
||||
HealthConnectAvailability.AVAILABLE -> "Health Connect is available"
|
||||
HealthConnectAvailability.NOT_INSTALLED -> "Health Connect is not installed"
|
||||
HealthConnectAvailability.NOT_SUPPORTED -> "Health Connect is not supported on this device"
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
if (availability != HealthConnectAvailability.AVAILABLE) {
|
||||
return@withContext ValidationResult(
|
||||
isCompatible = false,
|
||||
validations = validations,
|
||||
issues = listOf(ValidationIssue(
|
||||
severity = IssueSeverity.ERROR,
|
||||
category = "Health Connect",
|
||||
message = "Health Connect is not available"
|
||||
)),
|
||||
dataPreview = null
|
||||
)
|
||||
}
|
||||
|
||||
// Check permissions
|
||||
val hasPermissions = healthConnectManager.hasAllPermissions()
|
||||
val grantedPermissions = healthConnectManager.getGrantedPermissions()
|
||||
val totalPermissions = HealthConnectManager.PERMISSIONS.size
|
||||
|
||||
validations.add(
|
||||
ValidationItem(
|
||||
name = "Permissions",
|
||||
passed = hasPermissions,
|
||||
message = "Granted: ${grantedPermissions.size}/$totalPermissions"
|
||||
)
|
||||
)
|
||||
|
||||
if (!hasPermissions) {
|
||||
issues.add(ValidationIssue(
|
||||
severity = IssueSeverity.WARNING,
|
||||
category = "Permissions",
|
||||
message = "Not all permissions granted. Some data types may not sync."
|
||||
))
|
||||
}
|
||||
|
||||
// Try to read sample data to validate schema mapping
|
||||
try {
|
||||
val endDate = LocalDate.now()
|
||||
val startDate = endDate.minusDays(1)
|
||||
|
||||
// Test daily metrics reading
|
||||
val metrics = healthConnectManager.readDailyMetrics(startDate, endDate)
|
||||
validations.add(
|
||||
ValidationItem(
|
||||
name = "Daily Metrics Schema",
|
||||
passed = true,
|
||||
message = "Successfully read ${metrics.size} day(s) of metrics"
|
||||
)
|
||||
)
|
||||
|
||||
// Validate each metric field
|
||||
val fieldValidations = validateDailyMetricsFields(metrics)
|
||||
validations.addAll(fieldValidations.validations)
|
||||
issues.addAll(fieldValidations.issues)
|
||||
|
||||
// Test exercise sessions reading
|
||||
val exercises = healthConnectManager.readExerciseSessions(startDate, endDate)
|
||||
validations.add(
|
||||
ValidationItem(
|
||||
name = "Exercise Sessions Schema",
|
||||
passed = true,
|
||||
message = "Successfully read ${exercises.size} exercise session(s)"
|
||||
)
|
||||
)
|
||||
|
||||
// Validate exercise fields
|
||||
val exerciseValidations = validateExerciseFields(exercises)
|
||||
validations.addAll(exerciseValidations.validations)
|
||||
issues.addAll(exerciseValidations.issues)
|
||||
|
||||
// Build data preview
|
||||
val dataPreview = buildDataPreview(metrics, exercises)
|
||||
|
||||
// Final compatibility check
|
||||
val hasErrors = issues.any { it.severity == IssueSeverity.ERROR }
|
||||
|
||||
ValidationResult(
|
||||
isCompatible = !hasErrors,
|
||||
validations = validations,
|
||||
issues = issues,
|
||||
dataPreview = dataPreview
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Validation failed", e)
|
||||
issues.add(ValidationIssue(
|
||||
severity = IssueSeverity.ERROR,
|
||||
category = "Data Reading",
|
||||
message = "Failed to read data: ${e.localizedMessage}"
|
||||
))
|
||||
|
||||
ValidationResult(
|
||||
isCompatible = false,
|
||||
validations = validations,
|
||||
issues = issues,
|
||||
dataPreview = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateDailyMetricsFields(metrics: List<DailyMetrics>): FieldValidationResult {
|
||||
val validations = mutableListOf<ValidationItem>()
|
||||
val issues = mutableListOf<ValidationIssue>()
|
||||
|
||||
if (metrics.isEmpty()) {
|
||||
issues.add(ValidationIssue(
|
||||
severity = IssueSeverity.INFO,
|
||||
category = "Daily Metrics",
|
||||
message = "No daily metrics data found for the test period"
|
||||
))
|
||||
return FieldValidationResult(validations, issues)
|
||||
}
|
||||
|
||||
val sample = metrics.first()
|
||||
|
||||
// Steps validation
|
||||
validations.add(ValidationItem(
|
||||
name = "Steps Field",
|
||||
passed = sample.steps >= 0,
|
||||
message = "Value: ${sample.steps}"
|
||||
))
|
||||
|
||||
// Distance validation
|
||||
validations.add(ValidationItem(
|
||||
name = "Distance Field",
|
||||
passed = sample.distanceMeters >= 0,
|
||||
message = "Value: ${String.format(Locale.US, "%.2f", sample.distanceMeters)} meters"
|
||||
))
|
||||
|
||||
// Calories validation
|
||||
validations.add(ValidationItem(
|
||||
name = "Calories Field",
|
||||
passed = sample.caloriesBurned >= 0,
|
||||
message = "Value: ${sample.caloriesBurned} kcal"
|
||||
))
|
||||
|
||||
// Heart rate validation (optional field)
|
||||
val hrStatus = if (sample.avgHeartRate != null) {
|
||||
"Value: ${sample.avgHeartRate} bpm"
|
||||
} else {
|
||||
"No data available (optional)"
|
||||
}
|
||||
validations.add(ValidationItem(
|
||||
name = "Heart Rate Field",
|
||||
passed = true,
|
||||
message = hrStatus
|
||||
))
|
||||
|
||||
// Sleep validation
|
||||
validations.add(ValidationItem(
|
||||
name = "Sleep Fields",
|
||||
passed = sample.sleepDurationMinutes >= 0,
|
||||
message = "Duration: ${sample.sleepDurationMinutes} min, Deep: ${sample.deepSleepMinutes} min"
|
||||
))
|
||||
|
||||
// Weight validation (optional)
|
||||
val weightStatus = if (sample.weight != null) {
|
||||
"Value: ${String.format(Locale.US, "%.1f", sample.weight)} kg"
|
||||
} else {
|
||||
"No data available (optional)"
|
||||
}
|
||||
validations.add(ValidationItem(
|
||||
name = "Weight Field",
|
||||
passed = true,
|
||||
message = weightStatus
|
||||
))
|
||||
|
||||
// SpO2 validation (optional)
|
||||
val spo2Status = if (sample.spo2 != null) {
|
||||
"Value: ${String.format(Locale.US, "%.1f", sample.spo2)}%"
|
||||
} else {
|
||||
"No data available (optional)"
|
||||
}
|
||||
validations.add(ValidationItem(
|
||||
name = "SpO2 Field",
|
||||
passed = true,
|
||||
message = spo2Status
|
||||
))
|
||||
|
||||
return FieldValidationResult(validations, issues)
|
||||
}
|
||||
|
||||
private fun validateExerciseFields(exercises: List<ExerciseData>): FieldValidationResult {
|
||||
val validations = mutableListOf<ValidationItem>()
|
||||
val issues = mutableListOf<ValidationIssue>()
|
||||
|
||||
if (exercises.isEmpty()) {
|
||||
issues.add(ValidationIssue(
|
||||
severity = IssueSeverity.INFO,
|
||||
category = "Exercise Sessions",
|
||||
message = "No exercise sessions found for the test period"
|
||||
))
|
||||
return FieldValidationResult(validations, issues)
|
||||
}
|
||||
|
||||
val sample = exercises.first()
|
||||
|
||||
// Time fields validation
|
||||
validations.add(ValidationItem(
|
||||
name = "Exercise Time Fields",
|
||||
passed = sample.startTime.isNotEmpty() && sample.endTime.isNotEmpty(),
|
||||
message = "Start: ${sample.startTime.take(19)}"
|
||||
))
|
||||
|
||||
// Exercise type validation
|
||||
validations.add(ValidationItem(
|
||||
name = "Exercise Type Field",
|
||||
passed = sample.exerciseType >= 0,
|
||||
message = "Type: ${sample.exerciseTypeName}"
|
||||
))
|
||||
|
||||
// Duration validation
|
||||
validations.add(ValidationItem(
|
||||
name = "Duration Field",
|
||||
passed = sample.durationMinutes > 0,
|
||||
message = "Duration: ${sample.durationMinutes} minutes"
|
||||
))
|
||||
|
||||
return FieldValidationResult(validations, issues)
|
||||
}
|
||||
|
||||
private fun buildDataPreview(metrics: List<DailyMetrics>, exercises: List<ExerciseData>): DataPreview {
|
||||
val metricsPreview = if (metrics.isNotEmpty()) {
|
||||
val m = metrics.first()
|
||||
"""
|
||||
|Date: ${m.date}
|
||||
|Steps: ${m.steps}
|
||||
|Distance: ${String.format(Locale.US, "%.0f", m.distanceMeters)}m
|
||||
|Calories: ${m.caloriesBurned}
|
||||
|Heart Rate: ${m.avgHeartRate ?: "N/A"} bpm
|
||||
|Sleep: ${m.sleepDurationMinutes} min
|
||||
""".trimMargin()
|
||||
} else {
|
||||
"No metrics data available"
|
||||
}
|
||||
|
||||
val exercisesPreview = if (exercises.isNotEmpty()) {
|
||||
exercises.take(3).joinToString("\n") { e ->
|
||||
"• ${e.exerciseTypeName}: ${e.durationMinutes} min"
|
||||
}
|
||||
} else {
|
||||
"No exercise sessions available"
|
||||
}
|
||||
|
||||
return DataPreview(
|
||||
dailyMetricsPreview = metricsPreview,
|
||||
exerciseSessionsPreview = exercisesPreview,
|
||||
metricsCount = metrics.size,
|
||||
exercisesCount = exercises.size
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== DATA CLASSES ====================
|
||||
|
||||
data class ValidationResult(
|
||||
val isCompatible: Boolean,
|
||||
val validations: List<ValidationItem>,
|
||||
val issues: List<ValidationIssue>,
|
||||
val dataPreview: DataPreview?
|
||||
) {
|
||||
fun getOverallStatus(): String {
|
||||
return if (isCompatible) {
|
||||
val warnings = issues.count { it.severity == IssueSeverity.WARNING }
|
||||
if (warnings > 0) {
|
||||
"Compatible with $warnings warning(s)"
|
||||
} else {
|
||||
"Fully compatible"
|
||||
}
|
||||
} else {
|
||||
"Incompatible - see errors"
|
||||
}
|
||||
}
|
||||
|
||||
fun getStatusIcon(): String {
|
||||
return if (isCompatible) {
|
||||
if (issues.any { it.severity == IssueSeverity.WARNING }) "⚠" else "✓"
|
||||
} else {
|
||||
"✗"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class ValidationItem(
|
||||
val name: String,
|
||||
val passed: Boolean,
|
||||
val message: String
|
||||
)
|
||||
|
||||
data class ValidationIssue(
|
||||
val severity: IssueSeverity,
|
||||
val category: String,
|
||||
val message: String
|
||||
)
|
||||
|
||||
enum class IssueSeverity {
|
||||
INFO,
|
||||
WARNING,
|
||||
ERROR
|
||||
}
|
||||
|
||||
data class DataPreview(
|
||||
val dailyMetricsPreview: String,
|
||||
val exerciseSessionsPreview: String,
|
||||
val metricsCount: Int,
|
||||
val exercisesCount: Int
|
||||
)
|
||||
|
||||
private data class FieldValidationResult(
|
||||
val validations: List<ValidationItem>,
|
||||
val issues: List<ValidationIssue>
|
||||
)
|
||||
@@ -0,0 +1,316 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import java.util.concurrent.TimeUnit
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Response
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import retrofit2.http.*
|
||||
|
||||
/** Retrofit API interface for communicating with HabitForge backend */
|
||||
interface HabitForgeApi {
|
||||
|
||||
/** Login with username and password (OAuth2 form data) Returns access token on success */
|
||||
@FormUrlEncoded
|
||||
@POST("api/auth/token")
|
||||
suspend fun login(
|
||||
@Field("username") username: String,
|
||||
@Field("password") password: String
|
||||
): Response<LoginResponse>
|
||||
|
||||
/** Refresh access token */
|
||||
@POST("api/auth/refresh")
|
||||
suspend fun refreshToken(@Body request: RefreshTokenRequest): Response<LoginResponse>
|
||||
|
||||
/** Get current user information */
|
||||
@GET("api/auth/me")
|
||||
suspend fun getCurrentUser(@Header("Authorization") token: String): Response<UserResponse>
|
||||
|
||||
/** Sync aggregated health data (legacy endpoint) */
|
||||
@POST("api/health-connect/sync")
|
||||
suspend fun syncHealthData(
|
||||
@Header("Authorization") token: String,
|
||||
@Body request: HealthConnectSyncRequest
|
||||
): Response<SyncResponse>
|
||||
|
||||
/** Sync raw health data (new endpoint for individual records) */
|
||||
@POST("api/health-connect/sync-raw")
|
||||
suspend fun syncRawHealthData(
|
||||
@Header("Authorization") token: String,
|
||||
@Body request: RawDataSyncRequest
|
||||
): Response<RawSyncResponse>
|
||||
|
||||
@GET("api/health-connect/status")
|
||||
suspend fun getSyncStatus(
|
||||
@Header("Authorization") token: String
|
||||
): Response<SyncStatusResponse>
|
||||
|
||||
companion object {
|
||||
fun create(baseUrl: String): HabitForgeApi {
|
||||
val logging =
|
||||
HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
}
|
||||
|
||||
val client =
|
||||
OkHttpClient.Builder()
|
||||
.addInterceptor(logging)
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(
|
||||
120,
|
||||
TimeUnit.SECONDS
|
||||
) // Increased for large raw data
|
||||
.writeTimeout(120, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(baseUrl)
|
||||
.client(client)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
.create(HabitForgeApi::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== LEGACY REQUEST/RESPONSE MODELS ====================
|
||||
|
||||
data class HealthConnectSyncRequest(
|
||||
val device_id: String,
|
||||
val sync_timestamp: String,
|
||||
val daily_metrics: List<DailyMetricsPayload>,
|
||||
val exercise_sessions: List<ExerciseSessionPayload>
|
||||
)
|
||||
|
||||
data class DailyMetricsPayload(
|
||||
val date: String,
|
||||
val step_count: Int,
|
||||
val distance_meters: Double,
|
||||
val calories_burned: Double,
|
||||
val total_calories: Double = 0.0,
|
||||
val avg_heart_rate: Int?,
|
||||
val min_heart_rate: Int?,
|
||||
val max_heart_rate: Int?,
|
||||
val resting_heart_rate: Int?,
|
||||
val sleep_duration_minutes: Int,
|
||||
val deep_sleep_minutes: Int,
|
||||
val light_sleep_minutes: Int,
|
||||
val rem_sleep_minutes: Int,
|
||||
val awake_duration_minutes: Int,
|
||||
val weight: Double?,
|
||||
val avg_spo2: Double?,
|
||||
val min_spo2: Double? = null,
|
||||
val max_spo2: Double? = null,
|
||||
// Data source tracking
|
||||
val data_source: String? = null, // Package name (e.g., "com.huami.watch.hmwatchmanager")
|
||||
val data_source_name: String? = null // Friendly name (e.g., "Zepp")
|
||||
)
|
||||
|
||||
data class ExerciseSessionPayload(
|
||||
val start_time: String,
|
||||
val end_time: String,
|
||||
val exercise_type: Int,
|
||||
val exercise_type_name: String?,
|
||||
val duration_seconds: Int,
|
||||
val title: String?,
|
||||
val notes: String?,
|
||||
val calories_burned: Double? = null,
|
||||
val distance_meters: Double? = null,
|
||||
val avg_heart_rate: Int? = null,
|
||||
val max_heart_rate: Int? = null,
|
||||
val min_heart_rate: Int? = null,
|
||||
val steps: Int? = null,
|
||||
val elevation_gained: Double? = null
|
||||
)
|
||||
|
||||
data class SyncResponse(
|
||||
val status: String,
|
||||
val message: String,
|
||||
val sync_id: Int,
|
||||
val metrics_synced: Int,
|
||||
val exercises_synced: Int
|
||||
)
|
||||
|
||||
data class SyncStatusResponse(
|
||||
val has_data: Boolean,
|
||||
val last_sync: String?,
|
||||
val total_records: Int
|
||||
)
|
||||
|
||||
// ==================== RAW DATA SYNC MODELS ====================
|
||||
|
||||
/** Complete raw data sync request */
|
||||
data class RawDataSyncRequest(
|
||||
val device_id: String,
|
||||
val sync_timestamp: String,
|
||||
val steps_records: List<RawStepsPayload> = emptyList(),
|
||||
val heart_rate_records: List<RawHeartRatePayload> = emptyList(),
|
||||
val resting_hr_records: List<RawRestingHRPayload> = emptyList(),
|
||||
val sleep_sessions: List<RawSleepSessionPayload> = emptyList(),
|
||||
val distance_records: List<RawDistancePayload> = emptyList(),
|
||||
val calories_records: List<RawCaloriesPayload> = emptyList(),
|
||||
val spo2_records: List<RawSpo2Payload> = emptyList(),
|
||||
val weight_records: List<RawWeightPayload> = emptyList(),
|
||||
val height_records: List<RawHeightPayload> = emptyList(),
|
||||
val body_fat_records: List<RawBodyFatPayload> = emptyList(),
|
||||
val exercise_sessions: List<RawExercisePayload> = emptyList(),
|
||||
val nutrition_records: List<RawNutritionPayload> = emptyList(),
|
||||
val hydration_records: List<RawHydrationPayload> = emptyList()
|
||||
)
|
||||
|
||||
data class RawStepsPayload(
|
||||
val record_id: String,
|
||||
val data_source: String,
|
||||
val data_source_name: String?,
|
||||
val start_time: String,
|
||||
val end_time: String,
|
||||
val count: Long
|
||||
)
|
||||
|
||||
data class HeartRateSamplePayload(val time: String, val bpm: Long)
|
||||
|
||||
data class RawHeartRatePayload(
|
||||
val record_id: String,
|
||||
val data_source: String,
|
||||
val data_source_name: String?,
|
||||
val start_time: String,
|
||||
val end_time: String,
|
||||
val samples: List<HeartRateSamplePayload>
|
||||
)
|
||||
|
||||
data class RawRestingHRPayload(
|
||||
val record_id: String,
|
||||
val data_source: String,
|
||||
val data_source_name: String?,
|
||||
val time: String,
|
||||
val bpm: Long
|
||||
)
|
||||
|
||||
data class SleepStagePayload(
|
||||
val start_time: String,
|
||||
val end_time: String,
|
||||
val stage_type: Int,
|
||||
val stage_name: String?
|
||||
)
|
||||
|
||||
data class RawSleepSessionPayload(
|
||||
val record_id: String,
|
||||
val data_source: String,
|
||||
val data_source_name: String?,
|
||||
val start_time: String,
|
||||
val end_time: String,
|
||||
val title: String?,
|
||||
val notes: String?,
|
||||
val stages: List<SleepStagePayload>
|
||||
)
|
||||
|
||||
data class RawDistancePayload(
|
||||
val record_id: String,
|
||||
val data_source: String,
|
||||
val data_source_name: String?,
|
||||
val start_time: String,
|
||||
val end_time: String,
|
||||
val distance_meters: Double
|
||||
)
|
||||
|
||||
data class RawCaloriesPayload(
|
||||
val record_id: String,
|
||||
val data_source: String,
|
||||
val data_source_name: String?,
|
||||
val start_time: String,
|
||||
val end_time: String,
|
||||
val calories: Double,
|
||||
val record_type: String
|
||||
)
|
||||
|
||||
data class RawSpo2Payload(
|
||||
val record_id: String,
|
||||
val data_source: String,
|
||||
val data_source_name: String?,
|
||||
val time: String,
|
||||
val percentage: Double
|
||||
)
|
||||
|
||||
data class RawWeightPayload(
|
||||
val record_id: String,
|
||||
val data_source: String,
|
||||
val data_source_name: String?,
|
||||
val time: String,
|
||||
val weight_kg: Double
|
||||
)
|
||||
|
||||
data class RawHeightPayload(
|
||||
val record_id: String,
|
||||
val data_source: String,
|
||||
val data_source_name: String?,
|
||||
val time: String,
|
||||
val height_meters: Double
|
||||
)
|
||||
|
||||
data class RawBodyFatPayload(
|
||||
val record_id: String,
|
||||
val data_source: String,
|
||||
val data_source_name: String?,
|
||||
val time: String,
|
||||
val percentage: Double
|
||||
)
|
||||
|
||||
data class RawExercisePayload(
|
||||
val record_id: String,
|
||||
val data_source: String,
|
||||
val data_source_name: String?,
|
||||
val start_time: String,
|
||||
val end_time: String,
|
||||
val exercise_type: Int,
|
||||
val exercise_type_name: String?,
|
||||
val title: String?,
|
||||
val notes: String?,
|
||||
val distance_meters: Double?,
|
||||
val calories: Double?,
|
||||
val avg_heart_rate: Int?,
|
||||
val max_heart_rate: Int?,
|
||||
val min_heart_rate: Int?,
|
||||
val steps: Int?,
|
||||
val elevation_gained_meters: Double?
|
||||
)
|
||||
|
||||
data class RawNutritionPayload(
|
||||
val record_id: String,
|
||||
val data_source: String,
|
||||
val data_source_name: String?,
|
||||
val start_time: String,
|
||||
val end_time: String,
|
||||
val name: String?,
|
||||
val meal_type: Int?,
|
||||
val calories: Double?,
|
||||
val protein_grams: Double?,
|
||||
val carbohydrates_grams: Double?,
|
||||
val fat_grams: Double?,
|
||||
val fiber_grams: Double?,
|
||||
val sugar_grams: Double?
|
||||
)
|
||||
|
||||
data class RawHydrationPayload(
|
||||
val record_id: String,
|
||||
val data_source: String,
|
||||
val data_source_name: String?,
|
||||
val start_time: String,
|
||||
val end_time: String,
|
||||
val volume_liters: Double
|
||||
)
|
||||
|
||||
data class RawSyncResponse(
|
||||
val status: String,
|
||||
val message: String,
|
||||
val sync_id: Int,
|
||||
val records_synced: Map<String, Int>
|
||||
)
|
||||
|
||||
// ==================== AUTH MODELS ====================
|
||||
|
||||
data class RefreshTokenRequest(val refresh_token: String)
|
||||
|
||||
data class LoginResponse(val access_token: String, val refresh_token: String, val token_type: String)
|
||||
|
||||
data class UserResponse(val id: Int, val username: String)
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.app.Application
|
||||
import androidx.work.Configuration
|
||||
|
||||
class HabitForgeApp : Application(), Configuration.Provider {
|
||||
|
||||
lateinit var healthConnectManager: HealthConnectManager
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
healthConnectManager = HealthConnectManager(this)
|
||||
|
||||
// Initialize periodic sync if user is logged in (ensures sync persists after app restart)
|
||||
RawDataSyncService.initializeIfLoggedIn(this)
|
||||
}
|
||||
|
||||
override val workManagerConfiguration: Configuration
|
||||
get() = Configuration.Builder().setMinimumLoggingLevel(android.util.Log.INFO).build()
|
||||
|
||||
companion object {
|
||||
lateinit var instance: HabitForgeApp
|
||||
private set
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,941 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.health.connect.client.HealthConnectClient
|
||||
import androidx.health.connect.client.permission.HealthPermission
|
||||
import androidx.health.connect.client.records.*
|
||||
import androidx.health.connect.client.request.ReadRecordsRequest
|
||||
import androidx.health.connect.client.time.TimeRangeFilter
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.temporal.ChronoUnit
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Health Connect Manager - Handles all interactions with Health Connect API Implements: Permission
|
||||
* requests, data reading, and source filtering
|
||||
*/
|
||||
class HealthConnectManager(private val context: Context) {
|
||||
|
||||
private var healthConnectClient: HealthConnectClient? = null
|
||||
|
||||
// Data source preferences
|
||||
private val prefs by lazy {
|
||||
context.getSharedPreferences("habitforge_sources", Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "HealthConnectManager"
|
||||
private const val PREF_PREFERRED_SOURCE = "preferred_data_source"
|
||||
private const val PREF_SOURCE_FILTER_ENABLED = "source_filter_enabled"
|
||||
|
||||
// All permissions we need to read health data
|
||||
val PERMISSIONS =
|
||||
setOf(
|
||||
// Activity
|
||||
HealthPermission.getReadPermission(StepsRecord::class),
|
||||
HealthPermission.getReadPermission(DistanceRecord::class),
|
||||
HealthPermission.getReadPermission(
|
||||
TotalCaloriesBurnedRecord::class
|
||||
),
|
||||
HealthPermission.getReadPermission(
|
||||
ActiveCaloriesBurnedRecord::class
|
||||
),
|
||||
|
||||
// Heart & Vitals
|
||||
HealthPermission.getReadPermission(HeartRateRecord::class),
|
||||
HealthPermission.getReadPermission(RestingHeartRateRecord::class),
|
||||
HealthPermission.getReadPermission(
|
||||
HeartRateVariabilityRmssdRecord::class
|
||||
),
|
||||
HealthPermission.getReadPermission(OxygenSaturationRecord::class),
|
||||
HealthPermission.getReadPermission(RespiratoryRateRecord::class),
|
||||
|
||||
// Body Measurements
|
||||
HealthPermission.getReadPermission(WeightRecord::class),
|
||||
HealthPermission.getReadPermission(HeightRecord::class),
|
||||
HealthPermission.getReadPermission(BodyFatRecord::class),
|
||||
HealthPermission.getReadPermission(BasalMetabolicRateRecord::class),
|
||||
|
||||
// Sleep
|
||||
HealthPermission.getReadPermission(SleepSessionRecord::class),
|
||||
|
||||
// Exercise
|
||||
HealthPermission.getReadPermission(ExerciseSessionRecord::class),
|
||||
HealthPermission.getReadPermission(PowerRecord::class),
|
||||
HealthPermission.getReadPermission(SpeedRecord::class),
|
||||
|
||||
// Nutrition
|
||||
HealthPermission.getReadPermission(NutritionRecord::class),
|
||||
HealthPermission.getReadPermission(HydrationRecord::class),
|
||||
|
||||
// Historical data access (for data older than 30 days)
|
||||
"android.permission.health.READ_HEALTH_DATA_HISTORY"
|
||||
)
|
||||
|
||||
// Known data sources with friendly names
|
||||
val KNOWN_SOURCES =
|
||||
mapOf(
|
||||
"com.huami.watch.hmwatchmanager" to "Zepp",
|
||||
"com.google.android.apps.fitness" to "Google Fit",
|
||||
"com.sec.android.app.shealth" to "Samsung Health",
|
||||
"com.xiaomi.wearable" to "Mi Fitness",
|
||||
"com.oneplus.health" to "OnePlus Health"
|
||||
// Add more as discovered
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== DATA SOURCE MANAGEMENT ====================
|
||||
|
||||
/** Get all available data sources from Health Connect */
|
||||
suspend fun getAvailableDataSources(): List<DataSourceInfo> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client = healthConnectClient ?: return@withContext emptyList()
|
||||
val sources = mutableSetOf<String>()
|
||||
|
||||
try {
|
||||
// Check sources from recent steps records
|
||||
val timeRange =
|
||||
TimeRangeFilter.between(
|
||||
LocalDate.now()
|
||||
.minusDays(30)
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant(),
|
||||
LocalDate.now()
|
||||
.plusDays(1)
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
)
|
||||
|
||||
val stepsRecords =
|
||||
readAllRecordsPaginated<StepsRecord>(client, timeRange)
|
||||
stepsRecords.forEach { record ->
|
||||
sources.add(record.metadata.dataOrigin.packageName)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Found ${sources.size} data sources: $sources")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error getting data sources", e)
|
||||
}
|
||||
|
||||
sources
|
||||
.map { packageName ->
|
||||
DataSourceInfo(
|
||||
packageName = packageName,
|
||||
displayName = KNOWN_SOURCES[packageName]
|
||||
?: packageName.substringAfterLast(
|
||||
"."
|
||||
)
|
||||
.replaceFirstChar {
|
||||
it.uppercase()
|
||||
},
|
||||
isPreferred = packageName == getPreferredSource()
|
||||
)
|
||||
}
|
||||
.sortedBy { it.displayName }
|
||||
}
|
||||
|
||||
/** Set the preferred data source */
|
||||
fun setPreferredSource(packageName: String?) {
|
||||
prefs.edit().putString(PREF_PREFERRED_SOURCE, packageName).apply()
|
||||
Log.d(TAG, "Preferred source set to: $packageName")
|
||||
}
|
||||
|
||||
/** Get the preferred data source */
|
||||
fun getPreferredSource(): String? {
|
||||
return prefs.getString(PREF_PREFERRED_SOURCE, null)
|
||||
}
|
||||
|
||||
/** Enable/disable source filtering */
|
||||
fun setSourceFilterEnabled(enabled: Boolean) {
|
||||
prefs.edit().putBoolean(PREF_SOURCE_FILTER_ENABLED, enabled).apply()
|
||||
}
|
||||
|
||||
/** Check if source filtering is enabled */
|
||||
fun isSourceFilterEnabled(): Boolean {
|
||||
return prefs.getBoolean(PREF_SOURCE_FILTER_ENABLED, false)
|
||||
}
|
||||
|
||||
/** Get friendly name for a package */
|
||||
fun getSourceDisplayName(packageName: String): String {
|
||||
return KNOWN_SOURCES[packageName]
|
||||
?: packageName.substringAfterLast(".").replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
|
||||
// ==================== AVAILABILITY & PERMISSIONS ====================
|
||||
|
||||
/** Check if Health Connect is available on this device */
|
||||
fun isAvailable(): HealthConnectAvailability {
|
||||
return when (HealthConnectClient.getSdkStatus(context)) {
|
||||
HealthConnectClient.SDK_AVAILABLE -> {
|
||||
healthConnectClient = HealthConnectClient.getOrCreate(context)
|
||||
HealthConnectAvailability.AVAILABLE
|
||||
}
|
||||
HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> {
|
||||
HealthConnectAvailability.NOT_INSTALLED
|
||||
}
|
||||
else -> HealthConnectAvailability.NOT_SUPPORTED
|
||||
}
|
||||
}
|
||||
|
||||
/** Check which permissions are currently granted */
|
||||
suspend fun getGrantedPermissions(): Set<String> {
|
||||
val client = healthConnectClient ?: return emptySet()
|
||||
return client.permissionController.getGrantedPermissions()
|
||||
}
|
||||
|
||||
/** Check if we have all required permissions */
|
||||
suspend fun hasAllPermissions(): Boolean {
|
||||
val granted = getGrantedPermissions()
|
||||
return PERMISSIONS.all { it in granted }
|
||||
}
|
||||
|
||||
// ==================== DATA READING METHODS ====================
|
||||
|
||||
/** Read daily metrics for a specific date range */
|
||||
suspend fun readDailyMetrics(startDate: LocalDate, endDate: LocalDate): List<DailyMetrics> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client =
|
||||
healthConnectClient
|
||||
?: run {
|
||||
if (isAvailable() ==
|
||||
HealthConnectAvailability.AVAILABLE
|
||||
)
|
||||
healthConnectClient
|
||||
else null
|
||||
}
|
||||
?: return@withContext emptyList()
|
||||
|
||||
// Validate date range
|
||||
val actualStartDate = if (startDate.isAfter(endDate)) endDate else startDate
|
||||
val actualEndDate =
|
||||
if (endDate.isBefore(actualStartDate)) actualStartDate else endDate
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"=== Starting readDailyMetrics from $actualStartDate to $actualEndDate ==="
|
||||
)
|
||||
|
||||
val preferredSource =
|
||||
if (isSourceFilterEnabled()) getPreferredSource() else null
|
||||
Log.d(
|
||||
TAG,
|
||||
"Source filter: ${if (preferredSource != null) "filtering by $preferredSource" else "disabled"}"
|
||||
)
|
||||
|
||||
val metrics = mutableListOf<DailyMetrics>()
|
||||
var chunkStartDate = actualStartDate
|
||||
var chunkNumber = 0
|
||||
|
||||
// Process in 30-day chunks to avoid OOM and partial failures
|
||||
while (!chunkStartDate.isAfter(actualEndDate)) {
|
||||
chunkNumber++
|
||||
val chunkEndDate =
|
||||
if (chunkStartDate.plusDays(29).isAfter(actualEndDate)) {
|
||||
actualEndDate
|
||||
} else {
|
||||
chunkStartDate.plusDays(29)
|
||||
}
|
||||
|
||||
try {
|
||||
Log.d(
|
||||
TAG,
|
||||
"=== Chunk $chunkNumber: $chunkStartDate to $chunkEndDate ==="
|
||||
)
|
||||
|
||||
val chunkStartInstant =
|
||||
chunkStartDate
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
val chunkEndInstant =
|
||||
chunkEndDate
|
||||
.plusDays(1)
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
val timeRange =
|
||||
TimeRangeFilter.between(
|
||||
chunkStartInstant,
|
||||
chunkEndInstant
|
||||
)
|
||||
|
||||
// Fetch all metrics for this chunk using pagination
|
||||
val chunkSteps =
|
||||
try {
|
||||
val allSteps =
|
||||
readAllRecordsPaginated<
|
||||
StepsRecord>(
|
||||
client,
|
||||
timeRange
|
||||
)
|
||||
// Filter by source if enabled, also filter
|
||||
// out invalid records
|
||||
allSteps.filter { record ->
|
||||
val validTime =
|
||||
record.startTime.isBefore(
|
||||
record.endTime
|
||||
)
|
||||
val matchesSource =
|
||||
preferredSource == null ||
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName ==
|
||||
preferredSource
|
||||
if (!validTime) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Skipping invalid StepsRecord: startTime >= endTime"
|
||||
)
|
||||
}
|
||||
validTime && matchesSource
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
TAG,
|
||||
"Chunk $chunkNumber: Error reading steps",
|
||||
e
|
||||
)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val chunkDistance =
|
||||
try {
|
||||
val all =
|
||||
readAllRecordsPaginated<
|
||||
DistanceRecord>(
|
||||
client,
|
||||
timeRange
|
||||
)
|
||||
all.filter { record ->
|
||||
val validTime =
|
||||
record.startTime.isBefore(
|
||||
record.endTime
|
||||
)
|
||||
val matchesSource =
|
||||
preferredSource == null ||
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName ==
|
||||
preferredSource
|
||||
validTime && matchesSource
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Chunk $chunkNumber: Error reading distance",
|
||||
e
|
||||
)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val chunkCalories =
|
||||
try {
|
||||
val all =
|
||||
readAllRecordsPaginated<
|
||||
TotalCaloriesBurnedRecord>(
|
||||
client,
|
||||
timeRange
|
||||
)
|
||||
all.filter { record ->
|
||||
val validTime =
|
||||
record.startTime.isBefore(
|
||||
record.endTime
|
||||
)
|
||||
val matchesSource =
|
||||
preferredSource == null ||
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName ==
|
||||
preferredSource
|
||||
validTime && matchesSource
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Chunk $chunkNumber: Error reading calories",
|
||||
e
|
||||
)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val chunkHeartRate =
|
||||
try {
|
||||
val all =
|
||||
readAllRecordsPaginated<
|
||||
HeartRateRecord>(
|
||||
client,
|
||||
timeRange
|
||||
)
|
||||
all.filter { record ->
|
||||
val validTime =
|
||||
record.startTime.isBefore(
|
||||
record.endTime
|
||||
)
|
||||
val matchesSource =
|
||||
preferredSource == null ||
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName ==
|
||||
preferredSource
|
||||
validTime && matchesSource
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Chunk $chunkNumber: Error reading heart rate",
|
||||
e
|
||||
)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val chunkRestingHR =
|
||||
try {
|
||||
val all =
|
||||
readAllRecordsPaginated<
|
||||
RestingHeartRateRecord>(
|
||||
client,
|
||||
timeRange
|
||||
)
|
||||
all.filter {
|
||||
preferredSource == null ||
|
||||
it.metadata
|
||||
.dataOrigin
|
||||
.packageName ==
|
||||
preferredSource
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Chunk $chunkNumber: Error reading resting HR",
|
||||
e
|
||||
)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val chunkSleep =
|
||||
try {
|
||||
val all =
|
||||
readAllRecordsPaginated<
|
||||
SleepSessionRecord>(
|
||||
client,
|
||||
timeRange
|
||||
)
|
||||
all.filter { record ->
|
||||
val validTime =
|
||||
record.startTime.isBefore(
|
||||
record.endTime
|
||||
)
|
||||
val matchesSource =
|
||||
preferredSource == null ||
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName ==
|
||||
preferredSource
|
||||
validTime && matchesSource
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Chunk $chunkNumber: Error reading sleep",
|
||||
e
|
||||
)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val chunkWeight =
|
||||
try {
|
||||
val all =
|
||||
readAllRecordsPaginated<
|
||||
WeightRecord>(
|
||||
client,
|
||||
timeRange
|
||||
)
|
||||
all.filter {
|
||||
preferredSource == null ||
|
||||
it.metadata
|
||||
.dataOrigin
|
||||
.packageName ==
|
||||
preferredSource
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Chunk $chunkNumber: Error reading weight",
|
||||
e
|
||||
)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val chunkSpo2 =
|
||||
try {
|
||||
val all =
|
||||
readAllRecordsPaginated<
|
||||
OxygenSaturationRecord>(
|
||||
client,
|
||||
timeRange
|
||||
)
|
||||
all.filter {
|
||||
preferredSource == null ||
|
||||
it.metadata
|
||||
.dataOrigin
|
||||
.packageName ==
|
||||
preferredSource
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Chunk $chunkNumber: Error reading spo2",
|
||||
e
|
||||
)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"Chunk $chunkNumber filtered data: Steps=${chunkSteps.size}, Distance=${chunkDistance.size}, Calories=${chunkCalories.size}, HR=${chunkHeartRate.size}, Sleep=${chunkSleep.size}"
|
||||
)
|
||||
|
||||
// Get primary source for this chunk
|
||||
val primarySource =
|
||||
chunkSteps.firstOrNull()
|
||||
?.metadata
|
||||
?.dataOrigin
|
||||
?.packageName
|
||||
?: ""
|
||||
|
||||
// Process each day in this chunk
|
||||
var currentDate = chunkStartDate
|
||||
var daysWithSteps = 0
|
||||
|
||||
while (!currentDate.isAfter(chunkEndDate)) {
|
||||
try {
|
||||
val dayStart =
|
||||
currentDate
|
||||
.atStartOfDay(
|
||||
ZoneId.systemDefault()
|
||||
)
|
||||
.toInstant()
|
||||
val dayEnd =
|
||||
currentDate
|
||||
.plusDays(1)
|
||||
.atStartOfDay(
|
||||
ZoneId.systemDefault()
|
||||
)
|
||||
.toInstant()
|
||||
|
||||
// Filter records for this specific day
|
||||
val daySteps =
|
||||
chunkSteps
|
||||
.filter {
|
||||
!it.startTime.isBefore(dayStart) &&
|
||||
it.startTime.isBefore(dayEnd)
|
||||
}
|
||||
.sumOf { it.count }
|
||||
|
||||
if (daySteps > 0) {
|
||||
daysWithSteps++
|
||||
}
|
||||
|
||||
val dayDistance =
|
||||
chunkDistance
|
||||
.filter {
|
||||
!it.startTime.isBefore(dayStart) &&
|
||||
it.startTime.isBefore(dayEnd)
|
||||
}
|
||||
.sumOf {
|
||||
it.distance.inMeters
|
||||
}
|
||||
|
||||
val dayCalories =
|
||||
chunkCalories
|
||||
.filter {
|
||||
!it.startTime.isBefore(dayStart) &&
|
||||
it.startTime.isBefore(dayEnd)
|
||||
}
|
||||
.sumOf {
|
||||
it.energy
|
||||
.inKilocalories
|
||||
}
|
||||
|
||||
val dayHRRecords =
|
||||
chunkHeartRate.filter {
|
||||
!it.startTime.isBefore(dayStart) &&
|
||||
it.startTime.isBefore(dayEnd)
|
||||
}
|
||||
val hrSamples =
|
||||
dayHRRecords.flatMap { it.samples }
|
||||
|
||||
val dayRestingHR =
|
||||
chunkRestingHR
|
||||
.filter {
|
||||
!it.time.isBefore(dayStart) &&
|
||||
it.time.isBefore(dayEnd)
|
||||
}
|
||||
.lastOrNull()
|
||||
?.beatsPerMinute
|
||||
?.toInt()
|
||||
|
||||
val dayWeight =
|
||||
chunkWeight
|
||||
.filter {
|
||||
!it.time.isBefore(dayStart) &&
|
||||
it.time.isBefore(dayEnd)
|
||||
}
|
||||
.lastOrNull()
|
||||
?.weight
|
||||
?.inKilograms
|
||||
|
||||
val daySpo2 =
|
||||
chunkSpo2
|
||||
.filter {
|
||||
!it.time.isBefore(dayStart) &&
|
||||
it.time.isBefore(dayEnd)
|
||||
}
|
||||
.lastOrNull()
|
||||
?.percentage
|
||||
?.value
|
||||
|
||||
// Sleep logic - attribute to waking day
|
||||
val daySleep =
|
||||
chunkSleep.filter {
|
||||
!it.endTime.isBefore(dayStart) &&
|
||||
!it.endTime.isAfter(dayEnd)
|
||||
}
|
||||
var totalSleep = 0
|
||||
var deep = 0
|
||||
var light = 0
|
||||
var rem = 0
|
||||
var awake = 0
|
||||
|
||||
daySleep.forEach { session ->
|
||||
totalSleep +=
|
||||
ChronoUnit.MINUTES
|
||||
.between(
|
||||
session.startTime,
|
||||
session.endTime
|
||||
)
|
||||
.toInt()
|
||||
session.stages.forEach { stage ->
|
||||
val dur =
|
||||
ChronoUnit.MINUTES
|
||||
.between(
|
||||
stage.startTime,
|
||||
stage.endTime
|
||||
)
|
||||
.toInt()
|
||||
when (stage.stage) {
|
||||
SleepSessionRecord
|
||||
.STAGE_TYPE_DEEP ->
|
||||
deep += dur
|
||||
SleepSessionRecord
|
||||
.STAGE_TYPE_LIGHT ->
|
||||
light += dur
|
||||
SleepSessionRecord
|
||||
.STAGE_TYPE_REM ->
|
||||
rem += dur
|
||||
SleepSessionRecord
|
||||
.STAGE_TYPE_AWAKE ->
|
||||
awake += dur
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
metrics.add(
|
||||
DailyMetrics(
|
||||
date =
|
||||
currentDate
|
||||
.toString(),
|
||||
steps = daySteps,
|
||||
distanceMeters =
|
||||
dayDistance,
|
||||
caloriesBurned =
|
||||
dayCalories,
|
||||
avgHeartRate =
|
||||
if (hrSamples
|
||||
.isNotEmpty()
|
||||
) {
|
||||
hrSamples
|
||||
.map {
|
||||
it.beatsPerMinute
|
||||
}
|
||||
.average()
|
||||
.toInt()
|
||||
} else null,
|
||||
minHeartRate =
|
||||
if (hrSamples
|
||||
.isNotEmpty()
|
||||
) {
|
||||
hrSamples
|
||||
.minOf {
|
||||
it.beatsPerMinute
|
||||
}
|
||||
.toInt()
|
||||
} else null,
|
||||
maxHeartRate =
|
||||
if (hrSamples
|
||||
.isNotEmpty()
|
||||
) {
|
||||
hrSamples
|
||||
.maxOf {
|
||||
it.beatsPerMinute
|
||||
}
|
||||
.toInt()
|
||||
} else null,
|
||||
restingHeartRate =
|
||||
dayRestingHR,
|
||||
sleepDurationMinutes =
|
||||
totalSleep,
|
||||
deepSleepMinutes = deep,
|
||||
lightSleepMinutes = light,
|
||||
remSleepMinutes = rem,
|
||||
awakeMinutes = awake,
|
||||
weight = dayWeight,
|
||||
spo2 = daySpo2,
|
||||
dataSource = primarySource
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
TAG,
|
||||
"Error processing day $currentDate in chunk $chunkNumber",
|
||||
e
|
||||
)
|
||||
}
|
||||
|
||||
currentDate = currentDate.plusDays(1)
|
||||
}
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"Chunk $chunkNumber complete: $daysWithSteps days with steps data"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
TAG,
|
||||
"=== Chunk $chunkNumber FAILED with exception ===",
|
||||
e
|
||||
)
|
||||
}
|
||||
|
||||
// Move to next chunk
|
||||
chunkStartDate = chunkEndDate.plusDays(1)
|
||||
}
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"=== readDailyMetrics complete: ${metrics.size} total days, ${metrics.count { it.steps > 0 }} with steps ==="
|
||||
)
|
||||
metrics
|
||||
}
|
||||
|
||||
/** Read exercise sessions for a date range with pagination support */
|
||||
suspend fun readExerciseSessions(
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): List<ExerciseData> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client =
|
||||
healthConnectClient
|
||||
?: run {
|
||||
if (isAvailable() ==
|
||||
HealthConnectAvailability.AVAILABLE
|
||||
)
|
||||
healthConnectClient
|
||||
else null
|
||||
}
|
||||
?: return@withContext emptyList()
|
||||
|
||||
val actualStartDate = if (startDate.isAfter(endDate)) endDate else startDate
|
||||
val actualEndDate =
|
||||
if (endDate.isBefore(actualStartDate)) actualStartDate else endDate
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"Reading exercise sessions from $actualStartDate to $actualEndDate"
|
||||
)
|
||||
|
||||
val preferredSource =
|
||||
if (isSourceFilterEnabled()) getPreferredSource() else null
|
||||
val exercises = mutableListOf<ExerciseData>()
|
||||
|
||||
try {
|
||||
val timeRange =
|
||||
TimeRangeFilter.between(
|
||||
actualStartDate
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant(),
|
||||
actualEndDate
|
||||
.plusDays(1)
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
)
|
||||
|
||||
val allRecords =
|
||||
readAllRecordsPaginated<ExerciseSessionRecord>(
|
||||
client,
|
||||
timeRange
|
||||
)
|
||||
.filter { record ->
|
||||
val validTime =
|
||||
record.startTime.isBefore(
|
||||
record.endTime
|
||||
)
|
||||
val matchesSource =
|
||||
preferredSource == null ||
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName ==
|
||||
preferredSource
|
||||
validTime && matchesSource
|
||||
}
|
||||
|
||||
for (record in allRecords) {
|
||||
val durationMinutes =
|
||||
ChronoUnit.MINUTES
|
||||
.between(record.startTime, record.endTime)
|
||||
.toInt()
|
||||
exercises.add(
|
||||
ExerciseData(
|
||||
startTime = record.startTime.toString(),
|
||||
endTime = record.endTime.toString(),
|
||||
exerciseType = record.exerciseType,
|
||||
exerciseTypeName =
|
||||
mapExerciseType(
|
||||
record.exerciseType
|
||||
),
|
||||
durationMinutes = durationMinutes,
|
||||
title = record.title
|
||||
?: mapExerciseType(
|
||||
record.exerciseType
|
||||
),
|
||||
notes = record.notes,
|
||||
dataSource =
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading exercise sessions", e)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Total exercise sessions found: ${exercises.size}")
|
||||
exercises
|
||||
}
|
||||
|
||||
// ==================== PRIVATE HELPER METHODS ====================
|
||||
|
||||
/**
|
||||
* Generic helper to read ALL records with pagination support. Health Connect returns max
|
||||
* 1000 records per request by default.
|
||||
*/
|
||||
private suspend inline fun <reified T : Record> readAllRecordsPaginated(
|
||||
client: HealthConnectClient,
|
||||
timeRange: TimeRangeFilter
|
||||
): List<T> {
|
||||
val allRecords = mutableListOf<T>()
|
||||
var pageToken: String? = null
|
||||
var pageCount = 0
|
||||
|
||||
do {
|
||||
pageCount++
|
||||
val request =
|
||||
if (pageToken != null) {
|
||||
ReadRecordsRequest(
|
||||
recordType = T::class,
|
||||
timeRangeFilter = timeRange,
|
||||
pageToken = pageToken
|
||||
)
|
||||
} else {
|
||||
ReadRecordsRequest(
|
||||
recordType = T::class,
|
||||
timeRangeFilter = timeRange
|
||||
)
|
||||
}
|
||||
|
||||
val response = client.readRecords(request)
|
||||
allRecords.addAll(response.records)
|
||||
pageToken = response.pageToken
|
||||
|
||||
if (response.records.isNotEmpty()) {
|
||||
Log.d(
|
||||
TAG,
|
||||
" Page $pageCount: ${response.records.size} ${T::class.simpleName} records (total: ${allRecords.size}, hasMore: ${pageToken != null})"
|
||||
)
|
||||
}
|
||||
} while (pageToken != null)
|
||||
|
||||
return allRecords
|
||||
}
|
||||
|
||||
private fun mapExerciseType(type: Int): String {
|
||||
return when (type) {
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_WALKING -> "Walking"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_RUNNING -> "Running"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_HIKING -> "Hiking"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_BIKING -> "Biking"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_POOL -> "Swimming"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING -> "Strength Training"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_YOGA -> "Yoga"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_PILATES -> "Pilates"
|
||||
else -> "Other"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== DATA CLASSES ====================
|
||||
|
||||
enum class HealthConnectAvailability {
|
||||
AVAILABLE,
|
||||
NOT_INSTALLED,
|
||||
NOT_SUPPORTED
|
||||
}
|
||||
|
||||
data class DataSourceInfo(
|
||||
val packageName: String,
|
||||
val displayName: String,
|
||||
val isPreferred: Boolean
|
||||
)
|
||||
|
||||
data class DailyMetrics(
|
||||
val date: String,
|
||||
val steps: Long,
|
||||
val distanceMeters: Double,
|
||||
val caloriesBurned: Double,
|
||||
val avgHeartRate: Int?,
|
||||
val minHeartRate: Int?,
|
||||
val maxHeartRate: Int?,
|
||||
val restingHeartRate: Int?,
|
||||
val sleepDurationMinutes: Int,
|
||||
val deepSleepMinutes: Int,
|
||||
val lightSleepMinutes: Int,
|
||||
val remSleepMinutes: Int,
|
||||
val awakeMinutes: Int,
|
||||
val weight: Double?,
|
||||
val spo2: Double?,
|
||||
val dataSource: String = "" // Package name of the data source
|
||||
)
|
||||
|
||||
data class ExerciseData(
|
||||
val startTime: String,
|
||||
val endTime: String,
|
||||
val exerciseType: Int,
|
||||
val exerciseTypeName: String,
|
||||
val durationMinutes: Int,
|
||||
val title: String?,
|
||||
val notes: String?,
|
||||
val dataSource: String = "" // Package name of the data source
|
||||
)
|
||||
|
||||
data class HeartRateData(val avg: Int?, val min: Int?, val max: Int?, val resting: Int?)
|
||||
|
||||
data class SleepData(
|
||||
val totalMinutes: Int,
|
||||
val deepMinutes: Int,
|
||||
val lightMinutes: Int,
|
||||
val remSleepMinutes: Int,
|
||||
val awakeMinutes: Int
|
||||
)
|
||||
+898
@@ -0,0 +1,898 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.health.connect.client.HealthConnectClient
|
||||
import androidx.health.connect.client.records.*
|
||||
import androidx.health.connect.client.request.ReadRecordsRequest
|
||||
import androidx.health.connect.client.time.TimeRangeFilter
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Health Connect Raw Data Reader
|
||||
*
|
||||
* Reads individual health records from Health Connect without aggregation. Each record includes its
|
||||
* full metadata (record ID, timestamps, source).
|
||||
*/
|
||||
class HealthConnectRawReader(private val context: Context) {
|
||||
|
||||
private var healthConnectClient: HealthConnectClient? = null
|
||||
|
||||
companion object {
|
||||
private const val TAG = "HCRawReader"
|
||||
|
||||
// Sleep stage type names
|
||||
val SLEEP_STAGE_NAMES =
|
||||
mapOf(
|
||||
SleepSessionRecord.STAGE_TYPE_UNKNOWN to "unknown",
|
||||
SleepSessionRecord.STAGE_TYPE_AWAKE to "awake",
|
||||
SleepSessionRecord.STAGE_TYPE_SLEEPING to "sleeping",
|
||||
SleepSessionRecord.STAGE_TYPE_OUT_OF_BED to "out_of_bed",
|
||||
SleepSessionRecord.STAGE_TYPE_LIGHT to "light",
|
||||
SleepSessionRecord.STAGE_TYPE_DEEP to "deep",
|
||||
SleepSessionRecord.STAGE_TYPE_REM to "rem",
|
||||
SleepSessionRecord.STAGE_TYPE_AWAKE_IN_BED to "awake_in_bed"
|
||||
)
|
||||
|
||||
// Known data sources with friendly names
|
||||
val KNOWN_SOURCES =
|
||||
mapOf(
|
||||
"com.huami.watch.hmwatchmanager" to "Zepp",
|
||||
"com.google.android.apps.fitness" to "Google Fit",
|
||||
"com.sec.android.app.shealth" to "Samsung Health",
|
||||
"com.xiaomi.wearable" to "Mi Fitness",
|
||||
"com.oneplus.health" to "OnePlus Health"
|
||||
)
|
||||
}
|
||||
|
||||
init {
|
||||
if (HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_AVAILABLE
|
||||
) {
|
||||
healthConnectClient = HealthConnectClient.getOrCreate(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSourceDisplayName(packageName: String): String {
|
||||
return KNOWN_SOURCES[packageName]
|
||||
?: packageName.substringAfterLast(".").replaceFirstChar { it.uppercase() }
|
||||
}
|
||||
|
||||
private fun Instant.toIsoString(): String {
|
||||
return DateTimeFormatter.ISO_INSTANT.format(this)
|
||||
}
|
||||
|
||||
// ==================== STEPS ====================
|
||||
|
||||
suspend fun readRawStepsRecords(
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): List<RawStepsRecord> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client = healthConnectClient ?: return@withContext emptyList()
|
||||
val records = mutableListOf<RawStepsRecord>()
|
||||
|
||||
try {
|
||||
val allRecords =
|
||||
readRangeRecords<StepsRecord>(client, startDate, endDate)
|
||||
|
||||
for (record in allRecords) {
|
||||
// Skip invalid records
|
||||
if (!record.startTime.isBefore(record.endTime)) continue
|
||||
|
||||
records.add(
|
||||
RawStepsRecord(
|
||||
recordId = record.metadata.id,
|
||||
dataSource =
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName,
|
||||
dataSourceName =
|
||||
getSourceDisplayName(
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName
|
||||
),
|
||||
startTime = record.startTime.toIsoString(),
|
||||
endTime = record.endTime.toIsoString(),
|
||||
count = record.count
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Read ${records.size} raw step records")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading raw steps", e)
|
||||
}
|
||||
|
||||
records
|
||||
}
|
||||
|
||||
// ==================== HEART RATE ====================
|
||||
|
||||
suspend fun readRawHeartRateRecords(
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): List<RawHeartRateRecord> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client = healthConnectClient ?: return@withContext emptyList()
|
||||
val records = mutableListOf<RawHeartRateRecord>()
|
||||
|
||||
try {
|
||||
val allRecords =
|
||||
readRangeRecords<HeartRateRecord>(
|
||||
client,
|
||||
startDate,
|
||||
endDate
|
||||
)
|
||||
|
||||
for (record in allRecords) {
|
||||
if (!record.startTime.isBefore(record.endTime)) continue
|
||||
|
||||
val samples =
|
||||
record.samples.map { sample ->
|
||||
HeartRateSample(
|
||||
time = sample.time.toIsoString(),
|
||||
bpm = sample.beatsPerMinute
|
||||
)
|
||||
}
|
||||
|
||||
records.add(
|
||||
RawHeartRateRecord(
|
||||
recordId = record.metadata.id,
|
||||
dataSource =
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName,
|
||||
dataSourceName =
|
||||
getSourceDisplayName(
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName
|
||||
),
|
||||
startTime = record.startTime.toIsoString(),
|
||||
endTime = record.endTime.toIsoString(),
|
||||
samples = samples
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"Read ${records.size} raw heart rate records with ${records.sumOf { it.samples.size }} samples"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading raw heart rate", e)
|
||||
}
|
||||
|
||||
records
|
||||
}
|
||||
|
||||
// ==================== RESTING HEART RATE ====================
|
||||
|
||||
suspend fun readRawRestingHeartRateRecords(
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): List<RawRestingHeartRateRecord> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client = healthConnectClient ?: return@withContext emptyList()
|
||||
val records = mutableListOf<RawRestingHeartRateRecord>()
|
||||
|
||||
try {
|
||||
val allRecords =
|
||||
readRangeRecords<RestingHeartRateRecord>(
|
||||
client,
|
||||
startDate,
|
||||
endDate
|
||||
)
|
||||
|
||||
for (record in allRecords) {
|
||||
records.add(
|
||||
RawRestingHeartRateRecord(
|
||||
recordId = record.metadata.id,
|
||||
dataSource =
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName,
|
||||
dataSourceName =
|
||||
getSourceDisplayName(
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName
|
||||
),
|
||||
time = record.time.toIsoString(),
|
||||
bpm = record.beatsPerMinute
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Read ${records.size} raw resting HR records")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading raw resting HR", e)
|
||||
}
|
||||
|
||||
records
|
||||
}
|
||||
|
||||
// ==================== SLEEP ====================
|
||||
|
||||
suspend fun readRawSleepSessions(
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): List<RawSleepSession> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client = healthConnectClient ?: return@withContext emptyList()
|
||||
val records = mutableListOf<RawSleepSession>()
|
||||
|
||||
try {
|
||||
val allRecords =
|
||||
readRangeRecords<SleepSessionRecord>(
|
||||
client,
|
||||
startDate,
|
||||
endDate
|
||||
)
|
||||
|
||||
for (record in allRecords) {
|
||||
if (!record.startTime.isBefore(record.endTime)) continue
|
||||
|
||||
val stages =
|
||||
record.stages.mapNotNull { stage ->
|
||||
if (stage.startTime.isBefore(stage.endTime)
|
||||
) {
|
||||
SleepStageData(
|
||||
startTime =
|
||||
stage.startTime
|
||||
.toIsoString(),
|
||||
endTime =
|
||||
stage.endTime
|
||||
.toIsoString(),
|
||||
stageType = stage.stage,
|
||||
stageName =
|
||||
SLEEP_STAGE_NAMES[
|
||||
stage.stage]
|
||||
?: "unknown"
|
||||
)
|
||||
} else null
|
||||
}
|
||||
|
||||
records.add(
|
||||
RawSleepSession(
|
||||
recordId = record.metadata.id,
|
||||
dataSource =
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName,
|
||||
dataSourceName =
|
||||
getSourceDisplayName(
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName
|
||||
),
|
||||
startTime = record.startTime.toIsoString(),
|
||||
endTime = record.endTime.toIsoString(),
|
||||
title = record.title,
|
||||
notes = record.notes,
|
||||
stages = stages
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Read ${records.size} raw sleep sessions")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading raw sleep", e)
|
||||
}
|
||||
|
||||
records
|
||||
}
|
||||
|
||||
// ==================== DISTANCE ====================
|
||||
|
||||
suspend fun readRawDistanceRecords(
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): List<RawDistanceRecord> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client = healthConnectClient ?: return@withContext emptyList()
|
||||
val records = mutableListOf<RawDistanceRecord>()
|
||||
|
||||
try {
|
||||
val allRecords =
|
||||
readRangeRecords<DistanceRecord>(client, startDate, endDate)
|
||||
|
||||
for (record in allRecords) {
|
||||
if (!record.startTime.isBefore(record.endTime)) continue
|
||||
|
||||
records.add(
|
||||
RawDistanceRecord(
|
||||
recordId = record.metadata.id,
|
||||
dataSource =
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName,
|
||||
dataSourceName =
|
||||
getSourceDisplayName(
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName
|
||||
),
|
||||
startTime = record.startTime.toIsoString(),
|
||||
endTime = record.endTime.toIsoString(),
|
||||
distanceMeters = record.distance.inMeters
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Read ${records.size} raw distance records")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading raw distance", e)
|
||||
}
|
||||
|
||||
records
|
||||
}
|
||||
|
||||
// ==================== CALORIES ====================
|
||||
|
||||
suspend fun readRawCaloriesRecords(
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): List<RawCaloriesRecord> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client = healthConnectClient ?: return@withContext emptyList()
|
||||
val records = mutableListOf<RawCaloriesRecord>()
|
||||
|
||||
try {
|
||||
|
||||
// Active calories
|
||||
val activeRecords =
|
||||
readRangeRecords<ActiveCaloriesBurnedRecord>(
|
||||
client,
|
||||
startDate,
|
||||
endDate
|
||||
)
|
||||
for (record in activeRecords) {
|
||||
if (!record.startTime.isBefore(record.endTime)) continue
|
||||
|
||||
records.add(
|
||||
RawCaloriesRecord(
|
||||
recordId = record.metadata.id,
|
||||
dataSource =
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName,
|
||||
dataSourceName =
|
||||
getSourceDisplayName(
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName
|
||||
),
|
||||
startTime = record.startTime.toIsoString(),
|
||||
endTime = record.endTime.toIsoString(),
|
||||
calories = record.energy.inKilocalories,
|
||||
recordType = "active"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Total calories
|
||||
val totalRecords =
|
||||
readRangeRecords<TotalCaloriesBurnedRecord>(
|
||||
client,
|
||||
startDate,
|
||||
endDate
|
||||
)
|
||||
for (record in totalRecords) {
|
||||
if (!record.startTime.isBefore(record.endTime)) continue
|
||||
|
||||
records.add(
|
||||
RawCaloriesRecord(
|
||||
recordId = record.metadata.id,
|
||||
dataSource =
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName,
|
||||
dataSourceName =
|
||||
getSourceDisplayName(
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName
|
||||
),
|
||||
startTime = record.startTime.toIsoString(),
|
||||
endTime = record.endTime.toIsoString(),
|
||||
calories = record.energy.inKilocalories,
|
||||
recordType = "total"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Read ${records.size} raw calories records")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading raw calories", e)
|
||||
}
|
||||
|
||||
records
|
||||
}
|
||||
|
||||
// ==================== OXYGEN SATURATION ====================
|
||||
|
||||
suspend fun readRawOxygenSaturationRecords(
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): List<RawOxygenSaturationRecord> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client = healthConnectClient ?: return@withContext emptyList()
|
||||
val records = mutableListOf<RawOxygenSaturationRecord>()
|
||||
|
||||
try {
|
||||
val allRecords =
|
||||
readRangeRecords<OxygenSaturationRecord>(
|
||||
client,
|
||||
startDate,
|
||||
endDate
|
||||
)
|
||||
|
||||
for (record in allRecords) {
|
||||
records.add(
|
||||
RawOxygenSaturationRecord(
|
||||
recordId = record.metadata.id,
|
||||
dataSource =
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName,
|
||||
dataSourceName =
|
||||
getSourceDisplayName(
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName
|
||||
),
|
||||
time = record.time.toIsoString(),
|
||||
percentage = record.percentage.value
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Read ${records.size} raw SpO2 records")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading raw SpO2", e)
|
||||
}
|
||||
|
||||
records
|
||||
}
|
||||
|
||||
// ==================== WEIGHT ====================
|
||||
|
||||
suspend fun readRawWeightRecords(
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): List<RawWeightRecord> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client = healthConnectClient ?: return@withContext emptyList()
|
||||
val records = mutableListOf<RawWeightRecord>()
|
||||
|
||||
try {
|
||||
val allRecords =
|
||||
readRangeRecords<WeightRecord>(client, startDate, endDate)
|
||||
|
||||
for (record in allRecords) {
|
||||
records.add(
|
||||
RawWeightRecord(
|
||||
recordId = record.metadata.id,
|
||||
dataSource =
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName,
|
||||
dataSourceName =
|
||||
getSourceDisplayName(
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName
|
||||
),
|
||||
time = record.time.toIsoString(),
|
||||
weightKg = record.weight.inKilograms
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Read ${records.size} raw weight records")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading raw weight", e)
|
||||
}
|
||||
|
||||
records
|
||||
}
|
||||
|
||||
// ==================== HEIGHT ====================
|
||||
|
||||
suspend fun readRawHeightRecords(
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): List<RawHeightRecord> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client = healthConnectClient ?: return@withContext emptyList()
|
||||
val records = mutableListOf<RawHeightRecord>()
|
||||
|
||||
try {
|
||||
val allRecords =
|
||||
readRangeRecords<HeightRecord>(client, startDate, endDate)
|
||||
|
||||
for (record in allRecords) {
|
||||
records.add(
|
||||
RawHeightRecord(
|
||||
recordId = record.metadata.id,
|
||||
dataSource =
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName,
|
||||
dataSourceName =
|
||||
getSourceDisplayName(
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName
|
||||
),
|
||||
time = record.time.toIsoString(),
|
||||
heightMeters = record.height.inMeters
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Read ${records.size} raw height records")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading raw height", e)
|
||||
}
|
||||
|
||||
records
|
||||
}
|
||||
|
||||
// ==================== BODY FAT ====================
|
||||
|
||||
suspend fun readRawBodyFatRecords(
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): List<RawBodyFatRecord> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client = healthConnectClient ?: return@withContext emptyList()
|
||||
val records = mutableListOf<RawBodyFatRecord>()
|
||||
|
||||
try {
|
||||
val allRecords =
|
||||
readRangeRecords<BodyFatRecord>(client, startDate, endDate)
|
||||
|
||||
for (record in allRecords) {
|
||||
records.add(
|
||||
RawBodyFatRecord(
|
||||
recordId = record.metadata.id,
|
||||
dataSource =
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName,
|
||||
dataSourceName =
|
||||
getSourceDisplayName(
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName
|
||||
),
|
||||
time = record.time.toIsoString(),
|
||||
percentage = record.percentage.value
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Read ${records.size} raw body fat records")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading raw body fat", e)
|
||||
}
|
||||
|
||||
records
|
||||
}
|
||||
|
||||
// ==================== EXERCISE ====================
|
||||
|
||||
suspend fun readRawExerciseSessions(
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): List<RawExerciseSession> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client = healthConnectClient ?: return@withContext emptyList()
|
||||
val records = mutableListOf<RawExerciseSession>()
|
||||
|
||||
try {
|
||||
val allRecords =
|
||||
readRangeRecords<ExerciseSessionRecord>(
|
||||
client,
|
||||
startDate,
|
||||
endDate
|
||||
)
|
||||
|
||||
for (record in allRecords) {
|
||||
if (!record.startTime.isBefore(record.endTime)) continue
|
||||
|
||||
records.add(
|
||||
RawExerciseSession(
|
||||
recordId = record.metadata.id,
|
||||
dataSource =
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName,
|
||||
dataSourceName =
|
||||
getSourceDisplayName(
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName
|
||||
),
|
||||
startTime = record.startTime.toIsoString(),
|
||||
endTime = record.endTime.toIsoString(),
|
||||
exerciseType = record.exerciseType,
|
||||
exerciseTypeName =
|
||||
mapExerciseType(
|
||||
record.exerciseType
|
||||
),
|
||||
title = record.title,
|
||||
notes = record.notes,
|
||||
distanceMeters =
|
||||
null, // Would need additional
|
||||
// segment queries
|
||||
calories = null,
|
||||
avgHeartRate = null,
|
||||
maxHeartRate = null,
|
||||
minHeartRate = null,
|
||||
steps = null,
|
||||
elevationGainedMeters = null
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Read ${records.size} raw exercise sessions")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading raw exercise", e)
|
||||
}
|
||||
|
||||
records
|
||||
}
|
||||
|
||||
// ==================== NUTRITION ====================
|
||||
|
||||
suspend fun readRawNutritionRecords(
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): List<RawNutritionRecord> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client = healthConnectClient ?: return@withContext emptyList()
|
||||
val records = mutableListOf<RawNutritionRecord>()
|
||||
|
||||
try {
|
||||
val allRecords =
|
||||
readRangeRecords<NutritionRecord>(
|
||||
client,
|
||||
startDate,
|
||||
endDate
|
||||
)
|
||||
|
||||
for (record in allRecords) {
|
||||
if (!record.startTime.isBefore(record.endTime)) continue
|
||||
|
||||
records.add(
|
||||
RawNutritionRecord(
|
||||
recordId = record.metadata.id,
|
||||
dataSource =
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName,
|
||||
dataSourceName =
|
||||
getSourceDisplayName(
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName
|
||||
),
|
||||
startTime = record.startTime.toIsoString(),
|
||||
endTime = record.endTime.toIsoString(),
|
||||
name = record.name,
|
||||
mealType = record.mealType,
|
||||
calories = record.energy?.inKilocalories,
|
||||
proteinGrams = record.protein?.inGrams,
|
||||
carbohydratesGrams =
|
||||
record.totalCarbohydrate?.inGrams,
|
||||
fatGrams = record.totalFat?.inGrams,
|
||||
fiberGrams = record.dietaryFiber?.inGrams,
|
||||
sugarGrams = record.sugar?.inGrams
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Read ${records.size} raw nutrition records")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading raw nutrition", e)
|
||||
}
|
||||
|
||||
records
|
||||
}
|
||||
|
||||
// ==================== HYDRATION ====================
|
||||
|
||||
suspend fun readRawHydrationRecords(
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): List<RawHydrationRecord> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val client = healthConnectClient ?: return@withContext emptyList()
|
||||
val records = mutableListOf<RawHydrationRecord>()
|
||||
|
||||
try {
|
||||
val allRecords =
|
||||
readRangeRecords<HydrationRecord>(
|
||||
client,
|
||||
startDate,
|
||||
endDate
|
||||
)
|
||||
|
||||
for (record in allRecords) {
|
||||
if (!record.startTime.isBefore(record.endTime)) continue
|
||||
|
||||
records.add(
|
||||
RawHydrationRecord(
|
||||
recordId = record.metadata.id,
|
||||
dataSource =
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName,
|
||||
dataSourceName =
|
||||
getSourceDisplayName(
|
||||
record.metadata
|
||||
.dataOrigin
|
||||
.packageName
|
||||
),
|
||||
startTime = record.startTime.toIsoString(),
|
||||
endTime = record.endTime.toIsoString(),
|
||||
volumeLiters = record.volume.inLiters
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Read ${records.size} raw hydration records")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading raw hydration", e)
|
||||
}
|
||||
|
||||
records
|
||||
}
|
||||
|
||||
// ==================== HELPER METHODS ====================
|
||||
|
||||
/**
|
||||
* OPTIMIZED: Reads records for the entire date range in a single query. Falls back to
|
||||
* day-by-day reading if the range query fails (e.g., corrupt records).
|
||||
*
|
||||
* Performance gain: ~99% fewer Health Connect queries (1 query instead of N days)
|
||||
*/
|
||||
private suspend inline fun <reified T : Record> readRangeRecords(
|
||||
client: HealthConnectClient,
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): List<T> {
|
||||
val typeName = T::class.simpleName ?: "Unknown"
|
||||
|
||||
return try {
|
||||
// Try reading the entire range in one query (optimal)
|
||||
val instantStart =
|
||||
startDate.atStartOfDay(ZoneId.systemDefault()).toInstant()
|
||||
val instantEnd =
|
||||
endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant()
|
||||
val timeRange = TimeRangeFilter.between(instantStart, instantEnd)
|
||||
|
||||
val records = readAllPaginated<T>(client, timeRange)
|
||||
Log.d(
|
||||
TAG,
|
||||
"[$typeName] Range read success: ${records.size} records from $startDate to $endDate"
|
||||
)
|
||||
records
|
||||
} catch (e: Exception) {
|
||||
// Fallback to day-by-day reading to isolate problematic records
|
||||
Log.w(
|
||||
TAG,
|
||||
"[$typeName] Range read failed, falling back to daily: ${e.message}"
|
||||
)
|
||||
readDailyRecords(client, startDate, endDate)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads records day-by-day to isolate failures (e.g. corrupt records) to a single day
|
||||
* instead of failing the entire range. Used as fallback when range reading fails.
|
||||
*/
|
||||
private suspend inline fun <reified T : Record> readDailyRecords(
|
||||
client: HealthConnectClient,
|
||||
startDate: LocalDate,
|
||||
endDate: LocalDate
|
||||
): List<T> {
|
||||
val allRecords = mutableListOf<T>()
|
||||
var current = startDate
|
||||
val typeName = T::class.simpleName ?: "Unknown"
|
||||
|
||||
// Ensure we don't loop forever
|
||||
val safetyLimit = 366 * 2 // Max 2 years
|
||||
var loopCount = 0
|
||||
var failedDays = 0
|
||||
|
||||
while (!current.isAfter(endDate) && loopCount < safetyLimit) {
|
||||
try {
|
||||
val instantStart =
|
||||
current.atStartOfDay(ZoneId.systemDefault()).toInstant()
|
||||
val instantEnd =
|
||||
current.plusDays(1)
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
|
||||
val timeRange = TimeRangeFilter.between(instantStart, instantEnd)
|
||||
val dayRecords = readAllPaginated<T>(client, timeRange)
|
||||
allRecords.addAll(dayRecords)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "[$typeName] Failed to read for $current: ${e.message}")
|
||||
failedDays++
|
||||
// Continue to next day
|
||||
}
|
||||
current = current.plusDays(1)
|
||||
loopCount++
|
||||
}
|
||||
|
||||
if (failedDays > 0) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"[$typeName] Daily read completed with $failedDays failed days, ${allRecords.size} records recovered"
|
||||
)
|
||||
} else {
|
||||
Log.d(TAG, "[$typeName] Daily read success: ${allRecords.size} records")
|
||||
}
|
||||
|
||||
return allRecords
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T : Record> readAllPaginated(
|
||||
client: HealthConnectClient,
|
||||
timeRange: TimeRangeFilter
|
||||
): List<T> {
|
||||
val allRecords = mutableListOf<T>()
|
||||
var pageToken: String? = null
|
||||
|
||||
do {
|
||||
val request =
|
||||
if (pageToken != null) {
|
||||
ReadRecordsRequest(
|
||||
recordType = T::class,
|
||||
timeRangeFilter = timeRange,
|
||||
pageToken = pageToken
|
||||
)
|
||||
} else {
|
||||
ReadRecordsRequest(
|
||||
recordType = T::class,
|
||||
timeRangeFilter = timeRange
|
||||
)
|
||||
}
|
||||
|
||||
val response = client.readRecords(request)
|
||||
allRecords.addAll(response.records)
|
||||
pageToken = response.pageToken
|
||||
} while (pageToken != null)
|
||||
|
||||
return allRecords
|
||||
}
|
||||
|
||||
private fun mapExerciseType(type: Int): String {
|
||||
return when (type) {
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_WALKING -> "Walking"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_RUNNING -> "Running"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_HIKING -> "Hiking"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_BIKING -> "Biking"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_SWIMMING_POOL -> "Swimming"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING -> "Strength Training"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_YOGA -> "Yoga"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_PILATES -> "Pilates"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_DANCING -> "Dancing"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_ELLIPTICAL -> "Elliptical"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_STAIR_CLIMBING -> "Stair Climbing"
|
||||
ExerciseSessionRecord.EXERCISE_TYPE_ROWING_MACHINE -> "Rowing"
|
||||
else -> "Exercise"
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,171 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
|
||||
/**
|
||||
* Tracks the count of records available in Health Connect vs synced to server. This helps users
|
||||
* identify what data is available and what has been synchronized.
|
||||
*/
|
||||
class MetricsCountTracker(context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences("metrics_count", Context.MODE_PRIVATE)
|
||||
private val gson = Gson()
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MetricsCountTracker"
|
||||
private const val KEY_AVAILABLE_COUNTS = "available_counts"
|
||||
private const val KEY_SYNCED_COUNTS = "synced_counts"
|
||||
private const val KEY_LAST_COUNT_TIME = "last_count_time"
|
||||
|
||||
// All trackable metric types with their display info
|
||||
val TRACKED_METRICS =
|
||||
listOf(
|
||||
TrackedMetricType("steps", "Steps", "👣"),
|
||||
TrackedMetricType("distance", "Distance", "📏"),
|
||||
TrackedMetricType("calories", "Calories", "🔥"),
|
||||
TrackedMetricType("heart_rate", "Heart Rate", "❤️"),
|
||||
TrackedMetricType("resting_hr", "Resting HR", "💗"),
|
||||
TrackedMetricType("sleep", "Sleep", "😴"),
|
||||
TrackedMetricType("weight", "Weight", "⚖️"),
|
||||
TrackedMetricType("height", "Height", "📐"),
|
||||
TrackedMetricType("body_fat", "Body Fat", "📊"),
|
||||
TrackedMetricType("spo2", "SpO2", "🫁"),
|
||||
TrackedMetricType("exercise", "Exercise", "🏃"),
|
||||
TrackedMetricType("nutrition", "Nutrition", "🍎"),
|
||||
TrackedMetricType("hydration", "Hydration", "💧")
|
||||
)
|
||||
}
|
||||
|
||||
/** Update the count of available records from Health Connect */
|
||||
fun updateAvailableCounts(counts: Map<String, Int>) {
|
||||
val json = gson.toJson(counts)
|
||||
prefs.edit()
|
||||
.putString(KEY_AVAILABLE_COUNTS, json)
|
||||
.putLong(KEY_LAST_COUNT_TIME, System.currentTimeMillis())
|
||||
.apply()
|
||||
Log.d(TAG, "Updated available counts: $counts")
|
||||
}
|
||||
|
||||
/** Update the count of records synced to server */
|
||||
fun updateSyncedCounts(counts: Map<String, Int>) {
|
||||
val currentCounts = getSyncedCounts().toMutableMap()
|
||||
// Add new synced counts to existing totals
|
||||
for ((key, value) in counts) {
|
||||
currentCounts[key] = value // Replace with server's reported count
|
||||
}
|
||||
val json = gson.toJson(currentCounts)
|
||||
prefs.edit().putString(KEY_SYNCED_COUNTS, json).apply()
|
||||
Log.d(TAG, "Updated synced counts: $currentCounts")
|
||||
}
|
||||
|
||||
/** Set exact synced counts (used when receiving totals from server) */
|
||||
fun setSyncedCounts(counts: Map<String, Int>) {
|
||||
val json = gson.toJson(counts)
|
||||
prefs.edit().putString(KEY_SYNCED_COUNTS, json).apply()
|
||||
Log.d(TAG, "Set synced counts: $counts")
|
||||
}
|
||||
|
||||
/** Get all metric count statuses */
|
||||
fun getAllMetricCounts(): List<MetricCountStatus> {
|
||||
val availableCounts = getAvailableCounts()
|
||||
val syncedCounts = getSyncedCounts()
|
||||
|
||||
return TRACKED_METRICS.map { metricType ->
|
||||
val available = availableCounts[metricType.id] ?: 0
|
||||
val synced = syncedCounts[metricType.id] ?: 0
|
||||
MetricCountStatus(
|
||||
metricType = metricType,
|
||||
availableCount = available,
|
||||
syncedCount = synced
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Get counts summary text for a specific metric */
|
||||
fun getMetricCountSummary(metricId: String): String {
|
||||
val availableCounts = getAvailableCounts()
|
||||
val syncedCounts = getSyncedCounts()
|
||||
val available = availableCounts[metricId] ?: 0
|
||||
val synced = syncedCounts[metricId] ?: 0
|
||||
return "$synced / $available"
|
||||
}
|
||||
|
||||
/** Get the last time counts were updated */
|
||||
fun getLastCountTime(): Long {
|
||||
return prefs.getLong(KEY_LAST_COUNT_TIME, 0)
|
||||
}
|
||||
|
||||
/** Clear all count data */
|
||||
fun clearAll() {
|
||||
prefs.edit()
|
||||
.remove(KEY_AVAILABLE_COUNTS)
|
||||
.remove(KEY_SYNCED_COUNTS)
|
||||
.remove(KEY_LAST_COUNT_TIME)
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun getAvailableCounts(): Map<String, Int> {
|
||||
val json = prefs.getString(KEY_AVAILABLE_COUNTS, null) ?: return emptyMap()
|
||||
return try {
|
||||
val type = object : TypeToken<Map<String, Int>>() {}.type
|
||||
gson.fromJson(json, type) ?: emptyMap()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error parsing available counts", e)
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSyncedCounts(): Map<String, Int> {
|
||||
val json = prefs.getString(KEY_SYNCED_COUNTS, null) ?: return emptyMap()
|
||||
return try {
|
||||
val type = object : TypeToken<Map<String, Int>>() {}.type
|
||||
gson.fromJson(json, type) ?: emptyMap()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error parsing synced counts", e)
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Represents a trackable metric type */
|
||||
data class TrackedMetricType(val id: String, val displayName: String, val icon: String)
|
||||
|
||||
/** Status of metric counts for available vs synced */
|
||||
data class MetricCountStatus(
|
||||
val metricType: TrackedMetricType,
|
||||
val availableCount: Int,
|
||||
val syncedCount: Int
|
||||
) {
|
||||
fun getSyncPercentage(): Int {
|
||||
return if (availableCount > 0) {
|
||||
((syncedCount.toDouble() / availableCount) * 100).toInt()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fun isSynced(): Boolean = availableCount > 0 && syncedCount >= availableCount
|
||||
|
||||
fun getSyncStatus(): String {
|
||||
return when {
|
||||
availableCount == 0 -> "No data"
|
||||
syncedCount >= availableCount -> "✓ Complete"
|
||||
syncedCount > 0 -> "⚠ Partial (${getSyncPercentage()}%)"
|
||||
else -> "✗ Not synced"
|
||||
}
|
||||
}
|
||||
|
||||
fun getStatusColor(): Int {
|
||||
return when {
|
||||
availableCount == 0 -> android.R.color.darker_gray
|
||||
syncedCount >= availableCount -> android.R.color.holo_green_dark
|
||||
syncedCount > 0 -> android.R.color.holo_orange_dark
|
||||
else -> android.R.color.holo_red_dark
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Tracks sync status for individual metric types
|
||||
*/
|
||||
class MetricsSyncTracker(context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences("metrics_sync", Context.MODE_PRIVATE)
|
||||
private val gson = Gson()
|
||||
|
||||
companion object {
|
||||
private const val KEY_METRICS_STATUS = "metrics_status"
|
||||
|
||||
// All available metric types
|
||||
val ALL_METRICS = listOf(
|
||||
MetricType("steps", "Steps", "👣", "Daily step count"),
|
||||
MetricType("distance", "Distance", "📏", "Distance walked/run"),
|
||||
MetricType("calories", "Calories", "🔥", "Calories burned"),
|
||||
MetricType("heart_rate", "Heart Rate", "❤️", "Average, min, max heart rate"),
|
||||
MetricType("resting_hr", "Resting HR", "💗", "Resting heart rate"),
|
||||
MetricType("sleep", "Sleep", "😴", "Sleep duration and stages"),
|
||||
MetricType("weight", "Weight", "⚖️", "Body weight"),
|
||||
MetricType("spo2", "SpO2", "🫁", "Blood oxygen saturation"),
|
||||
MetricType("exercise", "Exercise", "🏃", "Workout sessions")
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current status of all metrics
|
||||
*/
|
||||
fun getAllMetricsStatus(): List<MetricSyncStatus> {
|
||||
val savedStatus = loadSavedStatus()
|
||||
|
||||
return ALL_METRICS.map { metricType ->
|
||||
savedStatus[metricType.id] ?: MetricSyncStatus(
|
||||
metricType = metricType,
|
||||
lastSyncTime = null,
|
||||
lastValue = null,
|
||||
syncCount = 0,
|
||||
isAvailable = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update sync status for a specific metric
|
||||
*/
|
||||
fun updateMetricStatus(
|
||||
metricId: String,
|
||||
lastValue: String?,
|
||||
isAvailable: Boolean = true
|
||||
) {
|
||||
val savedStatus = loadSavedStatus().toMutableMap()
|
||||
val metricType = ALL_METRICS.find { it.id == metricId } ?: return
|
||||
|
||||
val existing = savedStatus[metricId]
|
||||
savedStatus[metricId] = MetricSyncStatus(
|
||||
metricType = metricType,
|
||||
lastSyncTime = System.currentTimeMillis(),
|
||||
lastValue = lastValue,
|
||||
syncCount = (existing?.syncCount ?: 0) + 1,
|
||||
isAvailable = isAvailable
|
||||
)
|
||||
|
||||
saveStatus(savedStatus)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk update metrics after a sync operation
|
||||
*/
|
||||
fun updateFromSyncData(
|
||||
steps: Long?,
|
||||
distance: Double?,
|
||||
calories: Double?,
|
||||
avgHeartRate: Int?,
|
||||
restingHeartRate: Int?,
|
||||
sleepMinutes: Int?,
|
||||
weight: Double?,
|
||||
spo2: Double?,
|
||||
exerciseCount: Int
|
||||
) {
|
||||
// Steps: always mark as available if we have any value (including 0)
|
||||
steps?.let { updateMetricStatus("steps", "$it steps", true) }
|
||||
|
||||
// Distance: available if value exists
|
||||
distance?.let { updateMetricStatus("distance", String.format(Locale.US, "%.1f m", it), true) }
|
||||
|
||||
// Calories: available if value exists
|
||||
calories?.let { updateMetricStatus("calories", String.format(Locale.US, "%.0f kcal", it), true) }
|
||||
|
||||
// Heart rate: available if we have a reading
|
||||
avgHeartRate?.let { updateMetricStatus("heart_rate", "$it bpm", it > 0) }
|
||||
restingHeartRate?.let { updateMetricStatus("resting_hr", "$it bpm", it > 0) }
|
||||
|
||||
// Sleep: available if we have duration
|
||||
sleepMinutes?.let {
|
||||
val hours = it / 60
|
||||
val mins = it % 60
|
||||
updateMetricStatus("sleep", "${hours}h ${mins}m", it > 0)
|
||||
}
|
||||
|
||||
// Weight: available if we have a measurement
|
||||
weight?.let { updateMetricStatus("weight", String.format(Locale.US, "%.1f kg", it), it > 0) }
|
||||
|
||||
// SpO2: available if we have a reading
|
||||
spo2?.let { updateMetricStatus("spo2", String.format(Locale.US, "%.0f%%", it), it > 0) }
|
||||
|
||||
// Exercise: available if we have sessions
|
||||
if (exerciseCount > 0) {
|
||||
updateMetricStatus("exercise", "$exerciseCount sessions", true)
|
||||
} else {
|
||||
updateMetricStatus("exercise", "0 sessions", false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark metric as unavailable (no data from Health Connect)
|
||||
*/
|
||||
fun markUnavailable(metricId: String) {
|
||||
val savedStatus = loadSavedStatus().toMutableMap()
|
||||
val metricType = ALL_METRICS.find { it.id == metricId } ?: return
|
||||
|
||||
val existing = savedStatus[metricId]
|
||||
savedStatus[metricId] = MetricSyncStatus(
|
||||
metricType = metricType,
|
||||
lastSyncTime = existing?.lastSyncTime,
|
||||
lastValue = existing?.lastValue,
|
||||
syncCount = existing?.syncCount ?: 0,
|
||||
isAvailable = false
|
||||
)
|
||||
|
||||
saveStatus(savedStatus)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all sync history
|
||||
*/
|
||||
fun clearAll() {
|
||||
prefs.edit().remove(KEY_METRICS_STATUS).apply()
|
||||
}
|
||||
|
||||
private fun loadSavedStatus(): Map<String, MetricSyncStatus> {
|
||||
val json = prefs.getString(KEY_METRICS_STATUS, null) ?: return emptyMap()
|
||||
return try {
|
||||
val type = object : TypeToken<Map<String, MetricSyncStatus>>() {}.type
|
||||
gson.fromJson(json, type)
|
||||
} catch (e: Exception) {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveStatus(status: Map<String, MetricSyncStatus>) {
|
||||
val json = gson.toJson(status)
|
||||
prefs.edit().putString(KEY_METRICS_STATUS, json).apply()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a type of health metric
|
||||
*/
|
||||
data class MetricType(
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
val icon: String,
|
||||
val description: String
|
||||
)
|
||||
|
||||
/**
|
||||
* Sync status for a specific metric
|
||||
*/
|
||||
data class MetricSyncStatus(
|
||||
val metricType: MetricType,
|
||||
val lastSyncTime: Long?,
|
||||
val lastValue: String?,
|
||||
val syncCount: Int,
|
||||
val isAvailable: Boolean
|
||||
) {
|
||||
fun getFormattedSyncTime(): String {
|
||||
return if (lastSyncTime != null) {
|
||||
val format = SimpleDateFormat("MMM dd, HH:mm", Locale.getDefault())
|
||||
format.format(Date(lastSyncTime))
|
||||
} else {
|
||||
"Never"
|
||||
}
|
||||
}
|
||||
|
||||
fun getStatusIcon(): String {
|
||||
return when {
|
||||
!isAvailable && lastSyncTime == null -> "⚪" // Never synced
|
||||
!isAvailable -> "🔴" // Was synced but no data available
|
||||
lastSyncTime != null -> "🟢" // Synced with data
|
||||
else -> "⚪"
|
||||
}
|
||||
}
|
||||
|
||||
fun getStatusText(): String {
|
||||
return when {
|
||||
!isAvailable && lastSyncTime == null -> "Not synced"
|
||||
!isAvailable -> "No data"
|
||||
lastSyncTime != null -> lastValue ?: "Synced"
|
||||
else -> "Pending"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.os.Bundle
|
||||
import android.webkit.WebView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
|
||||
/**
|
||||
* Privacy Policy Activity - Required by Health Connect
|
||||
* Shows why we need access to health data
|
||||
*/
|
||||
class PrivacyPolicyActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val webView = WebView(this)
|
||||
setContentView(webView)
|
||||
|
||||
webView.loadData(
|
||||
"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
h1 { color: #0ea5e9; }
|
||||
h2 { color: #666; margin-top: 24px; }
|
||||
ul { padding-left: 20px; }
|
||||
.highlight {
|
||||
background: #f0f9ff;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>HabitForge Companion</h1>
|
||||
<h2>Privacy Policy & Health Data Usage</h2>
|
||||
|
||||
<div class="highlight">
|
||||
<strong>Why we need access to your health data:</strong>
|
||||
<p>HabitForge uses your health data to help you track your fitness progress,
|
||||
analyze your habits, and achieve your wellness goals.</p>
|
||||
</div>
|
||||
|
||||
<h2>Data We Collect</h2>
|
||||
<ul>
|
||||
<li><strong>Activity Data:</strong> Steps, distance, calories burned</li>
|
||||
<li><strong>Heart Rate:</strong> Average, resting, and workout heart rates</li>
|
||||
<li><strong>Sleep:</strong> Duration and sleep stages</li>
|
||||
<li><strong>Body Measurements:</strong> Weight, height, BMI</li>
|
||||
<li><strong>Workouts:</strong> Exercise sessions and types</li>
|
||||
<li><strong>Vitals:</strong> SpO2, respiratory rate</li>
|
||||
</ul>
|
||||
|
||||
<h2>How We Use Your Data</h2>
|
||||
<ul>
|
||||
<li>Sync health metrics to your HabitForge account</li>
|
||||
<li>Display progress charts and statistics</li>
|
||||
<li>Track workout history and achievements</li>
|
||||
<li>Calculate readiness and recovery scores</li>
|
||||
</ul>
|
||||
|
||||
<h2>Data Security</h2>
|
||||
<ul>
|
||||
<li>Your data is encrypted during transmission (HTTPS)</li>
|
||||
<li>Data is stored securely on your configured HabitForge server</li>
|
||||
<li>You can delete your data at any time from the app</li>
|
||||
<li>We never sell or share your health data with third parties</li>
|
||||
</ul>
|
||||
|
||||
<h2>Your Rights</h2>
|
||||
<ul>
|
||||
<li>You can revoke permissions at any time</li>
|
||||
<li>You can request deletion of your data</li>
|
||||
<li>You control which data types are shared</li>
|
||||
</ul>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>For questions about your data, contact your HabitForge server administrator.</p>
|
||||
|
||||
<p style="margin-top: 40px; color: #999; font-size: 12px;">
|
||||
Last updated: January 2026
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
""".trimIndent(),
|
||||
"text/html",
|
||||
"UTF-8"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
/**
|
||||
* Raw Data Models - Data classes for individual Health Connect records. These represent raw,
|
||||
* unaggregated data from Health Connect.
|
||||
*/
|
||||
|
||||
// ==================== STEPS ====================
|
||||
|
||||
data class RawStepsRecord(
|
||||
val recordId: String,
|
||||
val dataSource: String,
|
||||
val dataSourceName: String,
|
||||
val startTime: String, // ISO8601 format
|
||||
val endTime: String,
|
||||
val count: Long
|
||||
)
|
||||
|
||||
// ==================== HEART RATE ====================
|
||||
|
||||
data class HeartRateSample(
|
||||
val time: String, // ISO8601
|
||||
val bpm: Long
|
||||
)
|
||||
|
||||
data class RawHeartRateRecord(
|
||||
val recordId: String,
|
||||
val dataSource: String,
|
||||
val dataSourceName: String,
|
||||
val startTime: String,
|
||||
val endTime: String,
|
||||
val samples: List<HeartRateSample>
|
||||
)
|
||||
|
||||
data class RawRestingHeartRateRecord(
|
||||
val recordId: String,
|
||||
val dataSource: String,
|
||||
val dataSourceName: String,
|
||||
val time: String,
|
||||
val bpm: Long
|
||||
)
|
||||
|
||||
// ==================== SLEEP ====================
|
||||
|
||||
data class SleepStageData(
|
||||
val startTime: String,
|
||||
val endTime: String,
|
||||
val stageType: Int,
|
||||
val stageName: String
|
||||
)
|
||||
|
||||
data class RawSleepSession(
|
||||
val recordId: String,
|
||||
val dataSource: String,
|
||||
val dataSourceName: String,
|
||||
val startTime: String,
|
||||
val endTime: String,
|
||||
val title: String?,
|
||||
val notes: String?,
|
||||
val stages: List<SleepStageData>
|
||||
)
|
||||
|
||||
// ==================== DISTANCE ====================
|
||||
|
||||
data class RawDistanceRecord(
|
||||
val recordId: String,
|
||||
val dataSource: String,
|
||||
val dataSourceName: String,
|
||||
val startTime: String,
|
||||
val endTime: String,
|
||||
val distanceMeters: Double
|
||||
)
|
||||
|
||||
// ==================== CALORIES ====================
|
||||
|
||||
data class RawCaloriesRecord(
|
||||
val recordId: String,
|
||||
val dataSource: String,
|
||||
val dataSourceName: String,
|
||||
val startTime: String,
|
||||
val endTime: String,
|
||||
val calories: Double,
|
||||
val recordType: String // "active" or "total"
|
||||
)
|
||||
|
||||
// ==================== OXYGEN SATURATION ====================
|
||||
|
||||
data class RawOxygenSaturationRecord(
|
||||
val recordId: String,
|
||||
val dataSource: String,
|
||||
val dataSourceName: String,
|
||||
val time: String,
|
||||
val percentage: Double
|
||||
)
|
||||
|
||||
// ==================== WEIGHT ====================
|
||||
|
||||
data class RawWeightRecord(
|
||||
val recordId: String,
|
||||
val dataSource: String,
|
||||
val dataSourceName: String,
|
||||
val time: String,
|
||||
val weightKg: Double
|
||||
)
|
||||
|
||||
// ==================== HEIGHT ====================
|
||||
|
||||
data class RawHeightRecord(
|
||||
val recordId: String,
|
||||
val dataSource: String,
|
||||
val dataSourceName: String,
|
||||
val time: String,
|
||||
val heightMeters: Double
|
||||
)
|
||||
|
||||
// ==================== BODY FAT ====================
|
||||
|
||||
data class RawBodyFatRecord(
|
||||
val recordId: String,
|
||||
val dataSource: String,
|
||||
val dataSourceName: String,
|
||||
val time: String,
|
||||
val percentage: Double
|
||||
)
|
||||
|
||||
// ==================== EXERCISE ====================
|
||||
|
||||
data class RawExerciseSession(
|
||||
val recordId: String,
|
||||
val dataSource: String,
|
||||
val dataSourceName: String,
|
||||
val startTime: String,
|
||||
val endTime: String,
|
||||
val exerciseType: Int,
|
||||
val exerciseTypeName: String,
|
||||
val title: String?,
|
||||
val notes: String?,
|
||||
val distanceMeters: Double?,
|
||||
val calories: Double?,
|
||||
val avgHeartRate: Int?,
|
||||
val maxHeartRate: Int?,
|
||||
val minHeartRate: Int?,
|
||||
val steps: Int?,
|
||||
val elevationGainedMeters: Double?
|
||||
)
|
||||
|
||||
// ==================== NUTRITION ====================
|
||||
|
||||
data class RawNutritionRecord(
|
||||
val recordId: String,
|
||||
val dataSource: String,
|
||||
val dataSourceName: String,
|
||||
val startTime: String,
|
||||
val endTime: String,
|
||||
val name: String?,
|
||||
val mealType: Int?,
|
||||
val calories: Double?,
|
||||
val proteinGrams: Double?,
|
||||
val carbohydratesGrams: Double?,
|
||||
val fatGrams: Double?,
|
||||
val fiberGrams: Double?,
|
||||
val sugarGrams: Double?
|
||||
)
|
||||
|
||||
// ==================== HYDRATION ====================
|
||||
|
||||
data class RawHydrationRecord(
|
||||
val recordId: String,
|
||||
val dataSource: String,
|
||||
val dataSourceName: String,
|
||||
val startTime: String,
|
||||
val endTime: String,
|
||||
val volumeLiters: Double
|
||||
)
|
||||
@@ -0,0 +1,842 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.work.*
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Raw Data Sync Service - Syncs individual health records to backend.
|
||||
*
|
||||
* This service reads raw (unaggregated) health data from Health Connect and sends each record
|
||||
* individually to the backend for storage.
|
||||
*
|
||||
* OPTIMIZED: Uses 30-day batches when syncing long ranges to improve performance and throughput.
|
||||
* Uses 7-day default sync period for scheduled jobs.
|
||||
*/
|
||||
class RawDataSyncService(context: Context, workerParams: WorkerParameters) :
|
||||
CoroutineWorker(context, workerParams) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "RawDataSyncService"
|
||||
private const val WORK_NAME = "health_connect_raw_sync"
|
||||
|
||||
// Default: 7 days for regular syncs (manageable volume)
|
||||
private const val SYNC_DAYS = 7
|
||||
// Full sync: 30 days (use only for initial/recovery)
|
||||
private const val FULL_SYNC_DAYS = 30
|
||||
// Max HR samples per record to limit payload size
|
||||
private const val MAX_HR_SAMPLES_PER_RECORD = 100
|
||||
|
||||
/**
|
||||
* Initialize periodic sync on app startup if user is logged in. This ensures sync
|
||||
* continues after app restart.
|
||||
*/
|
||||
fun initializeIfLoggedIn(context: Context) {
|
||||
val authPrefs =
|
||||
context.getSharedPreferences(
|
||||
"habitforge_auth",
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
val legacyPrefs =
|
||||
context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
|
||||
|
||||
val authToken =
|
||||
authPrefs.getString("access_token", null)
|
||||
?: legacyPrefs.getString("auth_token", null)
|
||||
|
||||
if (!authToken.isNullOrEmpty()) {
|
||||
val periodMinutes = legacyPrefs.getInt("sync_period_minutes", 360)
|
||||
if (periodMinutes > 0) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"[INIT] User is logged in, restoring periodic sync (every ${periodMinutes}min)"
|
||||
)
|
||||
schedulePeriodicSync(context, periodMinutes)
|
||||
} else {
|
||||
Log.i(
|
||||
TAG,
|
||||
"[INIT] User is logged in but periodic sync is disabled"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Log.i(
|
||||
TAG,
|
||||
"[INIT] No auth token found, skipping periodic sync initialization"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Schedule periodic raw data sync */
|
||||
fun schedulePeriodicSync(context: Context, periodMinutes: Int = 360) {
|
||||
// Remove strict battery constraints to ensure it runs
|
||||
val constraints =
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
val syncWork =
|
||||
PeriodicWorkRequestBuilder<RawDataSyncService>(
|
||||
periodMinutes.toLong(),
|
||||
TimeUnit.MINUTES,
|
||||
15,
|
||||
TimeUnit.MINUTES
|
||||
)
|
||||
.setConstraints(constraints)
|
||||
.setBackoffCriteria(
|
||||
BackoffPolicy.LINEAR,
|
||||
WorkRequest.MIN_BACKOFF_MILLIS,
|
||||
TimeUnit.MILLISECONDS
|
||||
)
|
||||
.addTag("periodic_raw_sync")
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniquePeriodicWork(
|
||||
WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.UPDATE,
|
||||
syncWork
|
||||
)
|
||||
|
||||
Log.i(
|
||||
TAG,
|
||||
"[SCHEDULE] Periodic sync scheduled every $periodMinutes minutes (Network required)"
|
||||
)
|
||||
|
||||
// Also log to shared prefs for debugging
|
||||
context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putLong("last_sync_scheduled", System.currentTimeMillis())
|
||||
.putInt("sync_period_minutes", periodMinutes)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** Trigger an immediate raw data sync */
|
||||
fun syncNow(context: Context, days: Int? = null) {
|
||||
val actualDays =
|
||||
days
|
||||
?: context.getSharedPreferences(
|
||||
"habitforge",
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
.getInt("sync_days", SYNC_DAYS)
|
||||
|
||||
val constraints =
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
val syncWork =
|
||||
OneTimeWorkRequestBuilder<RawDataSyncService>()
|
||||
.setConstraints(constraints)
|
||||
.setInputData(
|
||||
workDataOf(
|
||||
"days" to actualDays,
|
||||
"is_manual" to true
|
||||
)
|
||||
)
|
||||
.addTag("manual_raw_sync")
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context).enqueue(syncWork)
|
||||
Log.i(
|
||||
TAG,
|
||||
"[MANUAL] Immediate raw data sync triggered for $actualDays days"
|
||||
)
|
||||
}
|
||||
|
||||
/** Full sync for initial setup or recovery (30 days) */
|
||||
fun syncFull(context: Context) {
|
||||
syncNow(context, FULL_SYNC_DAYS)
|
||||
}
|
||||
|
||||
/** Cancel raw data sync */
|
||||
fun cancelSync(context: Context) {
|
||||
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
|
||||
Log.i(TAG, "[CANCEL] Raw data sync cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result =
|
||||
withContext(Dispatchers.IO) {
|
||||
val isManual = inputData.getBoolean("is_manual", false)
|
||||
val syncTypeLabel = if (isManual) "MANUAL" else "SCHEDULED"
|
||||
Log.i(TAG, "========================================")
|
||||
Log.i(TAG, "[$syncTypeLabel] Starting raw data sync...")
|
||||
Log.i(TAG, "========================================")
|
||||
val overallStartTime = System.currentTimeMillis()
|
||||
|
||||
try {
|
||||
// Get auth token
|
||||
val authPrefs =
|
||||
applicationContext.getSharedPreferences(
|
||||
"habitforge_auth",
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
var authToken = authPrefs.getString("access_token", null)
|
||||
|
||||
val legacyPrefs =
|
||||
applicationContext.getSharedPreferences(
|
||||
"habitforge",
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
|
||||
if (authToken == null) {
|
||||
authToken = legacyPrefs.getString("auth_token", null)
|
||||
}
|
||||
|
||||
if (authToken.isNullOrEmpty()) {
|
||||
Log.e(TAG, "No auth token configured")
|
||||
return@withContext Result.failure()
|
||||
}
|
||||
|
||||
// Initialize raw reader
|
||||
val rawReader = HealthConnectRawReader(applicationContext)
|
||||
|
||||
// Determine sync range
|
||||
val configuredDays = legacyPrefs.getInt("sync_days", SYNC_DAYS)
|
||||
val days = inputData.getInt("days", configuredDays)
|
||||
|
||||
// Ensure we have a valid range
|
||||
val finalEndDate = LocalDate.now()
|
||||
val overallStartDate = finalEndDate.minusDays(days.toLong())
|
||||
|
||||
Log.i(
|
||||
TAG,
|
||||
"Syncing raw data from $overallStartDate to $finalEndDate ($days days)"
|
||||
)
|
||||
|
||||
// Configure batching
|
||||
// Use 30-day batches to improve throughput while staying within
|
||||
// safe limits
|
||||
val BATCH_DAYS = 30
|
||||
var currentStartDate = overallStartDate
|
||||
var totalRecordsSynced = 0
|
||||
var totalBatches = 0
|
||||
var failedBatches = 0
|
||||
|
||||
// Track available counts per metric type
|
||||
val availableCounts = mutableMapOf<String, Int>()
|
||||
val syncedCounts = mutableMapOf<String, Int>()
|
||||
|
||||
val historyManager = SyncHistoryManager(applicationContext)
|
||||
val metricsCountTracker = MetricsCountTracker(applicationContext)
|
||||
|
||||
var backendUrl = authPrefs.getString("server_url", null)
|
||||
if (backendUrl == null) {
|
||||
backendUrl =
|
||||
legacyPrefs.getString(
|
||||
"server_url",
|
||||
BuildConfig.BACKEND_URL
|
||||
)
|
||||
}
|
||||
backendUrl = backendUrl ?: BuildConfig.BACKEND_URL
|
||||
val api = HabitForgeApi.create(backendUrl)
|
||||
|
||||
// Get device ID once
|
||||
val deviceId =
|
||||
Settings.Secure.getString(
|
||||
applicationContext.contentResolver,
|
||||
Settings.Secure.ANDROID_ID
|
||||
)
|
||||
|
||||
// Iterate in batches
|
||||
while (!currentStartDate.isAfter(finalEndDate)) {
|
||||
val currentEndDate =
|
||||
currentStartDate.plusDays((BATCH_DAYS - 1).toLong())
|
||||
.let {
|
||||
if (it.isAfter(finalEndDate))
|
||||
finalEndDate
|
||||
else it
|
||||
}
|
||||
|
||||
Log.i(
|
||||
TAG,
|
||||
"Processing batch: $currentStartDate to $currentEndDate"
|
||||
)
|
||||
|
||||
try {
|
||||
// OPTIMIZED: Read all records for this batch IN
|
||||
// PARALLEL
|
||||
// Performance gain: ~70% faster by reading all 13
|
||||
// metric types concurrently
|
||||
val readStartTime = System.currentTimeMillis()
|
||||
|
||||
val (
|
||||
stepsRecords,
|
||||
heartRateRecords,
|
||||
restingHRRecords,
|
||||
sleepSessions,
|
||||
distanceRecords,
|
||||
caloriesRecords,
|
||||
spo2Records,
|
||||
weightRecords,
|
||||
heightRecords,
|
||||
bodyFatRecords,
|
||||
exerciseSessions,
|
||||
nutritionRecords,
|
||||
hydrationRecords) =
|
||||
coroutineScope {
|
||||
val stepsDeferred = async {
|
||||
rawReader
|
||||
.readRawStepsRecords(
|
||||
currentStartDate,
|
||||
currentEndDate
|
||||
)
|
||||
}
|
||||
val heartRateDeferred = async {
|
||||
rawReader
|
||||
.readRawHeartRateRecords(
|
||||
currentStartDate,
|
||||
currentEndDate
|
||||
)
|
||||
}
|
||||
val restingHRDeferred = async {
|
||||
rawReader
|
||||
.readRawRestingHeartRateRecords(
|
||||
currentStartDate,
|
||||
currentEndDate
|
||||
)
|
||||
}
|
||||
val sleepDeferred = async {
|
||||
rawReader
|
||||
.readRawSleepSessions(
|
||||
currentStartDate,
|
||||
currentEndDate
|
||||
)
|
||||
}
|
||||
val distanceDeferred = async {
|
||||
rawReader
|
||||
.readRawDistanceRecords(
|
||||
currentStartDate,
|
||||
currentEndDate
|
||||
)
|
||||
}
|
||||
val caloriesDeferred = async {
|
||||
rawReader
|
||||
.readRawCaloriesRecords(
|
||||
currentStartDate,
|
||||
currentEndDate
|
||||
)
|
||||
}
|
||||
val spo2Deferred = async {
|
||||
rawReader
|
||||
.readRawOxygenSaturationRecords(
|
||||
currentStartDate,
|
||||
currentEndDate
|
||||
)
|
||||
}
|
||||
val weightDeferred = async {
|
||||
rawReader
|
||||
.readRawWeightRecords(
|
||||
currentStartDate,
|
||||
currentEndDate
|
||||
)
|
||||
}
|
||||
val heightDeferred = async {
|
||||
rawReader
|
||||
.readRawHeightRecords(
|
||||
currentStartDate,
|
||||
currentEndDate
|
||||
)
|
||||
}
|
||||
val bodyFatDeferred = async {
|
||||
rawReader
|
||||
.readRawBodyFatRecords(
|
||||
currentStartDate,
|
||||
currentEndDate
|
||||
)
|
||||
}
|
||||
val exerciseDeferred = async {
|
||||
rawReader
|
||||
.readRawExerciseSessions(
|
||||
currentStartDate,
|
||||
currentEndDate
|
||||
)
|
||||
}
|
||||
val nutritionDeferred = async {
|
||||
rawReader
|
||||
.readRawNutritionRecords(
|
||||
currentStartDate,
|
||||
currentEndDate
|
||||
)
|
||||
}
|
||||
val hydrationDeferred = async {
|
||||
rawReader
|
||||
.readRawHydrationRecords(
|
||||
currentStartDate,
|
||||
currentEndDate
|
||||
)
|
||||
}
|
||||
|
||||
// Await all results (this is where
|
||||
// parallelism happens)
|
||||
Tuple13(
|
||||
stepsDeferred.await(),
|
||||
heartRateDeferred.await(),
|
||||
restingHRDeferred.await(),
|
||||
sleepDeferred.await(),
|
||||
distanceDeferred.await(),
|
||||
caloriesDeferred.await(),
|
||||
spo2Deferred.await(),
|
||||
weightDeferred.await(),
|
||||
heightDeferred.await(),
|
||||
bodyFatDeferred.await(),
|
||||
exerciseDeferred.await(),
|
||||
nutritionDeferred.await(),
|
||||
hydrationDeferred.await()
|
||||
)
|
||||
}
|
||||
|
||||
val readDuration =
|
||||
System.currentTimeMillis() - readStartTime
|
||||
Log.d(
|
||||
TAG,
|
||||
"[$syncTypeLabel] Parallel read completed in ${readDuration}ms for batch $currentStartDate to $currentEndDate"
|
||||
)
|
||||
|
||||
// Convert to API payloads
|
||||
val stepsPayload =
|
||||
stepsRecords.map { r ->
|
||||
RawStepsPayload(
|
||||
r.recordId,
|
||||
r.dataSource,
|
||||
r.dataSourceName,
|
||||
r.startTime,
|
||||
r.endTime,
|
||||
r.count
|
||||
)
|
||||
}
|
||||
|
||||
val heartRatePayload =
|
||||
heartRateRecords.map { r ->
|
||||
RawHeartRatePayload(
|
||||
r.recordId,
|
||||
r.dataSource,
|
||||
r.dataSourceName,
|
||||
r.startTime,
|
||||
r.endTime,
|
||||
r.samples.take(
|
||||
MAX_HR_SAMPLES_PER_RECORD
|
||||
)
|
||||
.map { s ->
|
||||
HeartRateSamplePayload(
|
||||
s.time,
|
||||
s.bpm
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val restingHRPayload =
|
||||
restingHRRecords.map { r ->
|
||||
RawRestingHRPayload(
|
||||
r.recordId,
|
||||
r.dataSource,
|
||||
r.dataSourceName,
|
||||
r.time,
|
||||
r.bpm
|
||||
)
|
||||
}
|
||||
|
||||
val sleepPayload =
|
||||
sleepSessions.map { r ->
|
||||
RawSleepSessionPayload(
|
||||
r.recordId,
|
||||
r.dataSource,
|
||||
r.dataSourceName,
|
||||
r.startTime,
|
||||
r.endTime,
|
||||
r.title,
|
||||
r.notes,
|
||||
r.stages.map { s ->
|
||||
SleepStagePayload(
|
||||
s.startTime,
|
||||
s.endTime,
|
||||
s.stageType,
|
||||
s.stageName
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val distancePayload =
|
||||
distanceRecords.map { r ->
|
||||
RawDistancePayload(
|
||||
r.recordId,
|
||||
r.dataSource,
|
||||
r.dataSourceName,
|
||||
r.startTime,
|
||||
r.endTime,
|
||||
r.distanceMeters
|
||||
)
|
||||
}
|
||||
|
||||
val caloriesPayload =
|
||||
caloriesRecords.map { r: RawCaloriesRecord
|
||||
->
|
||||
RawCaloriesPayload(
|
||||
r.recordId,
|
||||
r.dataSource,
|
||||
r.dataSourceName,
|
||||
r.startTime,
|
||||
r.endTime,
|
||||
r.calories,
|
||||
r.recordType
|
||||
)
|
||||
}
|
||||
|
||||
val spo2Payload =
|
||||
spo2Records.map {
|
||||
r: RawOxygenSaturationRecord ->
|
||||
RawSpo2Payload(
|
||||
r.recordId,
|
||||
r.dataSource,
|
||||
r.dataSourceName,
|
||||
r.time,
|
||||
r.percentage
|
||||
)
|
||||
}
|
||||
|
||||
val weightPayload =
|
||||
weightRecords.map { r: RawWeightRecord ->
|
||||
RawWeightPayload(
|
||||
r.recordId,
|
||||
r.dataSource,
|
||||
r.dataSourceName,
|
||||
r.time,
|
||||
r.weightKg
|
||||
)
|
||||
}
|
||||
|
||||
val heightPayload =
|
||||
heightRecords.map { r: RawHeightRecord ->
|
||||
RawHeightPayload(
|
||||
r.recordId,
|
||||
r.dataSource,
|
||||
r.dataSourceName,
|
||||
r.time,
|
||||
r.heightMeters
|
||||
)
|
||||
}
|
||||
|
||||
val bodyFatPayload =
|
||||
bodyFatRecords.map { r: RawBodyFatRecord ->
|
||||
RawBodyFatPayload(
|
||||
r.recordId,
|
||||
r.dataSource,
|
||||
r.dataSourceName,
|
||||
r.time,
|
||||
r.percentage
|
||||
)
|
||||
}
|
||||
|
||||
val exercisePayload =
|
||||
exerciseSessions.map { r: RawExerciseSession
|
||||
->
|
||||
RawExercisePayload(
|
||||
r.recordId,
|
||||
r.dataSource,
|
||||
r.dataSourceName,
|
||||
r.startTime,
|
||||
r.endTime,
|
||||
r.exerciseType,
|
||||
r.exerciseTypeName,
|
||||
r.title,
|
||||
r.notes,
|
||||
r.distanceMeters,
|
||||
r.calories,
|
||||
r.avgHeartRate,
|
||||
r.maxHeartRate,
|
||||
r.minHeartRate,
|
||||
r.steps,
|
||||
r.elevationGainedMeters
|
||||
)
|
||||
}
|
||||
|
||||
val nutritionPayload =
|
||||
nutritionRecords.map { r: RawNutritionRecord
|
||||
->
|
||||
RawNutritionPayload(
|
||||
r.recordId,
|
||||
r.dataSource,
|
||||
r.dataSourceName,
|
||||
r.startTime,
|
||||
r.endTime,
|
||||
r.name,
|
||||
r.mealType,
|
||||
r.calories,
|
||||
r.proteinGrams,
|
||||
r.carbohydratesGrams,
|
||||
r.fatGrams,
|
||||
r.fiberGrams,
|
||||
r.sugarGrams
|
||||
)
|
||||
}
|
||||
|
||||
val hydrationPayload =
|
||||
hydrationRecords.map { r: RawHydrationRecord
|
||||
->
|
||||
RawHydrationPayload(
|
||||
r.recordId,
|
||||
r.dataSource,
|
||||
r.dataSourceName,
|
||||
r.startTime,
|
||||
r.endTime,
|
||||
r.volumeLiters
|
||||
)
|
||||
}
|
||||
|
||||
val batchRecordsCount =
|
||||
stepsPayload.size +
|
||||
heartRatePayload.size +
|
||||
restingHRPayload.size +
|
||||
sleepPayload.size +
|
||||
distancePayload.size +
|
||||
caloriesPayload.size +
|
||||
spo2Payload.size +
|
||||
weightPayload.size +
|
||||
heightPayload.size +
|
||||
bodyFatPayload.size +
|
||||
exercisePayload.size +
|
||||
nutritionPayload.size +
|
||||
hydrationPayload.size
|
||||
|
||||
// Accumulate available counts for this batch
|
||||
availableCounts["steps"] =
|
||||
(availableCounts["steps"]
|
||||
?: 0) + stepsPayload.size
|
||||
availableCounts["heart_rate"] =
|
||||
(availableCounts["heart_rate"]
|
||||
?: 0) + heartRatePayload.size
|
||||
availableCounts["resting_hr"] =
|
||||
(availableCounts["resting_hr"]
|
||||
?: 0) + restingHRPayload.size
|
||||
availableCounts["sleep"] =
|
||||
(availableCounts["sleep"]
|
||||
?: 0) + sleepPayload.size
|
||||
availableCounts["distance"] =
|
||||
(availableCounts["distance"]
|
||||
?: 0) + distancePayload.size
|
||||
availableCounts["calories"] =
|
||||
(availableCounts["calories"]
|
||||
?: 0) + caloriesPayload.size
|
||||
availableCounts["spo2"] =
|
||||
(availableCounts["spo2"]
|
||||
?: 0) + spo2Payload.size
|
||||
availableCounts["weight"] =
|
||||
(availableCounts["weight"]
|
||||
?: 0) + weightPayload.size
|
||||
availableCounts["height"] =
|
||||
(availableCounts["height"]
|
||||
?: 0) + heightPayload.size
|
||||
availableCounts["body_fat"] =
|
||||
(availableCounts["body_fat"]
|
||||
?: 0) + bodyFatPayload.size
|
||||
availableCounts["exercise"] =
|
||||
(availableCounts["exercise"]
|
||||
?: 0) + exercisePayload.size
|
||||
availableCounts["nutrition"] =
|
||||
(availableCounts["nutrition"]
|
||||
?: 0) + nutritionPayload.size
|
||||
availableCounts["hydration"] =
|
||||
(availableCounts["hydration"]
|
||||
?: 0) + hydrationPayload.size
|
||||
|
||||
if (batchRecordsCount > 0) {
|
||||
// Build request
|
||||
val request =
|
||||
RawDataSyncRequest(
|
||||
device_id = deviceId,
|
||||
sync_timestamp =
|
||||
Instant.now()
|
||||
.toString(),
|
||||
steps_records =
|
||||
stepsPayload,
|
||||
heart_rate_records =
|
||||
heartRatePayload,
|
||||
resting_hr_records =
|
||||
restingHRPayload,
|
||||
sleep_sessions =
|
||||
sleepPayload,
|
||||
distance_records =
|
||||
distancePayload,
|
||||
calories_records =
|
||||
caloriesPayload,
|
||||
spo2_records = spo2Payload,
|
||||
weight_records =
|
||||
weightPayload,
|
||||
height_records =
|
||||
heightPayload,
|
||||
body_fat_records =
|
||||
bodyFatPayload,
|
||||
exercise_sessions =
|
||||
exercisePayload,
|
||||
nutrition_records =
|
||||
nutritionPayload,
|
||||
hydration_records =
|
||||
hydrationPayload
|
||||
)
|
||||
|
||||
Log.i(
|
||||
TAG,
|
||||
"Sending batch $currentStartDate to $currentEndDate with $batchRecordsCount records..."
|
||||
)
|
||||
val response =
|
||||
api.syncRawHealthData(
|
||||
"Bearer $authToken",
|
||||
request
|
||||
)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()
|
||||
val syncedInBatch =
|
||||
body?.records_synced?.values
|
||||
?.sum()
|
||||
?: batchRecordsCount
|
||||
totalRecordsSynced += syncedInBatch
|
||||
|
||||
// Track synced counts from server
|
||||
// response
|
||||
body?.records_synced?.forEach {
|
||||
(key, count) ->
|
||||
syncedCounts[key] =
|
||||
(syncedCounts[key]
|
||||
?: 0) +
|
||||
count
|
||||
}
|
||||
|
||||
Log.i(
|
||||
TAG,
|
||||
"[$syncTypeLabel] Batch synced successfully: $syncedInBatch records"
|
||||
)
|
||||
} else {
|
||||
Log.e(
|
||||
TAG,
|
||||
"Batch sync failed: ${response.code()} ${response.message()}"
|
||||
)
|
||||
failedBatches++
|
||||
}
|
||||
} else {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Empty batch for $currentStartDate to $currentEndDate, skipping upload"
|
||||
)
|
||||
}
|
||||
|
||||
totalBatches++
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
TAG,
|
||||
"Error processing batch $currentStartDate",
|
||||
e
|
||||
)
|
||||
failedBatches++
|
||||
}
|
||||
|
||||
// Move to next batch
|
||||
currentStartDate = currentEndDate.plusDays(1)
|
||||
}
|
||||
|
||||
// Update last sync time
|
||||
legacyPrefs
|
||||
.edit()
|
||||
.putLong("last_raw_sync", System.currentTimeMillis())
|
||||
.putLong("last_sync", System.currentTimeMillis())
|
||||
.apply()
|
||||
|
||||
// Update metric counts tracker
|
||||
metricsCountTracker.updateAvailableCounts(availableCounts)
|
||||
if (syncedCounts.isNotEmpty()) {
|
||||
metricsCountTracker.updateSyncedCounts(syncedCounts)
|
||||
}
|
||||
|
||||
val totalDuration = System.currentTimeMillis() - overallStartTime
|
||||
Log.i(TAG, "========================================")
|
||||
Log.i(
|
||||
TAG,
|
||||
"[$syncTypeLabel] Sync finished. Total records: $totalRecordsSynced, Batches: $totalBatches, Failed: $failedBatches"
|
||||
)
|
||||
Log.i(TAG, "[$syncTypeLabel] Duration: ${totalDuration}ms")
|
||||
Log.i(TAG, "[$syncTypeLabel] Available counts: $availableCounts")
|
||||
Log.i(TAG, "[$syncTypeLabel] Synced counts: $syncedCounts")
|
||||
Log.i(TAG, "========================================")
|
||||
|
||||
// Record result
|
||||
val syncType =
|
||||
if (inputData.getBoolean("is_manual", false))
|
||||
SyncType.MANUAL
|
||||
else SyncType.SCHEDULED
|
||||
val status =
|
||||
if (failedBatches == totalBatches && totalBatches > 0)
|
||||
SyncStatus.FAILED
|
||||
else if (failedBatches > 0) SyncStatus.PARTIAL
|
||||
else SyncStatus.SUCCESS
|
||||
|
||||
// Only log to history if:
|
||||
// 1. It was a manual sync (user feedback required)
|
||||
// 2. OR Data was actually synced (avoid noise)
|
||||
// 3. OR The sync encountered errors (visibility required)
|
||||
if (syncType == SyncType.MANUAL ||
|
||||
totalRecordsSynced > 0 ||
|
||||
status != SyncStatus.SUCCESS
|
||||
) {
|
||||
historyManager.addSyncRecord(
|
||||
SyncRecord(
|
||||
timestamp = System.currentTimeMillis(),
|
||||
status = status,
|
||||
metricsSynced = totalRecordsSynced,
|
||||
exercisesSynced = syncedCounts["exercise"]
|
||||
?: 0,
|
||||
syncDurationMs = totalDuration,
|
||||
syncType = syncType,
|
||||
errorMessage =
|
||||
if (failedBatches > 0)
|
||||
"$failedBatches/$totalBatches batches failed"
|
||||
else null,
|
||||
details = syncedCounts
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (status == SyncStatus.FAILED) {
|
||||
return@withContext Result.retry()
|
||||
} else {
|
||||
return@withContext Result.success()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Raw sync fatal error: ${e.message}", e)
|
||||
return@withContext Result.failure()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tuple class for holding 13 values to support parallel reading of all metric types. Used for
|
||||
* destructuring assignment in coroutineScope parallel reads.
|
||||
*
|
||||
* Note: Kotlin data classes auto-generate component1() through component5() for the first 5
|
||||
* properties. We manually add component6() through component13() to support full destructuring.
|
||||
*/
|
||||
data class Tuple13<A, B, C, D, E, F, G, H, I, J, K, L, M>(
|
||||
val first: A,
|
||||
val second: B,
|
||||
val third: C,
|
||||
val fourth: D,
|
||||
val fifth: E,
|
||||
val sixth: F,
|
||||
val seventh: G,
|
||||
val eighth: H,
|
||||
val ninth: I,
|
||||
val tenth: J,
|
||||
val eleventh: K,
|
||||
val twelfth: L,
|
||||
val thirteenth: M
|
||||
)
|
||||
+710
@@ -0,0 +1,710 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.health.connect.client.HealthConnectClient
|
||||
import androidx.health.connect.client.records.*
|
||||
import androidx.health.connect.client.request.ReadRecordsRequest
|
||||
import androidx.health.connect.client.time.TimeRangeFilter
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.habitforge.companion.databinding.ActivityRawDataViewerBinding
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Raw Data Viewer Activity Displays raw Health Connect records for debugging and data inspection
|
||||
* Supports filtering by data source
|
||||
*/
|
||||
class RawDataViewerActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityRawDataViewerBinding
|
||||
private lateinit var adapter: RawDataAdapter
|
||||
private lateinit var healthConnectManager: HealthConnectManager
|
||||
private var healthConnectClient: HealthConnectClient? = null
|
||||
|
||||
// Source filtering
|
||||
private var availableSources: List<DataSourceInfo> = emptyList()
|
||||
private var selectedSourcePackage: String? = null // null = all sources
|
||||
|
||||
private val dateRangeOptions =
|
||||
listOf(
|
||||
DateRangeOption("Last 7 days", 7),
|
||||
DateRangeOption("Last 14 days", 14),
|
||||
DateRangeOption("Last 30 days", 30),
|
||||
DateRangeOption("Last 60 days", 60),
|
||||
DateRangeOption("Last 90 days", 90),
|
||||
DateRangeOption("Last 180 days", 180),
|
||||
DateRangeOption("Last 365 days", 365)
|
||||
)
|
||||
|
||||
private val dataTypes =
|
||||
listOf(
|
||||
DataTypeTab("Steps", "steps", "👣"),
|
||||
DataTypeTab("Heart Rate", "heart_rate", "❤️"),
|
||||
DataTypeTab("Distance", "distance", "📏"),
|
||||
DataTypeTab("Calories", "calories", "🔥"),
|
||||
DataTypeTab("Sleep", "sleep", "😴"),
|
||||
DataTypeTab("SpO2", "spo2", "💉"),
|
||||
DataTypeTab("Weight", "weight", "⚖️"),
|
||||
DataTypeTab("Resting HR", "resting_hr", "💗")
|
||||
)
|
||||
|
||||
private var selectedDays = 30
|
||||
private var selectedDataType = "steps"
|
||||
|
||||
companion object {
|
||||
private const val TAG = "RawDataViewer"
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityRawDataViewerBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
healthConnectManager = HealthConnectManager(this)
|
||||
|
||||
// Initialize Health Connect client
|
||||
if (HealthConnectClient.getSdkStatus(this) == HealthConnectClient.SDK_AVAILABLE) {
|
||||
healthConnectClient = HealthConnectClient.getOrCreate(this)
|
||||
}
|
||||
|
||||
setupToolbar()
|
||||
setupTabs()
|
||||
setupDateRangeSpinner()
|
||||
setupRecyclerView()
|
||||
loadAvailableSources()
|
||||
|
||||
binding.btnLoadData.setOnClickListener { loadData() }
|
||||
}
|
||||
|
||||
private fun setupToolbar() {
|
||||
binding.toolbar.setNavigationOnClickListener { finish() }
|
||||
|
||||
// Add menu for source selection
|
||||
binding.toolbar.inflateMenu(R.menu.menu_raw_data_viewer)
|
||||
binding.toolbar.setOnMenuItemClickListener { menuItem ->
|
||||
when (menuItem.itemId) {
|
||||
R.id.action_select_source -> {
|
||||
showSourceSelectionDialog()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupTabs() {
|
||||
dataTypes.forEach { type ->
|
||||
val tab = binding.tabLayout.newTab()
|
||||
tab.text = "${type.icon} ${type.name}"
|
||||
tab.tag = type.id
|
||||
binding.tabLayout.addTab(tab)
|
||||
}
|
||||
|
||||
binding.tabLayout.addOnTabSelectedListener(
|
||||
object : TabLayout.OnTabSelectedListener {
|
||||
override fun onTabSelected(tab: TabLayout.Tab?) {
|
||||
selectedDataType = tab?.tag as? String ?: "steps"
|
||||
loadData()
|
||||
}
|
||||
override fun onTabUnselected(tab: TabLayout.Tab?) {}
|
||||
override fun onTabReselected(tab: TabLayout.Tab?) {}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupDateRangeSpinner() {
|
||||
val spinnerAdapter =
|
||||
ArrayAdapter(
|
||||
this,
|
||||
android.R.layout.simple_spinner_item,
|
||||
dateRangeOptions.map { it.label }
|
||||
)
|
||||
spinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
binding.spinnerDateRange.adapter = spinnerAdapter
|
||||
binding.spinnerDateRange.setSelection(2) // Default to 30 days
|
||||
|
||||
binding.spinnerDateRange.onItemSelectedListener =
|
||||
object : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(
|
||||
parent: AdapterView<*>?,
|
||||
view: View?,
|
||||
position: Int,
|
||||
id: Long
|
||||
) {
|
||||
selectedDays = dateRangeOptions[position].days
|
||||
}
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupRecyclerView() {
|
||||
adapter = RawDataAdapter(healthConnectManager)
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(this)
|
||||
binding.recyclerView.adapter = adapter
|
||||
}
|
||||
|
||||
private fun loadAvailableSources() {
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
availableSources = healthConnectManager.getAvailableDataSources()
|
||||
Log.d(TAG, "Found ${availableSources.size} data sources")
|
||||
|
||||
// Update toolbar subtitle with current source
|
||||
updateSourceIndicator()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error loading sources", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSourceIndicator() {
|
||||
val sourceText =
|
||||
if (selectedSourcePackage != null) {
|
||||
val source = availableSources.find { it.packageName == selectedSourcePackage }
|
||||
"Source: ${source?.displayName ?: "Unknown"}"
|
||||
} else {
|
||||
"Source: All"
|
||||
}
|
||||
binding.toolbar.subtitle = sourceText
|
||||
}
|
||||
|
||||
private fun showSourceSelectionDialog() {
|
||||
if (availableSources.isEmpty()) {
|
||||
Toast.makeText(this, "Loading data sources...", Toast.LENGTH_SHORT).show()
|
||||
loadAvailableSources()
|
||||
return
|
||||
}
|
||||
|
||||
val options = mutableListOf("All Sources (may have conflicts)")
|
||||
options.addAll(availableSources.map { it.displayName })
|
||||
|
||||
val currentSelection =
|
||||
if (selectedSourcePackage == null) 0
|
||||
else availableSources.indexOfFirst { it.packageName == selectedSourcePackage } + 1
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("Select Data Source")
|
||||
.setSingleChoiceItems(options.toTypedArray(), currentSelection) { dialog, which ->
|
||||
selectedSourcePackage =
|
||||
if (which == 0) null else availableSources[which - 1].packageName
|
||||
updateSourceIndicator()
|
||||
dialog.dismiss()
|
||||
|
||||
// Reload data with new source filter
|
||||
loadData()
|
||||
}
|
||||
.setNegativeButton("Cancel", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun loadData() {
|
||||
val client =
|
||||
healthConnectClient
|
||||
?: run {
|
||||
showEmpty("Health Connect not available")
|
||||
return
|
||||
}
|
||||
|
||||
val sourceLabel =
|
||||
if (selectedSourcePackage != null) {
|
||||
availableSources.find { it.packageName == selectedSourcePackage }?.displayName
|
||||
?: "filtered source"
|
||||
} else "all sources"
|
||||
|
||||
showLoading("Loading $selectedDataType from $sourceLabel for last $selectedDays days...")
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val endDate = LocalDate.now()
|
||||
val startDate = endDate.minusDays(selectedDays.toLong())
|
||||
|
||||
Log.d(TAG, "Date range: $startDate to $endDate")
|
||||
Log.d(TAG, "Source filter: ${selectedSourcePackage ?: "All"}")
|
||||
|
||||
val startInstant = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant()
|
||||
val endInstant =
|
||||
endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant()
|
||||
|
||||
Log.d(TAG, "Instant range: $startInstant to $endInstant")
|
||||
|
||||
// Validate time range
|
||||
if (!startInstant.isBefore(endInstant)) {
|
||||
showEmpty("Invalid date range: start must be before end")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val timeRange = TimeRangeFilter.between(startInstant, endInstant)
|
||||
|
||||
val records =
|
||||
withContext(Dispatchers.IO) {
|
||||
readRawRecords(
|
||||
client,
|
||||
timeRange,
|
||||
selectedDataType,
|
||||
selectedSourcePackage
|
||||
)
|
||||
}
|
||||
|
||||
if (records.isEmpty()) {
|
||||
showEmpty("No $selectedDataType data found for this date range and source")
|
||||
} else {
|
||||
showData(records, startDate, endDate)
|
||||
}
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Log.e(TAG, "IllegalArgumentException loading data: ${e.message}", e)
|
||||
showEmpty(
|
||||
"Time range error: ${e.message}\n\nThis may be caused by corrupted Health Connect data."
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error loading data: ${e::class.simpleName} - ${e.message}", e)
|
||||
showEmpty("Error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readRawRecords(
|
||||
client: HealthConnectClient,
|
||||
timeRange: TimeRangeFilter,
|
||||
dataType: String,
|
||||
sourceFilter: String?
|
||||
): List<RawDataRecord> {
|
||||
val records = mutableListOf<RawDataRecord>()
|
||||
|
||||
when (dataType) {
|
||||
"steps" -> {
|
||||
try {
|
||||
val response = readAllPaginated<StepsRecord>(client, timeRange)
|
||||
Log.d(TAG, "Found ${response.size} StepsRecord objects (before filtering)")
|
||||
|
||||
val filtered =
|
||||
response.filter { record ->
|
||||
val validTime = record.startTime.isBefore(record.endTime)
|
||||
val matchesSource =
|
||||
sourceFilter == null ||
|
||||
record.metadata.dataOrigin.packageName ==
|
||||
sourceFilter
|
||||
if (!validTime) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Skipping invalid: startTime=${record.startTime}, endTime=${record.endTime}"
|
||||
)
|
||||
}
|
||||
validTime && matchesSource
|
||||
}
|
||||
|
||||
Log.d(TAG, "After filtering: ${filtered.size} records")
|
||||
|
||||
filtered.forEach { record ->
|
||||
records.add(
|
||||
RawDataRecord(
|
||||
timestamp = record.startTime,
|
||||
endTime = record.endTime,
|
||||
value = "${record.count} steps",
|
||||
numericValue = record.count.toDouble(),
|
||||
dataSource = record.metadata.dataOrigin.packageName,
|
||||
recordType = "StepsRecord",
|
||||
details = "ID: ${record.metadata.id}"
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading StepsRecord", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
"heart_rate" -> {
|
||||
val response = readAllPaginated<HeartRateRecord>(client, timeRange)
|
||||
Log.d(TAG, "Found ${response.size} HeartRateRecord objects")
|
||||
|
||||
val filtered =
|
||||
response.filter { record ->
|
||||
val validTime = record.startTime.isBefore(record.endTime)
|
||||
val matchesSource =
|
||||
sourceFilter == null ||
|
||||
record.metadata.dataOrigin.packageName == sourceFilter
|
||||
validTime && matchesSource
|
||||
}
|
||||
|
||||
filtered.forEach { record ->
|
||||
val avgBpm =
|
||||
if (record.samples.isNotEmpty()) {
|
||||
record.samples.map { it.beatsPerMinute }.average().toInt()
|
||||
} else 0
|
||||
val sampleCount = record.samples.size
|
||||
|
||||
records.add(
|
||||
RawDataRecord(
|
||||
timestamp = record.startTime,
|
||||
endTime = record.endTime,
|
||||
value = "$avgBpm bpm avg ($sampleCount samples)",
|
||||
numericValue = avgBpm.toDouble(),
|
||||
dataSource = record.metadata.dataOrigin.packageName,
|
||||
recordType = "HeartRateRecord",
|
||||
details =
|
||||
"Samples: ${record.samples.take(5).map { it.beatsPerMinute }.joinToString(", ")}${if (sampleCount > 5) "..." else ""}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
"distance" -> {
|
||||
val response = readAllPaginated<DistanceRecord>(client, timeRange)
|
||||
Log.d(TAG, "Found ${response.size} DistanceRecord objects")
|
||||
|
||||
val filtered =
|
||||
response.filter { record ->
|
||||
val validTime = record.startTime.isBefore(record.endTime)
|
||||
val matchesSource =
|
||||
sourceFilter == null ||
|
||||
record.metadata.dataOrigin.packageName == sourceFilter
|
||||
validTime && matchesSource
|
||||
}
|
||||
|
||||
filtered.forEach { record ->
|
||||
records.add(
|
||||
RawDataRecord(
|
||||
timestamp = record.startTime,
|
||||
endTime = record.endTime,
|
||||
value = "%.2f m".format(record.distance.inMeters),
|
||||
numericValue = record.distance.inMeters,
|
||||
dataSource = record.metadata.dataOrigin.packageName,
|
||||
recordType = "DistanceRecord",
|
||||
details = "ID: ${record.metadata.id}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
"calories" -> {
|
||||
val response = readAllPaginated<TotalCaloriesBurnedRecord>(client, timeRange)
|
||||
Log.d(TAG, "Found ${response.size} TotalCaloriesBurnedRecord objects")
|
||||
|
||||
val filtered =
|
||||
response.filter { record ->
|
||||
val validTime = record.startTime.isBefore(record.endTime)
|
||||
val matchesSource =
|
||||
sourceFilter == null ||
|
||||
record.metadata.dataOrigin.packageName == sourceFilter
|
||||
validTime && matchesSource
|
||||
}
|
||||
|
||||
filtered.forEach { record ->
|
||||
records.add(
|
||||
RawDataRecord(
|
||||
timestamp = record.startTime,
|
||||
endTime = record.endTime,
|
||||
value = "%.1f kcal".format(record.energy.inKilocalories),
|
||||
numericValue = record.energy.inKilocalories,
|
||||
dataSource = record.metadata.dataOrigin.packageName,
|
||||
recordType = "TotalCaloriesBurnedRecord",
|
||||
details = "ID: ${record.metadata.id}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
"sleep" -> {
|
||||
val response = readAllPaginated<SleepSessionRecord>(client, timeRange)
|
||||
Log.d(TAG, "Found ${response.size} SleepSessionRecord objects")
|
||||
|
||||
val filtered =
|
||||
response.filter { record ->
|
||||
val validTime = record.startTime.isBefore(record.endTime)
|
||||
val matchesSource =
|
||||
sourceFilter == null ||
|
||||
record.metadata.dataOrigin.packageName == sourceFilter
|
||||
validTime && matchesSource
|
||||
}
|
||||
|
||||
filtered.forEach { record ->
|
||||
val durationMinutes =
|
||||
java.time.temporal.ChronoUnit.MINUTES.between(
|
||||
record.startTime,
|
||||
record.endTime
|
||||
)
|
||||
val hours = durationMinutes / 60
|
||||
val mins = durationMinutes % 60
|
||||
|
||||
records.add(
|
||||
RawDataRecord(
|
||||
timestamp = record.startTime,
|
||||
endTime = record.endTime,
|
||||
value = "${hours}h ${mins}m",
|
||||
numericValue = durationMinutes.toDouble(),
|
||||
dataSource = record.metadata.dataOrigin.packageName,
|
||||
recordType = "SleepSessionRecord",
|
||||
details = "Stages: ${record.stages.size}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
"spo2" -> {
|
||||
val response = readAllPaginated<OxygenSaturationRecord>(client, timeRange)
|
||||
Log.d(TAG, "Found ${response.size} OxygenSaturationRecord objects")
|
||||
|
||||
val filtered =
|
||||
response.filter { record ->
|
||||
sourceFilter == null ||
|
||||
record.metadata.dataOrigin.packageName == sourceFilter
|
||||
}
|
||||
|
||||
filtered.forEach { record ->
|
||||
records.add(
|
||||
RawDataRecord(
|
||||
timestamp = record.time,
|
||||
endTime = null,
|
||||
value = "%.1f%%".format(record.percentage.value),
|
||||
numericValue = record.percentage.value,
|
||||
dataSource = record.metadata.dataOrigin.packageName,
|
||||
recordType = "OxygenSaturationRecord",
|
||||
details = "ID: ${record.metadata.id}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
"weight" -> {
|
||||
val response = readAllPaginated<WeightRecord>(client, timeRange)
|
||||
Log.d(TAG, "Found ${response.size} WeightRecord objects")
|
||||
|
||||
val filtered =
|
||||
response.filter { record ->
|
||||
sourceFilter == null ||
|
||||
record.metadata.dataOrigin.packageName == sourceFilter
|
||||
}
|
||||
|
||||
filtered.forEach { record ->
|
||||
records.add(
|
||||
RawDataRecord(
|
||||
timestamp = record.time,
|
||||
endTime = null,
|
||||
value = "%.2f kg".format(record.weight.inKilograms),
|
||||
numericValue = record.weight.inKilograms,
|
||||
dataSource = record.metadata.dataOrigin.packageName,
|
||||
recordType = "WeightRecord",
|
||||
details = "ID: ${record.metadata.id}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
"resting_hr" -> {
|
||||
val response = readAllPaginated<RestingHeartRateRecord>(client, timeRange)
|
||||
Log.d(TAG, "Found ${response.size} RestingHeartRateRecord objects")
|
||||
|
||||
val filtered =
|
||||
response.filter { record ->
|
||||
sourceFilter == null ||
|
||||
record.metadata.dataOrigin.packageName == sourceFilter
|
||||
}
|
||||
|
||||
filtered.forEach { record ->
|
||||
records.add(
|
||||
RawDataRecord(
|
||||
timestamp = record.time,
|
||||
endTime = null,
|
||||
value = "${record.beatsPerMinute} bpm",
|
||||
numericValue = record.beatsPerMinute.toDouble(),
|
||||
dataSource = record.metadata.dataOrigin.packageName,
|
||||
recordType = "RestingHeartRateRecord",
|
||||
details = "ID: ${record.metadata.id}"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp descending (most recent first)
|
||||
return records.sortedByDescending { it.timestamp }
|
||||
}
|
||||
|
||||
private suspend inline fun <reified T : Record> readAllPaginated(
|
||||
client: HealthConnectClient,
|
||||
timeRange: TimeRangeFilter
|
||||
): List<T> {
|
||||
val allRecords = mutableListOf<T>()
|
||||
var pageToken: String? = null
|
||||
var pageCount = 0
|
||||
|
||||
try {
|
||||
do {
|
||||
pageCount++
|
||||
val request =
|
||||
if (pageToken != null) {
|
||||
ReadRecordsRequest(
|
||||
recordType = T::class,
|
||||
timeRangeFilter = timeRange,
|
||||
pageToken = pageToken
|
||||
)
|
||||
} else {
|
||||
ReadRecordsRequest(recordType = T::class, timeRangeFilter = timeRange)
|
||||
}
|
||||
|
||||
val response = client.readRecords(request)
|
||||
allRecords.addAll(response.records)
|
||||
pageToken = response.pageToken
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"Page $pageCount: ${response.records.size} ${T::class.simpleName} records, total: ${allRecords.size}, hasMore: ${pageToken != null}"
|
||||
)
|
||||
} while (pageToken != null)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// This can happen if Health Connect returns invalid time ranges
|
||||
Log.e(TAG, "IllegalArgumentException reading ${T::class.simpleName}: ${e.message}", e)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error reading ${T::class.simpleName}: ${e.message}", e)
|
||||
throw e
|
||||
}
|
||||
|
||||
return allRecords
|
||||
}
|
||||
|
||||
private fun showLoading(message: String) {
|
||||
binding.layoutLoading.visibility = View.VISIBLE
|
||||
binding.tvLoadingMessage.text = message
|
||||
binding.recyclerView.visibility = View.GONE
|
||||
binding.layoutEmpty.visibility = View.GONE
|
||||
binding.layoutStats.visibility = View.GONE
|
||||
}
|
||||
|
||||
private fun showEmpty(message: String) {
|
||||
binding.layoutLoading.visibility = View.GONE
|
||||
binding.recyclerView.visibility = View.GONE
|
||||
binding.layoutEmpty.visibility = View.VISIBLE
|
||||
binding.tvEmptyMessage.text = message
|
||||
binding.layoutStats.visibility = View.GONE
|
||||
}
|
||||
|
||||
private fun showData(records: List<RawDataRecord>, startDate: LocalDate, endDate: LocalDate) {
|
||||
binding.layoutLoading.visibility = View.GONE
|
||||
binding.layoutEmpty.visibility = View.GONE
|
||||
binding.recyclerView.visibility = View.VISIBLE
|
||||
binding.layoutStats.visibility = View.VISIBLE
|
||||
|
||||
binding.tvTotalRecords.text = "Records: ${records.size}"
|
||||
binding.tvDateRange.text = "$startDate → $endDate"
|
||||
|
||||
adapter.submitList(records)
|
||||
}
|
||||
}
|
||||
|
||||
// Data classes
|
||||
data class DateRangeOption(val label: String, val days: Int)
|
||||
|
||||
data class DataTypeTab(val name: String, val id: String, val icon: String)
|
||||
|
||||
data class RawDataRecord(
|
||||
val timestamp: Instant,
|
||||
val endTime: Instant?,
|
||||
val value: String,
|
||||
val numericValue: Double,
|
||||
val dataSource: String,
|
||||
val recordType: String,
|
||||
val details: String,
|
||||
val isSynced: Boolean = false,
|
||||
val syncTimestamp: Instant? = null
|
||||
)
|
||||
|
||||
// RecyclerView Adapter
|
||||
class RawDataAdapter(private val healthConnectManager: HealthConnectManager) :
|
||||
RecyclerView.Adapter<RawDataAdapter.ViewHolder>() {
|
||||
|
||||
private var items: List<RawDataRecord> = emptyList()
|
||||
|
||||
private val dateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("MMM dd, yyyy HH:mm:ss").withZone(ZoneId.systemDefault())
|
||||
|
||||
private val timeFormatter =
|
||||
DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneId.systemDefault())
|
||||
|
||||
fun submitList(newItems: List<RawDataRecord>) {
|
||||
items = newItems
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val view =
|
||||
LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_raw_data_record, parent, false)
|
||||
return ViewHolder(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
holder.bind(items[position])
|
||||
}
|
||||
|
||||
override fun getItemCount() = items.size
|
||||
|
||||
inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
private val tvDateTime: TextView = itemView.findViewById(R.id.tvRecordDateTime)
|
||||
private val tvValue: TextView = itemView.findViewById(R.id.tvRecordValue)
|
||||
private val tvDataSource: TextView = itemView.findViewById(R.id.tvDataSource)
|
||||
private val tvDetails: TextView = itemView.findViewById(R.id.tvRecordDetails)
|
||||
private val tvDuration: TextView = itemView.findViewById(R.id.tvRecordDuration)
|
||||
private val tvSyncInfo: TextView = itemView.findViewById(R.id.tvSyncInfo)
|
||||
private val viewSyncStatus: View = itemView.findViewById(R.id.viewSyncStatus)
|
||||
|
||||
fun bind(record: RawDataRecord) {
|
||||
// Date/Time
|
||||
tvDateTime.text = dateTimeFormatter.format(record.timestamp)
|
||||
|
||||
// Value
|
||||
tvValue.text = record.value
|
||||
|
||||
// Data Source (use friendly name)
|
||||
tvDataSource.text = healthConnectManager.getSourceDisplayName(record.dataSource)
|
||||
|
||||
// Duration (if applicable)
|
||||
if (record.endTime != null) {
|
||||
val durationSeconds =
|
||||
java.time.Duration.between(record.timestamp, record.endTime).seconds
|
||||
val hours = durationSeconds / 3600
|
||||
val minutes = (durationSeconds % 3600) / 60
|
||||
val secs = durationSeconds % 60
|
||||
|
||||
tvDuration.visibility = View.VISIBLE
|
||||
tvDuration.text =
|
||||
when {
|
||||
hours > 0 -> "Duration: ${hours}h ${minutes}m"
|
||||
minutes > 0 -> "Duration: ${minutes}m ${secs}s"
|
||||
else -> "Duration: ${secs}s"
|
||||
}
|
||||
|
||||
// Show time range in details
|
||||
tvDetails.visibility = View.VISIBLE
|
||||
tvDetails.text =
|
||||
"Start: ${timeFormatter.format(record.timestamp)} → End: ${timeFormatter.format(record.endTime)}"
|
||||
} else {
|
||||
tvDuration.visibility = View.GONE
|
||||
tvDetails.visibility = if (record.details.isNotEmpty()) View.VISIBLE else View.GONE
|
||||
tvDetails.text = record.details
|
||||
}
|
||||
|
||||
// Sync status
|
||||
if (record.isSynced && record.syncTimestamp != null) {
|
||||
viewSyncStatus.setBackgroundResource(R.drawable.status_dot_green)
|
||||
tvSyncInfo.visibility = View.VISIBLE
|
||||
tvSyncInfo.text = "✓ Synced: ${dateTimeFormatter.format(record.syncTimestamp)}"
|
||||
tvSyncInfo.setTextColor(Color.parseColor("#4CAF50"))
|
||||
} else {
|
||||
viewSyncStatus.setBackgroundResource(R.drawable.status_dot_gray)
|
||||
tvSyncInfo.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.URL
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** Manages server connection tests and availability checks */
|
||||
class ServerConnectionManager(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ServerConnectionManager"
|
||||
private const val CONNECTION_TIMEOUT_MS = 5000
|
||||
private const val READ_TIMEOUT_MS = 5000
|
||||
}
|
||||
|
||||
/** Test connection to the server Returns a detailed result with status and response time */
|
||||
suspend fun testConnection(): ConnectionTestResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
// Try reading from AuthManager's prefs first (source of truth)
|
||||
val authPrefs =
|
||||
context.getSharedPreferences("habitforge_auth", Context.MODE_PRIVATE)
|
||||
var serverUrl = authPrefs.getString("server_url", null)
|
||||
var authToken = authPrefs.getString("access_token", null)
|
||||
|
||||
// Fallback to legacy prefs if not found
|
||||
if (serverUrl == null) {
|
||||
val legacyPrefs =
|
||||
context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
|
||||
serverUrl = legacyPrefs.getString("server_url", BuildConfig.BACKEND_URL)
|
||||
authToken = authToken ?: legacyPrefs.getString("auth_token", null)
|
||||
}
|
||||
|
||||
serverUrl = serverUrl ?: BuildConfig.BACKEND_URL
|
||||
|
||||
Log.d(TAG, "Testing connection to: $serverUrl")
|
||||
|
||||
val startTime = System.currentTimeMillis()
|
||||
|
||||
try {
|
||||
// First test basic connectivity
|
||||
val baseUrl = if (serverUrl.endsWith("/")) serverUrl.dropLast(1) else serverUrl
|
||||
val healthUrl = URL("$baseUrl/api/health")
|
||||
val connection = healthUrl.openConnection() as HttpURLConnection
|
||||
connection.requestMethod = "GET"
|
||||
connection.connectTimeout = CONNECTION_TIMEOUT_MS
|
||||
connection.readTimeout = READ_TIMEOUT_MS
|
||||
|
||||
val responseCode = connection.responseCode
|
||||
val responseTime = System.currentTimeMillis() - startTime
|
||||
connection.disconnect()
|
||||
|
||||
when (responseCode) {
|
||||
200 -> {
|
||||
// Now test authentication if token is provided
|
||||
if (!authToken.isNullOrEmpty()) {
|
||||
val authResult = testAuthentication(serverUrl, authToken)
|
||||
if (authResult.authenticated) {
|
||||
ConnectionTestResult(
|
||||
status = ServerStatus.ONLINE,
|
||||
responseTimeMs = responseTime,
|
||||
message = "Server connected & authenticated",
|
||||
authenticated = true
|
||||
)
|
||||
} else {
|
||||
ConnectionTestResult(
|
||||
status = ServerStatus.AUTH_ERROR,
|
||||
responseTimeMs = responseTime,
|
||||
message =
|
||||
"Server reachable but authentication failed: ${authResult.message}",
|
||||
authenticated = false
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ConnectionTestResult(
|
||||
status = ServerStatus.ONLINE,
|
||||
responseTimeMs = responseTime,
|
||||
message = "Server reachable (no auth token configured)",
|
||||
authenticated = false
|
||||
)
|
||||
}
|
||||
}
|
||||
401, 403 ->
|
||||
ConnectionTestResult(
|
||||
status = ServerStatus.AUTH_ERROR,
|
||||
responseTimeMs = responseTime,
|
||||
message = "Authentication required (HTTP $responseCode)"
|
||||
)
|
||||
in 500..599 ->
|
||||
ConnectionTestResult(
|
||||
status = ServerStatus.SERVER_ERROR,
|
||||
responseTimeMs = responseTime,
|
||||
message = "Server error (HTTP $responseCode)"
|
||||
)
|
||||
else ->
|
||||
ConnectionTestResult(
|
||||
status = ServerStatus.UNKNOWN_ERROR,
|
||||
responseTimeMs = responseTime,
|
||||
message = "Unexpected response (HTTP $responseCode)"
|
||||
)
|
||||
}
|
||||
} catch (e: SocketTimeoutException) {
|
||||
Log.e(TAG, "Connection timeout", e)
|
||||
ConnectionTestResult(
|
||||
status = ServerStatus.TIMEOUT,
|
||||
responseTimeMs = CONNECTION_TIMEOUT_MS.toLong(),
|
||||
message = "Connection timeout after ${CONNECTION_TIMEOUT_MS}ms"
|
||||
)
|
||||
} catch (e: java.net.UnknownHostException) {
|
||||
Log.e(TAG, "Unknown host", e)
|
||||
ConnectionTestResult(
|
||||
status = ServerStatus.UNREACHABLE,
|
||||
responseTimeMs = 0,
|
||||
message = "Server not found (check URL)"
|
||||
)
|
||||
} catch (e: java.net.ConnectException) {
|
||||
Log.e(TAG, "Connection refused", e)
|
||||
ConnectionTestResult(
|
||||
status = ServerStatus.UNREACHABLE,
|
||||
responseTimeMs = 0,
|
||||
message = "Connection refused (server may be down)"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Connection test failed", e)
|
||||
ConnectionTestResult(
|
||||
status = ServerStatus.UNKNOWN_ERROR,
|
||||
responseTimeMs = 0,
|
||||
message = "Error: ${e.localizedMessage}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if server is currently reachable (quick check) */
|
||||
suspend fun isServerReachable(): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
// Try reading from AuthManager's prefs first
|
||||
val authPrefs =
|
||||
context.getSharedPreferences("habitforge_auth", Context.MODE_PRIVATE)
|
||||
var serverUrl = authPrefs.getString("server_url", null)
|
||||
|
||||
// Fallback to legacy prefs
|
||||
if (serverUrl == null) {
|
||||
val legacyPrefs =
|
||||
context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
|
||||
serverUrl = legacyPrefs.getString("server_url", BuildConfig.BACKEND_URL)
|
||||
}
|
||||
|
||||
serverUrl = serverUrl ?: BuildConfig.BACKEND_URL
|
||||
|
||||
try {
|
||||
val baseUrl = if (serverUrl.endsWith("/")) serverUrl.dropLast(1) else serverUrl
|
||||
val url = URL("$baseUrl/api/health")
|
||||
val connection = url.openConnection() as HttpURLConnection
|
||||
connection.requestMethod = "HEAD"
|
||||
connection.connectTimeout = 3000
|
||||
connection.readTimeout = 3000
|
||||
|
||||
val reachable = connection.responseCode == 200
|
||||
connection.disconnect()
|
||||
reachable
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** Test authentication with the server */
|
||||
private suspend fun testAuthentication(serverUrl: String, token: String): AuthTestResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val baseUrl = if (serverUrl.endsWith("/")) serverUrl else "$serverUrl/"
|
||||
val api = HabitForgeApi.create(baseUrl)
|
||||
val response = api.getCurrentUser("Bearer $token")
|
||||
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val user = response.body()!!
|
||||
AuthTestResult(
|
||||
authenticated = true,
|
||||
message = "Logged in as ${user.username}"
|
||||
)
|
||||
} else {
|
||||
AuthTestResult(authenticated = false, message = "HTTP ${response.code()}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AuthTestResult(
|
||||
authenticated = false,
|
||||
message = e.localizedMessage ?: "Unknown error"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Server status enumeration */
|
||||
enum class ServerStatus {
|
||||
ONLINE,
|
||||
OFFLINE,
|
||||
UNREACHABLE,
|
||||
TIMEOUT,
|
||||
AUTH_ERROR,
|
||||
SERVER_ERROR,
|
||||
UNKNOWN_ERROR
|
||||
}
|
||||
|
||||
/** Result of a connection test */
|
||||
data class ConnectionTestResult(
|
||||
val status: ServerStatus,
|
||||
val responseTimeMs: Long = 0,
|
||||
val message: String = "",
|
||||
val authenticated: Boolean = false
|
||||
) {
|
||||
fun getStatusIcon(): String {
|
||||
return when (status) {
|
||||
ServerStatus.ONLINE -> if (authenticated) "✓" else "⚠"
|
||||
ServerStatus.OFFLINE -> "○"
|
||||
ServerStatus.UNREACHABLE -> "✗"
|
||||
ServerStatus.TIMEOUT -> "⏱"
|
||||
ServerStatus.AUTH_ERROR -> "🔒"
|
||||
ServerStatus.SERVER_ERROR -> "⚠"
|
||||
ServerStatus.UNKNOWN_ERROR -> "?"
|
||||
}
|
||||
}
|
||||
|
||||
fun getStatusColor(): Int {
|
||||
return when (status) {
|
||||
ServerStatus.ONLINE ->
|
||||
if (authenticated) android.R.color.holo_green_dark
|
||||
else android.R.color.holo_orange_dark
|
||||
ServerStatus.OFFLINE -> android.R.color.darker_gray
|
||||
ServerStatus.UNREACHABLE -> android.R.color.holo_red_dark
|
||||
ServerStatus.TIMEOUT -> android.R.color.holo_orange_dark
|
||||
ServerStatus.AUTH_ERROR -> android.R.color.holo_orange_dark
|
||||
ServerStatus.SERVER_ERROR -> android.R.color.holo_red_dark
|
||||
ServerStatus.UNKNOWN_ERROR -> android.R.color.holo_orange_dark
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Result of authentication test */
|
||||
data class AuthTestResult(val authenticated: Boolean, val message: String)
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.widget.Button
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.google.android.material.appbar.MaterialToolbar
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
|
||||
class SyncConfigActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var etSyncDays: TextInputEditText
|
||||
private lateinit var tvCurrentConfig: TextView
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_sync_config)
|
||||
|
||||
val toolbar = findViewById<MaterialToolbar>(R.id.toolbar)
|
||||
toolbar.setNavigationOnClickListener { finish() }
|
||||
|
||||
etSyncDays = findViewById(R.id.etSyncDays)
|
||||
tvCurrentConfig = findViewById(R.id.tvCurrentConfig)
|
||||
val btnSave = findViewById<Button>(R.id.btnSave)
|
||||
|
||||
loadConfig()
|
||||
|
||||
btnSave.setOnClickListener { saveConfig() }
|
||||
}
|
||||
|
||||
private fun loadConfig() {
|
||||
val prefs = getSharedPreferences("habitforge", Context.MODE_PRIVATE)
|
||||
val syncDays = prefs.getInt("sync_days", 7) // Default to 7
|
||||
|
||||
etSyncDays.setText(syncDays.toString())
|
||||
tvCurrentConfig.text = "Currently syncing: $syncDays days"
|
||||
}
|
||||
|
||||
private fun saveConfig() {
|
||||
val input = etSyncDays.text.toString()
|
||||
val days = input.toIntOrNull()
|
||||
|
||||
if (days == null || days <= 0 || days > 365) {
|
||||
Toast.makeText(this, "Please enter a valid number of days (1-365)", Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
return
|
||||
}
|
||||
|
||||
val prefs = getSharedPreferences("habitforge", Context.MODE_PRIVATE)
|
||||
prefs.edit().putInt("sync_days", days).apply()
|
||||
|
||||
tvCurrentConfig.text = "Currently syncing: $days days"
|
||||
Toast.makeText(this, "Configuration saved", Toast.LENGTH_SHORT).show()
|
||||
|
||||
// Optionally trigger a new sync schedule if needed, but the service reads prefs on run.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
/** Manages sync history for display in the UI */
|
||||
class SyncHistoryManager(context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences("sync_history", Context.MODE_PRIVATE)
|
||||
private val gson = Gson()
|
||||
|
||||
companion object {
|
||||
private const val KEY_HISTORY = "sync_history_list"
|
||||
private const val MAX_HISTORY_ITEMS = 50
|
||||
}
|
||||
|
||||
/** Add a new sync record to history */
|
||||
fun addSyncRecord(record: SyncRecord) {
|
||||
val history = getHistory().toMutableList()
|
||||
history.add(0, record) // Add to beginning
|
||||
|
||||
// Keep only last MAX_HISTORY_ITEMS
|
||||
while (history.size > MAX_HISTORY_ITEMS) {
|
||||
history.removeAt(history.size - 1)
|
||||
}
|
||||
|
||||
saveHistory(history)
|
||||
}
|
||||
|
||||
/** Get all sync history records */
|
||||
fun getHistory(): List<SyncRecord> {
|
||||
val json = prefs.getString(KEY_HISTORY, null) ?: return emptyList()
|
||||
return try {
|
||||
val type = object : TypeToken<List<SyncRecord>>() {}.type
|
||||
gson.fromJson(json, type) ?: emptyList()
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/** Get recent sync records (last N items) */
|
||||
fun getRecentHistory(count: Int = 10): List<SyncRecord> {
|
||||
return getHistory().take(count)
|
||||
}
|
||||
|
||||
/** Clear all sync history */
|
||||
fun clearHistory() {
|
||||
prefs.edit().remove(KEY_HISTORY).apply()
|
||||
}
|
||||
|
||||
/** Get sync statistics */
|
||||
fun getStats(): SyncStats {
|
||||
val history = getHistory()
|
||||
val successful = history.count { it.status == SyncStatus.SUCCESS }
|
||||
val failed = history.count { it.status == SyncStatus.FAILED }
|
||||
val totalMetrics =
|
||||
history.filter { it.status == SyncStatus.SUCCESS }.sumOf { it.metricsSynced }
|
||||
val totalExercises =
|
||||
history.filter { it.status == SyncStatus.SUCCESS }.sumOf { it.exercisesSynced }
|
||||
|
||||
return SyncStats(
|
||||
totalSyncs = history.size,
|
||||
successfulSyncs = successful,
|
||||
failedSyncs = failed,
|
||||
totalMetricsSynced = totalMetrics,
|
||||
totalExercisesSynced = totalExercises,
|
||||
lastSuccessfulSync =
|
||||
history.firstOrNull { it.status == SyncStatus.SUCCESS }?.timestamp
|
||||
)
|
||||
}
|
||||
|
||||
private fun saveHistory(history: List<SyncRecord>) {
|
||||
val json = gson.toJson(history)
|
||||
prefs.edit().putString(KEY_HISTORY, json).apply()
|
||||
}
|
||||
}
|
||||
|
||||
/** Represents a single sync operation record */
|
||||
data class SyncRecord(
|
||||
val timestamp: Long,
|
||||
val status: SyncStatus,
|
||||
val metricsSynced: Int = 0,
|
||||
val exercisesSynced: Int = 0,
|
||||
val errorMessage: String? = null,
|
||||
val syncDurationMs: Long = 0,
|
||||
val syncType: SyncType? = SyncType.MANUAL,
|
||||
val details: Map<String, Int> = emptyMap()
|
||||
) {
|
||||
fun getSyncTypeOrDefault(): SyncType {
|
||||
return syncType ?: SyncType.MANUAL
|
||||
}
|
||||
|
||||
fun getFormattedTimestamp(): String {
|
||||
val dateFormat = SimpleDateFormat("MMM dd, HH:mm:ss", Locale.getDefault())
|
||||
return dateFormat.format(Date(timestamp))
|
||||
}
|
||||
|
||||
fun getStatusIcon(): String {
|
||||
return when (status) {
|
||||
SyncStatus.SUCCESS -> "✓"
|
||||
SyncStatus.FAILED -> "✗"
|
||||
SyncStatus.PARTIAL -> "⚠"
|
||||
SyncStatus.IN_PROGRESS -> "⟳"
|
||||
}
|
||||
}
|
||||
|
||||
fun getStatusColor(): Int {
|
||||
return when (status) {
|
||||
SyncStatus.SUCCESS -> android.R.color.holo_green_dark
|
||||
SyncStatus.FAILED -> android.R.color.holo_red_dark
|
||||
SyncStatus.PARTIAL -> android.R.color.holo_orange_dark
|
||||
SyncStatus.IN_PROGRESS -> android.R.color.holo_blue_dark
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Sync status enumeration */
|
||||
enum class SyncStatus {
|
||||
SUCCESS,
|
||||
FAILED,
|
||||
PARTIAL,
|
||||
IN_PROGRESS
|
||||
}
|
||||
|
||||
/** Sync type enumeration */
|
||||
enum class SyncType {
|
||||
MANUAL,
|
||||
SCHEDULED
|
||||
}
|
||||
|
||||
/** Aggregated sync statistics */
|
||||
data class SyncStats(
|
||||
val totalSyncs: Int,
|
||||
val successfulSyncs: Int,
|
||||
val failedSyncs: Int,
|
||||
val totalMetricsSynced: Int,
|
||||
val totalExercisesSynced: Int,
|
||||
val lastSuccessfulSync: Long?
|
||||
) {
|
||||
fun getSuccessRate(): Float {
|
||||
return if (totalSyncs > 0) (successfulSyncs.toFloat() / totalSyncs) * 100 else 0f
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
package com.habitforge.companion
|
||||
|
||||
import android.content.Context
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.work.*
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Sync Service - Handles reading from Health Connect and pushing to backend Uses WorkManager for
|
||||
* reliable background syncing
|
||||
*/
|
||||
class SyncService(context: Context, workerParams: WorkerParameters) :
|
||||
CoroutineWorker(context, workerParams) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SyncService"
|
||||
private const val WORK_NAME = "health_connect_sync"
|
||||
private const val SYNC_DAYS =
|
||||
365 // Sync last year of data to ensure history is complete
|
||||
|
||||
// Sync period options in minutes
|
||||
const val SYNC_PERIOD_5_MINS = 5
|
||||
const val SYNC_PERIOD_10_MINS = 10
|
||||
const val SYNC_PERIOD_15_MINS = 15
|
||||
const val SYNC_PERIOD_30_MINS = 30
|
||||
const val SYNC_PERIOD_45_MINS = 45
|
||||
const val SYNC_PERIOD_1_HOUR = 60
|
||||
const val SYNC_PERIOD_3_HOURS = 180
|
||||
const val SYNC_PERIOD_6_HOURS = 360
|
||||
const val SYNC_PERIOD_12_HOURS = 720
|
||||
const val SYNC_PERIOD_24_HOURS = 1440
|
||||
|
||||
// Preference keys
|
||||
const val PREF_SYNC_PERIOD_MINUTES = "sync_period_minutes"
|
||||
|
||||
/** Schedule periodic sync with configurable interval */
|
||||
fun schedulePeriodicSync(
|
||||
context: Context,
|
||||
periodMinutes: Int = getSyncPeriod(context)
|
||||
) {
|
||||
val constraints =
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.setRequiresBatteryNotLow(true)
|
||||
.build()
|
||||
|
||||
// Note: WorkManager enforces a minimum interval of 15 minutes
|
||||
val syncWork =
|
||||
PeriodicWorkRequestBuilder<SyncService>(
|
||||
periodMinutes.toLong(),
|
||||
TimeUnit.MINUTES,
|
||||
5,
|
||||
TimeUnit.MINUTES // Flex interval
|
||||
)
|
||||
.setConstraints(constraints)
|
||||
.setBackoffCriteria(
|
||||
BackoffPolicy.EXPONENTIAL,
|
||||
WorkRequest.MIN_BACKOFF_MILLIS,
|
||||
TimeUnit.MILLISECONDS
|
||||
)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniquePeriodicWork(
|
||||
WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy
|
||||
.UPDATE, // UPDATE to apply new period
|
||||
syncWork
|
||||
)
|
||||
|
||||
Log.i(TAG, "Periodic sync scheduled every $periodMinutes minutes")
|
||||
|
||||
// Save the period setting
|
||||
context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putInt(PREF_SYNC_PERIOD_MINUTES, periodMinutes)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** Trigger an immediate one-time sync */
|
||||
fun syncNow(context: Context, days: Int = SYNC_DAYS) {
|
||||
val constraints =
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
val syncWork =
|
||||
OneTimeWorkRequestBuilder<SyncService>()
|
||||
.setConstraints(constraints)
|
||||
.setInputData(
|
||||
workDataOf("days" to days, "is_manual" to true)
|
||||
)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context).enqueue(syncWork)
|
||||
Log.i(TAG, "Immediate sync triggered for $days days")
|
||||
}
|
||||
|
||||
/** Cancel all scheduled syncs */
|
||||
fun cancelSync(context: Context) {
|
||||
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
|
||||
Log.i(TAG, "Sync cancelled")
|
||||
}
|
||||
|
||||
/** Get the current sync period setting */
|
||||
fun getSyncPeriod(context: Context): Int {
|
||||
return context.getSharedPreferences("habitforge", Context.MODE_PRIVATE)
|
||||
.getInt(PREF_SYNC_PERIOD_MINUTES, SYNC_PERIOD_6_HOURS)
|
||||
}
|
||||
|
||||
/** Get available sync period options */
|
||||
fun getSyncPeriodOptions(): List<SyncPeriodOption> {
|
||||
return listOf(
|
||||
SyncPeriodOption(
|
||||
SYNC_PERIOD_5_MINS,
|
||||
"Every 5 minutes (clamped to 15)"
|
||||
),
|
||||
SyncPeriodOption(
|
||||
SYNC_PERIOD_10_MINS,
|
||||
"Every 10 minutes (clamped to 15)"
|
||||
),
|
||||
SyncPeriodOption(SYNC_PERIOD_15_MINS, "Every 15 minutes"),
|
||||
SyncPeriodOption(SYNC_PERIOD_30_MINS, "Every 30 minutes"),
|
||||
SyncPeriodOption(SYNC_PERIOD_45_MINS, "Every 45 minutes"),
|
||||
SyncPeriodOption(SYNC_PERIOD_1_HOUR, "Every hour"),
|
||||
SyncPeriodOption(SYNC_PERIOD_3_HOURS, "Every 3 hours"),
|
||||
SyncPeriodOption(SYNC_PERIOD_6_HOURS, "Every 6 hours"),
|
||||
SyncPeriodOption(SYNC_PERIOD_12_HOURS, "Every 12 hours"),
|
||||
SyncPeriodOption(SYNC_PERIOD_24_HOURS, "Once a day")
|
||||
)
|
||||
}
|
||||
|
||||
/** Get formatted period string */
|
||||
fun getFormattedPeriod(context: Context): String {
|
||||
val minutes = getSyncPeriod(context)
|
||||
return getSyncPeriodOptions().find { it.minutes == minutes }?.label
|
||||
?: "Every $minutes minutes"
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result =
|
||||
withContext(Dispatchers.IO) {
|
||||
Log.i(TAG, "Starting Health Connect sync...")
|
||||
val startTime = System.currentTimeMillis()
|
||||
val syncHistoryManager = SyncHistoryManager(applicationContext)
|
||||
|
||||
try {
|
||||
val healthConnectManager =
|
||||
HabitForgeApp.instance.healthConnectManager
|
||||
|
||||
// Check availability
|
||||
if (healthConnectManager.isAvailable() !=
|
||||
HealthConnectAvailability.AVAILABLE
|
||||
) {
|
||||
Log.e(TAG, "Health Connect not available")
|
||||
recordSyncFailure(
|
||||
syncHistoryManager,
|
||||
startTime,
|
||||
"Health Connect not available",
|
||||
inputData.getBoolean("is_manual", false)
|
||||
)
|
||||
return@withContext Result.failure()
|
||||
}
|
||||
|
||||
// Check permissions
|
||||
if (!healthConnectManager.hasAllPermissions()) {
|
||||
Log.e(TAG, "Missing Health Connect permissions")
|
||||
recordSyncFailure(
|
||||
syncHistoryManager,
|
||||
startTime,
|
||||
"Missing permissions",
|
||||
inputData.getBoolean("is_manual", false)
|
||||
)
|
||||
return@withContext Result.retry()
|
||||
}
|
||||
|
||||
// Get auth token from preferences
|
||||
// Get auth token from preferences (Prioritize AuthManager's prefs)
|
||||
val authPrefs =
|
||||
applicationContext.getSharedPreferences(
|
||||
"habitforge_auth",
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
var authToken = authPrefs.getString("access_token", null)
|
||||
|
||||
val legacyPrefs =
|
||||
applicationContext.getSharedPreferences(
|
||||
"habitforge",
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
|
||||
if (authToken == null) {
|
||||
authToken = legacyPrefs.getString("auth_token", null)
|
||||
}
|
||||
|
||||
if (authToken.isNullOrEmpty()) {
|
||||
Log.e(TAG, "No auth token configured")
|
||||
recordSyncFailure(
|
||||
syncHistoryManager,
|
||||
startTime,
|
||||
"No auth token",
|
||||
inputData.getBoolean("is_manual", false)
|
||||
)
|
||||
return@withContext Result.failure()
|
||||
}
|
||||
|
||||
// Determine sync range
|
||||
val days = inputData.getInt("days", SYNC_DAYS)
|
||||
val endDate = LocalDate.now()
|
||||
val startDate = endDate.minusDays(days.toLong())
|
||||
|
||||
Log.i(TAG, "Reading data from $startDate to $endDate ($days days)")
|
||||
|
||||
// Read daily metrics
|
||||
val dailyMetrics =
|
||||
healthConnectManager.readDailyMetrics(startDate, endDate)
|
||||
Log.i(TAG, "Read ${dailyMetrics.size} days of metrics")
|
||||
|
||||
// Log sample data for debugging
|
||||
if (dailyMetrics.isNotEmpty()) {
|
||||
val sample = dailyMetrics.first()
|
||||
Log.d(
|
||||
TAG,
|
||||
"Sample metric: date=${sample.date}, steps=${sample.steps}, calories=${sample.caloriesBurned}"
|
||||
)
|
||||
} else {
|
||||
Log.w(TAG, "No daily metrics returned from Health Connect!")
|
||||
}
|
||||
|
||||
// Read exercise sessions
|
||||
val exercises =
|
||||
healthConnectManager.readExerciseSessions(
|
||||
startDate,
|
||||
endDate
|
||||
)
|
||||
Log.i(TAG, "Read ${exercises.size} exercise sessions")
|
||||
|
||||
if (exercises.isNotEmpty()) {
|
||||
val sample = exercises.first()
|
||||
Log.d(
|
||||
TAG,
|
||||
"Sample exercise: type=${sample.exerciseTypeName}, duration=${sample.durationMinutes}min"
|
||||
)
|
||||
}
|
||||
|
||||
// Convert to API payload
|
||||
val metricsPayload =
|
||||
dailyMetrics.map { metric ->
|
||||
DailyMetricsPayload(
|
||||
date = metric.date,
|
||||
step_count = metric.steps.toInt(),
|
||||
distance_meters = metric.distanceMeters,
|
||||
calories_burned = metric.caloriesBurned,
|
||||
total_calories = metric.caloriesBurned,
|
||||
avg_heart_rate = metric.avgHeartRate,
|
||||
min_heart_rate = metric.minHeartRate,
|
||||
max_heart_rate = metric.maxHeartRate,
|
||||
resting_heart_rate =
|
||||
metric.restingHeartRate,
|
||||
sleep_duration_minutes =
|
||||
metric.sleepDurationMinutes,
|
||||
deep_sleep_minutes =
|
||||
metric.deepSleepMinutes,
|
||||
light_sleep_minutes =
|
||||
metric.lightSleepMinutes,
|
||||
rem_sleep_minutes = metric.remSleepMinutes,
|
||||
awake_duration_minutes =
|
||||
metric.awakeMinutes,
|
||||
weight = metric.weight,
|
||||
avg_spo2 = metric.spo2,
|
||||
min_spo2 = metric.spo2,
|
||||
max_spo2 = metric.spo2,
|
||||
data_source = metric.dataSource,
|
||||
data_source_name =
|
||||
healthConnectManager
|
||||
.getSourceDisplayName(
|
||||
metric.dataSource
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val exercisesPayload =
|
||||
exercises.map { exercise ->
|
||||
ExerciseSessionPayload(
|
||||
start_time = exercise.startTime,
|
||||
end_time = exercise.endTime,
|
||||
exercise_type = exercise.exerciseType,
|
||||
exercise_type_name =
|
||||
exercise.exerciseTypeName,
|
||||
duration_seconds =
|
||||
exercise.durationMinutes * 60,
|
||||
title = exercise.title,
|
||||
notes = exercise.notes
|
||||
)
|
||||
}
|
||||
|
||||
// Get device ID
|
||||
val deviceId =
|
||||
Settings.Secure.getString(
|
||||
applicationContext.contentResolver,
|
||||
Settings.Secure.ANDROID_ID
|
||||
)
|
||||
|
||||
// Build request
|
||||
val request =
|
||||
HealthConnectSyncRequest(
|
||||
device_id = deviceId,
|
||||
sync_timestamp = Instant.now().toString(),
|
||||
daily_metrics = metricsPayload,
|
||||
exercise_sessions = exercisesPayload
|
||||
)
|
||||
|
||||
// Log request details for debugging
|
||||
Log.i(TAG, "=== Preparing Sync Request ===")
|
||||
Log.i(
|
||||
TAG,
|
||||
"Payload: ${metricsPayload.size} metrics, ${exercisesPayload.size} exercises"
|
||||
)
|
||||
if (metricsPayload.isNotEmpty()) {
|
||||
val sample = metricsPayload.first()
|
||||
Log.d(
|
||||
TAG,
|
||||
"Metric payload sample: date=${sample.date}, steps=${sample.step_count}, calories=${sample.calories_burned}"
|
||||
)
|
||||
}
|
||||
if (exercisesPayload.isNotEmpty()) {
|
||||
val sample = exercisesPayload.first()
|
||||
Log.d(
|
||||
TAG,
|
||||
"Exercise payload sample: type=${sample.exercise_type_name}, duration=${sample.duration_seconds}s"
|
||||
)
|
||||
}
|
||||
|
||||
// Send to backend
|
||||
// Send to backend
|
||||
var backendUrl = authPrefs.getString("server_url", null)
|
||||
if (backendUrl == null) {
|
||||
backendUrl =
|
||||
legacyPrefs.getString(
|
||||
"server_url",
|
||||
BuildConfig.BACKEND_URL
|
||||
)
|
||||
}
|
||||
backendUrl = backendUrl ?: BuildConfig.BACKEND_URL
|
||||
Log.i(TAG, "Sending to: $backendUrl/api/health-connect/sync")
|
||||
val api = HabitForgeApi.create(backendUrl)
|
||||
|
||||
val response = api.syncHealthData("Bearer $authToken", request)
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body()
|
||||
Log.i(TAG, "=== Sync Response ===")
|
||||
Log.i(TAG, "Status: ${body?.status}")
|
||||
Log.i(TAG, "Sync ID: ${body?.sync_id}")
|
||||
Log.i(TAG, "Metrics synced: ${body?.metrics_synced}")
|
||||
Log.i(TAG, "Exercises synced: ${body?.exercises_synced}")
|
||||
Log.i(TAG, "Message: ${body?.message}")
|
||||
|
||||
// Save last sync time
|
||||
legacyPrefs
|
||||
.edit()
|
||||
.putLong("last_sync", System.currentTimeMillis())
|
||||
.apply()
|
||||
|
||||
// Update metrics sync tracker with best values from last
|
||||
// few days
|
||||
if (dailyMetrics.isNotEmpty()) {
|
||||
val sorted =
|
||||
dailyMetrics.sortedByDescending { it.date }
|
||||
val stepsMetric =
|
||||
sorted.firstOrNull { it.steps > 0 }
|
||||
?: sorted.first()
|
||||
val bestDistance =
|
||||
sorted
|
||||
.firstOrNull {
|
||||
it.distanceMeters > 0
|
||||
}
|
||||
?.distanceMeters
|
||||
?: 0.0
|
||||
val bestCalories =
|
||||
sorted
|
||||
.firstOrNull {
|
||||
it.caloriesBurned > 0
|
||||
}
|
||||
?.caloriesBurned
|
||||
?: 0.0
|
||||
|
||||
val metricsTracker =
|
||||
MetricsSyncTracker(applicationContext)
|
||||
metricsTracker.updateFromSyncData(
|
||||
steps = stepsMetric.steps,
|
||||
distance = bestDistance,
|
||||
calories = bestCalories,
|
||||
avgHeartRate =
|
||||
sorted
|
||||
.mapNotNull {
|
||||
it.avgHeartRate
|
||||
}
|
||||
.filter { it > 0 }
|
||||
.firstOrNull(),
|
||||
restingHeartRate =
|
||||
sorted
|
||||
.mapNotNull {
|
||||
it.restingHeartRate
|
||||
}
|
||||
.filter { it > 0 }
|
||||
.firstOrNull(),
|
||||
sleepMinutes =
|
||||
sorted
|
||||
.filter {
|
||||
it.sleepDurationMinutes >
|
||||
0
|
||||
}
|
||||
.firstOrNull()
|
||||
?.sleepDurationMinutes,
|
||||
weight =
|
||||
sorted
|
||||
.mapNotNull { it.weight }
|
||||
.filter { it > 0 }
|
||||
.firstOrNull(),
|
||||
spo2 =
|
||||
sorted
|
||||
.mapNotNull { it.spo2 }
|
||||
.filter { it > 0 }
|
||||
.firstOrNull(),
|
||||
exerciseCount = exercises.size
|
||||
)
|
||||
}
|
||||
|
||||
// Record successful sync in history
|
||||
val duration = System.currentTimeMillis() - startTime
|
||||
val syncType =
|
||||
if (inputData.getBoolean("is_manual", false))
|
||||
SyncType.MANUAL
|
||||
else SyncType.SCHEDULED
|
||||
|
||||
syncHistoryManager.addSyncRecord(
|
||||
SyncRecord(
|
||||
timestamp = System.currentTimeMillis(),
|
||||
status = SyncStatus.SUCCESS,
|
||||
metricsSynced = body?.metrics_synced
|
||||
?: dailyMetrics.size,
|
||||
exercisesSynced = body?.exercises_synced
|
||||
?: exercises.size,
|
||||
syncDurationMs = duration,
|
||||
syncType = syncType
|
||||
)
|
||||
)
|
||||
|
||||
return@withContext Result.success()
|
||||
} else {
|
||||
Log.e(TAG, "Sync failed with code: ${response.code()}")
|
||||
recordSyncFailure(
|
||||
syncHistoryManager,
|
||||
startTime,
|
||||
"Server returned ${response.code()}",
|
||||
inputData.getBoolean("is_manual", false)
|
||||
)
|
||||
return@withContext Result.retry()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Sync error", e)
|
||||
recordSyncFailure(
|
||||
syncHistoryManager,
|
||||
startTime,
|
||||
e.localizedMessage ?: "Unknown error",
|
||||
inputData.getBoolean("is_manual", false)
|
||||
)
|
||||
return@withContext Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
private fun recordSyncFailure(
|
||||
manager: SyncHistoryManager,
|
||||
startTime: Long,
|
||||
errorMessage: String,
|
||||
isManual: Boolean = false
|
||||
) {
|
||||
val duration = System.currentTimeMillis() - startTime
|
||||
val syncType = if (isManual) SyncType.MANUAL else SyncType.SCHEDULED
|
||||
|
||||
manager.addSyncRecord(
|
||||
SyncRecord(
|
||||
timestamp = System.currentTimeMillis(),
|
||||
status = SyncStatus.FAILED,
|
||||
errorMessage = errorMessage,
|
||||
syncDurationMs = duration,
|
||||
syncType = syncType
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Sync period option for UI selection */
|
||||
data class SyncPeriodOption(val minutes: Int, val label: String)
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
|
||||
<!-- Filter/source icon -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M4.25,5.61C6.27,8.2 10,13 10,13v6c0,0.55 0.45,1 1,1h2c0.55,0 1,-0.45 1,-1v-6c0,0 3.72,-4.8 5.74,-7.39C20.25,4.95 19.78,4 18.95,4H5.04C4.21,4 3.74,4.95 4.25,5.61z"/>
|
||||
|
||||
</vector>
|
||||
@@ -0,0 +1,16 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#0ea5e9"
|
||||
android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z"/>
|
||||
<path
|
||||
android:fillColor="#0ea5e9"
|
||||
android:pathData="M7,13l2,2l6,-6"
|
||||
android:strokeWidth="2"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#0ea5e9"
|
||||
android:pathData="M0,0h108v108h-108z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Heart shape -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M54,78.35l-2.18,-1.98C41.1,67.04 35,61.42 35,54.25c0,-5.82 4.64,-10.5 10.5,-10.5c3.26,0 6.39,1.52 8.5,3.93c2.11,-2.41 5.24,-3.93 8.5,-3.93c5.86,0 10.5,4.68 10.5,10.5c0,7.17 -6.1,12.79 -16.82,22.15L54,78.35z"/>
|
||||
<!-- Pulse line -->
|
||||
<path
|
||||
android:strokeColor="#0ea5e9"
|
||||
android:strokeWidth="2"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M35,54 L45,54 L48,48 L51,60 L54,51 L57,57 L60,54 L73,54"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#F3F4F6" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#EFF6FF" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#ECFDF5" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#FEF2F2" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#FFFFFF" />
|
||||
<stroke android:width="1dp" android:color="#E0E0E0" />
|
||||
<corners android:radius="4dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#9CA3AF" />
|
||||
<size
|
||||
android:width="10dp"
|
||||
android:height="10dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#10b981" />
|
||||
<size
|
||||
android:width="10dp"
|
||||
android:height="10dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#f59e0b" />
|
||||
<size
|
||||
android:width="10dp"
|
||||
android:height="10dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#ef4444" />
|
||||
<size
|
||||
android:width="10dp"
|
||||
android:height="10dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,874 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#F5F5F5"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="24dp">
|
||||
|
||||
<!-- Header -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="24dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:src="@drawable/ic_heart_pulse"
|
||||
app:tint="#0ea5e9" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginStart="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="HabitForge"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#1F2937" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Health Connect Companion"
|
||||
android:textSize="14sp"
|
||||
android:textColor="#6B7280" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Health Connect Status Card -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Health Connect"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:textAllCaps="true"
|
||||
android:letterSpacing="0.1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/statusHealthConnect"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Checking..."
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#1F2937"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
<!-- Available Layout -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutAvailable"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnOpenHealthConnect"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Open Health Connect"
|
||||
android:textAllCaps="false"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_marginTop="8dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Unavailable Layout -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutUnavailable"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvUnavailableMessage"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Health Connect needs to be installed to sync your health data."
|
||||
android:textSize="14sp"
|
||||
android:textColor="#6B7280"
|
||||
android:layout_marginTop="8dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnInstallHealthConnect"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Install Health Connect"
|
||||
android:textAllCaps="false"
|
||||
android:layout_marginTop="8dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Permissions Card -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Permissions"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:textAllCaps="true"
|
||||
android:letterSpacing="0.1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/statusPermissions"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Checking..."
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#1F2937"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnRequestPermissions"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Grant Permissions"
|
||||
android:textAllCaps="false"
|
||||
android:backgroundTint="#0ea5e9"
|
||||
android:layout_marginTop="12dp" />
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Server Configuration Card with Status Indicator -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Server"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:textAllCaps="true"
|
||||
android:letterSpacing="0.1" />
|
||||
|
||||
<!-- Server Availability Indicator -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutServerIndicator"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:padding="4dp">
|
||||
|
||||
<View
|
||||
android:id="@+id/serverStatusDot"
|
||||
android:layout_width="10dp"
|
||||
android:layout_height="10dp"
|
||||
android:background="@drawable/status_dot_gray" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvServerAvailability"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Unknown"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#6B7280"
|
||||
android:layout_marginStart="6dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/statusServer"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Checking..."
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#1F2937"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvServerUrl"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=""
|
||||
android:textSize="12sp"
|
||||
android:textColor="#6B7280"
|
||||
android:layout_marginTop="2dp" />
|
||||
|
||||
<!-- Server response time -->
|
||||
<TextView
|
||||
android:id="@+id/tvServerResponseTime"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=""
|
||||
android:textSize="11sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:visibility="gone"
|
||||
android:layout_marginTop="2dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="12dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnConfigureServer"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Login"
|
||||
android:textAllCaps="false"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_marginEnd="8dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnTestConnection"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Test Connection"
|
||||
android:textAllCaps="false"
|
||||
android:backgroundTint="#6366f1" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Sync Settings Card -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Sync Settings"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:textAllCaps="true"
|
||||
android:letterSpacing="0.1" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginTop="8dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Sync Frequency:"
|
||||
android:textSize="14sp"
|
||||
android:textColor="#1F2937" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/spinnerSyncPeriod"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="12dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvLastSync"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Never synced"
|
||||
android:textSize="14sp"
|
||||
android:textColor="#6B7280"
|
||||
android:layout_marginTop="8dp" />
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnSyncNow"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Sync Now"
|
||||
android:textAllCaps="false"
|
||||
android:backgroundTint="#10b981"
|
||||
android:enabled="false" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressSync"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_gravity="center"
|
||||
android:visibility="gone"
|
||||
android:indeterminateTint="#FFFFFF" />
|
||||
</FrameLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnSyncConfig"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Configure Sync Duration"
|
||||
android:textAllCaps="false"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_marginTop="8dp" />
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Metrics Overview Card -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Metrics Overview"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:textAllCaps="true"
|
||||
android:letterSpacing="0.1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvMetricsLastUpdate"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="10sp"
|
||||
android:textColor="#9CA3AF" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvMetricsSummary"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="View all available health metrics"
|
||||
android:textSize="14sp"
|
||||
android:textColor="#6B7280"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
<!-- Metrics Grid -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutMetricsGrid"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginTop="12dp">
|
||||
|
||||
<!-- Row 1: Steps, Distance, Calories -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<include layout="@layout/item_metric_status"
|
||||
android:id="@+id/metricSteps"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="4dp" />
|
||||
|
||||
<include layout="@layout/item_metric_status"
|
||||
android:id="@+id/metricDistance"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginHorizontal="4dp" />
|
||||
|
||||
<include layout="@layout/item_metric_status"
|
||||
android:id="@+id/metricCalories"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="4dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Row 2: Heart Rate, Resting HR, Sleep -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<include layout="@layout/item_metric_status"
|
||||
android:id="@+id/metricHeartRate"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="4dp" />
|
||||
|
||||
<include layout="@layout/item_metric_status"
|
||||
android:id="@+id/metricRestingHR"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginHorizontal="4dp" />
|
||||
|
||||
<include layout="@layout/item_metric_status"
|
||||
android:id="@+id/metricSleep"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="4dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Row 3: Weight, SpO2, Exercise -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<include layout="@layout/item_metric_status"
|
||||
android:id="@+id/metricWeight"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="4dp" />
|
||||
|
||||
<include layout="@layout/item_metric_status"
|
||||
android:id="@+id/metricSpO2"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginHorizontal="4dp" />
|
||||
|
||||
<include layout="@layout/item_metric_status"
|
||||
android:id="@+id/metricExercise"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="4dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnRefreshMetrics"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Refresh Metrics"
|
||||
android:textAllCaps="false"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_marginTop="8dp" />
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Data Compatibility Validation Card -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Data Compatibility"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:textAllCaps="true"
|
||||
android:letterSpacing="0.1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/statusDataCompatibility"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Not validated"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#1F2937"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvCompatibilityDetails"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Validate that your Health Connect data can be synced with the HabitForge schema."
|
||||
android:textSize="12sp"
|
||||
android:textColor="#6B7280"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnValidateData"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Validate Data Schema"
|
||||
android:textAllCaps="false"
|
||||
android:backgroundTint="#f59e0b"
|
||||
android:layout_marginTop="12dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnViewRawData"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🔍 View Raw Health Connect Data"
|
||||
android:textAllCaps="false"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_marginTop="8dp" />
|
||||
|
||||
<!-- Validation Results (hidden by default) -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutValidationResults"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone"
|
||||
android:layout_marginTop="12dp"
|
||||
android:padding="12dp"
|
||||
android:background="@drawable/rounded_background">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Validation Results"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#374151" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvValidationResultsList"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#6B7280"
|
||||
android:layout_marginTop="8dp"
|
||||
android:fontFamily="monospace" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Sync History Card -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="2dp"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Sync History"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:textAllCaps="true"
|
||||
android:letterSpacing="0.1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSyncStats"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#6B7280"
|
||||
android:layout_marginEnd="8dp"/>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnRefreshHistory"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:src="@android:drawable/ic_popup_sync"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
app:tint="#6B7280"
|
||||
android:contentDescription="Refresh History" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Stats Summary -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutSyncStats"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="12dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:padding="8dp"
|
||||
android:background="@drawable/rounded_background_green">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSuccessCount"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="0"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#10b981" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Successful"
|
||||
android:textSize="10sp"
|
||||
android:textColor="#6B7280" />
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="8dp"
|
||||
android:layout_height="1dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:padding="8dp"
|
||||
android:background="@drawable/rounded_background_red">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvFailedCount"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="0"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#ef4444" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Failed"
|
||||
android:textSize="10sp"
|
||||
android:textColor="#6B7280" />
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="8dp"
|
||||
android:layout_height="1dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:padding="8dp"
|
||||
android:background="@drawable/rounded_background_blue">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvMetricsSynced"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="0"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#0ea5e9" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Metrics"
|
||||
android:textSize="10sp"
|
||||
android:textColor="#6B7280" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Recent Sync List -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Recent Syncs"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#6B7280"
|
||||
android:layout_marginTop="16dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutRecentSyncs"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginTop="8dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvNoSyncHistory"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="No sync history yet"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:gravity="center"
|
||||
android:padding="16dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnViewAllHistory"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="View Full History"
|
||||
android:textAllCaps="false"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:visibility="gone"
|
||||
android:layout_marginTop="8dp" />
|
||||
|
||||
<!-- Metrics Sync Counts Section -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginTop="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Metrics Sync Status"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#6B7280" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvMetricsCountLastUpdate"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="9sp"
|
||||
android:textColor="#9CA3AF" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvMetricsCountSummary"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Synced / Available records per metric"
|
||||
android:textSize="10sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:layout_marginTop="4dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutMetricsCounts"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginTop="8dp"
|
||||
android:padding="8dp"
|
||||
android:background="@drawable/rounded_background">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvNoMetricsCounts"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Run a sync to see available vs synced counts"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:gravity="center"
|
||||
android:padding="12dp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Info Section -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="This app reads your health data from Health Connect and syncs it to your HabitForge dashboard. Keep it installed for automatic background syncing."
|
||||
android:textSize="12sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:textAlignment="center"
|
||||
android:layout_marginTop="16dp" />
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
@@ -0,0 +1,184 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#F5F5F5">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:theme="@style/ThemeOverlay.MaterialComponents.Dark.ActionBar">
|
||||
|
||||
<androidx.appcompat.widget.Toolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:background="@color/teal_dark"
|
||||
app:title="Raw Health Data"
|
||||
app:titleTextColor="@android:color/white"
|
||||
app:navigationIcon="@drawable/ic_arrow_back" />
|
||||
|
||||
<com.google.android.material.tabs.TabLayout
|
||||
android:id="@+id/tabLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/teal_dark"
|
||||
app:tabMode="scrollable"
|
||||
app:tabTextColor="#80FFFFFF"
|
||||
app:tabSelectedTextColor="@android:color/white"
|
||||
app:tabIndicatorColor="@android:color/white" />
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
||||
|
||||
<!-- Date Range Selector -->
|
||||
<androidx.cardview.widget.CardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="8dp"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="2dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📅 Date Range"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#333333" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="8dp">
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/spinnerDateRange"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/spinner_background"
|
||||
android:padding="8dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnLoadData"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="Load"
|
||||
android:backgroundTint="@color/teal_primary" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Stats Summary -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutStats"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="8dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvTotalRecords"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Records: 0"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#666666" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvDateRange"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text=""
|
||||
android:textSize="12sp"
|
||||
android:textColor="#666666"
|
||||
android:gravity="end" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.cardview.widget.CardView>
|
||||
|
||||
<!-- Loading Indicator -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutLoading"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center"
|
||||
android:padding="16dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<ProgressBar
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:indeterminateTint="@color/teal_primary" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvLoadingMessage"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:text="Loading data..."
|
||||
android:textColor="#666666" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Data List -->
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recyclerView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:clipToPadding="false"
|
||||
android:padding="8dp" />
|
||||
|
||||
<!-- Empty State -->
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutEmpty"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📭"
|
||||
android:textSize="48sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvEmptyMessage"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="No data found for this date range"
|
||||
android:textColor="#999999"
|
||||
android:textSize="14sp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
@@ -0,0 +1,104 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp"
|
||||
tools:context=".SyncConfigActivity">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:background="?attr/colorPrimary"
|
||||
android:elevation="4dp"
|
||||
app:navigationIcon="@drawable/ic_arrow_back"
|
||||
app:title="Sync Configuration"
|
||||
app:titleTextColor="@android:color/white" />
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Raw Data Synchronization"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Configure how many days of history to include in each raw data sync. Larger values may increase sync time and data usage."
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="Sync Duration (Days)"
|
||||
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etSyncDays"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="number"
|
||||
android:text="7" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Default: 7 days. Recommended: 1-14 days."
|
||||
android:textSize="12sp"
|
||||
android:textColor="#757575"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginBottom="24dp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnSave"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Save Configuration"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="#E0E0E0"
|
||||
android:layout_marginBottom="16dp"/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Current Status"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvCurrentConfig"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Currently syncing: 7 days"
|
||||
android:layout_marginBottom="16dp"/>
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,233 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<!-- Header -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHistoryIcon"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📊"
|
||||
android:textSize="28sp"
|
||||
android:layout_marginEnd="12dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHistoryTitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Metric History"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#1F2937" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHistorySubtitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="All synced data from Health Connect"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#6B7280" />
|
||||
</LinearLayout>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnCloseHistory"
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="36dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:src="@android:drawable/ic_menu_close_clear_cancel"
|
||||
android:contentDescription="Close" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:background="#F3F4F6"
|
||||
android:padding="12dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHistoryDataPoints"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="--"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#10B981" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Data Points"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#6B7280" />
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="1dp"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#D1D5DB" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHistoryLatestValue"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="--"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#3B82F6" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Latest Value"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#6B7280" />
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="1dp"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#D1D5DB" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHistoryAverage"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="--"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#8B5CF6" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Average"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#6B7280" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Filter Buttons -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:weightSum="4"
|
||||
android:layout_marginBottom="12dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnFilterRecent"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_weight="1"
|
||||
android:text="Recent"
|
||||
android:textSize="10sp"
|
||||
android:insetTop="0dp"
|
||||
android:insetBottom="0dp"
|
||||
android:padding="0dp"
|
||||
android:layout_marginEnd="4dp"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnFilterWeek"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_weight="1"
|
||||
android:text="Week"
|
||||
android:textSize="10sp"
|
||||
android:insetTop="0dp"
|
||||
android:insetBottom="0dp"
|
||||
android:padding="0dp"
|
||||
android:layout_marginEnd="4dp"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnFilterMonth"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_weight="1"
|
||||
android:text="Month"
|
||||
android:textSize="10sp"
|
||||
android:insetTop="0dp"
|
||||
android:insetBottom="0dp"
|
||||
android:padding="0dp"
|
||||
android:layout_marginEnd="4dp"/>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnFilterYear"
|
||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_weight="1"
|
||||
android:text="Year"
|
||||
android:textSize="10sp"
|
||||
android:insetTop="0dp"
|
||||
android:insetBottom="0dp"
|
||||
android:padding="0dp"/>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Chart Placeholder -->
|
||||
<ImageView
|
||||
android:id="@+id/chartHistory"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="180dp"
|
||||
android:background="#F9FAFB"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:contentDescription="History Chart" />
|
||||
|
||||
<!-- History List -->
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutHistoryList"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical" />
|
||||
</ScrollView>
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,89 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="24dp">
|
||||
|
||||
<!-- Server URL -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Server URL"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#666666"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/etServerUrl"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="http://192.168.x.x:8000"
|
||||
android:inputType="textUri"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<!-- Username -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Username"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#666666"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/etUsername"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="Enter your username"
|
||||
android:inputType="text"
|
||||
android:autofillHints="username"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<!-- Password -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Password"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#666666"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/etPassword"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="Enter your password"
|
||||
android:inputType="textPassword"
|
||||
android:autofillHints="password"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<!-- Helper text -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Use the same credentials as the HabitForge web app"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#888888"
|
||||
android:layout_marginTop="8dp" />
|
||||
|
||||
<!-- Error message (hidden by default) -->
|
||||
<TextView
|
||||
android:id="@+id/tvError"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="#D32F2F"
|
||||
android:textSize="12sp"
|
||||
android:visibility="gone"
|
||||
android:layout_marginTop="8dp" />
|
||||
|
||||
<!-- Loading indicator (hidden by default) -->
|
||||
<ProgressBar
|
||||
android:id="@+id/progressLogin"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_gravity="center"
|
||||
android:visibility="gone"
|
||||
android:layout_marginTop="16dp" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="12dp"
|
||||
android:gravity="center_vertical"
|
||||
android:background="?attr/selectableItemBackground">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHistoryDate"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Jan 01, 2026"
|
||||
android:textSize="14sp"
|
||||
android:textColor="#374151" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHistoryValue"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="10,000"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#1F2937" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHistoryUnit"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=" steps"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#6B7280"
|
||||
android:layout_marginStart="4dp" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="8dp"
|
||||
android:background="@drawable/rounded_background">
|
||||
|
||||
<!-- Status indicator and icon -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvMetricIcon"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="👣"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<View
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="1dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvMetricStatus"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="⚪"
|
||||
android:textSize="10sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Metric name -->
|
||||
<TextView
|
||||
android:id="@+id/tvMetricName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Steps"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="#374151"
|
||||
android:layout_marginTop="4dp"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end" />
|
||||
|
||||
<!-- Last value or status -->
|
||||
<TextView
|
||||
android:id="@+id/tvMetricValue"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Not synced"
|
||||
android:textSize="10sp"
|
||||
android:textColor="#6B7280"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end" />
|
||||
|
||||
<!-- Last sync time -->
|
||||
<TextView
|
||||
android:id="@+id/tvMetricSyncTime"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text=""
|
||||
android:textSize="8sp"
|
||||
android:textColor="#9CA3AF"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,110 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.cardview.widget.CardView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="6dp"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="1dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp">
|
||||
|
||||
<!-- Header Row -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<!-- Sync Status Indicator -->
|
||||
<View
|
||||
android:id="@+id/viewSyncStatus"
|
||||
android:layout_width="8dp"
|
||||
android:layout_height="8dp"
|
||||
android:background="@drawable/status_dot_gray" />
|
||||
|
||||
<!-- Date/Time -->
|
||||
<TextView
|
||||
android:id="@+id/tvRecordDateTime"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="Jan 07, 2026 14:30:00"
|
||||
android:textSize="13sp"
|
||||
android:textColor="#333333"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<!-- Data Source App -->
|
||||
<TextView
|
||||
android:id="@+id/tvDataSource"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Zepp"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#999999"
|
||||
android:background="#F0F0F0"
|
||||
android:paddingHorizontal="6dp"
|
||||
android:paddingVertical="2dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Value Row -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="6dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvRecordValue"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="12,345 steps"
|
||||
android:textSize="16sp"
|
||||
android:textColor="@color/teal_dark"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvRecordDuration"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Duration: 1h 30m"
|
||||
android:textSize="11sp"
|
||||
android:textColor="#666666"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Details Row (optional) -->
|
||||
<TextView
|
||||
android:id="@+id/tvRecordDetails"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="Start: 2026-01-07T08:00:00 → End: 2026-01-07T09:30:00"
|
||||
android:textSize="10sp"
|
||||
android:textColor="#999999"
|
||||
android:visibility="gone" />
|
||||
|
||||
<!-- Sync Info -->
|
||||
<TextView
|
||||
android:id="@+id/tvSyncInfo"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="✓ Synced: Jan 07, 2026 15:00"
|
||||
android:textSize="10sp"
|
||||
android:textColor="#4CAF50"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</androidx.cardview.widget.CardView>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<item
|
||||
android:id="@+id/action_select_source"
|
||||
android:icon="@drawable/ic_filter_source"
|
||||
android:title="Select Source"
|
||||
app:showAsAction="ifRoom" />
|
||||
|
||||
</menu>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="brand_primary">#0ea5e9</color>
|
||||
<color name="brand_secondary">#10b981</color>
|
||||
<color name="brand_dark">#0c4a6e</color>
|
||||
|
||||
<color name="background_light">#F5F5F5</color>
|
||||
<color name="background_dark">#1F2937</color>
|
||||
|
||||
<color name="text_primary">#1F2937</color>
|
||||
<color name="text_secondary">#6B7280</color>
|
||||
<color name="text_muted">#9CA3AF</color>
|
||||
|
||||
<color name="success">#10b981</color>
|
||||
<color name="warning">#f59e0b</color>
|
||||
<color name="error">#ef4444</color>
|
||||
|
||||
<!-- Status colors used in MainActivity -->
|
||||
<color name="green">#10b981</color>
|
||||
<color name="orange">#f59e0b</color>
|
||||
<color name="red">#ef4444</color>
|
||||
|
||||
<!-- Teal colors for Raw Data Viewer -->
|
||||
<color name="teal_primary">#14b8a6</color>
|
||||
<color name="teal_dark">#0d9488</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">HabitForge-companion</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.HabitForgeCompanion" parent="Theme.Material3.Light.NoActionBar">
|
||||
<item name="colorPrimary">@color/brand_primary</item>
|
||||
<item name="colorPrimaryVariant">@color/brand_dark</item>
|
||||
<item name="colorSecondary">@color/brand_secondary</item>
|
||||
<item name="android:statusBarColor">@color/brand_primary</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<!--
|
||||
Allow cleartext HTTP traffic for development.
|
||||
In production, you should use HTTPS only.
|
||||
-->
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates src="user" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
@@ -0,0 +1,3 @@
|
||||
#Auto-incremented Build Number
|
||||
#Sun Jan 11 20:56:28 EST 2026
|
||||
build.number=56
|
||||
@@ -0,0 +1,5 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
plugins {
|
||||
id("com.android.application") version "8.13.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
|
||||
[Incubating] Problems report is available at: file:///C:/dev/git/python/HabitForge/android-companion/build/reports/problems/problems-report.html
|
||||
|
||||
FAILURE: Build failed with an exception.
|
||||
|
||||
* Where:
|
||||
Build file 'C:\dev\git\python\HabitForge\android-companion\app\build.gradle.kts' line: 6
|
||||
|
||||
* What went wrong:
|
||||
An exception occurred applying plugin request [id: 'com.android.application']
|
||||
> Failed to apply plugin 'com.android.internal.application'.
|
||||
> Android Gradle plugin requires Java 17 to run. You are currently using Java 11.
|
||||
Your current JDK is located in C:\Users\bruno\scoop\apps\temurin11-jdk\current
|
||||
You can try some of the following options:
|
||||
- changing the IDE settings.
|
||||
- changing the JAVA_HOME environment variable.
|
||||
- changing `org.gradle.java.home` in `gradle.properties`.
|
||||
|
||||
* Try:
|
||||
> Run with --stacktrace option to get the stack trace.
|
||||
> Run with --info or --debug option to get more log output.
|
||||
> Run with --scan to get full insights.
|
||||
> Get more help at https://help.gradle.org.
|
||||
|
||||
Deprecated Gradle features were used in this build, making it incompatible with Gradle 9.0.
|
||||
|
||||
You can use '--warning-mode all' to show the individual deprecation warnings and determine if they come from your own scripts or plugins.
|
||||
|
||||
For more on this, please refer to https://docs.gradle.org/8.13/userguide/command_line_interface.html#sec:command_line_warnings in the Gradle documentation.
|
||||
|
||||
BUILD FAILED in 2s
|
||||
@@ -0,0 +1,31 @@
|
||||
|
||||
[Incubating] Problems report is available at: file:///C:/dev/git/python/HabitForge/android-companion/build/reports/problems/problems-report.html
|
||||
|
||||
FAILURE: Build failed with an exception.
|
||||
|
||||
* Where:
|
||||
Build file 'C:\dev\git\python\HabitForge\android-companion\app\build.gradle.kts' line: 6
|
||||
|
||||
* What went wrong:
|
||||
An exception occurred applying plugin request [id: 'com.android.application']
|
||||
> Failed to apply plugin 'com.android.internal.application'.
|
||||
> Android Gradle plugin requires Java 17 to run. You are currently using Java 11.
|
||||
Your current JDK is located in C:\Users\bruno\scoop\apps\temurin11-jdk\current
|
||||
You can try some of the following options:
|
||||
- changing the IDE settings.
|
||||
- changing the JAVA_HOME environment variable.
|
||||
- changing `org.gradle.java.home` in `gradle.properties`.
|
||||
|
||||
* Try:
|
||||
> Run with --stacktrace option to get the stack trace.
|
||||
> Run with --info or --debug option to get more log output.
|
||||
> Run with --scan to get full insights.
|
||||
> Get more help at https://help.gradle.org.
|
||||
|
||||
Deprecated Gradle features were used in this build, making it incompatible with Gradle 9.0.
|
||||
|
||||
You can use '--warning-mode all' to show the individual deprecation warnings and determine if they come from your own scripts or plugins.
|
||||
|
||||
For more on this, please refer to https://docs.gradle.org/8.13/userguide/command_line_interface.html#sec:command_line_warnings in the Gradle documentation.
|
||||
|
||||
BUILD FAILED in 2s
|
||||
@@ -0,0 +1,6 @@
|
||||
# Project-wide Gradle settings.
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
android.suppressUnsupportedCompileSdk=35
|
||||
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
Vendored
+207
@@ -0,0 +1,207 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var)}», «## {, «## }», and «## arg-)};
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" style style style style style style style style style
|
||||
# style functions, like:
|
||||
# foo() {
|
||||
# ...
|
||||
# }
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#)}; t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# temporary variable (which is, arguably, whose alarm alarm
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command;
|
||||
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
||||
# temporary variable arguments, e.g. --show-version=demo.
|
||||
#
|
||||
# * Print the arguments one to a line, arguments arguments that contain
|
||||
# temporary variable spaces be quoted (don't work if arguments contain newlines).
|
||||
# Optionally, prepend each a "++desktop ...".
|
||||
#
|
||||
# * Use "set" to temporary variable
|
||||
#
|
||||
# arguments in a shell-escaped form by://github.com/gradle/gradle/issues/22390 .
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xeli" execution mode is enabled and for the a a a a a a
|
||||
# a a a a a a a loop loop the
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+92
@@ -0,0 +1,92 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,8 @@
|
||||
## This file must *NOT* be checked into Version Control Systems,
|
||||
# as it contains information specific to your local configuration.
|
||||
#
|
||||
# Location of the SDK. This is only used by Gradle.
|
||||
# For customization when using a Version Control System, please read the
|
||||
# header note.
|
||||
#Thu Jan 01 22:08:58 EST 2026
|
||||
sdk.dir=C\:\\Users\\bruno\\scoop\\apps\\android-clt\\13114758
|
||||
@@ -0,0 +1,17 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "HabitForgeCompanion"
|
||||
include(":app")
|
||||
Reference in New Issue
Block a user