Skip to content

Commit

Permalink
Health Connect migration
Browse files Browse the repository at this point in the history
  • Loading branch information
feelsoftware authored Jan 8, 2025
2 parents 16c4714 + 61c9b43 commit 2af5890
Show file tree
Hide file tree
Showing 19 changed files with 1,017 additions and 27 deletions.
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -36,7 +36,7 @@ Four simple onboarding steps to pick your name, gender, weight and birthday.

<img src="https://github.com/feelsoftware/FeelFine/raw/main/readme/score_1.png" width="180" height="360" /> <img src="https://github.com/feelsoftware/FeelFine/raw/main/readme/score_2.png" width="180" height="360" /> <img src="https://github.com/feelsoftware/FeelFine/raw/main/readme/score_3.png" width="180" height="360" /> <img src="https://github.com/feelsoftware/FeelFine/raw/main/readme/score_4.png" width="180" height="360" />

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.


------------
Expand All @@ -46,7 +46,7 @@ A user’s data as steps, sleep, biking, running and other activities synced via

<img src="https://github.com/feelsoftware/FeelFine/raw/main/readme/statistic_1.png" width="180" height="360" /> <img src="https://github.com/feelsoftware/FeelFine/raw/main/readme/statistic_2.png" width="180" height="360" /> <img src="https://github.com/feelsoftware/FeelFine/raw/main/readme/statistic_3.png" width="180" height="360" />

Users could observe week/month/custom range activities statistics.
Users could observe week/month/custom range activities statistics.


------------
Expand All @@ -56,7 +56,7 @@ Users could observe week/month/custom range activities statistics.

<img src="https://github.com/feelsoftware/FeelFine/raw/main/readme/mood.png" width="180" height="360" />

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.


------------
Expand All @@ -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
4 changes: 3 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ android {

defaultConfig {
applicationId = "com.feelsoftware.feelfine"
minSdk = 23
minSdk = 28
//noinspection EditedTargetSdkVersion
targetSdk = 35
versionCode = props.getProperty("versionCode").toInt()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
32 changes: 32 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@
android:name="com.google.android.gms.permission.AD_ID"
tools:node="remove" />

<uses-permission android:name="android.permission.health.READ_EXERCISE" />
<uses-permission android:name="android.permission.health.READ_SLEEP" />
<uses-permission android:name="android.permission.health.READ_STEPS" />

<queries>
<package android:name="com.google.android.apps.healthdata" />
</queries>

<application
android:name=".FeelFineApplication"
android:allowBackup="false"
Expand All @@ -28,6 +36,30 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<!-- For supported versions through Android 13, create an activity to show the rationale
of Health Connect permissions once users click the privacy policy link. -->
<activity
android:name=".permission.PermissionsRationaleActivity"
android:exported="true">
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent-filter>
</activity>

<!-- For versions starting Android 14, create an activity alias to show the rationale
of Health Connect permissions once users click the privacy policy link. -->
<activity-alias
android:name="ViewPermissionUsageActivity"
android:exported="true"
android:permission="android.permission.START_VIEW_PERMISSION_USAGE"
android:targetActivity=".permission.PermissionsRationaleActivity">
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
</intent-filter>
</activity-alias>

</application>

</manifest>
4 changes: 4 additions & 0 deletions app/src/main/java/com/feelsoftware/feelfine/di/KoinInit.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -21,7 +22,10 @@ object KoinInit {
utilsModule,
onboardingModule,
)
// FIXME: ActivityEngine to initialize ActivityLifecycleCallbacks
koin.get<ActivityEngine>()
// FIXME: FitPermissionManager to initialize HealthConnectFitPermissionManagerWrapper#activityEngine
koin.get<FitPermissionManager>()
}
}
}
93 changes: 74 additions & 19 deletions app/src/main/java/com/feelsoftware/feelfine/di/fit.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<HealthConnectClientProvider> {
HealthConnectClientProviderImpl(
context = androidApplication(),
)
}
single<HealthConnectPermissionManager> {
HealthConnectPermissionManagerImpl(
clientProvider = get<HealthConnectClientProvider>(),
)
}
factory<HealthConnectRepository> {
HealthConnectRepositoryImpl(
clientProvider = get<HealthConnectClientProvider>(),
permissionManager = get<HealthConnectPermissionManager>(),
)
}
factory<FitRepository> {
val profile = get<UserRepository>().getProfileLegacy().firstOrError().blockingGet()
if (profile.isDemo) {
MockFitRepository()
} else {
GoogleFitRepository(get<ActivityEngine>(), get<FitPermissionManager>())
if (hasHealthConnect) {
HealthConnectFitRepositoryWrapper(
repository = get<HealthConnectRepository>(),
)
} else {
GoogleFitRepository(
activityEngine = get<ActivityEngine>(),
permissionManager = get<FitPermissionManager>(),
)
}
}
}
single<FitPermissionManager> {
GoogleFitPermissionManager(
get<ActivityDao>(),
get<ActivityEngine>(),
get<SleepDao>(),
get<StepsDao>(),
get<UserRepository>(),
)
if (hasHealthConnect) {
HealthConnectFitPermissionManagerWrapper(
activityDao = get<ActivityDao>(),
activityEngine = get<ActivityEngine>(),
permissionManager = get<HealthConnectPermissionManager>(),
sleepDao = get<SleepDao>(),
stepsDao = get<StepsDao>(),
userRepository = get<UserRepository>(),
)
} else {
GoogleFitPermissionManager(
activityDao = get<ActivityDao>(),
activityEngine = get<ActivityEngine>(),
sleepDao = get<SleepDao>(),
stepsDao = get<StepsDao>(),
userRepository = get<UserRepository>(),
)
}
}
factory<GetFitDataUseCase> {
GetFitDataUseCase(
get<StepsDataRepository>(),
get<SleepDataRepository>(),
get<ActivityDataRepository>(),
stepsRepository = get<StepsDataRepository>(),
sleepRepository = get<SleepDataRepository>(),
activityRepository = get<ActivityDataRepository>(),
)
}
factory<StepsDataRepository> {
StepsDataRepository(
get<StepsDao>(),
StepsRemoteDataSource(get<FitRepository>())
localDataSource = get<StepsDao>(),
remoteDataSource = StepsRemoteDataSource(get<FitRepository>())
)
}
factory<SleepDataRepository> {
SleepDataRepository(
get<SleepDao>(),
SleepRemoteDataSource(get<FitRepository>())
localDataSource = get<SleepDao>(),
remoteDataSource = SleepRemoteDataSource(get<FitRepository>())
)
}
factory<ActivityDataRepository> {
ActivityDataRepository(
get<ActivityDao>(),
ActivityRemoteDataSource(get<FitRepository>())
localDataSource = get<ActivityDao>(),
remoteDataSource = ActivityRemoteDataSource(get<FitRepository>())
)
}
}
}

private inline val Scope.hasHealthConnect: Boolean
get() = get<HealthConnectClientProvider>().invoke().getOrNull() != null
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ interface FitRepository {
fun getActivity(startTime: Date, endTime: Date): Single<List<ActivityInfo>>
}

@Deprecated("Migrate to HealthConnectRepository")
class GoogleFitRepository(
private val activityEngine: ActivityEngine,
private val permissionManager: FitPermissionManager,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<HealthConnectClient>

/**
* Call to perform update after `invoke()` returned `Error.UpdateRequired`.
*/
suspend fun performUpdate(): Result<Unit>

@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<HealthConnectClient> = 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<Unit> = 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"
}
}
Loading

0 comments on commit 2af5890

Please sign in to comment.