From fd10e15870e921006985b979d6ea5533982b9edd Mon Sep 17 00:00:00 2001 From: Kaveman <66108693+kavemang@users.noreply.github.com> Date: Fri, 21 Aug 2026 01:32:12 -0500 Subject: [PATCH 1/2] Split Health Connect import permissions --- .../com/noop/ingest/HealthConnectImporter.kt | 236 +++++++++++++----- .../src/main/java/com/noop/ui/AppViewModel.kt | 5 +- .../java/com/noop/ui/DataSourcesScreen.kt | 22 +- .../noop/ui/HealthConnectCategorySelector.kt | 96 +++++++ .../main/java/com/noop/ui/OnboardingScreen.kt | 24 +- .../app/src/main/res/values-de/strings.xml | 8 + .../app/src/main/res/values-es/strings.xml | 8 + .../app/src/main/res/values-fr/strings.xml | 8 + .../app/src/main/res/values-pl/strings.xml | 8 + .../src/main/res/values-pt-rPT/strings.xml | 8 + .../app/src/main/res/values-zh/strings.xml | 8 + android/app/src/main/res/values/strings.xml | 8 + .../HealthConnectPermissionCategoryTest.kt | 95 +++++++ 13 files changed, 454 insertions(+), 80 deletions(-) create mode 100644 android/app/src/main/java/com/noop/ui/HealthConnectCategorySelector.kt create mode 100644 android/app/src/test/java/com/noop/ingest/HealthConnectPermissionCategoryTest.kt diff --git a/android/app/src/main/java/com/noop/ingest/HealthConnectImporter.kt b/android/app/src/main/java/com/noop/ingest/HealthConnectImporter.kt index 01f3dc924d..89206eaf51 100644 --- a/android/app/src/main/java/com/noop/ingest/HealthConnectImporter.kt +++ b/android/app/src/main/java/com/noop/ingest/HealthConnectImporter.kt @@ -42,7 +42,7 @@ import kotlin.reflect.KClass /** * Native Android Health Connect importer. * - * Reads a fixed set of record types out of the on-device Health Connect store via + * Reads a user-selected subset of supported record types out of the on-device Health Connect store via * `androidx.health.connect:connect-client`, aggregates them **per LOCAL calendar day** * (the device's default zone), and upserts them into the same Room store the WHOOP/Apple * importers write to (see [WhoopRepository]). All timestamps written are wall-clock UNIX @@ -59,9 +59,10 @@ import kotlin.reflect.KClass * user has NO raw "my-whoop" rows, so the computed source is what marks their days as owned. * - Exercise sessions -> [WorkoutRow] with source "health-connect". * - * Permissions are assumed to have been granted by the UI (via the Health Connect permission - * flow) BEFORE [import] is called. If Health Connect is unavailable, or the required - * read permissions are not in fact granted, [import] returns [ImportSummary.failure]. + * The UI requests only the selected categories before [import] is called. Partial grants are valid: + * the importer reads granted types inside those categories and skips everything else. If Health + * Connect is unavailable or none of the selected permissions is granted, it returns + * [ImportSummary.failure]. */ object HealthConnectImporter { @@ -92,25 +93,67 @@ object HealthConnectImporter { * clipping keeps a neighbouring activity inside this window from over-counting. */ private const val DISTANCE_MATCH_BUFFER_S = 300L - /** The record types this importer reads, in one place so PERMISSIONS stays in sync. */ - private val READ_RECORDS: List> = listOf( - StepsRecord::class, - TotalCaloriesBurnedRecord::class, - ActiveCaloriesBurnedRecord::class, - HeartRateRecord::class, - RestingHeartRateRecord::class, - HeartRateVariabilityRmssdRecord::class, - SleepSessionRecord::class, - OxygenSaturationRecord::class, - RespiratoryRateRecord::class, - Vo2MaxRecord::class, - WeightRecord::class, - BodyFatRecord::class, - LeanBodyMassRecord::class, - ExerciseSessionRecord::class, - DistanceRecord::class, - HydrationRecord::class, - ) + /** + * User-selectable Health Connect read scopes (#645). Each record type belongs to exactly one + * category so the permission prompt can explain what the user is granting and the importer can + * skip a deselected category even when Android still holds an older grant for it. + */ + enum class ImportCategory( + val storageKey: String, + internal val recordTypes: Set>, + ) { + RECOVERY( + "recovery", + setOf( + HeartRateRecord::class, + RestingHeartRateRecord::class, + HeartRateVariabilityRmssdRecord::class, + SleepSessionRecord::class, + OxygenSaturationRecord::class, + RespiratoryRateRecord::class, + HydrationRecord::class, + ), + ), + ACTIVITY( + "activity", + setOf( + StepsRecord::class, + TotalCaloriesBurnedRecord::class, + ActiveCaloriesBurnedRecord::class, + Vo2MaxRecord::class, + ExerciseSessionRecord::class, + DistanceRecord::class, + ), + ), + BODY_COMPOSITION( + "body-composition", + setOf( + WeightRecord::class, + BodyFatRecord::class, + LeanBodyMassRecord::class, + ), + ), + } + + internal val ALL_CATEGORIES: Set = ImportCategory.entries.toSet() + internal val DEFAULT_CATEGORIES: Set = setOf(ImportCategory.RECOVERY) + + /** Every supported read permission. Kept as the compatibility/all-capabilities view. */ + val PERMISSIONS: Set = permissionsFor(ALL_CATEGORIES) + + /** The permissions represented by exactly [categories], with no implicit broadening. */ + fun permissionsFor(categories: Set): Set = + recordTypesFor(categories).mapTo(linkedSetOf()) { HealthPermission.getReadPermission(it) } + + internal fun recordTypesFor(categories: Set): Set> = + categories.flatMapTo(linkedSetOf()) { it.recordTypes } + + internal fun readableRecordTypes( + categories: Set, + grantedPermissions: Set, + ): Set> = recordTypesFor(categories).filterTo(linkedSetOf()) { + HealthPermission.getReadPermission(it) in grantedPermissions + } /** * Hydration import window, in days (#949) — deliberately much shorter than [WINDOW_YEARS]. @@ -124,36 +167,73 @@ object HealthConnectImporter { private const val HYDRATION_WINDOW_DAYS = 30L /** - * The set of Health Connect read-permission strings the UI must request before calling - * [import]. One `READ_*` permission per record type in [READ_RECORDS]. + * The selected categories. New installs start with the narrow recovery/wellness group; an + * existing install that was already prompted under the old all-at-once flow keeps all categories + * enabled so an update never silently stops importing data it imported before (#645). */ - val PERMISSIONS: Set = - READ_RECORDS.map { HealthPermission.getReadPermission(it) }.toSet() + fun selectedCategories(context: Context): Set { + val preferences = prefs(context) + return categoriesFromStoredKeys( + preferences.getStringSet(CATEGORY_SELECTION_KEY, null)?.toSet(), + preferences.contains(PERMISSION_SIGNATURE_KEY), + ) + } + + fun setSelectedCategories(context: Context, categories: Set) { + require(categories.isNotEmpty()) { "At least one Health Connect category must be selected" } + prefs(context).edit() + .putStringSet(CATEGORY_SELECTION_KEY, categories.mapTo(linkedSetOf()) { it.storageKey }) + .apply() + } + + internal fun categoriesFromStoredKeys( + storedKeys: Set?, + hadLegacyPermissionSignature: Boolean, + ): Set { + if (storedKeys != null) { + val restored = ImportCategory.entries.filterTo(linkedSetOf()) { it.storageKey in storedKeys } + if (restored.isNotEmpty()) return restored + } + return if (hadLegacyPermissionSignature) ALL_CATEGORIES else DEFAULT_CATEGORIES + } /** - * Whether the user has been asked about the CURRENT permission set (#949). + * Whether the user has been asked about every permission in the selected categories (#949/#645). * - * The import gate is `granted.any { ... }` by design (#150): partial grants are legitimate, so - * having any one permission is enough to import. But that also means a permission ADDED in an - * update is never requested — an existing user goes straight to importing and the new type reads - * as empty forever, indistinguishable from "you have no water logged". Water would have done - * nothing at all for every existing Android user. + * The stored value is the union of permissions previously presented, not just the last selected + * set. Narrowing a selection therefore never causes another prompt; adding a category or adding a + * record type to an enabled category prompts once. Declining is still remembered. * - * Comparing a stored fingerprint of [PERMISSIONS] catches that: when the set grows, the caller - * launches the request once so the user is asked about the new type, then marks it asked. It is - * asked ONCE — declining is remembered, so this never becomes a nag. + * The old implementation stored all permissions as the same comma-separated signature. Parsing + * it as an asked set makes this migration backward-compatible without a preference rewrite. */ - fun hasUnaskedPermissions(context: Context): Boolean = - prefs(context).getString(PERMISSION_SIGNATURE_KEY, null) != permissionSignature - - /** Record that the user has now been asked about the current [PERMISSIONS] set. */ - fun markPermissionsAsked(context: Context) { - prefs(context).edit().putString(PERMISSION_SIGNATURE_KEY, permissionSignature).apply() + fun hasUnaskedPermissions( + context: Context, + categories: Set = selectedCategories(context), + ): Boolean = unaskedPermissions(askedPermissions(context), categories).isNotEmpty() + + /** Record that the user has now been asked about the selected categories' permissions. */ + fun markPermissionsAsked( + context: Context, + categories: Set = selectedCategories(context), + ) { + val asked = askedPermissions(context) + permissionsFor(categories) + prefs(context).edit().putString(PERMISSION_SIGNATURE_KEY, asked.sorted().joinToString(",")).apply() } - private const val PERMISSION_SIGNATURE_KEY = "noop.hc.permissionSignature" + internal fun unaskedPermissions( + asked: Set, + categories: Set, + ): Set = permissionsFor(categories) - asked - private val permissionSignature: String get() = PERMISSIONS.sorted().joinToString(",") + private fun askedPermissions(context: Context): Set = + prefs(context).getString(PERMISSION_SIGNATURE_KEY, null) + ?.split(',') + ?.filterTo(linkedSetOf()) { it.isNotBlank() } + .orEmpty() + + private const val PERMISSION_SIGNATURE_KEY = "noop.hc.permissionSignature" + private const val CATEGORY_SELECTION_KEY = "noop.hc.importCategories" private fun prefs(context: Context) = context.getSharedPreferences(NoopPrefs.NAME, Context.MODE_PRIVATE) @@ -177,7 +257,12 @@ object HealthConnectImporter { * [heightCm] is the user's profile height, used ONLY to derive BMI on days that carry a weight * (Health Connect has no BMI record, unlike Apple Health). Pass 0.0 to skip BMI derivation. */ - suspend fun import(context: Context, repo: WhoopRepository, heightCm: Double = 0.0): ImportSummary { + suspend fun import( + context: Context, + repo: WhoopRepository, + heightCm: Double = 0.0, + categories: Set = selectedCategories(context), + ): ImportSummary { if (sdkStatus(context) != HealthConnectClient.SDK_AVAILABLE) { return ImportSummary.failure(SOURCE, "Health Connect is not available on this device.") } @@ -205,10 +290,11 @@ object HealthConnectImporter { // below is already independently fault-tolerant — a type whose read permission was revoked throws // and is caught/skipped in [readAll] (same path as #34) — so we only need to bail when NOTHING is // granted. The user choosing exactly what NOOP can see is the intended behaviour. - if (granted.none { it in PERMISSIONS }) { + val selectedPermissions = permissionsFor(categories) + if (granted.none { it in selectedPermissions }) { return ImportSummary.failure( SOURCE, - "No Health Connect data types are granted. Allow at least one type for NOOP in Health Connect, then import.", + "No selected Health Connect data types are granted. Allow at least one selected type, then import.", ) } @@ -218,6 +304,19 @@ object HealthConnectImporter { val filter = TimeRangeFilter.between(start, end) // #528: skip our own writes on import (see readAll / isSelfWritten). val selfPackage = context.packageName + val selectedRecordTypes = readableRecordTypes(categories, granted) + + // A granted permission can outlive the category selection that originally requested it. Gate + // on BOTH here so switching a category off stops its reads immediately without requiring the + // user to visit Android's Health Connect settings and revoke the old grant manually (#645). + suspend fun readSelected( + type: KClass, + range: TimeRangeFilter = filter, + onRecord: (T) -> Unit, + ): Boolean { + if (type !in selectedRecordTypes) return false + return readAll(client, type, range, selfPackage, onRecord) + } // Per-day accumulators. Keyed by "YYYY-MM-DD" (local). val acc = HashMap() @@ -259,14 +358,14 @@ object HealthConnectImporter { // walk, so summing across sources double-counts (~2x). Sum WITHIN a source (keyed by the record's // dataOrigin package), then take the MAX source per day at write-out, mirroring the de-overlap // already shipped on iOS/macOS and the Android XML importer. - readAll(client, StepsRecord::class, filter, selfPackage) { r -> + readSelected(StepsRecord::class) { r -> val b = bucket(dayOf(r.startTime, r.startZoneOffset)) val src = r.metadata.dataOrigin.packageName b.stepsBySource[src] = (b.stepsBySource[src] ?: 0L) + r.count } // --- Total calories burned (basal + active) --- // #589: per-SOURCE sums, max-across-sources at write-out (same overlap reasoning as steps). - readAll(client, TotalCaloriesBurnedRecord::class, filter, selfPackage) { r -> + readSelected(TotalCaloriesBurnedRecord::class) { r -> val b = bucket(dayOf(r.startTime, r.startZoneOffset)) val src = r.metadata.dataOrigin.packageName b.totalKcalBySource[src] = (b.totalKcalBySource[src] ?: 0.0) + r.energy.inKilocalories @@ -278,7 +377,7 @@ object HealthConnectImporter { // per-record window list below gets EVERY record, tagged with its source: the per-workout credit // de-overlaps by source ITSELF (#835 — it used to cross-source SUM, roughly doubling a ride that // two apps both logged), so the per-source map here only governs the day total. - readAll(client, ActiveCaloriesBurnedRecord::class, filter, selfPackage) { r -> + readSelected(ActiveCaloriesBurnedRecord::class) { r -> val b = bucket(dayOf(r.startTime, r.startZoneOffset)) val src = r.metadata.dataOrigin.packageName b.activeKcalBySource[src] = (b.activeKcalBySource[src] ?: 0.0) + r.energy.inKilocalories @@ -286,7 +385,7 @@ object HealthConnectImporter { KcalRecord(r.startTime.epochSecond, r.endTime.epochSecond, r.energy.inKilocalories, src)) } // --- Heart rate (instantaneous samples) -> per-day average --- - readAll(client, HeartRateRecord::class, filter, selfPackage) { r -> + readSelected(HeartRateRecord::class) { r -> for (s in r.samples) { val b = bucket(dayOf(s.time, r.startZoneOffset)) b.hrSum += s.beatsPerMinute @@ -294,19 +393,19 @@ object HealthConnectImporter { } } // --- Resting heart rate -> per-day average (rounded to Int) --- - readAll(client, RestingHeartRateRecord::class, filter, selfPackage) { r -> + readSelected(RestingHeartRateRecord::class) { r -> val b = bucket(dayOf(r.time, r.zoneOffset)) b.rhrSum += r.beatsPerMinute b.rhrCount += 1 } // --- HRV (RMSSD, ms) -> per-day average --- - readAll(client, HeartRateVariabilityRmssdRecord::class, filter, selfPackage) { r -> + readSelected(HeartRateVariabilityRmssdRecord::class) { r -> val b = bucket(dayOf(r.time, r.zoneOffset)) b.hrvSum += r.heartRateVariabilityMillis b.hrvCount += 1 } // --- Sleep sessions -> per-day total sleep minutes, assigned to the WAKE day --- - readAll(client, SleepSessionRecord::class, filter, selfPackage) { r -> + readSelected(SleepSessionRecord::class) { r -> // Wake-day keyed, so the END offset is the right one — but a writer that sets only the // start offset is common, and the start is far better evidence of the sleeper's zone than // the phone's zone at import time. Fall through start before giving up. @@ -330,19 +429,19 @@ object HealthConnectImporter { )) } // --- SpO2 (%) -> per-day average --- - readAll(client, OxygenSaturationRecord::class, filter, selfPackage) { r -> + readSelected(OxygenSaturationRecord::class) { r -> val b = bucket(dayOf(r.time, r.zoneOffset)) b.spo2Sum += r.percentage.value b.spo2Count += 1 } // --- Respiratory rate (breaths/min) -> per-day average --- - readAll(client, RespiratoryRateRecord::class, filter, selfPackage) { r -> + readSelected(RespiratoryRateRecord::class) { r -> val b = bucket(dayOf(r.time, r.zoneOffset)) b.respSum += r.rate b.respCount += 1 } // --- VO2 max (ml/kg/min) -> latest value of the day wins --- - readAll(client, Vo2MaxRecord::class, filter, selfPackage) { r -> + readSelected(Vo2MaxRecord::class) { r -> val b = bucket(dayOf(r.time, r.zoneOffset)) if (r.time.epochSecond >= b.vo2maxTs) { b.vo2max = r.vo2MillilitersPerMinuteKilogram @@ -350,7 +449,7 @@ object HealthConnectImporter { } } // --- Weight (kg) -> latest value of the day wins --- - readAll(client, WeightRecord::class, filter, selfPackage) { r -> + readSelected(WeightRecord::class) { r -> val b = bucket(dayOf(r.time, r.zoneOffset)) if (r.time.epochSecond >= b.weightTs) { b.weightKg = r.weight.inKilograms @@ -360,7 +459,7 @@ object HealthConnectImporter { // --- Body fat (%) -> latest value of the day wins. Health Connect's Percentage.value is // already 0-100 (unlike Apple's 0..1 fraction), so it stores as-is and matches the iOS // "body_fat" key. --- - readAll(client, BodyFatRecord::class, filter, selfPackage) { r -> + readSelected(BodyFatRecord::class) { r -> val b = bucket(dayOf(r.time, r.zoneOffset)) if (r.time.epochSecond >= b.bodyFatTs) { b.bodyFatPct = r.percentage.value @@ -368,7 +467,7 @@ object HealthConnectImporter { } } // --- Lean body mass (kg) -> latest value of the day wins (iOS "lean_mass" twin). --- - readAll(client, LeanBodyMassRecord::class, filter, selfPackage) { r -> + readSelected(LeanBodyMassRecord::class) { r -> val b = bucket(dayOf(r.time, r.zoneOffset)) if (r.time.epochSecond >= b.leanMassTs) { b.leanMassKg = r.mass.inKilograms @@ -376,7 +475,7 @@ object HealthConnectImporter { } } // --- Exercise sessions -> WorkoutRow(source="health-connect") --- - readAll(client, ExerciseSessionRecord::class, filter, selfPackage) { r -> + readSelected(ExerciseSessionRecord::class) { r -> val startS = r.startTime.epochSecond val endS = r.endTime.epochSecond workouts.add( @@ -445,12 +544,11 @@ object HealthConnectImporter { var sum = 0L var n = 0L var max = 0L - readAll( - client, HeartRateRecord::class, + readSelected( + HeartRateRecord::class, TimeRangeFilter.between( Instant.ofEpochSecond(w.startTs), Instant.ofEpochSecond(w.endTs) ), - selfPackage, ) { hr -> for (s in hr.samples) { sum += s.beatsPerMinute @@ -484,13 +582,12 @@ object HealthConnectImporter { // so a neighbouring activity's record inside the buffer can't over-count. val ws = w.startTs val we = w.endTs - readAll( - client, DistanceRecord::class, + readSelected( + DistanceRecord::class, TimeRangeFilter.between( Instant.ofEpochSecond(ws - DISTANCE_MATCH_BUFFER_S), Instant.ofEpochSecond(we + DISTANCE_MATCH_BUFFER_S), ), - selfPackage, ) { d -> val rs = d.startTime.epochSecond val re = d.endTime.epochSecond @@ -516,8 +613,8 @@ object HealthConnectImporter { // twice. Taking the max here would silently drop whichever app logged less. val hydrationStart = LocalDate.now(zone).minusDays(HYDRATION_WINDOW_DAYS - 1) .atStartOfDay(zone).toInstant() - hydrationReadOk = readAll( - client, HydrationRecord::class, TimeRangeFilter.between(hydrationStart, end), selfPackage, + hydrationReadOk = readSelected( + HydrationRecord::class, TimeRangeFilter.between(hydrationStart, end), ) { r -> // #1002: hydration deliberately keeps the PHONE's zone, unlike every other record here. // Its write is a windowed REPLACE: `windowDays` below is built from LocalDate.now(zone), @@ -726,6 +823,7 @@ object HealthConnectImporter { */ suspend fun refreshTodaySteps(context: Context, repo: WhoopRepository): Int? { if (sdkStatus(context) != HealthConnectClient.SDK_AVAILABLE) return null + if (ImportCategory.ACTIVITY !in selectedCategories(context)) return null val client = client(context) val granted = try { client.permissionController.getGrantedPermissions() diff --git a/android/app/src/main/java/com/noop/ui/AppViewModel.kt b/android/app/src/main/java/com/noop/ui/AppViewModel.kt index 6c061e2f08..ffe3957e39 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -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 } diff --git a/android/app/src/main/java/com/noop/ui/DataSourcesScreen.kt b/android/app/src/main/java/com/noop/ui/DataSourcesScreen.kt index 6814716a64..ab03764693 100644 --- a/android/app/src/main/java/com/noop/ui/DataSourcesScreen.kt +++ b/android/app/src/main/java/com/noop/ui/DataSourcesScreen.kt @@ -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() } @@ -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() @@ -306,16 +310,17 @@ fun DataSourcesScreen(vm: AppViewModel) { val granted = runCatching { HealthConnectImporter.client(context).permissionController.getGrantedPermissions() }.getOrDefault(emptySet()) + 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) } } } @@ -449,6 +454,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, diff --git a/android/app/src/main/java/com/noop/ui/HealthConnectCategorySelector.kt b/android/app/src/main/java/com/noop/ui/HealthConnectCategorySelector.kt new file mode 100644 index 0000000000..7bb996af8a --- /dev/null +++ b/android/app/src/main/java/com/noop/ui/HealthConnectCategorySelector.kt @@ -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, + onSelectionChange: (Set) -> 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 +} diff --git a/android/app/src/main/java/com/noop/ui/OnboardingScreen.kt b/android/app/src/main/java/com/noop/ui/OnboardingScreen.kt index 3f17c00dd9..fb13678840 100644 --- a/android/app/src/main/java/com/noop/ui/OnboardingScreen.kt +++ b/android/app/src/main/java/com/noop/ui/OnboardingScreen.kt @@ -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(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) @@ -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 @@ -828,15 +832,16 @@ 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) + 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) } } } @@ -870,6 +875,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, diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index ca0475a8ee..68944e94ad 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -402,6 +402,14 @@ Datenquellen Alle Gesundheit Connect + Wähle aus, was NOOP lesen darf + Wähle die Health-Connect-Kategorien aus, die importiert werden sollen. Du kannst dies später ändern. + Erholung & Wohlbefinden + Herzfrequenz, HRV, Schlaf, SpO₂, Atmung und Flüssigkeitszufuhr + Aktivität + Schritte, Kalorien, Trainingseinheiten, Distanz und VO₂max + Körperzusammensetzung + Gewicht, Körperfett und fettfreie Masse Importieren Sie Apple Health Export ... Import von Health Connect Lifting Log ... diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index b6d4f7379c..0ae6fea64a 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -199,6 +199,14 @@ Fuentes de datos Cada uno Health Connect + Elige qué puede leer NOOP + Selecciona las categorías de Health Connect que quieres importar. Puedes cambiarlo más tarde. + Recuperación y bienestar + Frecuencia cardíaca, VFC, sueño, SpO₂, respiración e hidratación + Actividad + Pasos, calorías, entrenamientos, distancia y VO₂ máx. + Composición corporal + Peso, grasa corporal y masa magra Importar la exportación de Apple Health... Importación de Health Connect Registro de elevación de importación... diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index 42606413e7..cc48d19960 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -217,6 +217,14 @@ Sources de données Chaque Connexion santé + Choisissez ce que NOOP peut lire + Sélectionnez les catégories Health Connect à importer. Vous pourrez modifier ce choix plus tard. + Récupération et bien-être + Fréquence cardiaque, VFC, sommeil, SpO₂, respiration et hydratation + Activité + Pas, calories, entraînements, distance et VO₂ max + Composition corporelle + Poids, masse grasse et masse maigre Importation Apple Health exportation... Importation depuis Health Connect Importer le log... diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml index 985d66f1b2..189875db7d 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -367,6 +367,14 @@ Źródła danych Każdy Połączenie zdrowotne + Wybierz dane, które NOOP może odczytywać + Wybierz kategorie Health Connect do zaimportowania. Możesz zmienić ten wybór później. + Regeneracja i zdrowie + Tętno, HRV, sen, SpO₂, oddech i nawodnienie + Aktywność + Kroki, kalorie, treningi, dystans i VO₂ max + Skład ciała + Masa ciała, tkanka tłuszczowa i beztłuszczowa masa ciała Importuj eksport Apple Health… Importuj z Health Connect Importuj dziennik podnoszenia… diff --git a/android/app/src/main/res/values-pt-rPT/strings.xml b/android/app/src/main/res/values-pt-rPT/strings.xml index e3b1dfd503..d89304eb12 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -367,6 +367,14 @@ Fontes de dados Cada Health Connect + Escolha o que o NOOP pode ler + Selecione as categorias do Health Connect a importar. Pode alterar esta escolha mais tarde. + Recuperação e bem-estar + Frequência cardíaca, VFC, sono, SpO₂, respiração e hidratação + Atividade + Passos, calorias, treinos, distância e VO₂ máx. + Composição corporal + Peso, gordura corporal e massa magra Importar exportação do Apple Health… Importar do Health Connect Importar registo de levantamento… diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index b7cd60e5f4..771205e419 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -364,6 +364,14 @@ 数据源 每隔 Health Connect + 选择 NOOP 可读取的内容 + 选择要导入的 Health Connect 类别。稍后可随时更改。 + 恢复与健康 + 心率、HRV、睡眠、SpO₂、呼吸和饮水 + 活动 + 步数、卡路里、锻炼、距离和最大摄氧量 + 身体成分 + 体重、体脂和瘦体重 导入 Apple Health 数据包… 从 Health Connect 导入 导入举重记录… diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index e3459f8d99..776127b159 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -411,6 +411,14 @@ Data Sources Every Health Connect + Choose what NOOP can read + Select the Health Connect categories to import. You can change this later. + Recovery & wellness + Heart rate, HRV, sleep, SpO₂, breathing, and hydration + Activity + Steps, calories, workouts, distance, and VO₂ max + Body composition + Weight, body fat, and lean mass Import Apple Health export… Import from Health Connect Import lifting log… diff --git a/android/app/src/test/java/com/noop/ingest/HealthConnectPermissionCategoryTest.kt b/android/app/src/test/java/com/noop/ingest/HealthConnectPermissionCategoryTest.kt new file mode 100644 index 0000000000..072658eb14 --- /dev/null +++ b/android/app/src/test/java/com/noop/ingest/HealthConnectPermissionCategoryTest.kt @@ -0,0 +1,95 @@ +package com.noop.ingest + +import androidx.health.connect.client.permission.HealthPermission +import androidx.health.connect.client.records.HeartRateRecord +import androidx.health.connect.client.records.StepsRecord +import androidx.health.connect.client.records.WeightRecord +import com.noop.ingest.HealthConnectImporter.ImportCategory +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Pure coverage for the category consent contract in issue #645. */ +class HealthConnectPermissionCategoryTest { + private val recovery = setOf(ImportCategory.RECOVERY) + private val activity = setOf(ImportCategory.ACTIVITY) + private val body = setOf(ImportCategory.BODY_COMPOSITION) + + @Test + fun categoriesPartitionEverySupportedPermissionExactlyOnce() { + val perCategory = ImportCategory.entries.map { HealthConnectImporter.permissionsFor(setOf(it)) } + + assertEquals(perCategory.sumOf { it.size }, perCategory.flatten().toSet().size) + assertEquals(HealthConnectImporter.PERMISSIONS, perCategory.flatten().toSet()) + } + + @Test + fun recoveryDoesNotRequestActivityOrBodyComposition() { + val permissions = HealthConnectImporter.permissionsFor(recovery) + + assertTrue(HealthPermission.getReadPermission(HeartRateRecord::class) in permissions) + assertFalse(HealthPermission.getReadPermission(StepsRecord::class) in permissions) + assertFalse(HealthPermission.getReadPermission(WeightRecord::class) in permissions) + } + + @Test + fun newInstallsDefaultNarrowWhileLegacyInstallsKeepExistingImports() { + assertEquals( + recovery, + HealthConnectImporter.categoriesFromStoredKeys(null, hadLegacyPermissionSignature = false), + ) + assertEquals( + HealthConnectImporter.ALL_CATEGORIES, + HealthConnectImporter.categoriesFromStoredKeys(null, hadLegacyPermissionSignature = true), + ) + } + + @Test + fun storedSelectionWinsOverLegacyFallback() { + assertEquals( + activity + body, + HealthConnectImporter.categoriesFromStoredKeys( + setOf(ImportCategory.ACTIVITY.storageKey, ImportCategory.BODY_COMPOSITION.storageKey), + hadLegacyPermissionSignature = true, + ), + ) + } + + @Test + fun narrowingNeverRepromptsAndExpandingRequestsOnlyNewCategory() { + val recoveryPermissions = HealthConnectImporter.permissionsFor(recovery) + + assertTrue(HealthConnectImporter.unaskedPermissions(recoveryPermissions, recovery).isEmpty()) + assertEquals( + HealthConnectImporter.permissionsFor(body), + HealthConnectImporter.unaskedPermissions(recoveryPermissions, recovery + body), + ) + } + + @Test + fun partialGrantReadsOnlyGrantedTypesInsideSelectedCategories() { + val heartRate = HealthPermission.getReadPermission(HeartRateRecord::class) + val steps = HealthPermission.getReadPermission(StepsRecord::class) + val readable = HealthConnectImporter.readableRecordTypes( + categories = recovery + activity, + grantedPermissions = setOf(heartRate, steps), + ) + + assertEquals(setOf(HeartRateRecord::class, StepsRecord::class), readable) + assertFalse(WeightRecord::class in readable) + } + + @Test + fun grantFromDeselectedCategoryIsNotRead() { + val readable = HealthConnectImporter.readableRecordTypes( + categories = recovery, + grantedPermissions = setOf( + HealthPermission.getReadPermission(HeartRateRecord::class), + HealthPermission.getReadPermission(StepsRecord::class), + ), + ) + + assertEquals(setOf(HeartRateRecord::class), readable) + } +} From 02593cd5e0df2096fb74cefd09bc1a8b76fb7998 Mon Sep 17 00:00:00 2001 From: Kaveman <66108693+kavemang@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:54:19 -0500 Subject: [PATCH 2/2] Preserve legacy Health Connect selections --- .../com/noop/ingest/HealthConnectImporter.kt | 42 +++++++++++++++++-- .../java/com/noop/ui/DataSourcesScreen.kt | 5 +++ .../main/java/com/noop/ui/OnboardingScreen.kt | 5 +++ .../HealthConnectPermissionCategoryTest.kt | 38 +++++++++++++++++ 4 files changed, 87 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/java/com/noop/ingest/HealthConnectImporter.kt b/android/app/src/main/java/com/noop/ingest/HealthConnectImporter.kt index 89206eaf51..5d1db2db2b 100644 --- a/android/app/src/main/java/com/noop/ingest/HealthConnectImporter.kt +++ b/android/app/src/main/java/com/noop/ingest/HealthConnectImporter.kt @@ -186,6 +186,34 @@ object HealthConnectImporter { .apply() } + /** + * The categories implied by grants Android already holds. + * + * A user who granted Health Connect before #645 existed has no stored selection, and — if they + * onboarded before #949 added it — no permission signature either. The importer has shipped since + * 2026-06-07 and that key only since 2026-07-30, so there is a real cohort with neither. Falling back + * to [DEFAULT_CATEGORIES] for them would silently stop importing Activity and Body composition while + * Android still shows those permissions as granted: nothing on screen would say why steps stopped. + * + * Their grants are the honest record of what they agreed to, so read the scope back off those. + */ + internal fun categoriesFromGrantedPermissions(granted: Set): Set = + ImportCategory.entries.filterTo(linkedSetOf()) { category -> + permissionsFor(setOf(category)).any { it in granted } + } + + /** + * One-time backfill of the selection for a user who predates it, from what Android has granted. + * + * Only ever writes when NOTHING is stored, so a user who deliberately narrows to Recovery is never + * re-broadened, and it is safe to call from every entry point that can be the first one reached. + */ + fun migrateSelectionFromGrants(context: Context, granted: Set) { + if (prefs(context).getStringSet(CATEGORY_SELECTION_KEY, null) != null) return + val inferred = categoriesFromGrantedPermissions(granted) + if (inferred.isNotEmpty()) setSelectedCategories(context, inferred) + } + internal fun categoriesFromStoredKeys( storedKeys: Set?, hadLegacyPermissionSignature: Boolean, @@ -261,7 +289,9 @@ object HealthConnectImporter { context: Context, repo: WhoopRepository, heightCm: Double = 0.0, - categories: Set = selectedCategories(context), + // Null means "whatever the user has selected", resolved AFTER the grant-based migration below. A + // non-lazy default is evaluated at CALL time, before the migration could widen it. + categories: Set? = null, ): ImportSummary { if (sdkStatus(context) != HealthConnectClient.SDK_AVAILABLE) { return ImportSummary.failure(SOURCE, "Health Connect is not available on this device.") @@ -285,12 +315,18 @@ object HealthConnectImporter { } catch (e: Exception) { return ImportSummary.failure(SOURCE, "Could not read Health Connect permissions: ${e.message}") } + // #645 follow-up: a user who predates the category selector has nothing stored — recover their + // real scope from the grants before deciding what to read, or the first import after the update + // would quietly narrow them to Recovery. + migrateSelectionFromGrants(context, granted) + val effectiveCategories = categories ?: selectedCategories(context) + // Partial permissions are fine (#150): import the record types the user DID grant and skip the // rest, instead of refusing the whole import when any single type is missing. Each per-type read // below is already independently fault-tolerant — a type whose read permission was revoked throws // and is caught/skipped in [readAll] (same path as #34) — so we only need to bail when NOTHING is // granted. The user choosing exactly what NOOP can see is the intended behaviour. - val selectedPermissions = permissionsFor(categories) + val selectedPermissions = permissionsFor(effectiveCategories) if (granted.none { it in selectedPermissions }) { return ImportSummary.failure( SOURCE, @@ -304,7 +340,7 @@ object HealthConnectImporter { val filter = TimeRangeFilter.between(start, end) // #528: skip our own writes on import (see readAll / isSelfWritten). val selfPackage = context.packageName - val selectedRecordTypes = readableRecordTypes(categories, granted) + val selectedRecordTypes = readableRecordTypes(effectiveCategories, granted) // A granted permission can outlive the category selection that originally requested it. Gate // on BOTH here so switching a category off stops its reads immediately without requiring the diff --git a/android/app/src/main/java/com/noop/ui/DataSourcesScreen.kt b/android/app/src/main/java/com/noop/ui/DataSourcesScreen.kt index ab03764693..1d9f6dfbd8 100644 --- a/android/app/src/main/java/com/noop/ui/DataSourcesScreen.kt +++ b/android/app/src/main/java/com/noop/ui/DataSourcesScreen.kt @@ -310,6 +310,11 @@ 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 diff --git a/android/app/src/main/java/com/noop/ui/OnboardingScreen.kt b/android/app/src/main/java/com/noop/ui/OnboardingScreen.kt index fb13678840..b65058268a 100644 --- a/android/app/src/main/java/com/noop/ui/OnboardingScreen.kt +++ b/android/app/src/main/java/com/noop/ui/OnboardingScreen.kt @@ -832,6 +832,11 @@ private fun ImportStep(viewModel: 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) if (granted.any { it in selectedPermissions } && !HealthConnectImporter.hasUnaskedPermissions(context, hcReadCategories) diff --git a/android/app/src/test/java/com/noop/ingest/HealthConnectPermissionCategoryTest.kt b/android/app/src/test/java/com/noop/ingest/HealthConnectPermissionCategoryTest.kt index 072658eb14..ca13b21f0e 100644 --- a/android/app/src/test/java/com/noop/ingest/HealthConnectPermissionCategoryTest.kt +++ b/android/app/src/test/java/com/noop/ingest/HealthConnectPermissionCategoryTest.kt @@ -92,4 +92,42 @@ class HealthConnectPermissionCategoryTest { assertEquals(setOf(HeartRateRecord::class), readable) } + + /** + * #645 migration: a user who granted Health Connect before the selector existed has no stored + * selection, and if they onboarded before #949 no permission signature either — the importer has + * shipped since 2026-06-07 and that key only since 2026-07-30. Their Android grants are the only + * honest record of what they agreed to, so the scope is read back off those rather than defaulted. + * + * Without this they would silently stop importing Activity and Body composition while Android still + * showed those permissions as granted, with nothing on screen saying why steps had stopped. + */ + @Test + fun grantsFromBeforeTheSelectorRecoverTheirCategories() { + val granted = setOf( + HealthPermission.getReadPermission(HeartRateRecord::class), + HealthPermission.getReadPermission(StepsRecord::class), + ) + assertEquals( + setOf(ImportCategory.RECOVERY, ImportCategory.ACTIVITY), + HealthConnectImporter.categoriesFromGrantedPermissions(granted), + ) + } + + /** A fresh install grants nothing, so there is nothing to recover and the caller keeps its default. */ + @Test + fun noGrantsRecoversNothing() { + assertTrue(HealthConnectImporter.categoriesFromGrantedPermissions(emptySet()).isEmpty()) + } + + /** One granted type is enough to claim its whole category — partial grants stay supported (#150). */ + @Test + fun oneGrantedTypeClaimsItsCategory() { + assertEquals( + setOf(ImportCategory.BODY_COMPOSITION), + HealthConnectImporter.categoriesFromGrantedPermissions( + setOf(HealthPermission.getReadPermission(WeightRecord::class)), + ), + ) + } }