Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,9 @@ dependencies {
ksp(libs.hilt.android.compiler)
implementation(libs.androidx.hilt.navigation.compose)

// Health connect
implementation(libs.androidx.health.connect)
Comment thread
IamDg marked this conversation as resolved.


// AboutLibraries to show used dependencies in jetpack compose
implementation(libs.aboutlibraries.compose.m3)
Expand Down
15 changes: 14 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@
xmlns:tools="http://schemas.android.com/tools">

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

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.health.WRITE_BODY_FAT" />
<uses-permission android:name="android.permission.health.WRITE_WEIGHT" />

<application
android:allowBackup="true"
Expand Down Expand Up @@ -54,9 +57,19 @@
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE_FOR_PERIOD" />

<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
</intent-filter>
</activity>

<activity-alias
android:name=".activities.HealthPermissionsRationaleActivity"
android:targetActivity=".activities.PrivacyActivity"
android:exported="true">
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent-filter>
</activity-alias>

<activity
android:name=".activities.ErrorActivity"
android:exported="true"
Expand All @@ -83,4 +96,4 @@
</service>

</application>
</manifest>
</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ private val SHOW_KEEP_ANDROID_OPEN_KEY = booleanPreferencesKey("showKeepAndroidO
private val USE_SCROLL_WHEEL_FOR_INPUT_KEY = booleanPreferencesKey("use_number_picker")
private val DISMISS_SCROLL_WHELL_INPUT_AUTOMATICALLY =
booleanPreferencesKey("dismiss_input_modal_bottom_sheet_automatically_key")
private val HEALTH_CONNECT_ENABLED_KEY = booleanPreferencesKey("health_connect_enabled")

/**
* A repository to handle user preferences using [androidx.datastore.core.DataStore].
Expand Down Expand Up @@ -160,6 +161,14 @@ class UserPreferencesRepository @Inject constructor(
initialValue = false
)

val healthConnectEnabled: StateFlow<Boolean> = dataStore.data
.map { preferences -> preferences[HEALTH_CONNECT_ENABLED_KEY] == true }
.stateIn(
scope = applicationScope,
started = SharingStarted.Eagerly,
initialValue = false
)

/**
* A Flow that emits the new Locale whenever the app's configuration changes.
*/
Expand Down Expand Up @@ -255,4 +264,8 @@ class UserPreferencesRepository @Inject constructor(
preferences[DISMISS_SCROLL_WHELL_INPUT_AUTOMATICALLY] = dismissAutomatically
}
}

suspend fun saveHealthConnectEnabled(isEnabled: Boolean) {
dataStore.edit { preferences -> preferences[HEALTH_CONNECT_ENABLED_KEY] = isEnabled }
}
}
101 changes: 101 additions & 0 deletions app/src/main/java/org/librefit/health/HealthConnectRepository.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
* Copyright (c) 2026. The LibreFit Contributors
*
* LibreFit is subject to additional terms covering author attribution and trademark usage;
* see the ADDITIONAL_TERMS.md and TRADEMARK_POLICY.md files in the project root.
*/

package org.librefit.health

