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
14 changes: 13 additions & 1 deletion android/app/src/main/java/com/noop/ble/PuffinExperiment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,15 @@ class PuffinExperiment(private val prefs: SharedPreferences) {
editor.apply()
}

/**
* "Clear a stale phone pairing" - may NOOP call removeBond() when the OS holds a pairing the strap
* no longer honours? Default OFF, like every other switch here that changes hardware or OS state.
* See [shouldRemoveStaleBond] for the gate and why the threshold is above the guide's.
*/
var clearStaleBond: Boolean
get() = prefs.getBoolean(KEY_CLEAR_STALE_BOND, false)
set(v) { prefs.edit().putBoolean(KEY_CLEAR_STALE_BOND, v).apply() }

companion object {
/** Persisted preferences file. Internal so a UI screen can observe external writes to it. */
internal const val PREFS = "noop_experiments"
Expand All @@ -247,6 +256,9 @@ class PuffinExperiment(private val prefs: SharedPreferences) {
* so no macOS key to mirror. */
const val KEY_EXPLICIT_BOND = "noopWhoop5ExplicitBond"

/** "Clear a stale phone pairing" opt-in — see [shouldRemoveStaleBond]. */
const val KEY_CLEAR_STALE_BOND = "noopWhoop5ClearStaleBond"

/** "Try history sync without pairing" opt-in — the unbonded offload probe (#1635). Android-only,
* so no macOS key to mirror. */
const val KEY_UNBONDED_OFFLOAD = "noopWhoop5UnbondedOffload"
Expand All @@ -258,7 +270,7 @@ class PuffinExperiment(private val prefs: SharedPreferences) {
* SettingsScreen watches exactly these for external writes. Two lists would drift. */
internal val FIVE_MG_GATED_KEYS =
listOf(KEY, KEY_CAPTURE, KEY_DEEP_DATA, KEY_BROADCAST_HR, KEY_ECG_RAW_DATA, KEY_EXPLICIT_BOND,
KEY_UNBONDED_OFFLOAD)
KEY_UNBONDED_OFFLOAD, KEY_CLEAR_STALE_BOND)

/** "Experimental sleep staging (V2)" opt-in (mirrors macOS `PuffinExperiment.experimentalSleepV2Key`). */
const val KEY_EXPERIMENTAL_SLEEP_V2 = "noopExperimentalSleepV2"
Expand Down
77 changes: 77 additions & 0 deletions android/app/src/main/java/com/noop/ble/StaleBondRemoval.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package com.noop.ble

/**
* When the app may clear a STALE platform pairing on the user's behalf.
*
* A bonded fast-path connect that drops before it ever reaches a session means the OS is holding a bond
* the strap no longer honours — a firmware update, or the official WHOOP app re-bonding, leaves Android
* with a pairing the band has forgotten. NOOP has always detected this ([WhoopBleClient] logs "stale OS
* bond") and, from the second failure, shown the forget-and-re-pair guide. Asking is the right first
* move, and it stays: this is what happens when asking did not work.
*
* OFF by default and behind its own switch, per the rule every strap-affecting probe in
* [PuffinExperiment] follows. `removeBond()` deletes a pairing from the phone's Bluetooth settings —
* at least as consequential as `createBond()` forming one, which [shouldRequestExplicitBond] already
* gates the same way — so it must not ride in on consent given for something else.
*
* Independently arrived at by OpenStrap/edge, whose gen5 bootstrap removes the platform bond once after
* five consecutive exchange failures and clears the counter only when a connect reaches READY. The
* threshold and the once-only shape are taken from that behaviour as a fact; none of its code is.
*/

/**
* Consecutive stale-bond failures before the pairing is cleared automatically.
*
* Five, not the two at which the guide appears: the guide asks the user to do this themselves, and they
* deserve several reconnect cycles to act before the app does it for them. A strap whose pairing really
* is stale reaches five within a couple of minutes, and every one of those cycles is a failed connect.
*/
internal const val STALE_BOND_REMOVAL_THRESHOLD = 5

/**
* Should this disconnect clear the platform pairing?
*
* Every clause is load-bearing:
* - [optedIn]: default-off, see the file note. No switch, no bond removal, ever.
* - [isWhoop5]: a 4.0 bonds by the route it always has. This failure mode is the 5/MG fast-path one,
* and removing a WHOOP 4 pairing would break a link that works.
* - [osBonded]: nothing to remove otherwise, and calling it anyway would log a failure that means
* nothing.
* - [consecutiveStaleFailures]: the streak, not a single drop — one stale-looking disconnect is a
* transient, and this is not an action to take on a transient.
* - [alreadyRemovedThisRun]: exactly ONCE per streak. Removing a bond and immediately failing again
* means the bond was not the problem, and repeating it would delete a pairing per reconnect for a
* cause it cannot fix. Cleared only by a genuine bond, which is the event that proves the strap and
* the phone agree again.
*/
internal fun shouldRemoveStaleBond(
optedIn: Boolean,
isWhoop5: Boolean,
osBonded: Boolean,
consecutiveStaleFailures: Int,
alreadyRemovedThisRun: Boolean,
threshold: Int = STALE_BOND_REMOVAL_THRESHOLD,
): Boolean {
if (!optedIn) return false
if (!isWhoop5) return false
if (!osBonded) return false
if (alreadyRemovedThisRun) return false
return consecutiveStaleFailures >= threshold
}

/**
* The one line that says what was done and whether the OS accepted it.
*
* `removeBond()` is not public API and is reached by reflection, so "we asked" and "it happened" are
* genuinely different outcomes and the log must not conflate them. No PII: no MAC, no serial.
*/
internal fun staleBondRemovalLine(failures: Int, accepted: Boolean): String =
if (accepted) {
"Stale pairing: cleared the phone's Bluetooth pairing for this strap after $failures failed " +
"bonded connects. Android accepted the request; the next connect starts from an unpaired " +
"state, which is what the re-pair guide was asking you to do by hand (#1635)."
} else {
"Stale pairing: asked Android to clear this strap's pairing after $failures failed bonded " +
"connects and it REFUSED (removeBond is not public API and can be denied). Nothing has " +
"changed - clear it yourself in Settings > Bluetooth, as the re-pair guide describes."
}
34 changes: 34 additions & 0 deletions android/app/src/main/java/com/noop/ble/WhoopBleClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5533,6 +5533,10 @@ class WhoopBleClient(
* nag the user. Reset to 0 on any genuine bond. (5/MG firmware reset parity, 2026-06) */
private var staleDirectFailures = 0

/** #1635: the stale-pairing clear is once per streak. Cleared by a genuine bond, the event that
* proves the phone and the strap agree again. */
private var staleBondRemoved = false

/**
* Which of OUR OWN paths last tore the link down, and when the current session started (#1020).
*
Expand Down Expand Up @@ -6506,6 +6510,7 @@ class WhoopBleClient(
noteGenuineBond(g.device.address) // #52: this strap bonds fine; clears any pin-refusal streak
clearPairingHint() // #78: a genuine bond means the pairing guidance no longer applies
staleDirectFailures = 0 // genuine bond — clear the wiped-bond counter (#84 parity)
staleBondRemoved = false // ...and re-arm the one-shot stale-pairing clear
}
_state.update { it.copy(bonded = true, encryptedBond = encrypted) }
bondedAtMs = System.currentTimeMillis() // #617: stamp the bond so handleDisconnect can spot a bond-then-quick-timeout loop
Expand Down Expand Up @@ -9730,6 +9735,15 @@ class WhoopBleClient(
// MARK: Disconnect / teardown (port of didDisconnectPeripheral)
// ====================================================================================

/**
* Ask Android to delete this device's pairing. Reflection because `removeBond()` is not public API;
* a throw or a `false` is a REFUSAL, not a crash, and the caller reports which happened.
*/
@SuppressLint("MissingPermission")
private fun removeOsBond(device: BluetoothDevice): Boolean = runCatching {
device.javaClass.getMethod("removeBond").invoke(device) as? Boolean ?: false
}.getOrDefault(false)

@SuppressLint("MissingPermission")
private fun handleDisconnect(status: Int) {
val helloWasUnacked = clientHelloWriteAtMs > 0L
Expand Down Expand Up @@ -9980,6 +9994,26 @@ class WhoopBleClient(
}
if (staleDirectBond) {
staleDirectFailures++
// Before `lastDevice = null` below: that handle is the only device left to act on here.
val staleDevice = lastDevice
if (staleDevice != null && shouldRemoveStaleBond(
optedIn = puffinExperiment.clearStaleBond,
// Belt and braces. `staleDirectBond` can only be set by the Easy-connect path,
// whose helpers match a 5/MG and which pins selectedModel to it — so this is
// already implied. Kept because `connectedFamily` fails CLOSED when it is stale
// (it holds the previous link's value), and the direction that matters is never
// removing a pairing we should not.
isWhoop5 = connectedFamily == DeviceFamily.WHOOP5,
osBonded = runCatching {
staleDevice.bondState == BluetoothDevice.BOND_BONDED
}.getOrDefault(false),
consecutiveStaleFailures = staleDirectFailures,
alreadyRemovedThisRun = staleBondRemoved,
)
) {
staleBondRemoved = true
log(staleBondRemovalLine(staleDirectFailures, removeOsBond(staleDevice)))
}
log("Disconnected (status=$status) before the bonded fast-path reached a session — stale OS bond (attempt $staleDirectFailures); falling back to a scan")
lastDevice = null
// Two consecutive wiped-bond failures = the strap really reset its pairing (firmware
Expand Down
15 changes: 15 additions & 0 deletions android/app/src/main/java/com/noop/ui/TestCentreScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ fun TestCentreScreen(vm: AppViewModel, onOpenGroundTruthCollector: () -> Unit =
var broadcastHr by remember { mutableStateOf(puffinExperiment.broadcastHr) }
var explicitBond by remember { mutableStateOf(puffinExperiment.explicitBond) }
var unbondedOffload by remember { mutableStateOf(puffinExperiment.unbondedOffload) }
var clearStaleBond by remember { mutableStateOf(puffinExperiment.clearStaleBond) }
var ecgRawData by remember { mutableStateOf(puffinExperiment.ecgRawData) }
val r22DisableReport by vm.ble.r22DisableReport.collectAsStateWithLifecycle()
val ecgGateReport by vm.ble.ecgRawDataGate.collectAsStateWithLifecycle()
Expand Down Expand Up @@ -259,6 +260,20 @@ fun TestCentreScreen(vm: AppViewModel, onOpenGroundTruthCollector: () -> Unit =
puffinExperiment.unbondedOffload = it
},
)
DeveloperToggleRow(
title = stringResource(R.string.raw_diag_clear_stale_bond),
detail = "When a bonded fast-path connect keeps dropping before it reaches a " +
"session, the phone is holding a pairing the strap no longer honours. NOOP " +
"already shows the forget-and-re-pair guide at two failures; with this on it " +
"does that step for you at five, once, and only until the strap bonds again. " +
"It cannot make a strap that refuses pairing pair. Leave it off unless you " +
"are testing #1635.",
checked = clearStaleBond,
onCheckedChange = {
clearStaleBond = it
puffinExperiment.clearStaleBond = it
},
)
DeveloperToggleRow(
title = stringResource(R.string.raw_diag_r22),
detail = "Accepted writes have not been shown to enable a separate live stream. Not required for normal sync or raw capture.",
Expand Down
1 change: 1 addition & 0 deletions android/app/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2628,6 +2628,7 @@
<string name="raw_diag_broadcast_hr">Herzfrequenz vom Band übertragen</string>
<string name="raw_diag_pair">Android um Kopplung bitten</string>
<string name="raw_diag_unbonded_offload">Verlaufssynchronisierung ohne Kopplung versuchen</string>
<string name="raw_diag_clear_stale_bond">Veraltete Kopplung entfernen</string>
<string name="raw_diag_r22">Altes R22-Feature-Flag-Experiment</string>
<string name="raw_diag_r22_enable">Alte R22-Aktivierungssequenz senden</string>
<string name="raw_diag_r22_clear">R22-Flags auf dem Band löschen</string>
Expand Down
1 change: 1 addition & 0 deletions android/app/src/main/res/values-es/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2613,6 +2613,7 @@
<string name="raw_diag_broadcast_hr">Transmitir frecuencia cardíaca desde la pulsera</string>
<string name="raw_diag_pair">Pedir a Android que vincule</string>
<string name="raw_diag_unbonded_offload">Probar la sincronización del historial sin vinculación</string>
<string name="raw_diag_clear_stale_bond">Borrar vinculación obsoleta</string>
<string name="raw_diag_r22">Experimento antiguo de indicadores R22</string>
<string name="raw_diag_r22_enable">Enviar secuencia de activación R22 antigua</string>
<string name="raw_diag_r22_clear">Borrar indicadores R22 de la pulsera</string>
Expand Down
1 change: 1 addition & 0 deletions android/app/src/main/res/values-fr/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2612,6 +2612,7 @@
<string name="raw_diag_broadcast_hr">Diffuser la fréquence cardiaque du bracelet</string>
<string name="raw_diag_pair">Demander l’appairage à Android</string>
<string name="raw_diag_unbonded_offload">Essayer la synchronisation de l\'historique sans appairage</string>
<string name="raw_diag_clear_stale_bond">Supprimer un appairage obsolète</string>
<string name="raw_diag_r22">Ancienne expérience d’indicateurs R22</string>
<string name="raw_diag_r22_enable">Envoyer l’ancienne séquence d’activation R22</string>
<string name="raw_diag_r22_clear">Effacer les indicateurs R22 du bracelet</string>
Expand Down
1 change: 1 addition & 0 deletions android/app/src/main/res/values-pl/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2617,6 +2617,7 @@
<string name="raw_diag_broadcast_hr">Transmituj tętno z opaski</string>
<string name="raw_diag_pair">Poproś Androida o sparowanie</string>
<string name="raw_diag_unbonded_offload">Spróbuj synchronizacji historii bez parowania</string>
<string name="raw_diag_clear_stale_bond">Usuń nieaktualne parowanie</string>
<string name="raw_diag_r22">Starszy eksperyment flag R22</string>
<string name="raw_diag_r22_enable">Wyślij starszą sekwencję włączenia R22</string>
<string name="raw_diag_r22_clear">Wyczyść flagi R22 na opasce</string>
Expand Down
1 change: 1 addition & 0 deletions android/app/src/main/res/values-pt-rPT/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2605,6 +2605,7 @@
<string name="raw_diag_broadcast_hr">Transmitir frequência cardíaca da bracelete</string>
<string name="raw_diag_pair">Pedir ao Android para emparelhar</string>
<string name="raw_diag_unbonded_offload">Tentar sincronizar o histórico sem emparelhamento</string>
<string name="raw_diag_clear_stale_bond">Limpar emparelhamento obsoleto</string>
<string name="raw_diag_r22">Experiência antiga de indicadores R22</string>
<string name="raw_diag_r22_enable">Enviar sequência antiga de ativação R22</string>
<string name="raw_diag_r22_clear">Limpar indicadores R22 da bracelete</string>
Expand Down
1 change: 1 addition & 0 deletions android/app/src/main/res/values-zh/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2526,6 +2526,7 @@
<string name="raw_diag_broadcast_hr">从手环广播心率</string>
<string name="raw_diag_pair">请求 Android 配对</string>
<string name="raw_diag_unbonded_offload">尝试在未配对状态下同步历史数据</string>
<string name="raw_diag_clear_stale_bond">清除过期配对</string>
<string name="raw_diag_r22">旧版 R22 功能标志实验</string>
<string name="raw_diag_r22_enable">发送旧版 R22 启用序列</string>
<string name="raw_diag_r22_clear">清除手环上的 R22 标志</string>
Expand Down
1 change: 1 addition & 0 deletions android/app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2641,6 +2641,7 @@
<string name="raw_diag_broadcast_hr">Broadcast heart rate from the strap</string>
<string name="raw_diag_pair">Ask Android to pair</string>
<string name="raw_diag_unbonded_offload">Try history sync without pairing</string>
<string name="raw_diag_clear_stale_bond">Clear a stale phone pairing</string>
<string name="raw_diag_r22">Legacy R22 feature-flag experiment</string>
<string name="raw_diag_r22_enable">Send legacy R22 enable sequence</string>
<string name="raw_diag_r22_clear">Clear legacy R22 flags on strap</string>
Expand Down
Loading
Loading