diff --git a/README.md b/README.md
index b16c2cc..67f10de 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
## FeelFine - activity tracker app
-Application to track your fitness activities with [Google Fit API](https://developers.google.com/fit "Google Fit API").
+Application to track your fitness activities with [Health Connect](https://developer.android.com/health-and-fitness/guides/health-connect "Health Connect") and [Google Fit](https://developers.google.com/fit "Google Fit") APIs.
- Kotlin
- Jetpack Compose
- [Koin](https://github.com/InsertKoinIO/koin "Koin") for DI
@@ -36,7 +36,7 @@ Four simple onboarding steps to pick your name, gender, weight and birthday.
-A user’s data as steps, sleep, biking, running and other activities synced via [Google Fit API](https://developers.google.com/fit "Google Fit API"). User has an every day score, based on his own metrics.
+Users data as steps, sleep, walking, running and other combined activities are synced via [Health Connect](https://developer.android.com/health-and-fitness/guides/health-connect "Health Connect") or [Google Fit](https://developers.google.com/fit "Google Fit") APIs. Users have an every day score, based on their own metrics.
------------
@@ -46,7 +46,7 @@ A user’s data as steps, sleep, biking, running and other activities synced via
-Users could observe week/month/custom range activities statistics.
+Users could observe week/month/custom range activities statistics.
------------
@@ -56,7 +56,7 @@ Users could observe week/month/custom range activities statistics.
-We are asking each day 'How are you?' for the mood score with 9 options for users.
+Every day we are asking users 'How are you?' for the mood score with 9 options.
------------
@@ -68,3 +68,13 @@ We are asking each day 'How are you?' for the mood score with 9 options for user
User profile with wage, age and goals (steps, sleep, activity) customizations.
+
+------------
+
+
+### What's next
+- Migrate to Kotlin Coroutines from RxJava
+- Migrate to Compose
+- Migrate to Kotlin Multiplatform (use cross-platform library https://github.com/vitoksmile/HealthKMP)
+- Night theme
+- Edit goals
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index fae8892..c2fc381 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -22,7 +22,7 @@ android {
defaultConfig {
applicationId = "com.feelsoftware.feelfine"
- minSdk = 23
+ minSdk = 28
//noinspection EditedTargetSdkVersion
targetSdk = 35
versionCode = props.getProperty("versionCode").toInt()
@@ -81,6 +81,7 @@ android {
dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.coroutines.android)
+ implementation(libs.kotlinx.datetime)
implementation(libs.core.ktx)
implementation(libs.appcompat)
@@ -116,6 +117,7 @@ dependencies {
implementation(libs.firebase.crashlytics.ktx)
// Google Fit
+ implementation(libs.health.connect)
implementation(libs.play.services.fitness)
implementation(libs.google.api.client)
implementation(libs.google.api.client.android)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b6beec6..5d035b1 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -9,6 +9,14 @@
android:name="com.google.android.gms.permission.AD_ID"
tools:node="remove" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/feelsoftware/feelfine/di/KoinInit.kt b/app/src/main/java/com/feelsoftware/feelfine/di/KoinInit.kt
index dd09f2d..024d807 100644
--- a/app/src/main/java/com/feelsoftware/feelfine/di/KoinInit.kt
+++ b/app/src/main/java/com/feelsoftware/feelfine/di/KoinInit.kt
@@ -1,6 +1,7 @@
package com.feelsoftware.feelfine.di
import android.app.Application
+import com.feelsoftware.feelfine.fit.FitPermissionManager
import com.feelsoftware.feelfine.ui.onboarding.onboardingModule
import com.feelsoftware.feelfine.utils.ActivityEngine
import org.koin.android.ext.koin.androidContext
@@ -21,7 +22,10 @@ object KoinInit {
utilsModule,
onboardingModule,
)
+ // FIXME: ActivityEngine to initialize ActivityLifecycleCallbacks
koin.get()
+ // FIXME: FitPermissionManager to initialize HealthConnectFitPermissionManagerWrapper#activityEngine
+ koin.get()
}
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/feelsoftware/feelfine/di/fit.kt b/app/src/main/java/com/feelsoftware/feelfine/di/fit.kt
index ae0847d..b57f7dd 100644
--- a/app/src/main/java/com/feelsoftware/feelfine/di/fit.kt
+++ b/app/src/main/java/com/feelsoftware/feelfine/di/fit.kt
@@ -5,57 +5,112 @@ package com.feelsoftware.feelfine.di
import com.feelsoftware.feelfine.data.db.dao.ActivityDao
import com.feelsoftware.feelfine.data.db.dao.SleepDao
import com.feelsoftware.feelfine.data.db.dao.StepsDao
-import com.feelsoftware.feelfine.data.repository.*
+import com.feelsoftware.feelfine.data.repository.ActivityDataRepository
+import com.feelsoftware.feelfine.data.repository.ActivityRemoteDataSource
+import com.feelsoftware.feelfine.data.repository.SleepDataRepository
+import com.feelsoftware.feelfine.data.repository.SleepRemoteDataSource
+import com.feelsoftware.feelfine.data.repository.StepsDataRepository
+import com.feelsoftware.feelfine.data.repository.StepsRemoteDataSource
+import com.feelsoftware.feelfine.data.repository.UserRepository
import com.feelsoftware.feelfine.fit.FitPermissionManager
import com.feelsoftware.feelfine.fit.FitRepository
import com.feelsoftware.feelfine.fit.GoogleFitPermissionManager
import com.feelsoftware.feelfine.fit.GoogleFitRepository
+import com.feelsoftware.feelfine.fit.HealthConnectClientProvider
+import com.feelsoftware.feelfine.fit.HealthConnectClientProviderImpl
+import com.feelsoftware.feelfine.fit.HealthConnectFitPermissionManagerWrapper
+import com.feelsoftware.feelfine.fit.HealthConnectFitRepositoryWrapper
+import com.feelsoftware.feelfine.fit.HealthConnectPermissionManager
+import com.feelsoftware.feelfine.fit.HealthConnectPermissionManagerImpl
+import com.feelsoftware.feelfine.fit.HealthConnectRepository
+import com.feelsoftware.feelfine.fit.HealthConnectRepositoryImpl
import com.feelsoftware.feelfine.fit.mock.MockFitRepository
import com.feelsoftware.feelfine.fit.usecase.GetFitDataUseCase
import com.feelsoftware.feelfine.utils.ActivityEngine
+import org.koin.android.ext.koin.androidApplication
+import org.koin.core.scope.Scope
import org.koin.dsl.module
val fitModule = module {
+ single {
+ HealthConnectClientProviderImpl(
+ context = androidApplication(),
+ )
+ }
+ single {
+ HealthConnectPermissionManagerImpl(
+ clientProvider = get(),
+ )
+ }
+ factory {
+ HealthConnectRepositoryImpl(
+ clientProvider = get(),
+ permissionManager = get(),
+ )
+ }
factory {
val profile = get().getProfileLegacy().firstOrError().blockingGet()
if (profile.isDemo) {
MockFitRepository()
} else {
- GoogleFitRepository(get(), get())
+ if (hasHealthConnect) {
+ HealthConnectFitRepositoryWrapper(
+ repository = get(),
+ )
+ } else {
+ GoogleFitRepository(
+ activityEngine = get(),
+ permissionManager = get(),
+ )
+ }
}
}
single {
- GoogleFitPermissionManager(
- get(),
- get(),
- get(),
- get(),
- get(),
- )
+ if (hasHealthConnect) {
+ HealthConnectFitPermissionManagerWrapper(
+ activityDao = get(),
+ activityEngine = get(),
+ permissionManager = get(),
+ sleepDao = get(),
+ stepsDao = get(),
+ userRepository = get(),
+ )
+ } else {
+ GoogleFitPermissionManager(
+ activityDao = get(),
+ activityEngine = get(),
+ sleepDao = get(),
+ stepsDao = get(),
+ userRepository = get(),
+ )
+ }
}
factory {
GetFitDataUseCase(
- get(),
- get(),
- get(),
+ stepsRepository = get(),
+ sleepRepository = get(),
+ activityRepository = get(),
)
}
factory {
StepsDataRepository(
- get(),
- StepsRemoteDataSource(get())
+ localDataSource = get(),
+ remoteDataSource = StepsRemoteDataSource(get())
)
}
factory {
SleepDataRepository(
- get(),
- SleepRemoteDataSource(get())
+ localDataSource = get(),
+ remoteDataSource = SleepRemoteDataSource(get())
)
}
factory {
ActivityDataRepository(
- get(),
- ActivityRemoteDataSource(get())
+ localDataSource = get(),
+ remoteDataSource = ActivityRemoteDataSource(get())
)
}
-}
\ No newline at end of file
+}
+
+private inline val Scope.hasHealthConnect: Boolean
+ get() = get().invoke().getOrNull() != null
diff --git a/app/src/main/java/com/feelsoftware/feelfine/fit/FitPermissionManager.kt b/app/src/main/java/com/feelsoftware/feelfine/fit/FitPermissionManager.kt
index c103037..4108524 100644
--- a/app/src/main/java/com/feelsoftware/feelfine/fit/FitPermissionManager.kt
+++ b/app/src/main/java/com/feelsoftware/feelfine/fit/FitPermissionManager.kt
@@ -35,6 +35,7 @@ interface FitPermissionManager {
private const val REQUEST_CODE = 1717
+@Deprecated("Migrate to HealthConnectPermissionManager")
class GoogleFitPermissionManager(
private val activityDao: ActivityDao,
private val activityEngine: ActivityEngine,
diff --git a/app/src/main/java/com/feelsoftware/feelfine/fit/FitRepository.kt b/app/src/main/java/com/feelsoftware/feelfine/fit/FitRepository.kt
index 25f4575..e77fe0b 100644
--- a/app/src/main/java/com/feelsoftware/feelfine/fit/FitRepository.kt
+++ b/app/src/main/java/com/feelsoftware/feelfine/fit/FitRepository.kt
@@ -34,6 +34,7 @@ interface FitRepository {
fun getActivity(startTime: Date, endTime: Date): Single>
}
+@Deprecated("Migrate to HealthConnectRepository")
class GoogleFitRepository(
private val activityEngine: ActivityEngine,
private val permissionManager: FitPermissionManager,
diff --git a/app/src/main/java/com/feelsoftware/feelfine/fit/HealthConnectClientProvider.kt b/app/src/main/java/com/feelsoftware/feelfine/fit/HealthConnectClientProvider.kt
new file mode 100644
index 0000000..df07bdf
--- /dev/null
+++ b/app/src/main/java/com/feelsoftware/feelfine/fit/HealthConnectClientProvider.kt
@@ -0,0 +1,72 @@
+package com.feelsoftware.feelfine.fit
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import androidx.health.connect.client.HealthConnectClient
+
+/**
+ * Provide instance of [HealthConnectClient].
+ */
+interface HealthConnectClientProvider {
+
+ /**
+ * @return [Error] when `Result.failure`.
+ */
+ operator fun invoke(): Result
+
+ /**
+ * Call to perform update after `invoke()` returned `Error.UpdateRequired`.
+ */
+ suspend fun performUpdate(): Result
+
+ @Suppress("JavaIoSerializableObjectMustHaveReadResolve")
+ sealed class Error : Throwable() {
+ data object UpdateRequired : Error()
+
+ data object NotAvailable : Error()
+ }
+}
+
+class HealthConnectClientProviderImpl(
+ private val context: Context,
+) : HealthConnectClientProvider {
+
+ private var client: HealthConnectClient? = null
+
+ override fun invoke(): Result = runCatching {
+ when (HealthConnectClient.getSdkStatus(context, PROVIDER_PACKAGE_NAME)) {
+ HealthConnectClient.SDK_AVAILABLE -> {
+ client ?: synchronized(this) {
+ HealthConnectClient.getOrCreate(context).also { client = it }
+ }
+ }
+
+ HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> {
+ throw HealthConnectClientProvider.Error.UpdateRequired
+ }
+
+ else -> {
+ throw HealthConnectClientProvider.Error.NotAvailable
+ }
+ }
+ }
+
+ override suspend fun performUpdate(): Result = runCatching {
+ // Redirect to package installer to find a provider
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ setPackage("com.android.vending")
+ data = Uri.parse(
+ "market://details?id=$PROVIDER_PACKAGE_NAME&url=healthconnect%3A%2F%2Fonboarding"
+ )
+ putExtra("overlay", true)
+ putExtra("callerId", context.packageName)
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+ }
+ context.startActivity(intent)
+ }
+
+ private companion object {
+ private const val PROVIDER_PACKAGE_NAME = "com.google.android.apps.healthdata"
+ }
+}
diff --git a/app/src/main/java/com/feelsoftware/feelfine/fit/HealthConnectPermissionManager.kt b/app/src/main/java/com/feelsoftware/feelfine/fit/HealthConnectPermissionManager.kt
new file mode 100644
index 0000000..3419514
--- /dev/null
+++ b/app/src/main/java/com/feelsoftware/feelfine/fit/HealthConnectPermissionManager.kt
@@ -0,0 +1,110 @@
+package com.feelsoftware.feelfine.fit
+
+import androidx.activity.ComponentActivity
+import androidx.activity.result.ActivityResultLauncher
+import androidx.health.connect.client.PermissionController
+import androidx.health.connect.client.permission.HealthPermission
+import androidx.health.connect.client.records.ExerciseSessionRecord
+import androidx.health.connect.client.records.SleepSessionRecord
+import androidx.health.connect.client.records.StepsRecord
+import androidx.lifecycle.lifecycleScope
+import kotlin.coroutines.resume
+import kotlinx.coroutines.CancellableContinuation
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.suspendCancellableCoroutine
+
+interface HealthConnectPermissionManager {
+
+ fun init(activity: ComponentActivity)
+
+ fun dispose()
+
+ fun hasPermission(): StateFlow
+
+ suspend fun requestPermission(): Result
+}
+
+class HealthConnectPermissionManagerImpl(
+ private val clientProvider: HealthConnectClientProvider,
+) : HealthConnectPermissionManager {
+
+ private val requiredPermissions: Set = setOf(
+ HealthPermission.getReadPermission(ExerciseSessionRecord::class),
+ HealthPermission.getReadPermission(SleepSessionRecord::class),
+ HealthPermission.getReadPermission(StepsRecord::class),
+ )
+
+ private var activity: ComponentActivity? = null
+ private var permissionsLauncher: ActivityResultLauncher>? = null
+ private var requestPermissionContinuation: CancellableContinuation? = null
+
+ private inline val coroutineScope: CoroutineScope?
+ get() = activity?.lifecycleScope
+
+ private val hasPermissionFlow = MutableStateFlow(false)
+
+ override fun init(activity: ComponentActivity) {
+ this.activity = activity
+ permissionsLauncher = activity.registerForActivityResult(
+ PermissionController.createRequestPermissionResultContract()
+ ) { granted ->
+ val hasPermission = granted.containsAll(requiredPermissions)
+ hasPermissionFlow.update { hasPermission }
+ requestPermissionContinuation?.resume(hasPermission)
+ requestPermissionContinuation = null
+ }
+ }
+
+ override fun dispose() {
+ activity = null
+ permissionsLauncher = null
+ requestPermissionContinuation?.cancel()
+ requestPermissionContinuation = null
+ }
+
+ override fun hasPermission(): StateFlow {
+ coroutineScope?.launch { hasPermissionInternal() }
+ return hasPermissionFlow.asStateFlow()
+ }
+
+ override suspend fun requestPermission(): Result {
+ return hasPermissionInternal()
+ .mapCatching { hasPermission ->
+ if (hasPermission) {
+ // Permissions already granted
+ true
+ } else {
+ // Lack of required permissions
+ requestPermissionContinuation?.cancel()
+ suspendCancellableCoroutine {
+ requestPermissionContinuation = it
+
+ requireNotNull(permissionsLauncher) {
+ "Permissions launcher is not ready, did you call init?"
+ }.launch(requiredPermissions)
+ }
+ }
+ }
+ }
+
+ private suspend fun hasPermissionInternal(): Result {
+ return clientProvider()
+ .mapCatching { client ->
+ client.permissionController.getGrantedPermissions()
+ }
+ .mapCatching { grantedPermissions ->
+ grantedPermissions.containsAll(requiredPermissions)
+ }
+ .onSuccess {
+ hasPermissionFlow.value = it
+ }
+ .onFailure {
+ hasPermissionFlow.value = false
+ }
+ }
+}
diff --git a/app/src/main/java/com/feelsoftware/feelfine/fit/HealthConnectRepository.kt b/app/src/main/java/com/feelsoftware/feelfine/fit/HealthConnectRepository.kt
new file mode 100644
index 0000000..b3d8c0d
--- /dev/null
+++ b/app/src/main/java/com/feelsoftware/feelfine/fit/HealthConnectRepository.kt
@@ -0,0 +1,188 @@
+@file:Suppress("SameParameterValue")
+
+package com.feelsoftware.feelfine.fit
+
+import androidx.health.connect.client.aggregate.AggregateMetric
+import androidx.health.connect.client.records.ExerciseSessionRecord
+import androidx.health.connect.client.records.Record
+import androidx.health.connect.client.records.SleepSessionRecord
+import androidx.health.connect.client.records.StepsRecord
+import androidx.health.connect.client.request.AggregateRequest
+import androidx.health.connect.client.request.ReadRecordsRequest
+import androidx.health.connect.client.time.TimeRangeFilter
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.datetime.DatePeriod
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.LocalDateTime
+import kotlinx.datetime.atTime
+import kotlinx.datetime.minus
+import kotlinx.datetime.toJavaLocalDateTime
+
+data class Activity(
+ val date: LocalDate,
+ val walking: Duration,
+ val running: Duration,
+ val other: Duration,
+)
+
+data class Sleep(
+ val date: LocalDate,
+ val lightSleep: Duration,
+ val deepSleep: Duration,
+ val awake: Duration,
+ val outOfBed: Duration,
+)
+
+data class Steps(
+ val date: LocalDate,
+ val count: Long,
+)
+
+interface HealthConnectRepository {
+
+ suspend fun getActivity(date: LocalDate): Result
+
+ suspend fun getSleep(date: LocalDate): Result
+
+ suspend fun getSteps(date: LocalDate): Result
+}
+
+class HealthConnectRepositoryImpl(
+ private val clientProvider: HealthConnectClientProvider,
+ permissionManager: HealthConnectPermissionManager,
+) : HealthConnectRepository {
+
+ private val hasPermission: StateFlow = permissionManager.hasPermission()
+
+ override suspend fun getActivity(date: LocalDate): Result {
+ return get(
+ startTime = date.dayStart(),
+ endTime = date.dayEnd(),
+ ).mapCatching { records ->
+ val walking = records.duration(ExerciseSessionRecord.EXERCISE_TYPE_WALKING)
+ val running = records.duration(ExerciseSessionRecord.EXERCISE_TYPE_RUNNING)
+ val other = records.duration() - walking - running
+
+ Activity(
+ date = date,
+ walking = walking,
+ running = running,
+ other = other,
+ )
+ }
+ }
+
+ override suspend fun getSleep(date: LocalDate): Result {
+ return get(
+ startTime = date.minus(DatePeriod(days = 1)).atTime(hour = 12, minute = 0, second = 0),
+ endTime = date.atTime(hour = 12, minute = 0, second = 0),
+ ).mapCatching { records ->
+ val lightSleep = records.duration(SleepSessionRecord.STAGE_TYPE_LIGHT) +
+ records.duration(SleepSessionRecord.STAGE_TYPE_REM) +
+ records.duration(SleepSessionRecord.STAGE_TYPE_UNKNOWN)
+ val deepSleep = records.duration(SleepSessionRecord.STAGE_TYPE_DEEP)
+ val awake = records.duration(SleepSessionRecord.STAGE_TYPE_AWAKE)
+ val outOfBed = records.duration(SleepSessionRecord.STAGE_TYPE_OUT_OF_BED)
+
+ Sleep(
+ date = date,
+ lightSleep = lightSleep,
+ deepSleep = deepSleep,
+ awake = awake,
+ outOfBed = outOfBed,
+ )
+ }
+ }
+
+ override suspend fun getSteps(date: LocalDate): Result {
+ return aggregate(
+ startTime = date.dayStart(),
+ endTime = date.dayEnd(),
+ metrics = setOf(StepsRecord.COUNT_TOTAL),
+ )
+ .mapCatching { records ->
+ val steps = records[StepsRecord.COUNT_TOTAL] as? Long ?: 0L
+
+ Steps(
+ date = date,
+ count = steps,
+ )
+ }
+ }
+
+ private suspend inline fun get(
+ startTime: LocalDateTime,
+ endTime: LocalDateTime,
+ ): Result> = runCatching {
+ if (!hasPermission.value) {
+ return@runCatching emptyList()
+ }
+ val client = clientProvider().getOrThrow()
+
+ val response = client.readRecords(
+ ReadRecordsRequest(
+ recordType = T::class,
+ timeRangeFilter = TimeRangeFilter.between(
+ startTime = startTime.toJavaLocalDateTime(),
+ endTime = endTime.toJavaLocalDateTime(),
+ )
+ )
+ )
+
+ response.records
+ }
+
+ private suspend fun aggregate(
+ startTime: LocalDateTime,
+ endTime: LocalDateTime,
+ metrics: Set>,
+ ): Result