Skip to content

Commit 2af5890

Browse files
authored
Health Connect migration
2 parents 16c4714 + 61c9b43 commit 2af5890

19 files changed

+1017
-27
lines changed

README.md

+14-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
## FeelFine - activity tracker app
22

3-
Application to track your fitness activities with [Google Fit API](https://developers.google.com/fit "Google Fit API").
3+
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.
44
- Kotlin
55
- Jetpack Compose
66
- [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.
3636

3737
<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" />
3838

39-
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.
39+
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.
4040

4141

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

4747
<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" />
4848

49-
Users could observe week/month/custom range activities statistics.
49+
Users could observe week/month/custom range activities statistics.
5050

5151

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

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

59-
We are asking each day 'How are you?' for the mood score with 9 options for users.
59+
Every day we are asking users 'How are you?' for the mood score with 9 options.
6060

6161

6262
------------
@@ -68,3 +68,13 @@ We are asking each day 'How are you?' for the mood score with 9 options for user
6868

6969
User profile with wage, age and goals (steps, sleep, activity) customizations.
7070

71+
72+
------------
73+
74+
75+
### What's next
76+
- Migrate to Kotlin Coroutines from RxJava
77+
- Migrate to Compose
78+
- Migrate to Kotlin Multiplatform (use cross-platform library https://github.com/vitoksmile/HealthKMP)
79+
- Night theme
80+
- Edit goals

app/build.gradle.kts

+3-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ android {
2222

2323
defaultConfig {
2424
applicationId = "com.feelsoftware.feelfine"
25-
minSdk = 23
25+
minSdk = 28
2626
//noinspection EditedTargetSdkVersion
2727
targetSdk = 35
2828
versionCode = props.getProperty("versionCode").toInt()
@@ -81,6 +81,7 @@ android {
8181
dependencies {
8282
implementation(libs.kotlinx.coroutines.core)
8383
implementation(libs.kotlinx.coroutines.android)
84+
implementation(libs.kotlinx.datetime)
8485

8586
implementation(libs.core.ktx)
8687
implementation(libs.appcompat)
@@ -116,6 +117,7 @@ dependencies {
116117
implementation(libs.firebase.crashlytics.ktx)
117118

118119
// Google Fit
120+
implementation(libs.health.connect)
119121
implementation(libs.play.services.fitness)
120122
implementation(libs.google.api.client)
121123
implementation(libs.google.api.client.android)

app/src/main/AndroidManifest.xml

+32
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@
99
android:name="com.google.android.gms.permission.AD_ID"
1010
tools:node="remove" />
1111

12+
<uses-permission android:name="android.permission.health.READ_EXERCISE" />
13+
<uses-permission android:name="android.permission.health.READ_SLEEP" />
14+
<uses-permission android:name="android.permission.health.READ_STEPS" />
15+
16+
<queries>
17+
<package android:name="com.google.android.apps.healthdata" />
18+
</queries>
19+
1220
<application
1321
android:name=".FeelFineApplication"
1422
android:allowBackup="false"
@@ -28,6 +36,30 @@
2836
<category android:name="android.intent.category.LAUNCHER" />
2937
</intent-filter>
3038
</activity>
39+
40+
<!-- For supported versions through Android 13, create an activity to show the rationale
41+
of Health Connect permissions once users click the privacy policy link. -->
42+
<activity
43+
android:name=".permission.PermissionsRationaleActivity"
44+
android:exported="true">
45+
<intent-filter>
46+
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
47+
</intent-filter>
48+
</activity>
49+
50+
<!-- For versions starting Android 14, create an activity alias to show the rationale
51+
of Health Connect permissions once users click the privacy policy link. -->
52+
<activity-alias
53+
android:name="ViewPermissionUsageActivity"
54+
android:exported="true"
55+
android:permission="android.permission.START_VIEW_PERMISSION_USAGE"
56+
android:targetActivity=".permission.PermissionsRationaleActivity">
57+
<intent-filter>
58+
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
59+
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
60+
</intent-filter>
61+
</activity-alias>
62+
3163
</application>
3264

3365
</manifest>

app/src/main/java/com/feelsoftware/feelfine/di/KoinInit.kt

+4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.feelsoftware.feelfine.di
22

33
import android.app.Application
4+
import com.feelsoftware.feelfine.fit.FitPermissionManager
45
import com.feelsoftware.feelfine.ui.onboarding.onboardingModule
56
import com.feelsoftware.feelfine.utils.ActivityEngine
67
import org.koin.android.ext.koin.androidContext
@@ -21,7 +22,10 @@ object KoinInit {
2122
utilsModule,
2223
onboardingModule,
2324
)
25+
// FIXME: ActivityEngine to initialize ActivityLifecycleCallbacks
2426
koin.get<ActivityEngine>()
27+
// FIXME: FitPermissionManager to initialize HealthConnectFitPermissionManagerWrapper#activityEngine
28+
koin.get<FitPermissionManager>()
2529
}
2630
}
2731
}

app/src/main/java/com/feelsoftware/feelfine/di/fit.kt

+74-19
Original file line numberDiff line numberDiff line change
@@ -5,57 +5,112 @@ package com.feelsoftware.feelfine.di
55
import com.feelsoftware.feelfine.data.db.dao.ActivityDao
66
import com.feelsoftware.feelfine.data.db.dao.SleepDao
77
import com.feelsoftware.feelfine.data.db.dao.StepsDao
8-
import com.feelsoftware.feelfine.data.repository.*
8+
import com.feelsoftware.feelfine.data.repository.ActivityDataRepository
9+
import com.feelsoftware.feelfine.data.repository.ActivityRemoteDataSource
10+
import com.feelsoftware.feelfine.data.repository.SleepDataRepository
11+
import com.feelsoftware.feelfine.data.repository.SleepRemoteDataSource
12+
import com.feelsoftware.feelfine.data.repository.StepsDataRepository
13+
import com.feelsoftware.feelfine.data.repository.StepsRemoteDataSource
14+
import com.feelsoftware.feelfine.data.repository.UserRepository
915
import com.feelsoftware.feelfine.fit.FitPermissionManager
1016
import com.feelsoftware.feelfine.fit.FitRepository
1117
import com.feelsoftware.feelfine.fit.GoogleFitPermissionManager
1218
import com.feelsoftware.feelfine.fit.GoogleFitRepository
19+
import com.feelsoftware.feelfine.fit.HealthConnectClientProvider
20+
import com.feelsoftware.feelfine.fit.HealthConnectClientProviderImpl
21+
import com.feelsoftware.feelfine.fit.HealthConnectFitPermissionManagerWrapper
22+
import com.feelsoftware.feelfine.fit.HealthConnectFitRepositoryWrapper
23+
import com.feelsoftware.feelfine.fit.HealthConnectPermissionManager
24+
import com.feelsoftware.feelfine.fit.HealthConnectPermissionManagerImpl
25+
import com.feelsoftware.feelfine.fit.HealthConnectRepository
26+
import com.feelsoftware.feelfine.fit.HealthConnectRepositoryImpl
1327
import com.feelsoftware.feelfine.fit.mock.MockFitRepository
1428
import com.feelsoftware.feelfine.fit.usecase.GetFitDataUseCase
1529
import com.feelsoftware.feelfine.utils.ActivityEngine
30+
import org.koin.android.ext.koin.androidApplication
31+
import org.koin.core.scope.Scope
1632
import org.koin.dsl.module
1733

1834
val fitModule = module {
35+
single<HealthConnectClientProvider> {
36+
HealthConnectClientProviderImpl(
37+
context = androidApplication(),
38+
)
39+
}
40+
single<HealthConnectPermissionManager> {
41+
HealthConnectPermissionManagerImpl(
42+
clientProvider = get<HealthConnectClientProvider>(),
43+
)
44+
}
45+
factory<HealthConnectRepository> {
46+
HealthConnectRepositoryImpl(
47+
clientProvider = get<HealthConnectClientProvider>(),
48+
permissionManager = get<HealthConnectPermissionManager>(),
49+
)
50+
}
1951
factory<FitRepository> {
2052
val profile = get<UserRepository>().getProfileLegacy().firstOrError().blockingGet()
2153
if (profile.isDemo) {
2254
MockFitRepository()
2355
} else {
24-
GoogleFitRepository(get<ActivityEngine>(), get<FitPermissionManager>())
56+
if (hasHealthConnect) {
57+
HealthConnectFitRepositoryWrapper(
58+
repository = get<HealthConnectRepository>(),
59+
)
60+
} else {
61+
GoogleFitRepository(
62+
activityEngine = get<ActivityEngine>(),
63+
permissionManager = get<FitPermissionManager>(),
64+
)
65+
}
2566
}
2667
}
2768
single<FitPermissionManager> {
28-
GoogleFitPermissionManager(
29-
get<ActivityDao>(),
30-
get<ActivityEngine>(),
31-
get<SleepDao>(),
32-
get<StepsDao>(),
33-
get<UserRepository>(),
34-
)
69+
if (hasHealthConnect) {
70+
HealthConnectFitPermissionManagerWrapper(
71+
activityDao = get<ActivityDao>(),
72+
activityEngine = get<ActivityEngine>(),
73+
permissionManager = get<HealthConnectPermissionManager>(),
74+
sleepDao = get<SleepDao>(),
75+
stepsDao = get<StepsDao>(),
76+
userRepository = get<UserRepository>(),
77+
)
78+
} else {
79+
GoogleFitPermissionManager(
80+
activityDao = get<ActivityDao>(),
81+
activityEngine = get<ActivityEngine>(),
82+
sleepDao = get<SleepDao>(),
83+
stepsDao = get<StepsDao>(),
84+
userRepository = get<UserRepository>(),
85+
)
86+
}
3587
}
3688
factory<GetFitDataUseCase> {
3789
GetFitDataUseCase(
38-
get<StepsDataRepository>(),
39-
get<SleepDataRepository>(),
40-
get<ActivityDataRepository>(),
90+
stepsRepository = get<StepsDataRepository>(),
91+
sleepRepository = get<SleepDataRepository>(),
92+
activityRepository = get<ActivityDataRepository>(),
4193
)
4294
}
4395
factory<StepsDataRepository> {
4496
StepsDataRepository(
45-
get<StepsDao>(),
46-
StepsRemoteDataSource(get<FitRepository>())
97+
localDataSource = get<StepsDao>(),
98+
remoteDataSource = StepsRemoteDataSource(get<FitRepository>())
4799
)
48100
}
49101
factory<SleepDataRepository> {
50102
SleepDataRepository(
51-
get<SleepDao>(),
52-
SleepRemoteDataSource(get<FitRepository>())
103+
localDataSource = get<SleepDao>(),
104+
remoteDataSource = SleepRemoteDataSource(get<FitRepository>())
53105
)
54106
}
55107
factory<ActivityDataRepository> {
56108
ActivityDataRepository(
57-
get<ActivityDao>(),
58-
ActivityRemoteDataSource(get<FitRepository>())
109+
localDataSource = get<ActivityDao>(),
110+
remoteDataSource = ActivityRemoteDataSource(get<FitRepository>())
59111
)
60112
}
61-
}
113+
}
114+
115+
private inline val Scope.hasHealthConnect: Boolean
116+
get() = get<HealthConnectClientProvider>().invoke().getOrNull() != null

app/src/main/java/com/feelsoftware/feelfine/fit/FitPermissionManager.kt

+1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ interface FitPermissionManager {
3535

3636
private const val REQUEST_CODE = 1717
3737

38+
@Deprecated("Migrate to HealthConnectPermissionManager")
3839
class GoogleFitPermissionManager(
3940
private val activityDao: ActivityDao,
4041
private val activityEngine: ActivityEngine,

app/src/main/java/com/feelsoftware/feelfine/fit/FitRepository.kt

+1
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ interface FitRepository {
3434
fun getActivity(startTime: Date, endTime: Date): Single<List<ActivityInfo>>
3535
}
3636

37+
@Deprecated("Migrate to HealthConnectRepository")
3738
class GoogleFitRepository(
3839
private val activityEngine: ActivityEngine,
3940
private val permissionManager: FitPermissionManager,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package com.feelsoftware.feelfine.fit
2+
3+
import android.content.Context
4+
import android.content.Intent
5+
import android.net.Uri
6+
import androidx.health.connect.client.HealthConnectClient
7+
8+
/**
9+
* Provide instance of [HealthConnectClient].
10+
*/
11+
interface HealthConnectClientProvider {
12+
13+
/**
14+
* @return [Error] when `Result.failure`.
15+
*/
16+
operator fun invoke(): Result<HealthConnectClient>
17+
18+
/**
19+
* Call to perform update after `invoke()` returned `Error.UpdateRequired`.
20+
*/
21+
suspend fun performUpdate(): Result<Unit>
22+
23+
@Suppress("JavaIoSerializableObjectMustHaveReadResolve")
24+
sealed class Error : Throwable() {
25+
data object UpdateRequired : Error()
26+
27+
data object NotAvailable : Error()
28+
}
29+
}
30+
31+
class HealthConnectClientProviderImpl(
32+
private val context: Context,
33+
) : HealthConnectClientProvider {
34+
35+
private var client: HealthConnectClient? = null
36+
37+
override fun invoke(): Result<HealthConnectClient> = runCatching {
38+
when (HealthConnectClient.getSdkStatus(context, PROVIDER_PACKAGE_NAME)) {
39+
HealthConnectClient.SDK_AVAILABLE -> {
40+
client ?: synchronized(this) {
41+
HealthConnectClient.getOrCreate(context).also { client = it }
42+
}
43+
}
44+
45+
HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> {
46+
throw HealthConnectClientProvider.Error.UpdateRequired
47+
}
48+
49+
else -> {
50+
throw HealthConnectClientProvider.Error.NotAvailable
51+
}
52+
}
53+
}
54+
55+
override suspend fun performUpdate(): Result<Unit> = runCatching {
56+
// Redirect to package installer to find a provider
57+
val intent = Intent(Intent.ACTION_VIEW).apply {
58+
setPackage("com.android.vending")
59+
data = Uri.parse(
60+
"market://details?id=$PROVIDER_PACKAGE_NAME&url=healthconnect%3A%2F%2Fonboarding"
61+
)
62+
putExtra("overlay", true)
63+
putExtra("callerId", context.packageName)
64+
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
65+
}
66+
context.startActivity(intent)
67+
}
68+
69+
private companion object {
70+
private const val PROVIDER_PACKAGE_NAME = "com.google.android.apps.healthdata"
71+
}
72+
}

0 commit comments

Comments
 (0)