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
3 changes: 3 additions & 0 deletions Strand/App/StrandApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import UserNotifications
@main
struct StrandApp: App {
init() {
// #1008: pin the pre-change Overnight-only default for existing installs before
// anything reads it. Idempotent; a no-op on fresh installs and after the first launch.
PuffinExperiment.migrateContinuousHrvOvernightDefault()
#if DEBUG
// DEBUG-only promo-screenshot harness: when launched with `--demo-hour <Int>`, pin the Today
// screen to that hour's day-cycle scene + a plausible per-hour stat frame. Runs synchronously
Expand Down
59 changes: 57 additions & 2 deletions Strand/BLE/PuffinExperiment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,67 @@ enum PuffinExperiment {
/// only inside the nightly window (the reused quiet-hours window convention: minutes since local
/// midnight, wrap-aware, 22:00 to 07:00 by default) instead of 24/7, roughly halving the battery
/// cost. Composed with the base toggle so existing users need no migration: base on + this off reads
/// ALWAYS (the pre-#927 behaviour). Default OFF. Read by BLEManager at EVERY arm site (re-derived at
/// ALWAYS (the pre-#927 behaviour). Defaults ON for fresh installs, OFF once Continuous HRV has been
/// used (#1008) — see below. Read by BLEManager at EVERY arm site (re-derived at
/// arm time, never precomputed; see ContinuousHrvSchedule). Mirrors the Android
/// `NoopPrefs.KEY_CONTINUOUS_HRV_OVERNIGHT`.
static let continuousHrvOvernightOnlyKey = "noopContinuousHrvOvernightOnly"

static var continuousHrvOvernightOnlyEnabled: Bool { UserDefaults.standard.bool(forKey: continuousHrvOvernightOnlyKey) }
/// Defaults to ON for anyone who has never touched Continuous HRV, and to OFF for anyone who has
/// (#1008). WHOOP publishes no daytime HRV figure at all — its reading is an overnight one — so a
/// 24/7 stream has no official-app analogue, and overnight-only roughly halves the battery cost.
/// Making the cheaper, WHOOP-comparable behaviour the default is the point; the expensive one stays
/// a deliberate choice.
///
/// `UserDefaults.bool(forKey:)` cannot express this on its own: it returns `false` for a missing key,
/// which is indistinguishable from an explicit off. The unset case is therefore resolved from whether
/// `keepRealtimeForDataKey` exists, rather than by writing a migration — the only thing that must not
/// happen is silently narrowing capture for someone already relying on it. Twin of the Android
/// `NoopPrefs.continuousHrvOvernight`.
static var continuousHrvOvernightOnlyEnabled: Bool {
UserDefaults.standard.object(forKey: continuousHrvOvernightOnlyKey) as? Bool ?? true
}

/// One-time migration for the #1008 default flip. Called once at launch, BEFORE anything reads the
/// setting, and before the user can reach the toggle.
///
/// The default moved from OFF to ON, so an install that predates the change has to be pinned to OFF
/// explicitly or it would be silently narrowed to overnight-only capture — removing daytime data the
/// user opted in for. "Predates the change" is read as "has ever toggled Continuous HRV", i.e. the
/// base key exists.
///
/// Deciding this at READ time instead does not work, and the way it fails is worth recording: the
/// discriminator would be the base key, which the user's own opt-in creates — so a fresh install
/// would default to ON and then flip to OFF the moment they enabled Continuous HRV, the exact
/// opposite of the intent.
///
/// A `@AppStorage` `onChange` hook cannot substitute for this either: `@AppStorage` writes the value
/// BEFORE the handler runs, so by then a first-ever toggle is indistinguishable from any other.
///
/// Idempotent: writes only when the overnight key is absent and the base key is present.
/// Twin of the Android `NoopPrefs.migrateContinuousHrvOvernightDefault`.
static func migrateContinuousHrvOvernightDefault() {
let defaults = UserDefaults.standard
guard shouldPinLegacyOvernightDefault(
hasOvernightChoice: defaults.object(forKey: continuousHrvOvernightOnlyKey) != nil,
hasUsedContinuousHrv: defaults.object(forKey: keepRealtimeForDataKey) != nil) else { return }
defaults.set(false, forKey: continuousHrvOvernightOnlyKey)
}

/// The migration's decision, lifted out so it is testable without touching `UserDefaults`. Twin of
/// the Android `NoopPrefs.shouldPinLegacyOvernightDefault`.
///
/// Pin the OLD default only for an install that has used Continuous HRV and never chose an overnight
/// setting. Everything else is left alone.
///
/// Note what this is NOT keyed on: the READ. An earlier attempt resolved the default at read time
/// from `hasUsedContinuousHrv`, which the user's own opt-in creates — so a fresh install read ON and
/// then flipped to OFF the moment Continuous HRV was enabled. Running the decision once at launch is
/// what makes the answer stable.
static func shouldPinLegacyOvernightDefault(hasOvernightChoice: Bool,
hasUsedContinuousHrv: Bool) -> Bool {
!hasOvernightChoice && hasUsedContinuousHrv
}

// MARK: - Power saving (#477), parity with Android NoopPrefs

Expand Down
11 changes: 8 additions & 3 deletions Strand/Screens/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,14 @@ struct SettingsView: View {

/// #927 "Overnight only" refinement of Continuous HRV capture (off by default): arm the stream only
/// inside the nightly quiet-hours window instead of 24/7. Composed with the base toggle (base on +
/// this off = ALWAYS, the pre-#927 behaviour) so existing users see no change and need no migration.
/// See [PuffinExperiment.continuousHrvOvernightOnlyKey].
@AppStorage(PuffinExperiment.continuousHrvOvernightOnlyKey) private var continuousHrvOvernightOnly = false
/// this off = ALWAYS, the pre-#927 behaviour); existing installs are pinned to OFF by
/// `PuffinExperiment.migrateContinuousHrvOvernightDefault()` at launch, so they still see no change.
///
/// The `@AppStorage` default MUST match `PuffinExperiment.continuousHrvOvernightOnlyEnabled` (#1008).
/// They read the same key by different routes, so a mismatch shows the toggle OFF on a fresh install
/// while capture is actually overnight-only — and a user "correcting" that would write an explicit
/// false and get the 24/7 behaviour they were trying to avoid.
@AppStorage(PuffinExperiment.continuousHrvOvernightOnlyKey) private var continuousHrvOvernightOnly = true

// #477 Power saving (parity with Android). Battery-adaptive sync cadence + an HRV-pause sub-option.
@AppStorage(PuffinExperiment.powerSavingKey) private var powerSavingEnabled = false
Expand Down
54 changes: 54 additions & 0 deletions StrandTests/ContinuousHrvOvernightDefaultTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import XCTest
@testable import Strand

/// #1008 — which way "Overnight only" falls when the user has never chosen. Twin of the Kotlin
/// `ContinuousHrvOvernightDefaultTest`; same cases in the same order.
///
/// WHOOP publishes no daytime HRV figure, so a 24/7 stream has no official-app analogue and costs
/// roughly twice the battery. The cheaper, WHOOP-comparable behaviour should be the default — but only
/// for someone not already running the other one.
///
/// The rule that must not break: an existing Continuous HRV user's capture is never silently narrowed.
/// They opted into "all day and night" and may be reading daytime Stress off it.
///
/// Note: `StrandTests` runs only under `xcodebuild` on macOS, and `app-build.yml` is disabled — so this
/// suite is not executed by CI today. The Kotlin twin is, under `testFullDebugUnitTest`.
final class ContinuousHrvOvernightDefaultTests: XCTestCase {

/// The case the migration exists for: used the feature, never chose — pin the old default.
func testAnExistingContinuousHrvUserIsPinnedToAlwaysOn() {
XCTAssertTrue(PuffinExperiment.shouldPinLegacyOvernightDefault(
hasOvernightChoice: false, hasUsedContinuousHrv: true))
}

/// A fresh install is left alone, so the read picks up the new ON default.
func testAFreshInstallIsLeftAloneAndTakesTheNewDefault() {
XCTAssertFalse(PuffinExperiment.shouldPinLegacyOvernightDefault(
hasOvernightChoice: false, hasUsedContinuousHrv: false))
}

/// An explicit choice is never overwritten, whichever way it points.
func testAnExplicitChoiceIsNeverOverwritten() {
XCTAssertFalse(PuffinExperiment.shouldPinLegacyOvernightDefault(
hasOvernightChoice: true, hasUsedContinuousHrv: true))
XCTAssertFalse(PuffinExperiment.shouldPinLegacyOvernightDefault(
hasOvernightChoice: true, hasUsedContinuousHrv: false))
}

/// Idempotence, which is what makes it safe to run on every launch.
func testTheMigrationIsIdempotent() {
XCTAssertTrue(PuffinExperiment.shouldPinLegacyOvernightDefault(
hasOvernightChoice: false, hasUsedContinuousHrv: true))
XCTAssertFalse(PuffinExperiment.shouldPinLegacyOvernightDefault(
hasOvernightChoice: true, hasUsedContinuousHrv: true))
}

/// The sequence that broke the first attempt: resolving the default at READ time from a fact the
/// user's own opt-in creates. Running the decision once at launch is what fixes it.
func testEnablingContinuousHrvAfterLaunchCannotChangeTheDecision() {
XCTAssertFalse(PuffinExperiment.shouldPinLegacyOvernightDefault(
hasOvernightChoice: false, hasUsedContinuousHrv: false))
XCTAssertTrue(PuffinExperiment.shouldPinLegacyOvernightDefault(
hasOvernightChoice: false, hasUsedContinuousHrv: true))
}
}
3 changes: 3 additions & 0 deletions StrandiOS/App/StrandiOSApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ struct StrandiOSApp: App {
@AppStorage(ChartStyle.storageKey) private var chartStyleRaw = ChartStyle.titanium.rawValue

init() {
// #1008: pin the pre-change Overnight-only default for existing installs before
// anything reads it. Idempotent; a no-op on fresh installs and after the first launch.
PuffinExperiment.migrateContinuousHrvOvernightDefault()
#if DEBUG
// DEBUG-only promo-screenshot harness: when launched with `--demo-hour <Int>`, pin Today to that
// hour's day-cycle scene + a per-hour stat frame. No-op (active stays nil) when the arg is absent.
Expand Down
3 changes: 3 additions & 0 deletions android/app/src/main/java/com/noop/NoopApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ class NoopApplication : Application() {

override fun onCreate() {
super.onCreate()
// #1008: pin the pre-change Overnight-only default for existing installs before anything
// reads it. Idempotent; a no-op on fresh installs and on every launch after the first.
com.noop.ui.NoopPrefs.migrateContinuousHrvOvernightDefault(this)
// Record any uncaught crash to a file so it rides along in the shareable strap log — a
// device-specific crash (e.g. Insights #224/#267) is otherwise lost to an unreachable logcat.
CrashCapture.install(this)
Expand Down
71 changes: 66 additions & 5 deletions android/app/src/main/java/com/noop/ui/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,9 @@ object NoopPrefs {

/** "Overnight only" refinement of Continuous HRV capture (#927): when on (with [KEY_CONTINUOUS_HRV]),
* the dense realtime stream is armed only inside the nightly quiet-hours window (22:00 to 07:00 by
* default, wrap-aware, local wall time) instead of 24/7, roughly halving the battery cost. Default
* OFF, so existing Continuous HRV users keep the always-on behaviour with no migration. Read by
* default, wrap-aware, local wall time) instead of 24/7, roughly halving the battery cost. Defaults
* ON for fresh installs and OFF for anyone who has already used Continuous HRV (#1008), so existing
* users keep the always-on behaviour with no migration. Read by
* [com.noop.ble.WhoopBleClient] at every arm site (re-derived at arm time, never cached). */
const val KEY_CONTINUOUS_HRV_OVERNIGHT = "noop.continuousHrvOvernight"

Expand Down Expand Up @@ -315,10 +316,70 @@ object NoopPrefs {
of(context).edit().putBoolean(KEY_CONTINUOUS_HRV, enabled).apply()
}

/** Whether Continuous HRV capture arms the stream only inside the nightly window (#927). Default
* false = always-on, the pre-#927 behaviour. */
/**
* Whether Continuous HRV capture arms the stream only inside the nightly window (#927).
*
* Defaults to ON for anyone who has never touched Continuous HRV, and to OFF for anyone who has
* (#1008). WHOOP publishes no daytime HRV figure at all — its reading is an overnight one — so a
* 24/7 stream has no official-app analogue, and the setting's own copy says overnight-only roughly
* halves the battery cost. Making the cheaper, WHOOP-comparable behaviour the one you get by
* default is the point; the expensive one stays a deliberate choice.
*
* The unset case is resolved from whether [KEY_CONTINUOUS_HRV] exists rather than by writing a
* migration, because the ONLY thing that must not happen is silently narrowing capture for someone
* already relying on it. Presence of that key means the user has been through this screen and
* experienced always-on; absence means a fresh install, which gets the new default. A user who
* toggled the base setting on and back off keeps always-on too — conservative on purpose, since
* they have seen the old behaviour.
*/
fun continuousHrvOvernight(context: Context): Boolean =
of(context).getBoolean(KEY_CONTINUOUS_HRV_OVERNIGHT, false)
of(context).getBoolean(KEY_CONTINUOUS_HRV_OVERNIGHT, true)

/**
* One-time migration for the #1008 default flip. Called once at process start, BEFORE anything reads
* the setting.
*
* The default moved from OFF to ON, so an install that predates the change has to be pinned to OFF
* explicitly or it would be silently narrowed to overnight-only capture — removing daytime data the
* user opted in for. "Predates the change" is read as "has ever toggled Continuous HRV", i.e. the
* base key exists.
*
* Deciding this at READ time instead does not work, and the way it fails is worth recording: the
* discriminator would be the base key, which the user's own opt-in creates — so a fresh install
* would default to ON, then flip to OFF the moment they enabled Continuous HRV, which is the exact
* opposite of the intent. The decision has to be pinned before the user can touch either setting.
*
* Idempotent: writes only when the overnight key is absent and the base key is present, so it is a
* no-op on every launch after the first and on every fresh install.
*/
fun migrateContinuousHrvOvernightDefault(context: Context) {
val prefs = of(context)
if (shouldPinLegacyOvernightDefault(
hasOvernightChoice = prefs.contains(KEY_CONTINUOUS_HRV_OVERNIGHT),
hasUsedContinuousHrv = prefs.contains(KEY_CONTINUOUS_HRV),
)
) {
prefs.edit().putBoolean(KEY_CONTINUOUS_HRV_OVERNIGHT, false).apply()
}
}

/**
* The migration's decision, lifted out so it is testable without a `Context`. Twin of the Swift
* `PuffinExperiment.shouldPinLegacyOvernightDefault`.
*
* Pin the OLD default only for an install that has used Continuous HRV and never chose an overnight
* setting. Everything else is left alone: an explicit choice is already recorded, or the install is
* fresh and should take the new default.
*
* Note what this is NOT keyed on: the READ. An earlier attempt resolved the default at read time
* from [hasUsedContinuousHrv], which the user's own opt-in creates — so a fresh install read ON and
* then flipped to OFF the moment Continuous HRV was enabled. Running the decision once at launch is
* what makes the answer stable, because it is taken before the user can change the inputs.
*/
internal fun shouldPinLegacyOvernightDefault(
hasOvernightChoice: Boolean,
hasUsedContinuousHrv: Boolean,
): Boolean = !hasOvernightChoice && hasUsedContinuousHrv

fun setContinuousHrvOvernight(context: Context, enabled: Boolean) {
of(context).edit().putBoolean(KEY_CONTINUOUS_HRV_OVERNIGHT, enabled).apply()
Expand Down
4 changes: 3 additions & 1 deletion android/app/src/main/java/com/noop/ui/SettingsScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -516,7 +516,9 @@ fun SettingsScreen(
var continuousHrv by remember { mutableStateOf(NoopPrefs.continuousHrv(context)) }

// "Overnight only" (#927): arm the continuous stream only inside the nightly quiet-hours window
// instead of 24/7. Default OFF so existing users keep the always-on behaviour. Local mirror.
// instead of 24/7. Defaults ON for fresh installs (#1008); existing installs are pinned to OFF by
// NoopPrefs.migrateContinuousHrvOvernightDefault() at launch, so they keep always-on. Local mirror,
// read through NoopPrefs so it cannot disagree with what the BLE client acts on.
var continuousHrvOvernight by remember { mutableStateOf(NoopPrefs.continuousHrvOvernight(context)) }

// #477 Power saving: battery-adaptive strap-sync cadence + optional HRV-capture pause. Local mirrors.
Expand Down
Loading