Skip to content
Merged
Show file tree
Hide file tree
Changes from 59 commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
4874573
barcode lookup is on by default
abdulsaheel Aug 19, 2026
425fcc0
podfile.lock: mobile_scanner in, video_player out
abdulsaheel Aug 19, 2026
9fef332
import: route by what the file holds, not what it's called
abdulsaheel Aug 19, 2026
ba52200
coach: serialize the keychain writes (#241)
abdulsaheel Aug 19, 2026
ae23ac8
ai briefing: the payload preview shows what was sent, not a dash
abdulsaheel Aug 19, 2026
edada74
readme: whoop 5 and mg work, say so
abdulsaheel Aug 19, 2026
0be0501
pr agent: don't go green without reviewing anything (#230)
abdulsaheel Aug 19, 2026
e38c218
bump health to 12.2.1 — 11.1.1 threw on every light-sleep write
abdulsaheel Aug 19, 2026
c797b52
double-tap can log water
abdulsaheel Aug 19, 2026
f6ae062
device_actions: name the files that exist
abdulsaheel Aug 19, 2026
2b98a8c
the double-tap picker, which never got rebuilt
abdulsaheel Aug 19, 2026
f736463
import: don't read a zip as a string on the journal probe
abdulsaheel Aug 19, 2026
fcdf022
ios: delete the whole night, not the calendar day
abdulsaheel Aug 19, 2026
ac3896c
detected workouts had nowhere to go, and manual logging had no screen…
abdulsaheel Aug 19, 2026
9e90e10
ios: write the in-bed envelope around the stages
abdulsaheel Aug 19, 2026
a8b874e
auto-detection gets an off switch, and the movement nudge gets one it…
abdulsaheel Aug 19, 2026
e250fb8
the notification→strap relay has a screen again (#92)
abdulsaheel Aug 19, 2026
9c49ac6
notifications settings: the three rows behind all of that
abdulsaheel Aug 19, 2026
b580d7a
note the missing health export on the coach's workout write (#130)
abdulsaheel Aug 19, 2026
799e8e0
health export seam takes a workout id (#130)
abdulsaheel Aug 19, 2026
1b90add
import: one unix_s header constant, not two
abdulsaheel Aug 19, 2026
e8e1bb0
readiness bands: 50 is the middle of the scale, not a warning (#250)
abdulsaheel Aug 19, 2026
d43353d
calories: pass the resting HR the new active gate needs
abdulsaheel Aug 19, 2026
effb90d
strain: state which quiet-waking level we mean
abdulsaheel Aug 19, 2026
88267d9
readiness: pass the settled fraction, so skin temp can actually be a …
abdulsaheel Aug 19, 2026
2a1f6ac
peak hr: the day peak and the manual save go through the same smoothi…
abdulsaheel Aug 19, 2026
af8744a
sleep: a night never re-stages shorter than the one already banked (#…
abdulsaheel Aug 19, 2026
74850a0
decode: absent accel stays absent, not 0
abdulsaheel Aug 19, 2026
7bfe440
tests: the energy fixtures need a resting HR now
abdulsaheel Aug 19, 2026
d4f7229
tier sentinel: 65 is "good to go" now, publish a mid-band score instead
abdulsaheel Aug 19, 2026
40cb3a1
v25: protocol hands us no vector at all now
abdulsaheel Aug 19, 2026
468c1f1
every workout write path exports, including the coach's
abdulsaheel Aug 19, 2026
7cd399c
the double-tap picker has a door again
abdulsaheel Aug 19, 2026
e8213a7
the sawtooth test straddles the gate it asks for
abdulsaheel Aug 19, 2026
5f1136c
algo 75, and repin both siblings
abdulsaheel Aug 19, 2026
66b0070
the ios delete asks for a type healthkit has never heard of
abdulsaheel Aug 19, 2026
b4e0ae6
the briefing kept its own readiness cuts and the ring moved
abdulsaheel Aug 19, 2026
0823d1f
auto-detect off, and the screen the notification opens still loaded them
abdulsaheel Aug 19, 2026
7a30252
barcode lookup defaults on, but not when we can't read the switch
abdulsaheel Aug 19, 2026
405db73
the relay screen said nothing is stored, and it stores package names
abdulsaheel Aug 19, 2026
393af74
a load that starts during a save straddles it and wins
abdulsaheel Aug 19, 2026
93c16a3
the router judged byte zero, the reader doesn't
abdulsaheel Aug 19, 2026
b8cd547
skipping the only step still leaves the job green
abdulsaheel Aug 19, 2026
14089ee
repin both siblings, and the third copy of the gate
abdulsaheel Aug 19, 2026
bcea409
forget the band and there was no way back to pairing
abdulsaheel Aug 19, 2026
98dee9c
notify: two prompts that actually ask you to log
abdulsaheel Aug 19, 2026
c324c13
the two log prompts get their switches
abdulsaheel Aug 19, 2026
64ee5e7
imported days were setting the baseline they're supposed to stay out of
abdulsaheel Aug 19, 2026
9c3b23c
widgets: same three rings as home, and two new faces
abdulsaheel Aug 19, 2026
066759d
screens didn't notice writes landing under them
abdulsaheel Aug 19, 2026
1a056bc
rough night reads measured days only
abdulsaheel Aug 19, 2026
14983ea
algo 76 — the baselines change for anyone who ever imported
abdulsaheel Aug 19, 2026
b9a37af
sleep ring could sweep past its own end
abdulsaheel Aug 20, 2026
b46a979
an older read could land after a newer one
abdulsaheel Aug 20, 2026
1815f39
the dose reminder lands on the medication tab now
abdulsaheel Aug 20, 2026
e1bfadf
a re-derived day stayed imported forever
abdulsaheel Aug 20, 2026
e3433d7
"no medications" and "couldn't read them" are different answers
abdulsaheel Aug 20, 2026
f36c0b6
paper cuts from the review
abdulsaheel Aug 20, 2026
42496ef
a sub-tab loading when the revision landed still committed the old data
abdulsaheel Aug 20, 2026
9ada7c6
make the "did you work out?" notification actually fire, and land on …
abdulsaheel Aug 20, 2026
771737d
labs data you can delete
abdulsaheel Aug 20, 2026
753a8f3
one bad number in the coach block took the whole wellness screen
abdulsaheel Aug 20, 2026
f06a88d
meds: say why nothing is due instead of an empty box
abdulsaheel Aug 20, 2026
d0f9173
the prompt class wants normal priority too, not just the route
abdulsaheel Aug 20, 2026
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
30 changes: 29 additions & 1 deletion .github/workflows/pr-agent.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,37 @@ jobs:
issues: write
pull-requests: write
contents: write
# Job-level so the step `if` below can see it: the `secrets` context is not
# available in an `if` expression, but `env` is.
env:
PR_AGENT_API_KEY: ${{ secrets.PR_AGENT_API_KEY }}
steps:
# A PR from a fork gets no secrets, so the step below ran with an empty
# key, reviewed nothing, and still went green - a check that says
# "reviewed" when it did not is worse than no check.
#
# Skipping the step alone did not fix that: the only step is skipped, the
# JOB still reports success, and a green required check still reads as a
# pass. So SAY SO, in the one place a reader of the PR looks - the check's
# summary - and make the log line an annotation on the PR itself.
- name: Not applicable - no review key on this PR
if: env.PR_AGENT_API_KEY == ''
run: |
echo "::notice title=PR Agent did not run::No review key is available \
on this pull request (forks get no secrets), so NOTHING was reviewed. \
A green check here means the job finished, not that the diff passed."
{
echo "## PR Agent: not applicable"
echo
echo "No \`PR_AGENT_API_KEY\` on this run - a fork PR gets no"
echo "secrets. **No review was performed.** Treat this check as"
echo "absent, not as a pass."
} >> "$GITHUB_STEP_SUMMARY"
- name: PR Agent action step
uses: the-pr-agent/pr-agent@main
if: env.PR_AGENT_API_KEY != ''
# Pinned, not @main: this action runs with `contents: write` and a token
# on every PR, and a floating ref means whatever landed upstream today.
uses: the-pr-agent/pr-agent@f6af7d77554ff8d26adffded077e6461329e92fa # v0.42.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Credentials only. The model chain lives in .pr_agent.toml so it is
Expand Down
8 changes: 6 additions & 2 deletions .pr_agent.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,13 @@ fallback_models = [
"openai/gpt-oss-120b-medium",
]
# Required: an `openai/`-prefixed name is not in PR-Agent's MAX_TOKENS map, and
# get_max_tokens (algo/utils.py:1008) raises rather than defaulting. Effective
# input is still min(this, max_model_tokens=32000).
# get_max_tokens (algo/utils.py:1008) raises rather than defaulting.
custom_model_max_tokens = 200000
# The effective input is min(custom_model_max_tokens, max_model_tokens), and
# max_model_tokens defaults to 32000 - so the 200k above bought nothing and a
# large diff was silently clipped to a third of the review it looked like it
# got. Raise the ceiling to match.
max_model_tokens = 200000

# Inject AGENTS.md as repository context into /review, /improve, /describe, /ask.
# NOTE: read from the DEFAULT BRANCH by default, so AGENTS.md only takes effect
Expand Down
9 changes: 5 additions & 4 deletions PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,10 @@ because a sideloaded app has no store to tell you a security fix exists.

**Barcode lookup for food logging**
The food log can read a barcode with the camera and fill in the nutrition
figures for you. Doing that means asking a database, so it is **off until you
turn it on**, and the App asks you before the first lookup ever happens — not
after.
figures for you. Doing that means asking a database, so it is **on by
default**: what leaves is a number the manufacturer printed on the packet, and
nothing about you goes with it. Turn it off and the scan stops asking anybody
anything.

- **What is sent is the barcode.** It goes to openfoodfacts.org, the free and
open food database. Nothing about you, your meals, your health or your device
Expand Down Expand Up @@ -157,7 +158,7 @@ moment you tap it:
further upload immediately, and the App tells you when the last one was.
- **Check for updates** — turning it off stops the App making any network
request of its own accord.
- **Look barcodes up online** — off by default; turning it off stops any
- **Look barcodes up online** — on by default; turning it off stops any
further lookup immediately, and the food log keeps working by hand.

You can also disable AI Coach or Health app integration at any time in Settings
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ drawer-bracelet problem can use it, or go dig through the code themselves.

## Checklist

- **WHOOP 4.0 only.** Haven't touched a WHOOP 5, don't know if it even shares a protocol.
- **WHOOP 4.0 is the one that's properly tested.** WHOOP 5 and MG work too, but they're
experimental — see the note further down.
- Not affiliated with WHOOP, doesn't talk to their servers.
- Not a clone of their algorithms — different math, published methods, cited in the
analytics repo. Don't expect identical numbers to what their app shows.
Expand Down Expand Up @@ -143,9 +144,10 @@ shortcuts, a smart alarm that buzzes the band.
against a lab, don't treat any of it as a diagnosis.
- Not on the App Store or Play Store yet. iOS is a public TestFlight beta, which is a
normal install but still a beta; Android is an APK straight off Releases.
- WHOOP 5.0 / MG support is in progress and **experimental** — the band is detected and
spoken to, but it hasn't been validated against real 5.0 hardware. WHOOP 4.0 is the
only one that's actually tested.
- WHOOP 5.0 / MG support is **experimental**. Both pair, sync and decode, and the work is
checked against real records off real bands — but 4.0 is the one I wear every day, so
it's the one that gets found out when it breaks. Expect rough edges on 5 and MG, and
open an issue when you hit one.

## Run it

Expand Down
22 changes: 22 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,28 @@
android:name="android.appwidget.provider"
android:resource="@xml/widget_openstrap_info" />
</receiver>
<receiver
android:name=".SleepWidgetProvider"
android:exported="true"
android:label="@string/widget_sleep_picker_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_sleep_info" />
</receiver>
<receiver
android:name=".OvernightWidgetProvider"
android:exported="true"
android:label="@string/widget_overnight_label">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_overnight_info" />
</receiver>
<receiver
android:name=".OpenStrapBatteryWidgetProvider"
android:exported="true"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,28 @@ import android.content.SharedPreferences
import android.os.Build
import android.os.Bundle
import android.util.SizeF
import android.view.View
import android.widget.RemoteViews
import es.antonborri.home_widget.HomeWidgetPlugin
import es.antonborri.home_widget.HomeWidgetProvider

/**
* Home-screen metrics widget — the Android sibling of OpenStrapWidget.swift
* (Readiness headline + the Strain · Sleep · HRV rings, Ember on Paper).
* Home-screen metrics widget — the Android sibling of OpenStrapWidget.swift.
*
* IT IS THE SAME THREE RINGS AS HOME: Recovery · Strain · Sleep, in that order,
* the icon inside the dial and the number under it. It used to be four rings
* (Ready · Strain · Sleep · HRV) with the number inside, which was the previous
* design system's home screen; HRV stopped being one of Home's rings in the
* rebuild and lives on OvernightWidgetProvider instead.
*
* Renders the snapshot WidgetService.push() writes through home_widget; the app
* broadcasts an update after every sync (this provider name is already wired in
* widget_service.dart). Unlike iOS there is no self-refresh fetch of /today —
* updatePeriodMillis just re-renders the cached snapshot so the theme/staleness
* stay honest when the app hasn't run for a while.
* broadcasts an update after every derive and every foreground. There is no
* network on this side — updatePeriodMillis only re-renders the cached snapshot
* so the theme and the staleness rule stay honest when the app hasn't run.
*
* Two layouts, like the iOS families: a 2×2 ring grid (small) and a readiness
* row over the triple rings (medium). On Android 12+ the launcher picks by live
* size (RemoteViews size map); below that we choose from the widget's min width.
* Two layouts, like the iOS families: three rows (2x2) and three columns (4x2),
* sharing every id so there is one render path. On Android 12+ the launcher
* picks by live size; below that we choose from the widget's min width.
*/
class OpenStrapWidgetProvider : HomeWidgetProvider() {

Expand Down Expand Up @@ -65,6 +70,26 @@ class OpenStrapWidgetProvider : HomeWidgetProvider() {
manager.updateAppWidget(id, views)
}

/** One ring's three views and the icon that goes in its dial. */
private class Slot(
val key: String,
val label: String,
val iconRes: Int,
val dial: Int,
val cap: Int,
val value: Int,
val sub: Int,
)

private val slots = listOf(
Slot("recovery", "RECOVERY", R.drawable.ic_widget_recovery,
R.id.dial_recovery, R.id.cap_recovery, R.id.val_recovery, R.id.sub_recovery),
Slot("strain", "STRAIN", R.drawable.ic_widget_strain,
R.id.dial_strain, R.id.cap_strain, R.id.val_strain, R.id.sub_strain),
Slot("sleep", "SLEEP", R.drawable.ic_widget_sleep,
R.id.dial_sleep, R.id.cap_sleep, R.id.val_sleep, R.id.sub_sleep),
)

private fun build(context: Context, prefs: SharedPreferences, small: Boolean): RemoteViews {
val w = StrapWidgets
val pal = w.pal(prefs)
Expand All @@ -75,83 +100,56 @@ class OpenStrapWidgetProvider : HomeWidgetProvider() {
// `updated_at` here, on every render.
if (!w.fresh(prefs)) return buildNoData(context, pal)

// Snapshot (sentinels: -1 = no data — mirrors OpenStrapEntry).
val readiness = w.readInt(prefs, "readiness", -1)
val strain = w.readDouble(prefs, "strain", -1.0)
val sleepMin = w.readInt(prefs, "sleep_min", -1)
// -1 = none. The Dart writer uses the same sentinel; the ring below
// already gates on needMin > 0, so an unknown need leaves it empty
// instead of filling against a fabricated 8h denominator.
val needMin = w.readInt(prefs, "sleep_need_min", -1)
val hrv = w.readInt(prefs, "hrv", -1)
val hrvBaseline = w.readInt(prefs, "hrv_baseline", -1)

// Ring fractions — negative means "nothing measured", so ringBitmap
// draws the track alone rather than an arc pinned at empty (which reads
// as a real value of zero).
val readinessT = if (readiness >= 0) readiness / 100.0 else -1.0
val tier = w.readInt(prefs, "readiness_tier", -1)
val readinessArc = w.readinessArc(tier)
val readinessColor = w.readinessColor(tier, pal)
// 0-21 is the headline scale strainScore maps TRIMP onto
// (analytics/lib/src/onehz/clinical/load_trimp.dart:104-122).
val strainT = if (strain >= 0) (strain / 21.0).coerceAtMost(1.0) else -1.0
val sleepT = if (sleepMin >= 0 && needMin > 0) {
(sleepMin.toDouble() / needMin).coerceAtMost(1.0)
} else {
-1.0
}
// HRV against YOUR OWN baseline: a full ring is at or above it. There
// is no population scale for RMSSD, so with no baseline there is no
// denominator and the arc is not drawn. This used to divide by a
// hard-coded 100 (and by 1.5 x baseline), neither of which exists
// anywhere in the pipeline.
val hrvT = if (hrv >= 0 && hrvBaseline > 0) {
(hrv.toDouble() / hrvBaseline).coerceAtMost(1.0)
} else {
-1.0
}
// HRV carries its domain accent and no colour judgement: a "0.8 x
// baseline is amber" cut-off was invented here and appears in no
// analytics output.
val hrvColor = if (hrv >= 0) w.GREEN else w.N400

// "" = no measurement. A bare dash is the one rendering the phone's
// grammar forbids outright.
val strainText = if (strain >= 0) String.format("%.1f", strain) else ""
val readinessText = if (readiness >= 0) "$readiness" else ""
val hrvText = if (hrv >= 0) "$hrv" else ""

val layout = if (small) R.layout.widget_openstrap_small else R.layout.widget_openstrap
val ringDp = if (small) 40 else 56
val strokeDp = if (small) 5f else 7f

val dialDp = if (small) 30 else 44
val strokeDp = if (small) 4.5f else 6f
val views = RemoteViews(context.packageName, layout)
views.setInt(R.id.widget_root, "setBackgroundResource", pal.bgRes)
views.setOnClickPendingIntent(R.id.widget_root, w.openAppIntent(context))

// Readiness leads the row; its VALUE carries the readiness colour (the
// iOS headline treatment, compressed into a cell).
views.setImageViewBitmap(
R.id.ring_readiness,
w.ringBitmap(context, ringDp, strokeDp, pal.track, readinessArc, readinessT),
)
views.setTextViewText(R.id.val_readiness, readinessText)
views.setTextColor(R.id.val_readiness, readinessColor)
views.setTextColor(R.id.cap_readiness, pal.inkMuted)
// Recovery wears its band's colour (from the published tier — the
// cut-offs are never re-derived here), the other two their domain accent.
val tier = w.readInt(prefs, "readiness_tier", -1)
var gap: Pair<String, String>? = null

fun metric(ring: Int, value: Int, cap: Int, bmpColor: Int, t: Double, text: String) {
for (slot in slots) {
val r = w.ring(prefs, slot.key)
val accent = when (slot.key) {
"recovery" -> w.tierColor(tier, pal)
"strain" -> pal.move
else -> pal.sleep
}
val tint = r.color(accent, pal)
views.setImageViewBitmap(
ring,
w.ringBitmap(context, ringDp, strokeDp, pal.track, bmpColor, t),
slot.dial,
w.dialBitmap(context, dialDp, strokeDp, pal.track, tint, r.frac, slot.iconRes),
)
views.setTextViewText(value, text)
views.setTextColor(value, pal.ink)
views.setTextColor(cap, pal.inkMuted)
views.setTextViewText(slot.cap, slot.label)
views.setTextColor(slot.cap, pal.inkMuted)
// The absence takes the SENTENCE colour rather than the numeral
// one, because it is a sentence: "No sleep" in full-weight ink
// would read as a score.
views.setTextViewText(slot.value, r.value)
views.setTextColor(slot.value, if (r.measured) pal.ink else pal.ink2)
if (!small) {
views.setTextViewText(slot.sub, r.sub)
views.setTextColor(slot.sub, pal.inkMuted)
}
if (gap == null && r.why.isNotEmpty()) gap = slot.label to r.why
}

// The first ring that is missing and said why. One line is what a
// widget can afford; the rest is one tap away in the app.
if (!small) {
val g = gap
if (g == null) {
views.setViewVisibility(R.id.gap_row, View.GONE)
} else {
views.setViewVisibility(R.id.gap_row, View.VISIBLE)
views.setTextViewText(R.id.gap_row, "${g.first} · ${g.second}")
views.setTextColor(R.id.gap_row, pal.inkMuted)
}
}
metric(R.id.ring_strain, R.id.val_strain, R.id.cap_strain, w.PURPLE, strainT, strainText)
metric(R.id.ring_sleep, R.id.val_sleep, R.id.cap_sleep, w.BLUE, sleepT, w.hm(sleepMin))
metric(R.id.ring_hrv, R.id.val_hrv, R.id.cap_hrv, hrvColor, hrvT, hrvText)
return views
}

Expand Down
Loading
Loading