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, Number>> = runCatching { + if (!hasPermission.value) { + return@runCatching emptyMap() + } + val client = clientProvider().getOrThrow() + + val response = client.aggregate( + AggregateRequest( + metrics = metrics, + timeRangeFilter = TimeRangeFilter.between( + startTime = startTime.toJavaLocalDateTime(), + endTime = endTime.toJavaLocalDateTime(), + ), + ) + ) + + metrics.associateWith { metric -> (response[metric] ?: 0L) } + } + + @JvmName("exerciseDuration") + private fun List.duration( + @ExerciseSessionRecord.ExerciseTypes type: Int, + ): Duration { + return filter { it.exerciseType == type } + .duration() + } + + private fun List.duration(): Duration { + return sumOf { it.endTime.epochSecond - it.startTime.epochSecond } + .seconds + } + + @JvmName("sleepDuration") + private fun List.duration( + @SleepSessionRecord.StageTypes type: Int + ): Duration { + return map { record -> record.stages.filter { type == it.stage } } + .flatten() + .sumOf { it.endTime.epochSecond - it.startTime.epochSecond } + .seconds + } + + private fun LocalDate.dayStart(): LocalDateTime = + atTime(hour = 0, minute = 0, second = 0) + + private fun LocalDate.dayEnd(): LocalDateTime = + atTime(hour = 23, minute = 59, second = 59) +} diff --git a/app/src/main/java/com/feelsoftware/feelfine/fit/HealthConnectWrappers.kt b/app/src/main/java/com/feelsoftware/feelfine/fit/HealthConnectWrappers.kt new file mode 100644 index 0000000..5fecda4 --- /dev/null +++ b/app/src/main/java/com/feelsoftware/feelfine/fit/HealthConnectWrappers.kt @@ -0,0 +1,266 @@ +package com.feelsoftware.feelfine.fit + +import android.content.Intent +import com.feelsoftware.feelfine.fit.model.ActivityInfo +import com.feelsoftware.feelfine.fit.model.Duration as LegacyDuration +import androidx.activity.ComponentActivity +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.UserRepository +import com.feelsoftware.feelfine.fit.model.SleepInfo +import com.feelsoftware.feelfine.fit.model.StepsInfo +import com.feelsoftware.feelfine.utils.ActivityEngine +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +import java.util.Calendar +import java.util.Date +import kotlin.time.Duration +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.datetime.DatePeriod +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.minus +import kotlinx.datetime.plus +import kotlinx.datetime.toLocalDateTime +import timber.log.Timber + +@Suppress("DEPRECATION") +@Deprecated("Migrate to HealthConnectPermissionManager") +class HealthConnectFitPermissionManagerWrapper( + private val activityDao: ActivityDao, + activityEngine: ActivityEngine, + private val permissionManager: HealthConnectPermissionManager, + private val sleepDao: SleepDao, + private val stepsDao: StepsDao, + private val userRepository: UserRepository, +) : FitPermissionManager { + + init { + activityEngine.registerCallback(object : ActivityEngine.Callback { + override fun onActivityCreated(activity: ComponentActivity) { + permissionManager.init(activity) + } + + override fun onActivityDestroyed() { + permissionManager.dispose() + } + }) + } + + override fun hasPermission(): Boolean { + return permissionManager.hasPermission().value + } + + override fun hasPermissionObservable(): Observable { + return createRxObservable { + permissionManager.hasPermission() + } + } + + override fun requestPermission(): Single { + return createRxSingle { + permissionManager.requestPermission() + }.flatMap { hasPermission -> + if (hasPermission) { + userRepository.getProfileLegacy() + .firstOrError() + .flatMapCompletable { profile -> + userRepository.setProfileLegacy(profile.copy(isDemo = false)) + } + // Clear cached mocked data + .andThen(activityDao.delete()) + .andThen(sleepDao.delete()) + .andThen(stepsDao.delete()) + .subscribeOn(Schedulers.io()) + .andThen(Single.just(true)) + } else { + Single.just(false) + } + } + } + + override fun resetPermission(): Single { + return Single.just(false) + } + + override fun onPermissionResult(requestCode: Int, resultCode: Int, data: Intent?) {} +} + +@Deprecated("Migrate to HealthConnectRepository") +class HealthConnectFitRepositoryWrapper( + private val repository: HealthConnectRepository, +) : FitRepository { + + override fun getSteps(startTime: Date, endTime: Date): Single> { + return getData( + startTime = startTime, + endTime = endTime, + fetcher = { + repository.getSteps(date = it) + .mapCatching { steps -> + StepsInfo( + date = steps.date.toJavaDate(), + count = steps.count.toInt(), + ) + } + }, + fallback = { + StepsInfo( + date = it, + count = 0, + ) + }, + ) + } + + override fun getSleep(startTime: Date, endTime: Date): Single> { + return getData( + startTime = startTime, + endTime = endTime, + fetcher = { + repository.getSleep(date = it) + .mapCatching { sleep -> + SleepInfo( + date = sleep.date.toJavaDate(), + lightSleep = sleep.lightSleep.toDuration(), + deepSleep = sleep.deepSleep.toDuration(), + awake = sleep.awake.toDuration(), + outOfBed = sleep.outOfBed.toDuration(), + ) + } + }, + fallback = { + SleepInfo( + date = endTime, + lightSleep = LegacyDuration(0), + deepSleep = LegacyDuration(0), + awake = LegacyDuration(0), + outOfBed = LegacyDuration(0), + ) + }, + ) + } + + override fun getActivity(startTime: Date, endTime: Date): Single> { + return getData( + startTime = startTime, + endTime = endTime, + fetcher = { + repository.getActivity(date = it) + .mapCatching { activity -> + ActivityInfo( + date = activity.date.toJavaDate(), + activityUnknown = activity.other.toDuration(), + activityWalking = activity.walking.toDuration(), + activityRunning = activity.running.toDuration(), + ) + } + }, + fallback = { + ActivityInfo( + date = endTime, + activityUnknown = LegacyDuration(0), + activityWalking = LegacyDuration(0), + activityRunning = LegacyDuration(0), + ) + }, + ) + } + + private inline fun getData( + startTime: Date, + endTime: Date, + crossinline fetcher: suspend (LocalDate) -> Result, + crossinline fallback: (Date) -> T, + ): Single> { + return createRxSingle { + var date = startTime.toLocalDate() + val days = endTime.toLocalDate() + .minus(startTime.toLocalDate()).days + 1 + val results = List(days) { + async { + fetcher(date.also { date = date.plus(DatePeriod(days = 1)) }) + } + }.awaitAll() + + // Return error only if all results failed, otherwise fallback to zero steps and log the error + if (results.all { it.isFailure }) { + return@createRxSingle Result.failure(results.first().exceptionOrNull()!!) + } + + results + .mapIndexed { day, result -> + result.getOrElse { + Timber.e(it, "Failed to get ${T::class}") + fallback( + startTime.toLocalDate() + .plus(DatePeriod(days = day)) + .toJavaDate() + ) + } + } + .let { Result.success(it) } + } + } +} + +private fun Date.toLocalDate(): LocalDate { + return Instant.fromEpochMilliseconds(time) + .toLocalDateTime(TimeZone.currentSystemDefault()).date +} + +private fun LocalDate.toJavaDate(): Date { + return Calendar.getInstance().apply { + set(Calendar.DAY_OF_MONTH, dayOfMonth) + set(Calendar.MONTH, monthNumber - 1) + set(Calendar.YEAR, year) + }.time +} + +private fun Duration.toDuration(): LegacyDuration { + return LegacyDuration(inWholeMinutes.toInt()) +} + +private fun createRxSingle(block: suspend CoroutineScope.() -> Result): Single { + return Single.create { emitter -> + val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + coroutineScope.launch { + block() + .onSuccess { emitter.onSuccess(it) } + .onFailure { emitter.onError(it) } + } + + emitter.setCancellable { + coroutineScope.cancel() + } + } +} + +private fun createRxObservable(block: () -> Flow): Observable { + return Observable.create { emitter -> + val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + block() + .onEach { emitter.onNext(it) } + .catch { emitter.onError(it) } + .launchIn(coroutineScope) + + emitter.setCancellable { + coroutineScope.cancel() + } + } +} diff --git a/app/src/main/java/com/feelsoftware/feelfine/fit/model/StepsInfo.kt b/app/src/main/java/com/feelsoftware/feelfine/fit/model/StepsInfo.kt index 0c64939..af48d99 100644 --- a/app/src/main/java/com/feelsoftware/feelfine/fit/model/StepsInfo.kt +++ b/app/src/main/java/com/feelsoftware/feelfine/fit/model/StepsInfo.kt @@ -1,6 +1,6 @@ package com.feelsoftware.feelfine.fit.model -import java.util.* +import java.util.Date data class StepsInfo( val date: Date, diff --git a/app/src/main/java/com/feelsoftware/feelfine/permission/PermissionRationaleView.kt b/app/src/main/java/com/feelsoftware/feelfine/permission/PermissionRationaleView.kt new file mode 100644 index 0000000..5033a50 --- /dev/null +++ b/app/src/main/java/com/feelsoftware/feelfine/permission/PermissionRationaleView.kt @@ -0,0 +1,49 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package com.feelsoftware.feelfine.permission + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.feelsoftware.feelfine.R +import com.feelsoftware.feelfine.ui.theme.FeelFineTheme + +@Composable +fun PermissionRationaleView() { + Scaffold( + topBar = { + TopAppBar( + title = { + Text(stringResource(R.string.app_name)) + } + ) + } + ) { + Column( + modifier = Modifier + .padding(it) + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + Text(stringResource(R.string.health_connect_permission_details)) + } + } +} + +@Composable +@Preview +private fun PermissionRationaleViewPreview() { + FeelFineTheme { + PermissionRationaleView() + } +} diff --git a/app/src/main/java/com/feelsoftware/feelfine/permission/PermissionsRationaleActivity.kt b/app/src/main/java/com/feelsoftware/feelfine/permission/PermissionsRationaleActivity.kt new file mode 100644 index 0000000..27931d0 --- /dev/null +++ b/app/src/main/java/com/feelsoftware/feelfine/permission/PermissionsRationaleActivity.kt @@ -0,0 +1,19 @@ +package com.feelsoftware.feelfine.permission + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import com.feelsoftware.feelfine.ui.theme.FeelFineTheme + +class PermissionsRationaleActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + FeelFineTheme { + PermissionRationaleView() + } + } + } +} diff --git a/app/src/main/java/com/feelsoftware/feelfine/sandbox/HealthConnectSandbox.kt b/app/src/main/java/com/feelsoftware/feelfine/sandbox/HealthConnectSandbox.kt new file mode 100644 index 0000000..d8c919b --- /dev/null +++ b/app/src/main/java/com/feelsoftware/feelfine/sandbox/HealthConnectSandbox.kt @@ -0,0 +1,110 @@ +package com.feelsoftware.feelfine.sandbox + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.feelsoftware.feelfine.fit.Activity +import com.feelsoftware.feelfine.fit.HealthConnectPermissionManager +import com.feelsoftware.feelfine.fit.HealthConnectRepository +import com.feelsoftware.feelfine.fit.Sleep +import com.feelsoftware.feelfine.fit.Steps +import kotlinx.coroutines.launch +import kotlinx.datetime.Clock +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +@Composable +fun HealthConnectSandbox( + permissionManager: HealthConnectPermissionManager, + repository: HealthConnectRepository, +) { + val coroutineScope = rememberCoroutineScope() + val hasPermission by permissionManager.hasPermission().collectAsState() + + var sleep by remember { mutableStateOf(null) } + var steps by remember { mutableStateOf(null) } + var activity by remember { mutableStateOf(null) } + + Scaffold { + Column( + modifier = Modifier + .fillMaxSize() + .padding(it) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = "Has permission $hasPermission") + Button( + enabled = !hasPermission, + onClick = { + coroutineScope.launch { + permissionManager.requestPermission() + } + }, + ) { + Text("Request permission") + } + + Text( + modifier = Modifier.padding(top = 16.dp), + text = "Sleep $sleep" + ) + Button( + onClick = { + coroutineScope.launch { + sleep = repository.getSleep(date = now()).getOrElse { null } + } + }, + ) { + Text("Get sleep") + } + + Text( + modifier = Modifier.padding(top = 16.dp), + text = "Steps $steps" + ) + Button( + onClick = { + coroutineScope.launch { + steps = repository.getSteps(date = now()).getOrElse { null } + } + }, + ) { + Text("Get steps") + } + + Text( + modifier = Modifier.padding(top = 16.dp), + text = "Activity $activity" + ) + Button( + onClick = { + coroutineScope.launch { + activity = repository.getActivity(date = now()).getOrElse { null } + } + }, + ) { + Text("Get activity") + } + } + } +} + +private fun now(): LocalDate { + return Clock.System.now() + .toLocalDateTime(TimeZone.currentSystemDefault()).date +} diff --git a/app/src/main/java/com/feelsoftware/feelfine/sandbox/SandboxActivity.kt b/app/src/main/java/com/feelsoftware/feelfine/sandbox/SandboxActivity.kt new file mode 100644 index 0000000..36dcfc2 --- /dev/null +++ b/app/src/main/java/com/feelsoftware/feelfine/sandbox/SandboxActivity.kt @@ -0,0 +1,48 @@ +package com.feelsoftware.feelfine.sandbox + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import com.feelsoftware.feelfine.fit.HealthConnectClientProvider +import com.feelsoftware.feelfine.fit.HealthConnectClientProviderImpl +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.ui.theme.FeelFineTheme + +class SandboxActivity : AppCompatActivity() { + + private val clientProvider: HealthConnectClientProvider = + HealthConnectClientProviderImpl( + context = this, + ) + private val permissionManager: HealthConnectPermissionManager = + HealthConnectPermissionManagerImpl( + clientProvider = clientProvider, + ) + private val repository: HealthConnectRepository = + HealthConnectRepositoryImpl( + clientProvider = clientProvider, + permissionManager = permissionManager, + ) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + permissionManager.init(this) + + setContent { + FeelFineTheme { + HealthConnectSandbox( + permissionManager = permissionManager, + repository = repository, + ) + } + } + } + + override fun onDestroy() { + super.onDestroy() + permissionManager.dispose() + } +} diff --git a/app/src/main/java/com/feelsoftware/feelfine/utils/ActivityEngine.kt b/app/src/main/java/com/feelsoftware/feelfine/utils/ActivityEngine.kt index 33b42ba..424e329 100644 --- a/app/src/main/java/com/feelsoftware/feelfine/utils/ActivityEngine.kt +++ b/app/src/main/java/com/feelsoftware/feelfine/utils/ActivityEngine.kt @@ -3,19 +3,31 @@ package com.feelsoftware.feelfine.utils import android.app.Activity import android.app.Application import android.os.Bundle +import androidx.activity.ComponentActivity import com.feelsoftware.feelfine.MainActivity import timber.log.Timber interface ActivityEngine { - val activity: Activity? + val activity: ComponentActivity? + + fun registerCallback(callback: Callback) + + interface Callback { + + fun onActivityCreated(activity: ComponentActivity) + + fun onActivityDestroyed() + } } class ActivityEngineImpl( application: Application ) : ActivityEngine { - override var activity: Activity? = null + override var activity: ComponentActivity? = null + + private val callbacks = mutableListOf() init { application.registerActivityLifecycleCallbacks(object : @@ -25,6 +37,7 @@ class ActivityEngineImpl( Timber.d("onActivityCreated $activity") if (activity is MainActivity) { this@ActivityEngineImpl.activity = activity + callbacks.forEach { it.onActivityCreated(activity) } } } @@ -50,8 +63,13 @@ class ActivityEngineImpl( Timber.d("onActivityDestroyed $activity") if (activity is MainActivity) { this@ActivityEngineImpl.activity = null + callbacks.forEach { it.onActivityDestroyed() } } } }) } + + override fun registerCallback(callback: ActivityEngine.Callback) { + callbacks += callback + } } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 339a3b0..d1253c2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -61,4 +61,5 @@ Close Demo You are not signed to Google Account and now you see demo steps, sleep and activity.\nOpen Profile tab and Sign In to Google to see your real data from Google Fit. + FeelFine reads your fitness data (activity, sleep, steps) and keep that data locally, not shared to 3rd party services. \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8614d79..64827d4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,9 +15,11 @@ firebaseCrashlytics = "3.0.2" googleApiClient = "1.30.11" googleApiServicesFitness = "v1-rev127-1.25.0" googleServices = "4.4.2" +healthConnect = "1.1.0-alpha10" koin = "4.0.1" kotlin = "2.1.0" kotlinxCoroutines = "1.9.0" +kotlinxDateTime = "0.6.1" ksp = "2.1.0-1.0.29" lifecycleViewmodelKtx = "2.8.7" listenablefuture = "9999.0-empty-to-avoid-conflict-with-guava" @@ -55,9 +57,11 @@ fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragm google-api-client = { module = "com.google.api-client:google-api-client", version.ref = "googleApiClient" } google-api-client-android = { module = "com.google.api-client:google-api-client-android", version.ref = "googleApiClient" } google-api-services-fitness = { module = "com.google.apis:google-api-services-fitness", version.ref = "googleApiServicesFitness" } +health-connect = { module = "androidx.health.connect:connect-client", version.ref = "healthConnect" } koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } +kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDateTime" } lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "lifecycleViewmodelKtx" } lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycleViewmodelKtx" } lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleViewmodelKtx" }