import android.content.Context
import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.HealthConnectClient.Companion.SDK_AVAILABLE
import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.BodyFatRecord
import androidx.health.connect.client.records.Record
import androidx.health.connect.client.records.WeightRecord
import androidx.health.connect.client.records.metadata.Metadata
import androidx.health.connect.client.units.Mass
import androidx.health.connect.client.units.Percentage
import dagger.hilt.android.qualifiers.ApplicationContext
import org.librefit.db.entity.Measurement
import java.time.ZoneId
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class HealthConnectRepository @Inject constructor(
@param:ApplicationContext private val context: Context
) {
val writePermissions: Set<String> = setOf(
HealthPermission.getWritePermission(BodyFatRecord::class),
HealthPermission.getWritePermission(WeightRecord::class)
)

private val healthConnectClient: HealthConnectClient by lazy {
HealthConnectClient.getOrCreate(context)
}

fun isAvailable(): Boolean {
return HealthConnectClient.getSdkStatus(context) == SDK_AVAILABLE
}

suspend fun hasWritePermissions(): Boolean {
if (!isAvailable()) return false

return healthConnectClient.permissionController
.getGrantedPermissions()
.containsAll(writePermissions)
}

suspend fun exportMeasurements(measurements: List<Measurement>): Int {
if (!hasWritePermissions()) {
throw SecurityException("Missing Health Connect write permissions")
}

val records = measurements.flatMap { it.toHealthConnectRecords() }
if (records.isEmpty()) return 0

healthConnectClient.insertRecords(records)
return records.size
}

private fun Measurement.toHealthConnectRecords(): List<Record> {
val zone = ZoneId.systemDefault()
val zonedDate = date.atZone(zone)
val instant = zonedDate.toInstant()
val zoneOffset = zonedDate.offset
val recordVersion = instant.toEpochMilli()

return buildList {
if (bodyWeight > 0.0) {
add(
WeightRecord(
metadata = Metadata.manualEntry(
clientRecordId = "librefit-measurement-$id-body-weight",
clientRecordVersion = recordVersion
),
time = instant,
zoneOffset = zoneOffset,
weight = Mass.kilograms(bodyWeight)
)
)
}

if (bodyFatPercentage > 0) {
add(
BodyFatRecord(
metadata = Metadata.manualEntry(
clientRecordId = "librefit-measurement-$id-body-fat",
clientRecordVersion = recordVersion
),
time = instant,
zoneOffset = zoneOffset,
percentage = Percentage(bodyFatPercentage.toDouble())
)
)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ fun PrivacyScreen(
stringResource(R.string.privacy_notice_summary)
+ "\n\n## " + stringResource(R.string.privacy_notice_optional_perm)
+ "\n\n" + stringResource(R.string.privacy_notice_notification_perm)
+ "\n\n" + stringResource(R.string.privacy_notice_health_connect_perm)
+ "\n\n## " + stringResource(R.string.privacy_notice_default_perm)
+ "\n\n" + stringResource(R.string.privacy_notice_foreground_perm)
)
Expand Down Expand Up @@ -97,4 +98,4 @@ private fun PrivacyScreenPreview() {
LibreFitTheme(dynamicColor = false, themeMode = ThemeMode.DARK) {
PrivacyScreen()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
* Copyright (c) 2026. The LibreFit Contributors
*
* LibreFit is subject to additional terms covering author attribution and trademark usage;
* see the ADDITIONAL_TERMS.md and TRADEMARK_POLICY.md files in the project root.
*/

package org.librefit.ui.screens.settings

data class HealthConnectState(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move this in ui/models

val status: HealthConnectStatus = HealthConnectStatus.UNAVAILABLE,
val isEnabled: Boolean = false,
val exportedRecords: Int? = null
) {
val isAvailable: Boolean
get() = status != HealthConnectStatus.UNAVAILABLE

val hasPermissions: Boolean
get() = status == HealthConnectStatus.READY ||
status == HealthConnectStatus.EXPORTING

val isExporting: Boolean
get() = status == HealthConnectStatus.EXPORTING

val isChecked: Boolean
get() = isEnabled && hasPermissions
}

enum class HealthConnectStatus {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move this in enums/healthConnect

UNAVAILABLE,
NEEDS_PERMISSIONS,
READY,
EXPORTING
}

enum class HealthConnectClickAction {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move this in enums/healthConnect

NONE,
REQUEST_PERMISSIONS
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
package org.librefit.ui.screens.settings

import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.animateContentSize
Expand Down Expand Up @@ -43,6 +44,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.health.connect.client.PermissionController
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
Expand Down Expand Up @@ -90,6 +92,14 @@ fun SettingsScreen(

val dismissScrollWheelInputAutomatically by viewModel.dismissScrollWheelInputAutomatically.collectAsStateWithLifecycle()

val healthConnectState by viewModel.healthConnectState.collectAsStateWithLifecycle()

val healthConnectPermissionLauncher = rememberLauncherForActivityResult(
contract = PermissionController.createRequestPermissionResultContract()
) { grantedPermissions ->
viewModel.updateHealthConnectPermissions(grantedPermissions)
}

preferences?.let {
PreferenceDialog(
currentPreference = currentPreference,
Expand All @@ -109,6 +119,7 @@ fun SettingsScreen(
keepWorkoutScreenOn = keepWorkoutScreenOn,
restTimerSoundOn = restTimerSoundOn,
isSupporter = isSupporter,
healthConnectState = healthConnectState,
useScrollWheelForInput = useScrollWheelForInput,
isWorkoutHeaderSticky = isWorkoutHeaderSticky,
dismissScrollWheelInputAutomatically = dismissScrollWheelInputAutomatically,
Expand All @@ -118,6 +129,14 @@ fun SettingsScreen(
onRestTimerSoundOnChange = viewModel::saveRestTimerSoundOn,
onIsWorkoutHeaderStickyChange = viewModel::saveIsWorkoutHeaderSticky,
onUseScrollWheelForInputChange = viewModel::saveUseScrollWheelForInput,
onHealthConnectClick = {
when (viewModel.onHealthConnectClick()) {
HealthConnectClickAction.REQUEST_PERMISSIONS -> {
healthConnectPermissionLauncher.launch(viewModel.healthConnectPermissions)
}
HealthConnectClickAction.NONE -> Unit
}
},
onDismissScrollWhellInputAutomaticallyChange = viewModel::saveDismissScrollWheelInputAutomatically
)
}
Expand All @@ -132,6 +151,7 @@ private fun SettingsScreenContent(
keepWorkoutScreenOn: Boolean,
restTimerSoundOn: Boolean,
isSupporter: Boolean,
healthConnectState: HealthConnectState,
isWorkoutHeaderSticky: Boolean,
useScrollWheelForInput: Boolean,
dismissScrollWheelInputAutomatically: Boolean,
Expand All @@ -141,6 +161,7 @@ private fun SettingsScreenContent(
onRestTimerSoundOnChange: (Boolean) -> Unit,
onIsWorkoutHeaderStickyChange: (Boolean) -> Unit,
onUseScrollWheelForInputChange: (Boolean) -> Unit,
onHealthConnectClick: () -> Unit,
onDismissScrollWhellInputAutomaticallyChange: (Boolean) -> Unit,
) {
LibreFitScaffold(
Expand Down Expand Up @@ -221,6 +242,36 @@ private fun SettingsScreenContent(
)
}

item {
SettingItem(
enabled = healthConnectState.isAvailable && !healthConnectState.isExporting,
onClick = onHealthConnectClick,
icon = painterResource(R.drawable.ic_monitor_weight),
settingName = stringResource(id = R.string.health_connect),
settingDesc = when {
!healthConnectState.isAvailable -> {
stringResource(id = R.string.health_connect_unavailable_desc)
}
healthConnectState.isExporting -> {
stringResource(id = R.string.health_connect_exporting_desc)
}
healthConnectState.exportedRecords != null -> {
stringResource(
id = R.string.health_connect_exported_desc,
healthConnectState.exportedRecords
)
}
healthConnectState.hasPermissions -> {
stringResource(id = R.string.health_connect_export_desc)
}
else -> {
stringResource(id = R.string.health_connect_permissions_desc)
}
},
isChecked = healthConnectState.isChecked
)
}

item {
SettingItem(
isChecked = isWorkoutHeaderSticky,
Expand Down Expand Up @@ -268,12 +319,14 @@ private fun SettingItem(
icon: Painter,
settingName: String,
settingDesc: String,
enabled: Boolean = true,
isChecked: Boolean? = null
) {
val haptic = LocalHapticFeedback.current

Button(
modifier = Modifier.animateContentSize(),
enabled = enabled,
onClick = {
haptic.performHapticFeedback(
hapticFeedbackType = isChecked?.let {
Expand Down Expand Up @@ -316,6 +369,7 @@ private fun SettingItem(
isChecked?.let {
Switch(
checked = it,
enabled = enabled,
onCheckedChange = null
)
}
Expand Down Expand Up @@ -345,6 +399,7 @@ fun SettingsScreenPreview() {
restTimerSoundOn = restTimerSoundOn,
updatePreferences = {},
isSupporter = Random.nextBoolean(),
healthConnectState = HealthConnectState(status = HealthConnectStatus.READY),
isWorkoutHeaderSticky = isWorkoutHeaderSticky,
useScrollWheelForInput = useScrollWheelForInput,
dismissScrollWheelInputAutomatically = Random.nextBoolean(),
Expand All @@ -353,7 +408,8 @@ fun SettingsScreenPreview() {
onRestTimerSoundOnChange = { restTimerSoundOn = it },
onIsWorkoutHeaderStickyChange = { isWorkoutHeaderSticky = it },
onUseScrollWheelForInputChange = { useScrollWheelForInput = it },
onHealthConnectClick = {},
onDismissScrollWhellInputAutomaticallyChange = {}
)
}
}
}
Loading
Loading