Skip to content
Open
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
270 changes: 202 additions & 68 deletions android/app/src/main/java/com/noop/ingest/HealthConnectImporter.kt

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion android/app/src/main/java/com/noop/ui/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2263,8 +2263,11 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
val granted = runCatching {
HealthConnectImporter.client(appContext).permissionController.getGrantedPermissions()
}.getOrDefault(emptySet())
val selectedPermissions = HealthConnectImporter.permissionsFor(
HealthConnectImporter.selectedCategories(appContext),
)
// Partial permissions are fine (#150): auto-import as long as at least one type is granted.
if (granted.none { it in HealthConnectImporter.PERMISSIONS }) return@withContext false
if (granted.none { it in selectedPermissions }) return@withContext false
// Pass the profile height so the importer can derive BMI (Health Connect has no BMI record).
runCatching { HealthConnectImporter.import(appContext, repository, profileStore.heightCm) }.isSuccess
}
Expand Down
27 changes: 22 additions & 5 deletions android/app/src/main/java/com/noop/ui/DataSourcesScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ fun DataSourcesScreen(vm: AppViewModel) {
val hcLastSync by vm.hcLastSync.collectAsStateWithLifecycle()
val hcWriteback by vm.hcWriteback.collectAsStateWithLifecycle()
val hcWbStatus by vm.hcWritebackStatus.collectAsStateWithLifecycle()
var hcReadCategories by remember {
mutableStateOf(HealthConnectImporter.selectedCategories(context))
}
// A background (BLE-path) writeback updates prefs, not the VM's flow — re-read on entry so the
// status line reflects the latest attempt whenever this screen is opened (#660).
LaunchedEffect(Unit) { vm.refreshHcWritebackStatus() }
Expand Down Expand Up @@ -284,7 +287,8 @@ fun DataSourcesScreen(vm: AppViewModel) {
val hcPermissionLauncher = rememberLauncherForActivityResult(
PermissionController.createRequestPermissionResultContract(),
) { granted ->
if (granted.any { it in HealthConnectImporter.PERMISSIONS }) {
val selectedPermissions = HealthConnectImporter.permissionsFor(hcReadCategories)
if (granted.any { it in selectedPermissions }) {
runImport { HealthConnectImporter.import(context, vm.repo, ProfileStore.from(context).heightCm) }
} else {
Toast.makeText(context, "Health Connect access not granted.", Toast.LENGTH_LONG).show()
Expand All @@ -306,16 +310,22 @@ fun DataSourcesScreen(vm: AppViewModel) {
val granted = runCatching {
HealthConnectImporter.client(context).permissionController.getGrantedPermissions()
}.getOrDefault(emptySet())
// #645: a user who predates the selector has nothing stored. Recover their real scope from
// what Android already grants BEFORE the checkboxes are read back, or a first visit would
// show Recovery-only and saving it would lock in the narrowing.
HealthConnectImporter.migrateSelectionFromGrants(context, granted)
hcReadCategories = HealthConnectImporter.selectedCategories(context)
val selectedPermissions = HealthConnectImporter.permissionsFor(hcReadCategories)
// `any` (not `all`) is deliberate — partial grants are supported (#150). But that alone
// would never ASK about a permission added in an update, so a newly-read type would come
// back empty forever (#949). Route through the request once when the set has grown.
if (granted.any { it in HealthConnectImporter.PERMISSIONS } &&
!HealthConnectImporter.hasUnaskedPermissions(context)
if (granted.any { it in selectedPermissions } &&
!HealthConnectImporter.hasUnaskedPermissions(context, hcReadCategories)
) {
runImport { HealthConnectImporter.import(context, vm.repo, ProfileStore.from(context).heightCm) }
} else {
HealthConnectImporter.markPermissionsAsked(context)
hcPermissionLauncher.launch(HealthConnectImporter.PERMISSIONS)
HealthConnectImporter.markPermissionsAsked(context, hcReadCategories)
hcPermissionLauncher.launch(selectedPermissions)
}
}
}
Expand Down Expand Up @@ -449,6 +459,13 @@ fun DataSourcesScreen(vm: AppViewModel) {
)
}
if (healthConnectAvailable) {
HealthConnectCategorySelector(
selected = hcReadCategories,
onSelectionChange = { categories ->
hcReadCategories = categories
HealthConnectImporter.setSelectedCategories(context, categories)
},
)
BackupButton(
label = uiString(R.string.l10n_data_sources_screen_import_from_health_connect_35d55e21),
icon = Icons.Filled.FileUpload,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package com.noop.ui

import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.noop.R
import com.noop.ingest.HealthConnectImporter.ImportCategory

/**
* Shared category consent surface for onboarding and Data Sources (#645). Keeping the same selector
* in both entry points prevents onboarding from quietly requesting a broader permission set than the
* settings flow. The last enabled category cannot be switched off because an empty import request has
* no useful or explainable result.
*/
@Composable
internal fun HealthConnectCategorySelector(
selected: Set<ImportCategory>,
onSelectionChange: (Set<ImportCategory>) -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
uiString(R.string.health_connect_categories_title),
style = NoopType.subhead,
color = Palette.textPrimary,
)
Text(
uiString(R.string.health_connect_categories_detail),
style = NoopType.footnote,
color = Palette.textTertiary,
)

ImportCategory.entries.forEach { category ->
val checked = category in selected
val canToggle = !checked || selected.size > 1
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
uiString(category.titleRes()),
style = NoopType.subhead,
color = Palette.textPrimary,
)
Text(
uiString(category.detailRes()),
style = NoopType.footnote,
color = Palette.textTertiary,
)
}
Switch(
checked = checked,
enabled = canToggle,
onCheckedChange = { enabled ->
val next = if (enabled) selected + category else selected - category
if (next.isNotEmpty()) onSelectionChange(next)
},
colors = SwitchDefaults.colors(
checkedThumbColor = Palette.surfaceBase,
checkedTrackColor = Palette.accent,
uncheckedThumbColor = Palette.textSecondary,
uncheckedTrackColor = Palette.surfaceInset,
uncheckedBorderColor = Palette.hairline,
),
)
}
}
}
}

@StringRes
private fun ImportCategory.titleRes(): Int = when (this) {
ImportCategory.RECOVERY -> R.string.health_connect_category_recovery
ImportCategory.ACTIVITY -> R.string.health_connect_category_activity
ImportCategory.BODY_COMPOSITION -> R.string.health_connect_category_body_composition
}

@StringRes
private fun ImportCategory.detailRes(): Int = when (this) {
ImportCategory.RECOVERY -> R.string.health_connect_category_recovery_detail
ImportCategory.ACTIVITY -> R.string.health_connect_category_activity_detail
ImportCategory.BODY_COMPOSITION -> R.string.health_connect_category_body_composition_detail
}
29 changes: 24 additions & 5 deletions android/app/src/main/java/com/noop/ui/OnboardingScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,9 @@ private fun ImportStep(viewModel: AppViewModel) {
// so a persisted busy=true would strand the buttons disabled with nothing running.
var busy by remember { mutableStateOf(false) }
var status by rememberSaveable { mutableStateOf<String?>(null) }
var hcReadCategories by remember {
mutableStateOf(HealthConnectImporter.selectedCategories(context))
}
val importingText = uiString(R.string.onboarding_importing)
val importLabel = uiString(R.string.onboarding_import_label)
val importFailed = uiString(R.string.onboarding_failed)
Expand Down Expand Up @@ -810,7 +813,8 @@ private fun ImportStep(viewModel: AppViewModel) {
val hcPermissionLauncher = rememberLauncherForActivityResult(
PermissionController.createRequestPermissionResultContract(),
) { granted ->
if (granted.any { it in HealthConnectImporter.PERMISSIONS }) {
val selectedPermissions = HealthConnectImporter.permissionsFor(hcReadCategories)
if (granted.any { it in selectedPermissions }) {
runImport { HealthConnectImporter.import(context, viewModel.repo, ProfileStore.from(context).heightCm) }
} else {
val message = healthConnectDenied
Expand All @@ -828,15 +832,21 @@ private fun ImportStep(viewModel: AppViewModel) {
val granted = runCatching {
HealthConnectImporter.client(context).permissionController.getGrantedPermissions()
}.getOrDefault(emptySet())
if (granted.any { it in HealthConnectImporter.PERMISSIONS } &&
!HealthConnectImporter.hasUnaskedPermissions(context)
// #645: a user who predates the selector has nothing stored. Recover their real scope from
// what Android already grants BEFORE the checkboxes are read back, or a first visit would
// show Recovery-only and saving it would lock in the narrowing.
HealthConnectImporter.migrateSelectionFromGrants(context, granted)
hcReadCategories = HealthConnectImporter.selectedCategories(context)
val selectedPermissions = HealthConnectImporter.permissionsFor(hcReadCategories)
if (granted.any { it in selectedPermissions } &&
!HealthConnectImporter.hasUnaskedPermissions(context, hcReadCategories)
) {
runImport { HealthConnectImporter.import(context, viewModel.repo, ProfileStore.from(context).heightCm) }
} else {
// Marked before launching so the request is made ONCE per permission set: a user who
// declines is not asked again on every visit (#949).
HealthConnectImporter.markPermissionsAsked(context)
hcPermissionLauncher.launch(HealthConnectImporter.PERMISSIONS)
HealthConnectImporter.markPermissionsAsked(context, hcReadCategories)
hcPermissionLauncher.launch(selectedPermissions)
}
}
}
Expand Down Expand Up @@ -870,6 +880,15 @@ private fun ImportStep(viewModel: AppViewModel) {
icon = Icons.Filled.MonitorHeart,
enabled = !busy && healthConnectAvailable,
) { startHealthConnect() }
if (healthConnectAvailable) {
HealthConnectCategorySelector(
selected = hcReadCategories,
onSelectionChange = { categories ->
hcReadCategories = categories
HealthConnectImporter.setSelectedCategories(context, categories)
},
)
}
OnboardingActionButton(
label = uiString(R.string.l10n_onboarding_screen_import_apple_health_export_077b5624),
icon = Icons.Filled.FavoriteBorder,
Expand Down
8 changes: 8 additions & 0 deletions android/app/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,14 @@
<string name="l10n_data_sources_screen_data_sources_5e43d6bb">Datenquellen</string>
<string name="l10n_data_sources_screen_every_3560d90b">Alle</string>
<string name="l10n_data_sources_screen_health_connect_be6bca3e">Gesundheit Connect</string>
<string name="health_connect_categories_title">Wähle aus, was NOOP lesen darf</string>
<string name="health_connect_categories_detail">Wähle die Health-Connect-Kategorien aus, die importiert werden sollen. Du kannst dies später ändern.</string>
<string name="health_connect_category_recovery">Erholung &amp; Wohlbefinden</string>
<string name="health_connect_category_recovery_detail">Herzfrequenz, HRV, Schlaf, SpO₂, Atmung und Flüssigkeitszufuhr</string>
<string name="health_connect_category_activity">Aktivität</string>
<string name="health_connect_category_activity_detail">Schritte, Kalorien, Trainingseinheiten, Distanz und VO₂max</string>
<string name="health_connect_category_body_composition">Körperzusammensetzung</string>
<string name="health_connect_category_body_composition_detail">Gewicht, Körperfett und fettfreie Masse</string>
<string name="l10n_data_sources_screen_import_apple_health_export_533fca27">Importieren Sie Apple Health Export ...</string>
<string name="l10n_data_sources_screen_import_from_health_connect_35d55e21">Import von Health Connect</string>
<string name="l10n_data_sources_screen_import_lifting_log_8fac7b68">Lifting Log ...</string>
Expand Down
8 changes: 8 additions & 0 deletions android/app/src/main/res/values-es/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,14 @@
<string name="l10n_data_sources_screen_data_sources_5e43d6bb">Fuentes de datos</string>
<string name="l10n_data_sources_screen_every_3560d90b">Cada uno</string>
<string name="l10n_data_sources_screen_health_connect_be6bca3e">Health Connect</string>
<string name="health_connect_categories_title">Elige qué puede leer NOOP</string>
<string name="health_connect_categories_detail">Selecciona las categorías de Health Connect que quieres importar. Puedes cambiarlo más tarde.</string>
<string name="health_connect_category_recovery">Recuperación y bienestar</string>
<string name="health_connect_category_recovery_detail">Frecuencia cardíaca, VFC, sueño, SpO₂, respiración e hidratación</string>
<string name="health_connect_category_activity">Actividad</string>
<string name="health_connect_category_activity_detail">Pasos, calorías, entrenamientos, distancia y VO₂ máx.</string>
<string name="health_connect_category_body_composition">Composición corporal</string>
<string name="health_connect_category_body_composition_detail">Peso, grasa corporal y masa magra</string>
<string name="l10n_data_sources_screen_import_apple_health_export_533fca27">Importar la exportación de Apple Health...</string>
<string name="l10n_data_sources_screen_import_from_health_connect_35d55e21">Importación de Health Connect</string>
<string name="l10n_data_sources_screen_import_lifting_log_8fac7b68">Registro de elevación de importación...</string>
Expand Down
8 changes: 8 additions & 0 deletions android/app/src/main/res/values-fr/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,14 @@
<string name="l10n_data_sources_screen_data_sources_5e43d6bb">Sources de données</string>
<string name="l10n_data_sources_screen_every_3560d90b">Chaque</string>
<string name="l10n_data_sources_screen_health_connect_be6bca3e">Connexion santé</string>
<string name="health_connect_categories_title">Choisissez ce que NOOP peut lire</string>
<string name="health_connect_categories_detail">Sélectionnez les catégories Health Connect à importer. Vous pourrez modifier ce choix plus tard.</string>
<string name="health_connect_category_recovery">Récupération et bien-être</string>
<string name="health_connect_category_recovery_detail">Fréquence cardiaque, VFC, sommeil, SpO₂, respiration et hydratation</string>
<string name="health_connect_category_activity">Activité</string>
<string name="health_connect_category_activity_detail">Pas, calories, entraînements, distance et VO₂ max</string>
<string name="health_connect_category_body_composition">Composition corporelle</string>
<string name="health_connect_category_body_composition_detail">Poids, masse grasse et masse maigre</string>
<string name="l10n_data_sources_screen_import_apple_health_export_533fca27">Importation Apple Health exportation...</string>
<string name="l10n_data_sources_screen_import_from_health_connect_35d55e21">Importation depuis Health Connect</string>
<string name="l10n_data_sources_screen_import_lifting_log_8fac7b68">Importer le log...</string>
Expand Down
8 changes: 8 additions & 0 deletions android/app/src/main/res/values-pl/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,14 @@
<string name="l10n_data_sources_screen_data_sources_5e43d6bb">Źródła danych</string>
<string name="l10n_data_sources_screen_every_3560d90b">Każdy</string>
<string name="l10n_data_sources_screen_health_connect_be6bca3e">Połączenie zdrowotne</string>
<string name="health_connect_categories_title">Wybierz dane, które NOOP może odczytywać</string>
<string name="health_connect_categories_detail">Wybierz kategorie Health Connect do zaimportowania. Możesz zmienić ten wybór później.</string>
<string name="health_connect_category_recovery">Regeneracja i zdrowie</string>
<string name="health_connect_category_recovery_detail">Tętno, HRV, sen, SpO₂, oddech i nawodnienie</string>
<string name="health_connect_category_activity">Aktywność</string>
<string name="health_connect_category_activity_detail">Kroki, kalorie, treningi, dystans i VO₂ max</string>
<string name="health_connect_category_body_composition">Skład ciała</string>
<string name="health_connect_category_body_composition_detail">Masa ciała, tkanka tłuszczowa i beztłuszczowa masa ciała</string>
<string name="l10n_data_sources_screen_import_apple_health_export_533fca27">Importuj eksport Apple Health…</string>
<string name="l10n_data_sources_screen_import_from_health_connect_35d55e21">Importuj z Health Connect</string>
<string name="l10n_data_sources_screen_import_lifting_log_8fac7b68">Importuj dziennik podnoszenia…</string>
Expand Down
8 changes: 8 additions & 0 deletions android/app/src/main/res/values-pt-rPT/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,14 @@
<string name="l10n_data_sources_screen_data_sources_5e43d6bb">Fontes de dados</string>
<string name="l10n_data_sources_screen_every_3560d90b">Cada</string>
<string name="l10n_data_sources_screen_health_connect_be6bca3e">Health Connect</string>
<string name="health_connect_categories_title">Escolha o que o NOOP pode ler</string>
<string name="health_connect_categories_detail">Selecione as categorias do Health Connect a importar. Pode alterar esta escolha mais tarde.</string>
<string name="health_connect_category_recovery">Recuperação e bem-estar</string>
<string name="health_connect_category_recovery_detail">Frequência cardíaca, VFC, sono, SpO₂, respiração e hidratação</string>
<string name="health_connect_category_activity">Atividade</string>
<string name="health_connect_category_activity_detail">Passos, calorias, treinos, distância e VO₂ máx.</string>
<string name="health_connect_category_body_composition">Composição corporal</string>
<string name="health_connect_category_body_composition_detail">Peso, gordura corporal e massa magra</string>
<string name="l10n_data_sources_screen_import_apple_health_export_533fca27">Importar exportação do Apple Health…</string>
<string name="l10n_data_sources_screen_import_from_health_connect_35d55e21">Importar do Health Connect</string>
<string name="l10n_data_sources_screen_import_lifting_log_8fac7b68">Importar registo de levantamento…</string>
Expand Down
Loading