From f88315ca59d9039ca47a052b3c21ea0f01770799 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Tue, 1 Sep 2026 18:35:41 +1200 Subject: [PATCH 1/3] ble: offer to clear a stale phone pairing, once, after asking first NOOP has always DETECTED the stale bond - a bonded fast-path connect that drops before it ever reaches a session means the OS holds a pairing the strap no longer honours - and from the second failure it shows the forget-and-re-pair guide. Step 2 of that guide is "Open Settings > Bluetooth, find your WHOOP, and Forget". This does that step for the user when asking did not work. Field evidence it is reachable: log 260901-1022 shows `stale OS bond (attempt 1)` and `(attempt 2)` on a real strap. OFF by default, 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. The key joins FIVE_MG_GATED_KEYS, whose doc says two lists would drift. Five failures, not the two where the guide appears: the guide asks the user to act and they deserve cycles to do it before the app acts for them. Once per streak, cleared only by a genuine bond - removing a pairing and failing again means the pairing was not the problem, and repeating would delete one per reconnect for a cause it cannot fix. removeBond() is not public API, so it goes through reflection and a refusal is a normal outcome: the log says whether Android ACCEPTED or REFUSED, because "we asked" and "it happened" are different facts and a user reading the line needs to know which they got. No MAC, no serial. The isWhoop5 guard is belt and braces - staleDirectBond can only be set by the Easy-connect path, which is 5/MG-only - and it is kept because connectedFamily fails CLOSED when stale. The direction that matters is never removing a pairing we should not. Threshold and once-only shape are taken as a FACT from OpenStrap/edge's gen5 bootstrap, which removes the platform bond once at five consecutive exchange failures and clears the counter only on a connect reaching READY. edge is Dart and none of its code is here. Android-only: CoreBluetooth exposes no bond-removal API at all, so there is nothing to twin. 5025 unit tests green; release lintVital and the i18n audit clean; the switch label is localized in all six locales. --- .../java/com/noop/ble/PuffinExperiment.kt | 14 +++- .../java/com/noop/ble/StaleBondRemoval.kt | 77 +++++++++++++++++ .../main/java/com/noop/ble/WhoopBleClient.kt | 34 ++++++++ .../main/java/com/noop/ui/SettingsScreen.kt | 31 +++++++ .../app/src/main/res/values-de/strings.xml | 1 + .../app/src/main/res/values-es/strings.xml | 1 + .../app/src/main/res/values-fr/strings.xml | 1 + .../app/src/main/res/values-pl/strings.xml | 1 + .../src/main/res/values-pt-rPT/strings.xml | 1 + .../app/src/main/res/values-zh/strings.xml | 1 + android/app/src/main/res/values/strings.xml | 1 + .../java/com/noop/ble/StaleBondRemovalTest.kt | 82 +++++++++++++++++++ 12 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/java/com/noop/ble/StaleBondRemoval.kt create mode 100644 android/app/src/test/java/com/noop/ble/StaleBondRemovalTest.kt diff --git a/android/app/src/main/java/com/noop/ble/PuffinExperiment.kt b/android/app/src/main/java/com/noop/ble/PuffinExperiment.kt index 24184f29d6..8144480562 100644 --- a/android/app/src/main/java/com/noop/ble/PuffinExperiment.kt +++ b/android/app/src/main/java/com/noop/ble/PuffinExperiment.kt @@ -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" @@ -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" @@ -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" diff --git a/android/app/src/main/java/com/noop/ble/StaleBondRemoval.kt b/android/app/src/main/java/com/noop/ble/StaleBondRemoval.kt new file mode 100644 index 0000000000..e39f38d8ef --- /dev/null +++ b/android/app/src/main/java/com/noop/ble/StaleBondRemoval.kt @@ -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." + } diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt index 15d73b2a50..f687c0e40a 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -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). * @@ -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 @@ -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 @@ -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 diff --git a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt index 0f0efdd104..7f3e4401ec 100644 --- a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt @@ -586,6 +586,7 @@ fun SettingsScreen( var broadcastHr by remember(rev) { mutableStateOf(puffinExperiment.broadcastHr) } var explicitBond by remember(rev) { mutableStateOf(puffinExperiment.explicitBond) } var unbondedOffload by remember(rev) { mutableStateOf(puffinExperiment.unbondedOffload) } + var clearStaleBond by remember(rev) { mutableStateOf(puffinExperiment.clearStaleBond) } var helloDespiteRefusal by remember(rev) { mutableStateOf(puffinExperiment.helloDespiteBondRefusal) } // ECG raw-data gate (#891): the opt-in, the write result, and the attested-MG gate the buttons need. var ecgRawData by remember(rev) { mutableStateOf(puffinExperiment.ecgRawData) } @@ -2355,6 +2356,36 @@ fun SettingsScreen( ) } + // --- Clear a stale phone pairing — the removeBond() escalation. (#1635) --- + // Sits after "Ask Android to pair" on purpose: one forms a pairing, this one deletes it, + // and a reader should meet them together. + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + uiString(R.string.l10n_settings_screen_clear_a_stale_phone_pairing_experimental_cd1e4af7), + style = NoopType.subhead, + color = Palette.textPrimary, + modifier = Modifier.weight(1f), + ) + Switch( + checked = clearStaleBond, + onCheckedChange = { + clearStaleBond = it + puffinExperiment.clearStaleBond = it + }, + colors = SwitchDefaults.colors( + checkedThumbColor = Palette.surfaceBase, + checkedTrackColor = Palette.accent, + uncheckedThumbColor = Palette.textSecondary, + uncheckedTrackColor = Palette.surfaceInset, + uncheckedBorderColor = Palette.hairline, + ), + ) + } + // --- Try the historical offload on a link that never bonded. (#1635) --- // The offload is gated on the CLIENT_HELLO ack, which a strap answering SMP "Pairing Not // Supported" can never give — so the gate is ours, not the strap's, and the assumption it diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 29cf15fda3..720e9ca4c0 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -2562,6 +2562,7 @@ Kein Sauerstoffsättigungswert von einem WHOOP 4.0 Dein Strap speichert die optischen Rohkanäle, aber NOOP rechnet sie nicht in einen Sauerstoffsättigungswert um - dies bleibt also leer, egal wie lange du ihn trägst. Importiere einen WHOOP-Export unter Datenquellen, um es aus deinem Kontoverlauf zu füllen. Android um Kopplung bitten (experimentell) + Veraltete Kopplung des Telefons entfernen (experimentell) NOOP hat bisher darauf gesetzt, dass ein Schreibvorgang an dein 5.0/MG Android zur Kopplung veranlasst. Das passiert nie. Diese Option fragt Android direkt und kann einen System-Kopplungsdialog anzeigen. Die Live-Herzfrequenz funktioniert in beiden Fällen weiter; schalte sie aus, wenn der Dialog stört. Android um Kopplung bitten 5/MG-Rohdaten-Collector diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index b6f211b2ec..79705fcada 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -2549,6 +2549,7 @@ Sin porcentaje de oxígeno en sangre desde un WHOOP 4.0 Tu pulsera guarda los canales ópticos en bruto, pero NOOP no los convierte en un porcentaje de oxígeno en sangre, así que esto seguirá vacío por mucho que la lleves. Importa una exportación de WHOOP en Fuentes de datos para rellenarlo con tu historial. Pedir a Android que vincule (experimental) + Borrar una vinculación obsoleta del teléfono (experimental) NOOP siempre ha confiado en que escribir en tu 5.0/MG hiciera que Android lo vinculara. Nunca ocurre. Esta opción se lo pide a Android directamente, por lo que puede mostrar un diálogo de vinculación del sistema. La frecuencia cardíaca en directo sigue funcionando igualmente; desactívala si el diálogo molesta. Pedir a Android que vincule Recopilador de datos sin procesar 5/MG diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index 65a5d2533d..8dc59ea7b5 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -2548,6 +2548,7 @@ Pas de pourcentage d\'oxygène sanguin depuis un WHOOP 4.0 Votre bracelet enregistre les canaux optiques bruts, mais NOOP ne les convertit pas en pourcentage d\'oxygène sanguin : cette section restera vide quel que soit le temps de port. Importez un export WHOOP dans Sources de données pour la remplir depuis votre historique. Demander à Android de s\'appairer (expérimental) + Supprimer un appairage obsolète du téléphone (expérimental) NOOP a toujours compté sur l\'écriture vers votre 5.0/MG pour qu\'Android s\'appaire. Cela n\'arrive jamais. Cette option le demande directement à Android et peut afficher une boîte de dialogue d\'appairage du système. La fréquence cardiaque en direct continue de fonctionner dans les deux cas ; désactivez-la si la boîte de dialogue vous gêne. Demander à Android de s\'appairer Collecteur de données brutes 5/MG diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml index c79b1d06c1..f2052a8a32 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -2553,6 +2553,7 @@ Brak wartości procentowej natlenienia krwi z WHOOP 4.0 Twoja opaska zapisuje surowe kanały optyczne, ale NOOP nie przelicza ich na wartość procentową natlenienia krwi, więc pozostanie to puste niezależnie od czasu noszenia. Zaimportuj eksport WHOOP w Źródłach danych, aby wypełnić to historią konta. Poproś Androida o sparowanie (eksperymentalne) + Usuń nieaktualne parowanie telefonu (eksperymentalne) NOOP zawsze liczył na to, że zapis do 5.0/MG skłoni Androida do sparowania. To się nigdy nie dzieje. Ta opcja prosi Androida bezpośrednio, więc może pojawić się systemowe okno parowania. Tętno na żywo działa tak czy inaczej; wyłącz, jeśli okno przeszkadza. Poproś Androida o sparowanie Rejestrator surowych danych 5/MG 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 039cd3d5ad..3a1684bc11 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -2541,6 +2541,7 @@ Sem percentagem de oxigénio no sangue a partir de um WHOOP 4.0 A sua bracelete guarda os canais óticos em bruto, mas o NOOP não os converte numa percentagem de oxigénio no sangue, por isso isto fica vazio por muito tempo que a use. Importe uma exportação WHOOP em Fontes de dados para preencher a partir do seu histórico. Pedir ao Android para emparelhar (experimental) + Limpar um emparelhamento obsoleto do telemóvel (experimental) O NOOP sempre contou que escrever na sua 5.0/MG fizesse o Android emparelhar. Isso nunca acontece. Esta opção pede diretamente ao Android, pelo que pode mostrar uma caixa de diálogo de emparelhamento do sistema. A frequência cardíaca em direto continua a funcionar de qualquer forma; desative se a caixa de diálogo incomodar. Pedir ao Android para emparelhar Recolha de dados em bruto 5/MG diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index 7c3c1d274b..0fe5773312 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -2462,6 +2462,7 @@ WHOOP 4.0 无法提供血氧百分比 你的手环会记录原始光学通道,但 NOOP 不会将其换算成血氧百分比,因此无论佩戴多久这里都会是空的。请在“数据来源”中导入 WHOOP 导出文件,以用账户历史填充。 请求 Android 配对(实验性) + 清除手机上的过期配对(实验性) NOOP 一直寄望于向 5.0/MG 写入数据来促使 Android 配对,但这从未发生。此选项会直接请求 Android,因此可能弹出系统配对对话框。无论哪种方式,实时心率都会继续工作;如果对话框造成困扰,请关闭此选项。 请求 Android 配对 5/MG 原始数据采集器 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index fde717148a..44849db05b 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1573,6 +1573,7 @@ Battery %1$s%% Broadcast heart rate Ask Android to pair (experimental) + Clear a stale phone pairing (experimental) NOOP has always hoped that writing to your 5.0/MG would make Android pair with it. It never does. This asks Android directly, so it may show a system pairing dialog. Live heart rate keeps working either way; turn it off if the dialog is a nuisance. Ask Android to pair Broadcast strap HR (Garmin/ANT) diff --git a/android/app/src/test/java/com/noop/ble/StaleBondRemovalTest.kt b/android/app/src/test/java/com/noop/ble/StaleBondRemovalTest.kt new file mode 100644 index 0000000000..884c83187a --- /dev/null +++ b/android/app/src/test/java/com/noop/ble/StaleBondRemovalTest.kt @@ -0,0 +1,82 @@ +package com.noop.ble + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * #1635: when NOOP may delete a pairing from the user's phone. + * + * Every clause is a separate way to get this wrong, and getting it wrong means removing a working + * pairing rather than a stale one — so each is pinned on its own rather than trusting one happy path. + */ +class StaleBondRemovalTest { + + private fun ok( + optedIn: Boolean = true, + isWhoop5: Boolean = true, + osBonded: Boolean = true, + failures: Int = STALE_BOND_REMOVAL_THRESHOLD, + already: Boolean = false, + ) = shouldRemoveStaleBond(optedIn, isWhoop5, osBonded, failures, already) + + @Test + fun `the streak at threshold, opted in, on a bonded 5MG, clears the pairing`() { + assertTrue(ok()) + } + + @Test + fun `no switch, no bond removal - deleting a pairing never rides in on other consent`() { + assertFalse(ok(optedIn = false)) + // ...not even far past the threshold. + assertFalse(ok(optedIn = false, failures = 500)) + } + + @Test + fun `a WHOOP 4 is never touched - it bonds by the route it always has`() { + assertFalse(ok(isWhoop5 = false)) + } + + @Test + fun `nothing to remove when the OS holds no bond`() { + assertFalse(ok(osBonded = false)) + } + + @Test + fun `one drop is a transient, not a stale pairing`() { + for (n in 0 until STALE_BOND_REMOVAL_THRESHOLD) { + assertFalse("failures=$n must not remove", ok(failures = n)) + } + assertTrue(ok(failures = STALE_BOND_REMOVAL_THRESHOLD)) + } + + @Test + fun `exactly once per streak - a second failure does not delete a second pairing`() { + assertFalse(ok(already = true)) + assertFalse(ok(already = true, failures = 500)) + } + + @Test + fun `it waits longer than the guide, which asks the user first`() { + // The re-pair guide appears at 2. Acting before the user has had cycles to act themselves would + // take the choice away from them; this is what happens when asking did not work. + assertTrue(STALE_BOND_REMOVAL_THRESHOLD > 2) + } + + @Test + fun `the line separates asking from happening, and carries no PII`() { + val accepted = staleBondRemovalLine(5, accepted = true) + val refused = staleBondRemovalLine(5, accepted = false) + assertTrue(accepted.contains("Android accepted")) + assertTrue(refused.contains("REFUSED")) + assertTrue("a refusal must say nothing changed", refused.contains("Nothing has changed")) + assertEquals(2, listOf(accepted, refused).count { it.contains("5 failed") }) + // A bare ":" is prose punctuation here, so match the SHAPE of a MAC rather than the character. + val macLike = Regex("[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}") + for (line in listOf(accepted, refused)) { + assertFalse("no MAC in a shared log: $line", macLike.containsMatchIn(line)) + assertFalse("no serial prefix", line.contains("MGB")) + } + } +} From d8b87fdd0b41d7b87bca8c63d7c72096e3f7b7e2 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Tue, 1 Sep 2026 18:49:12 +1200 Subject: [PATCH 2/3] ble: put the stale-pairing switch where switches actually are Re-review of my own PR: the row went into SettingsScreen, inside if (false && showFiveMGControls) { whose comment says it outright - "Developer-only 5/MG controls now live in Test Centre. Keep the implementation below during the compatibility transition, but never render a second copy in everyday Settings." I had added a toggle nobody could reach, next to an explicitBond switch that is equally unreachable and only appears to work because the pref persists from before that block was disabled. Moved to TestCentreScreen, beside "Ask Android to pair" and "Try history sync without pairing" - the two switches this escalates from, now sitting together where a reader meets them in one place. Inside , which is live. That also corrects the string. Test Centre titles use the raw_diag_* namespace, so the l10n_settings_screen_* key is gone from all seven files and replaced by raw_diag_clear_stale_bond, translated in the same six locales. The developer detail beside it stays an English literal, as every sibling row's does. Nothing was added to the dead block: a row that never shipped in Settings needs no compatibility copy, and adding one would only be more to delete later. 5025 unit tests green; release lintVital, the i18n audit and doc lint clean. --- .../main/java/com/noop/ui/SettingsScreen.kt | 31 ------------------- .../main/java/com/noop/ui/TestCentreScreen.kt | 15 +++++++++ .../app/src/main/res/values-de/strings.xml | 2 +- .../app/src/main/res/values-es/strings.xml | 2 +- .../app/src/main/res/values-fr/strings.xml | 2 +- .../app/src/main/res/values-pl/strings.xml | 2 +- .../src/main/res/values-pt-rPT/strings.xml | 2 +- .../app/src/main/res/values-zh/strings.xml | 2 +- android/app/src/main/res/values/strings.xml | 2 +- 9 files changed, 22 insertions(+), 38 deletions(-) diff --git a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt index 7f3e4401ec..0f0efdd104 100644 --- a/android/app/src/main/java/com/noop/ui/SettingsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SettingsScreen.kt @@ -586,7 +586,6 @@ fun SettingsScreen( var broadcastHr by remember(rev) { mutableStateOf(puffinExperiment.broadcastHr) } var explicitBond by remember(rev) { mutableStateOf(puffinExperiment.explicitBond) } var unbondedOffload by remember(rev) { mutableStateOf(puffinExperiment.unbondedOffload) } - var clearStaleBond by remember(rev) { mutableStateOf(puffinExperiment.clearStaleBond) } var helloDespiteRefusal by remember(rev) { mutableStateOf(puffinExperiment.helloDespiteBondRefusal) } // ECG raw-data gate (#891): the opt-in, the write result, and the attested-MG gate the buttons need. var ecgRawData by remember(rev) { mutableStateOf(puffinExperiment.ecgRawData) } @@ -2356,36 +2355,6 @@ fun SettingsScreen( ) } - // --- Clear a stale phone pairing — the removeBond() escalation. (#1635) --- - // Sits after "Ask Android to pair" on purpose: one forms a pairing, this one deletes it, - // and a reader should meet them together. - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - uiString(R.string.l10n_settings_screen_clear_a_stale_phone_pairing_experimental_cd1e4af7), - style = NoopType.subhead, - color = Palette.textPrimary, - modifier = Modifier.weight(1f), - ) - Switch( - checked = clearStaleBond, - onCheckedChange = { - clearStaleBond = it - puffinExperiment.clearStaleBond = it - }, - colors = SwitchDefaults.colors( - checkedThumbColor = Palette.surfaceBase, - checkedTrackColor = Palette.accent, - uncheckedThumbColor = Palette.textSecondary, - uncheckedTrackColor = Palette.surfaceInset, - uncheckedBorderColor = Palette.hairline, - ), - ) - } - // --- Try the historical offload on a link that never bonded. (#1635) --- // The offload is gated on the CLIENT_HELLO ack, which a strap answering SMP "Pairing Not // Supported" can never give — so the gate is ours, not the strap's, and the assumption it diff --git a/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt b/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt index 84595a09e9..3b17c54cf8 100644 --- a/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt +++ b/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt @@ -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() @@ -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.", diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 720e9ca4c0..1006182fd1 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -2562,7 +2562,6 @@ Kein Sauerstoffsättigungswert von einem WHOOP 4.0 Dein Strap speichert die optischen Rohkanäle, aber NOOP rechnet sie nicht in einen Sauerstoffsättigungswert um - dies bleibt also leer, egal wie lange du ihn trägst. Importiere einen WHOOP-Export unter Datenquellen, um es aus deinem Kontoverlauf zu füllen. Android um Kopplung bitten (experimentell) - Veraltete Kopplung des Telefons entfernen (experimentell) NOOP hat bisher darauf gesetzt, dass ein Schreibvorgang an dein 5.0/MG Android zur Kopplung veranlasst. Das passiert nie. Diese Option fragt Android direkt und kann einen System-Kopplungsdialog anzeigen. Die Live-Herzfrequenz funktioniert in beiden Fällen weiter; schalte sie aus, wenn der Dialog stört. Android um Kopplung bitten 5/MG-Rohdaten-Collector @@ -2629,6 +2628,7 @@ Herzfrequenz vom Band übertragen Android um Kopplung bitten Verlaufssynchronisierung ohne Kopplung versuchen + Veraltete Kopplung entfernen Altes R22-Feature-Flag-Experiment Alte R22-Aktivierungssequenz senden R22-Flags auf dem Band löschen diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index 79705fcada..2abd505303 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -2549,7 +2549,6 @@ Sin porcentaje de oxígeno en sangre desde un WHOOP 4.0 Tu pulsera guarda los canales ópticos en bruto, pero NOOP no los convierte en un porcentaje de oxígeno en sangre, así que esto seguirá vacío por mucho que la lleves. Importa una exportación de WHOOP en Fuentes de datos para rellenarlo con tu historial. Pedir a Android que vincule (experimental) - Borrar una vinculación obsoleta del teléfono (experimental) NOOP siempre ha confiado en que escribir en tu 5.0/MG hiciera que Android lo vinculara. Nunca ocurre. Esta opción se lo pide a Android directamente, por lo que puede mostrar un diálogo de vinculación del sistema. La frecuencia cardíaca en directo sigue funcionando igualmente; desactívala si el diálogo molesta. Pedir a Android que vincule Recopilador de datos sin procesar 5/MG @@ -2614,6 +2613,7 @@ Transmitir frecuencia cardíaca desde la pulsera Pedir a Android que vincule Probar la sincronización del historial sin vinculación + Borrar vinculación obsoleta Experimento antiguo de indicadores R22 Enviar secuencia de activación R22 antigua Borrar indicadores R22 de la pulsera diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index 8dc59ea7b5..bc0790e792 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -2548,7 +2548,6 @@ Pas de pourcentage d\'oxygène sanguin depuis un WHOOP 4.0 Votre bracelet enregistre les canaux optiques bruts, mais NOOP ne les convertit pas en pourcentage d\'oxygène sanguin : cette section restera vide quel que soit le temps de port. Importez un export WHOOP dans Sources de données pour la remplir depuis votre historique. Demander à Android de s\'appairer (expérimental) - Supprimer un appairage obsolète du téléphone (expérimental) NOOP a toujours compté sur l\'écriture vers votre 5.0/MG pour qu\'Android s\'appaire. Cela n\'arrive jamais. Cette option le demande directement à Android et peut afficher une boîte de dialogue d\'appairage du système. La fréquence cardiaque en direct continue de fonctionner dans les deux cas ; désactivez-la si la boîte de dialogue vous gêne. Demander à Android de s\'appairer Collecteur de données brutes 5/MG @@ -2613,6 +2612,7 @@ Diffuser la fréquence cardiaque du bracelet Demander l’appairage à Android Essayer la synchronisation de l\'historique sans appairage + Supprimer un appairage obsolète Ancienne expérience d’indicateurs R22 Envoyer l’ancienne séquence d’activation R22 Effacer les indicateurs R22 du bracelet diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml index f2052a8a32..134280a652 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -2553,7 +2553,6 @@ Brak wartości procentowej natlenienia krwi z WHOOP 4.0 Twoja opaska zapisuje surowe kanały optyczne, ale NOOP nie przelicza ich na wartość procentową natlenienia krwi, więc pozostanie to puste niezależnie od czasu noszenia. Zaimportuj eksport WHOOP w Źródłach danych, aby wypełnić to historią konta. Poproś Androida o sparowanie (eksperymentalne) - Usuń nieaktualne parowanie telefonu (eksperymentalne) NOOP zawsze liczył na to, że zapis do 5.0/MG skłoni Androida do sparowania. To się nigdy nie dzieje. Ta opcja prosi Androida bezpośrednio, więc może pojawić się systemowe okno parowania. Tętno na żywo działa tak czy inaczej; wyłącz, jeśli okno przeszkadza. Poproś Androida o sparowanie Rejestrator surowych danych 5/MG @@ -2618,6 +2617,7 @@ Transmituj tętno z opaski Poproś Androida o sparowanie Spróbuj synchronizacji historii bez parowania + Usuń nieaktualne parowanie Starszy eksperyment flag R22 Wyślij starszą sekwencję włączenia R22 Wyczyść flagi R22 na opasce 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 3a1684bc11..00da9bb722 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -2541,7 +2541,6 @@ Sem percentagem de oxigénio no sangue a partir de um WHOOP 4.0 A sua bracelete guarda os canais óticos em bruto, mas o NOOP não os converte numa percentagem de oxigénio no sangue, por isso isto fica vazio por muito tempo que a use. Importe uma exportação WHOOP em Fontes de dados para preencher a partir do seu histórico. Pedir ao Android para emparelhar (experimental) - Limpar um emparelhamento obsoleto do telemóvel (experimental) O NOOP sempre contou que escrever na sua 5.0/MG fizesse o Android emparelhar. Isso nunca acontece. Esta opção pede diretamente ao Android, pelo que pode mostrar uma caixa de diálogo de emparelhamento do sistema. A frequência cardíaca em direto continua a funcionar de qualquer forma; desative se a caixa de diálogo incomodar. Pedir ao Android para emparelhar Recolha de dados em bruto 5/MG @@ -2606,6 +2605,7 @@ Transmitir frequência cardíaca da bracelete Pedir ao Android para emparelhar Tentar sincronizar o histórico sem emparelhamento + Limpar emparelhamento obsoleto Experiência antiga de indicadores R22 Enviar sequência antiga de ativação R22 Limpar indicadores R22 da bracelete diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index 0fe5773312..a98ee0e47b 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -2462,7 +2462,6 @@ WHOOP 4.0 无法提供血氧百分比 你的手环会记录原始光学通道,但 NOOP 不会将其换算成血氧百分比,因此无论佩戴多久这里都会是空的。请在“数据来源”中导入 WHOOP 导出文件,以用账户历史填充。 请求 Android 配对(实验性) - 清除手机上的过期配对(实验性) NOOP 一直寄望于向 5.0/MG 写入数据来促使 Android 配对,但这从未发生。此选项会直接请求 Android,因此可能弹出系统配对对话框。无论哪种方式,实时心率都会继续工作;如果对话框造成困扰,请关闭此选项。 请求 Android 配对 5/MG 原始数据采集器 @@ -2527,6 +2526,7 @@ 从手环广播心率 请求 Android 配对 尝试在未配对状态下同步历史数据 + 清除过期配对 旧版 R22 功能标志实验 发送旧版 R22 启用序列 清除手环上的 R22 标志 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 44849db05b..d32df2df16 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1573,7 +1573,6 @@ Battery %1$s%% Broadcast heart rate Ask Android to pair (experimental) - Clear a stale phone pairing (experimental) NOOP has always hoped that writing to your 5.0/MG would make Android pair with it. It never does. This asks Android directly, so it may show a system pairing dialog. Live heart rate keeps working either way; turn it off if the dialog is a nuisance. Ask Android to pair Broadcast strap HR (Garmin/ANT) @@ -2642,6 +2641,7 @@ Broadcast heart rate from the strap Ask Android to pair Try history sync without pairing + Clear a stale phone pairing Legacy R22 feature-flag experiment Send legacy R22 enable sequence Clear legacy R22 flags on strap From 0d8e3ef208e4d1fc60677c771a2684979d3ea6ec Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Tue, 1 Sep 2026 19:05:29 +1200 Subject: [PATCH 3/3] test(ble): pin that the stale-pairing switch is reachable The bug the last commit fixed had no test that could have caught it. Every assertion passed and all four checks were green while the toggle sat in SettingsScreen's `if (false && showFiveMGControls)` block, unreachable. A UI switch has no assertion that says "a user can get to me". So this reads the source, the way SystemInitiatedConnectTest does for connect entry points: the toggle must be wired in Test Centre and bound to its experiment, Test Centre must not have grown a disabled block of its own, and the retained-but-dead Settings copy must not have gained it back. Verified it can fail by unwiring the toggle and watching this one assertion go red, then restoring. It fails loudly rather than skipping when the source is not found, for the same reason the push entry-point gate does. 5026 unit tests green. --- .../java/com/noop/ble/StaleBondRemovalTest.kt | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/android/app/src/test/java/com/noop/ble/StaleBondRemovalTest.kt b/android/app/src/test/java/com/noop/ble/StaleBondRemovalTest.kt index 884c83187a..e62b94970e 100644 --- a/android/app/src/test/java/com/noop/ble/StaleBondRemovalTest.kt +++ b/android/app/src/test/java/com/noop/ble/StaleBondRemovalTest.kt @@ -79,4 +79,42 @@ class StaleBondRemovalTest { assertFalse("no serial prefix", line.contains("MGB")) } } + + /** + * The switch has to be REACHABLE, and nothing else in this suite can tell you that. + * + * The first cut of this feature put its row in SettingsScreen inside `if (false && ...)` — a block + * whose own comment says developer 5/MG controls moved to Test Centre and that it must never render. + * Every test passed and CI was green with a toggle no user could turn on. It looked right because + * the sibling "Ask Android to pair" row sits in the same dead block and appears to work only + * because its pref persists from before that block was disabled. + * + * So this reads the source: the switch must be wired in Test Centre, and Test Centre must not have + * grown a disabled block of its own. + */ + @Test + fun `the toggle is wired somewhere a user can actually reach`() { + val testCentre = uiSource("TestCentreScreen.kt") + assertTrue("the toggle must be wired in Test Centre, which is where 5/MG switches live", + testCentre.contains("R.string.raw_diag_clear_stale_bond")) + assertTrue("and bound to the experiment it gates", + testCentre.contains("puffinExperiment.clearStaleBond = it")) + assertFalse("Test Centre must not grow a disabled block the way Settings did", + testCentre.contains("if (false")) + + // And it must NOT have been added to the retained-but-dead Settings copy: a row that never + // shipped there needs no compatibility copy, and one would only be more to delete later. + assertFalse("the dead Settings block must not gain new switches", + uiSource("SettingsScreen.kt").contains("clearStaleBond")) + } + + private fun uiSource(name: String): String { + val rel = "android/app/src/main/java/com/noop/ui/$name" + val start = java.io.File(System.getProperty("user.dir") ?: ".").absoluteFile + val f = generateSequence(start) { it.parentFile }.map { java.io.File(it, rel) } + .firstOrNull { it.isFile } + assertTrue("$name not found walking up from $start — this check cannot run, and one that " + + "silently skips is worse than none", f != null) + return f!!.readText() + } }