Skip to content
Merged
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
4 changes: 3 additions & 1 deletion android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1828,8 +1828,10 @@ class WhoopBleClient(
}
// Keep the opt-in Health Connect writeback fresh in background-only operation too.
if (NoopPrefs.hcWriteback(context)) {
// #660: log the count AND any PII-safe failure categories (the writer also persists
// the outcome to prefs, so Data Sources surfaces a failing background share).
runCatching { HealthConnectWriter.write(context, repository, deviceId) }
.onSuccess { log("HC writeback: $it record(s)") }
.onSuccess { r -> log("HC writeback: ${r.written} record(s)" + if (r.ok) "" else " (failed: ${r.failures.joinToString()})") }
}
} finally {
analyzeAfterBackfillScheduled.set(false)
Expand Down
94 changes: 75 additions & 19 deletions android/app/src/main/java/com/noop/ingest/HealthConnectWriter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,37 @@ import java.time.LocalTime
import java.time.ZoneId
import kotlin.reflect.KClass

/** PII-safe category of a Health Connect writeback failure — a coarse reason, never a raw message. */
enum class WritebackFailure { PERMISSION_DENIED, REMOTE_ERROR }

/**
* Outcome of a writeback attempt (#660): how many records landed, plus any per-concern failure
* categories. Lets callers/UI distinguish "wrote 0 because nothing to share" from "wrote 0 because
* the write FAILED" — the silent-zero the previous `Int` return couldn't express.
*/
data class WritebackResult(val written: Int, val failures: List<WritebackFailure>) {
val ok: Boolean get() = failures.isEmpty()

/** PII-safe status code persisted for the UI — a permission failure outranks a generic one, since it
* needs a distinct "re-grant" action. Pure/testable; must equal the `NoopPrefs.HC_WB_*` constants. */
val statusCode: String get() = when {
WritebackFailure.PERMISSION_DENIED in failures -> "PERMISSION_DENIED"
failures.isNotEmpty() -> "REMOTE_ERROR"
else -> "OK"
}

companion object {
/** HC unavailable / nothing attempted — benign, not a failure. */
val UNAVAILABLE = WritebackResult(0, emptyList())
}
}

/** Map a thrown write error to a PII-safe category; rethrow coroutine cancellation (never a "failure"). */
private fun Throwable.writebackCategory(): WritebackFailure {
if (this is kotlin.coroutines.cancellation.CancellationException) throw this
return if (this is SecurityException) WritebackFailure.PERMISSION_DENIED else WritebackFailure.REMOTE_ERROR
}

/**
* OPT-IN writeback: pushes NOOP's on-device computed nightly metrics (resting HR, HRV RMSSD, sleep
* SpO2, respiratory rate) INTO Health Connect, so other apps can see what the strap measured.
Expand Down Expand Up @@ -57,20 +88,30 @@ object HealthConnectWriter {
WRITE_RECORDS.map { HealthPermission.getWritePermission(it) }.toSet()

/**
* Write the last [WINDOW_DAYS] of computed metrics. Returns the number of records written,
* 0 when HC is unavailable / nothing computed yet. Assumes [PERMISSIONS] are granted (HC
* throws SecurityException otherwise — callers wrap in runCatching).
* Write the last [WINDOW_DAYS] of computed metrics. Returns a [WritebackResult] — the record
* count PLUS any per-concern failure categories, so a revoked permission no longer looks like a
* benign "wrote 0" (#660). Persists the outcome via [recordStatus] for the Data Sources UI.
* Assumes [PERMISSIONS] are granted (HC throws SecurityException otherwise — caught + categorized).
*
* [deviceId] must be the registry's ACTIVE strap id (SPINE / #814): a wizard-paired strap banks
* rows under `whoop-<address>`, so a hardcoded legacy "my-whoop" id reads empty tables and
* exports nothing.
*/
suspend fun write(context: Context, repo: WhoopRepository, deviceId: String): Int {
if (HealthConnectClient.getSdkStatus(context) != HealthConnectClient.SDK_AVAILABLE) return 0
val client = HealthConnectClient.getOrCreate(context)
suspend fun write(context: Context, repo: WhoopRepository, deviceId: String): WritebackResult {
if (HealthConnectClient.getSdkStatus(context) != HealthConnectClient.SDK_AVAILABLE) return WritebackResult.UNAVAILABLE

val cutoff = LocalDate.now().minusDays(WINDOW_DAYS).toString()
val days = repo.days(repo.computedDeviceId(deviceId)).filter { it.day >= cutoff }
// Guard the pre-insert work (client acquisition + the day read) the same way the concern inserts
// below are guarded, so a provider race or DB error can't throw PAST recordStatus and leave the
// UI showing a stale "OK" while sharing is actually broken (#660). Cancellation still propagates.
val (client, days) = runCatching {
val c = HealthConnectClient.getOrCreate(context)
val cutoff = LocalDate.now().minusDays(WINDOW_DAYS).toString()
c to repo.days(repo.computedDeviceId(deviceId)).filter { it.day >= cutoff }
}.getOrElse { t ->
val result = WritebackResult(0, listOf(t.writebackCategory()))
recordStatus(context, result)
return result
}

val zone = ZoneId.systemDefault()
// Stamp every record in this batch with one version so a later recompute (higher stamp)
Expand Down Expand Up @@ -118,15 +159,27 @@ object HealthConnectWriter {
// totals. iOS (#249) excludes them for the same reason; this keeps the two platforms aligned.
// The unique strap signals (vitals, HR, sleep, workouts) are still written below.

// Each export concern inserts independently (own runCatching) so a failure in one — e.g. a
// revoked per-type WRITE permission — can't suppress the others.
// Each export concern inserts independently so a failure in one — e.g. a revoked per-type WRITE
// permission — can't suppress the others. Failures are CATEGORIZED (PII-safe, never raw messages)
// and folded into the result instead of collapsing to a silent 0, so the UI can surface them (#660).
var total = 0
val failures = mutableListOf<WritebackFailure>()
if (records.isNotEmpty()) {
total += runCatching { client.insertRecords(records); records.size }.getOrDefault(0)
runCatching { client.insertRecords(records); records.size }
.fold({ total += it }, { failures += it.writebackCategory() })
}
total += runCatching { writeHeartRate(client, context, repo, deviceId, version) }.getOrDefault(0)
total += runCatching { writeSleep(client, repo, deviceId) }.getOrDefault(0)
return total
runCatching { writeHeartRate(client, context, repo, deviceId, version) }
.fold({ total += it }, { failures += it.writebackCategory() })
runCatching { writeSleep(client, repo, deviceId) }
.fold({ total += it }, { failures += it.writebackCategory() })
val result = WritebackResult(total, failures.distinct())
recordStatus(context, result)
return result
}

/** Persist the last writeback outcome (PII-safe category + count + time) for the Data Sources UI (#660). */
private fun recordStatus(context: Context, result: WritebackResult) {
NoopPrefs.setHcWritebackStatus(context, result.statusCode, result.written, System.currentTimeMillis())
}

private fun meta(metric: String, day: String, version: Long) = Metadata(
Expand Down Expand Up @@ -273,11 +326,14 @@ object HealthConnectWriter {
return out
}

/** Insert one workout's records into Health Connect. Opt-in caller; wrap in runCatching. */
suspend fun writeExercise(context: Context, row: WorkoutRow, exerciseType: Int): Int {
if (HealthConnectClient.getSdkStatus(context) != HealthConnectClient.SDK_AVAILABLE) return 0
/** Insert one workout's records into Health Connect. Opt-in caller. Returns a [WritebackResult] so a
* failed exercise share is visible instead of silently swallowed, and records the outcome (#660). */
suspend fun writeExercise(context: Context, row: WorkoutRow, exerciseType: Int): WritebackResult {
if (HealthConnectClient.getSdkStatus(context) != HealthConnectClient.SDK_AVAILABLE) return WritebackResult.UNAVAILABLE
val recs = buildExerciseRecords(row, exerciseType)
HealthConnectClient.getOrCreate(context).insertRecords(recs)
return recs.size
val result = runCatching { HealthConnectClient.getOrCreate(context).insertRecords(recs); recs.size }
.fold({ WritebackResult(it, emptyList()) }, { WritebackResult(0, listOf(it.writebackCategory())) })
recordStatus(context, result)
return result
}
}
19 changes: 19 additions & 0 deletions android/app/src/main/java/com/noop/ui/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ import kotlin.math.roundToInt
* daily metrics. Mirrors the macOS AppModel responsibilities (LiveState bridge,
* `bpm` smoothing, health-alert string) without any networking.
*/
/** Last Health Connect writeback outcome for the Data Sources UI (#660). [code] is a PII-safe
* category (see [NoopPrefs.HC_WB_OK] etc.); "" = never attempted. */
data class HcWritebackStatus(val code: String, val atMs: Long, val written: Int)

class AppViewModel(app: Application) : AndroidViewModel(app) {

/** Process-wide context for prefs + the background-connection service. */
Expand Down Expand Up @@ -904,6 +908,7 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
// update) break the analysis loop.
if (_hcWriteback.value) {
runCatching { HealthConnectWriter.write(appContext, repository, deviceId) }
refreshHcWritebackStatus() // #660: reflect the outcome the writer just persisted
}
// 15-min backstop cadence, but wake EARLY on an app-resume kick (#386 self-heal) so a
// night the overnight tick was killed before scoring catches up the moment the user opens
Expand Down Expand Up @@ -1825,6 +1830,19 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
private val _hcWriteback = MutableStateFlow(NoopPrefs.hcWriteback(appContext))
val hcWriteback: StateFlow<Boolean> = _hcWriteback.asStateFlow()

// Last writeback outcome (#660). Read from prefs (the writer persists it — including on the
// background BLE path, which never touches this VM), so Data Sources shows a failing share
// instead of a healthy-looking toggle. [refreshHcWritebackStatus] re-reads after each attempt.
private val _hcWritebackStatus = MutableStateFlow(readHcWritebackStatus())
val hcWritebackStatus: StateFlow<HcWritebackStatus> = _hcWritebackStatus.asStateFlow()
private fun readHcWritebackStatus() = HcWritebackStatus(
code = NoopPrefs.hcWritebackStatus(appContext),
atMs = NoopPrefs.hcWritebackAt(appContext),
written = NoopPrefs.hcWritebackWritten(appContext),
)
/** Re-read the persisted writeback outcome (a background BLE-path write updates prefs, not this VM). */
fun refreshHcWritebackStatus() { _hcWritebackStatus.value = readHcWritebackStatus() }

init {
// On app open, catch up the Health Connect sync if it's overdue. This on-open import is the
// ONLY auto-sync path: we deliberately skip a true-background worker — it needs a sensitive
Expand Down Expand Up @@ -1871,6 +1889,7 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
withContext(Dispatchers.IO) {
runCatching { HealthConnectWriter.write(appContext, repository, deviceId) }
}
refreshHcWritebackStatus() // #660: surface the just-recorded outcome in Data Sources
}
}

Expand Down
30 changes: 30 additions & 0 deletions android/app/src/main/java/com/noop/ui/DataSourcesScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ fun DataSourcesScreen(vm: AppViewModel) {
val hcSyncHours by vm.hcSyncHours.collectAsStateWithLifecycle()
val hcLastSync by vm.hcLastSync.collectAsStateWithLifecycle()
val hcWriteback by vm.hcWriteback.collectAsStateWithLifecycle()
val hcWbStatus by vm.hcWritebackStatus.collectAsStateWithLifecycle()
// 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() }

// Cached-store counts, loaded once from the repo (newest data is fine to recount).
var whoopDays by remember { mutableStateOf<Int?>(null) }
Expand Down Expand Up @@ -536,6 +540,32 @@ fun DataSourcesScreen(vm: AppViewModel) {
},
)
}
// #660: surface the last writeback OUTCOME so a silently-failing share (revoked
// permission, provider error) is visible instead of a healthy-looking toggle. Only
// while enabled and after at least one attempt (empty status = never tried).
if (hcWriteback && hcWbStatus.code.isNotEmpty()) {
when (hcWbStatus.code) {
NoopPrefs.HC_WB_PERMISSION_DENIED -> Text(
uiString(R.string.l10n_data_sources_screen_sharing_paused_health_connect_permission_was_e8950315),
style = NoopType.footnote,
color = Palette.accent,
modifier = Modifier
.fillMaxWidth()
.clickable { startWriteback() }, // tap re-requests the WRITE permissions
)
NoopPrefs.HC_WB_REMOTE_ERROR -> Text(
uiString(R.string.l10n_data_sources_screen_last_share_didn_t_finish_noop_0d6c27f0),
style = NoopType.footnote,
color = Palette.accent,
)
else -> Text( // HC_WB_OK
uiString(R.string.l10n_data_sources_screen_last_shared_6bf8389c) +
DateUtils.getRelativeTimeSpanString(hcWbStatus.atMs).toString(),
style = NoopType.footnote,
color = Palette.textTertiary,
)
}
}
} else {
RoadmapNote("Health Connect isn't set up on this device. Install it from Google Play, then return here to import.")
}
Expand Down
21 changes: 21 additions & 0 deletions android/app/src/main/java/com/noop/ui/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,27 @@ object NoopPrefs {
of(context).edit().putBoolean(KEY_HC_WRITEBACK, enabled).apply()
}

/** Last writeback OUTCOME (#660) — surfaced in Data Sources so a silently-failing share (revoked
* permission, provider error) is visible instead of a healthy-looking toggle. [KEY_HC_WB_STATUS]
* holds a PII-safe category ([HC_WB_OK] / [HC_WB_PERMISSION_DENIED] / [HC_WB_REMOTE_ERROR]); "" = never. */
const val KEY_HC_WB_STATUS = "noop.hcWritebackStatus"
const val KEY_HC_WB_AT = "noop.hcWritebackAtMs"
const val KEY_HC_WB_WRITTEN = "noop.hcWritebackWritten"
const val HC_WB_OK = "OK"
const val HC_WB_PERMISSION_DENIED = "PERMISSION_DENIED"
const val HC_WB_REMOTE_ERROR = "REMOTE_ERROR"

fun hcWritebackStatus(context: Context): String = of(context).getString(KEY_HC_WB_STATUS, "") ?: ""
fun hcWritebackAt(context: Context): Long = of(context).getLong(KEY_HC_WB_AT, 0L)
fun hcWritebackWritten(context: Context): Int = of(context).getInt(KEY_HC_WB_WRITTEN, 0)
fun setHcWritebackStatus(context: Context, code: String, written: Int, atMs: Long) {
of(context).edit()
.putString(KEY_HC_WB_STATUS, code)
.putInt(KEY_HC_WB_WRITTEN, written)
.putLong(KEY_HC_WB_AT, atMs)
.apply()
}

/** #528, last HR sample epoch-second exported to Health Connect (0 = nothing exported yet). The
* HR share-back only emits samples newer than this, so each writeback is incremental. */
const val KEY_HC_HR_FRONTIER = "noop.hcHrFrontierTs"
Expand Down
3 changes: 3 additions & 0 deletions android/app/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1697,4 +1697,7 @@
<item quantity="one">%1$d Workout</item>
<item quantity="other">%1$d Workouts</item>
</plurals>
<string name="l10n_data_sources_screen_last_shared_6bf8389c">"Zuletzt geteilt "</string>
<string name="l10n_data_sources_screen_sharing_paused_health_connect_permission_was_e8950315">Teilen pausiert – die Health-Connect-Berechtigung wurde entzogen. Zum erneuten Erteilen tippen.</string>
<string name="l10n_data_sources_screen_last_share_didn_t_finish_noop_0d6c27f0">Letztes Teilen unvollständig – NOOP versucht es automatisch erneut.</string>
</resources>
3 changes: 3 additions & 0 deletions android/app/src/main/res/values-es/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1682,4 +1682,7 @@
<item quantity="one">%1$d entrenamiento</item>
<item quantity="other">%1$d entrenamientos</item>
</plurals>
<string name="l10n_data_sources_screen_last_shared_6bf8389c">"Compartido por última vez "</string>
<string name="l10n_data_sources_screen_sharing_paused_health_connect_permission_was_e8950315">Uso compartido en pausa: se revocó el permiso de Health Connect. Toca para volver a concederlo.</string>
<string name="l10n_data_sources_screen_last_share_didn_t_finish_noop_0d6c27f0">El último uso compartido no se completó: NOOP lo reintentará automáticamente.</string>
</resources>
3 changes: 3 additions & 0 deletions android/app/src/main/res/values-fr/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1682,4 +1682,7 @@
<item quantity="one">%1$d entraînement</item>
<item quantity="other">%1$d entraînements</item>
</plurals>
<string name="l10n_data_sources_screen_last_shared_6bf8389c">"Dernier partage "</string>
<string name="l10n_data_sources_screen_sharing_paused_health_connect_permission_was_e8950315">Partage en pause — l’autorisation Health Connect a été révoquée. Touchez pour l’accorder à nouveau.</string>
<string name="l10n_data_sources_screen_last_share_didn_t_finish_noop_0d6c27f0">Le dernier partage n’a pas abouti — NOOP réessaiera automatiquement.</string>
</resources>
3 changes: 3 additions & 0 deletions android/app/src/main/res/values-zh/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1623,4 +1623,7 @@
<string name="explore_description_charge">你当前的恢复水平,以 HRV 相对于你个人基线的表现为主导。</string>
<string name="explore_description_effort">全天累积的心血管负荷,采用 0-100 分制(旧版为 0-21)。</string>
<string name="explore_description_rest">你的睡眠质量与修复力:综合睡眠时长、睡眠效率、深睡+REM占比以及入睡时机等。</string>
<string name="l10n_data_sources_screen_last_shared_6bf8389c">"上次分享 "</string>
<string name="l10n_data_sources_screen_sharing_paused_health_connect_permission_was_e8950315">分享已暂停——Health Connect 权限已被撤销。点按以重新授予。</string>
<string name="l10n_data_sources_screen_last_share_didn_t_finish_noop_0d6c27f0">上次分享未完成——NOOP 将自动重试。</string>
</resources>
3 changes: 3 additions & 0 deletions android/app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1706,4 +1706,7 @@
<item quantity="one">%1$d workout</item>
<item quantity="other">%1$d workouts</item>
</plurals>
<string name="l10n_data_sources_screen_last_shared_6bf8389c">"Last shared "</string>
<string name="l10n_data_sources_screen_sharing_paused_health_connect_permission_was_e8950315">Sharing paused — Health Connect permission was revoked. Tap to re-grant.</string>
<string name="l10n_data_sources_screen_last_share_didn_t_finish_noop_0d6c27f0">Last share didn’t finish — NOOP will retry automatically.</string>
</resources>
Loading
Loading