Add initial project

This commit is contained in:
2026-07-30 23:25:20 -04:00
commit cff6aa8289
164 changed files with 26672 additions and 0 deletions
+203
View File
@@ -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"
+115
View File
@@ -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
View File
@@ -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
)
@@ -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
)
@@ -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
)
@@ -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
}
}
}
}
@@ -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>
+3
View File
@@ -0,0 +1,3 @@
#Auto-incremented Build Number
#Sun Jan 11 20:56:28 EST 2026
build.number=56
+5
View File
@@ -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
}
+31
View File
@@ -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
+31
View File
@@ -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
+6
View File
@@ -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
+207
View File
@@ -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" "$@"
+92
View File
@@ -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
+8
View File
@@ -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
+17
View File
@@ -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")