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
7 changes: 7 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@
<uses-permission android:name="android.permission.health.WRITE_SLEEP" />
<uses-permission android:name="android.permission.health.READ_EXERCISE" />
<uses-permission android:name="android.permission.health.WRITE_EXERCISE" />
<!-- READ only, and no WRITE counterpart on purpose: body metrics are read
INTO the local profile (weight drifts, and the calorie estimate
depends on it) and this app has no business writing them back. Health
Connect silently returns nothing for an undeclared permission rather
than failing, so an omission here looks exactly like an empty store. -->
<uses-permission android:name="android.permission.health.READ_WEIGHT" />
<uses-permission android:name="android.permission.health.READ_HEIGHT" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
<!-- CompanionDeviceManager availability (not required: devices without it just skip
the association and rely on the sticky service + watchdog). -->
Expand Down
9 changes: 9 additions & 0 deletions ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@
</array>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>OpenStrap connects to your WHOOP band over Bluetooth to sync your health data.</string>
<!-- The automatic-backup folder lives in the app's Documents directory.
Without BOTH of these it exists but is invisible in Files, so nothing
the user might sync it with (iCloud Drive, Nextcloud, a cable) can
reach it — which makes the whole feature pointless rather than broken,
and therefore easy to miss. -->
<key>UIFileSharingEnabled</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>NSHealthShareUsageDescription</key>
<string>OpenStrap reads your step count from Apple Health to show your daily steps, and reads back its own recent samples so it never writes duplicates.</string>
<key>NSHealthUpdateUsageDescription</key>
Expand Down
5 changes: 5 additions & 0 deletions lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ class _OpenStrapAppState extends State<OpenStrapApp> with WidgetsBindingObserver
// already-running process (openAppWhenRun doesn't guarantee a fresh
// launch) — the constructor-time check alone would miss that case.
unawaited(app.checkPendingSiriRoute());
// Backups run on foreground, when due — there is no background scheduler
// that works on both platforms, and a schedule that claims "daily" while
// delivering whenever the OS feels like it is worse than one that is
// honest about when it fires.
unawaited(app.runBackupIfDue());
if (app.isPaired) app.openSession();
} else if (state == AppLifecycleState.paused) {
// Backgrounded: hand the band to the iOS restore path so it can wake-and-drain
Expand Down
187 changes: 147 additions & 40 deletions lib/compute/derivation_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import 'dart:isolate';
import 'dart:math' as math;

import 'package:flutter/foundation.dart';
import 'nap_edits.dart';
import 'package:openstrap_analytics/onehz.dart' as ana;
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_performance/firebase_performance.dart';
Expand Down Expand Up @@ -608,7 +609,21 @@ import 'substrate.dart';
// O(window) sum that ran before its cadence gate was checked. Both curves keep
// their sampling intent; points that were previously emitted a beat or two
// after a failed attempt now land on the next cadence tick instead.
const int kAlgoVersion = 60;
// v61 - NAP EDITS. The nap detector's answer is now a PROPOSAL: a nap the user
// logged is added, and one they rejected is suppressed, replayed over the
// detector's output on every derivation rather than written into it (so a
// better detector later still respects "there was no nap here"). Rejection
// matches by OVERLAP, not by exact bounds, because the detector's boundaries
// shift between runs and an edit that stopped applying when a boundary moved
// by a minute would be worse than useless.
//
// This moves numbers, which is why it is a version bump rather than a read
// path: `nap_min` is summed over the merged list, so a logged nap credits
// against sleep need and sleep debt exactly as a detected one does — that
// was the explicit product decision, not an accident of where the code sat.
// Days carrying an edit are force-derived alongside sleep-override days, so
// an edit to an already-finalized day actually takes effect.
const int kAlgoVersion = 61;

// Fold idempotency, the minimum-nights warm-up, and legacy-payload handling
// all live in SleepProfilePolicy (pure, unit-tested) — see
Expand Down Expand Up @@ -1046,7 +1061,11 @@ class DerivationEngine {
// FINALIZED (locked) day — it's the user's word. Force those back into the
// todo set. (No-raw days are guarded in the per-day loop so we never
// clobber a good manual result with an empty re-derive once raw is pruned.)
final overrideDays = await LocalDb.sleepOverrideDays();
final overrideDays = {
...await LocalDb.sleepOverrideDays(),
// A nap edit on a finalized day has to take effect too — same reason.
...await LocalDb.napEditDays(),
};
final todoDays = [
for (final day in scope.targetDays)
if (!finalized.contains(day) || overrideDays.contains(day)) day,
Expand Down Expand Up @@ -2416,9 +2435,22 @@ class DerivationEngine {

// Built on THIS isolate so the Isolate.run closure captures only this plain
// sendable object (never `this`, `day`, or `bundle`).
// Read HERE, on the main isolate — the worker has no database.
final napEdits = [
for (final row in await LocalDb.napEdits(day.date))
NapEdit(
kind: row['source'] == 'rejected'
? NapEditKind.rejected
: NapEditKind.added,
startSec: (row['start_ts'] as num).toInt(),
endSec: (row['end_ts'] as num).toInt(),
),
];

final blocksInput = _DayBlocksInput(
daySub: daySub,
napSub: day.napSub,
napEdits: napEdits,
sleepSub: sleepSub,
profile: profile,
onsetSec: day.sleepOnsetSec,
Expand Down Expand Up @@ -4333,6 +4365,57 @@ class DerivationEngine {
/// distinguishable only by HOW the abstention happened. A reader that checks
/// `bundle['naps']?['value'] == null` and one that checks
/// `bundle.containsKey('naps')` would disagree.
/// Abstention path that still honours what the USER logged.
///
/// The detector abstains on exactly the days this feature exists for — strap
/// off for part of the afternoon, a short record, a failure. Returning early
/// there dropped every logged nap on the floor: no card to see, no minutes
/// credited, and no way to delete the row the user had just created, while
/// the edit kept force-re-deriving that day forever.
///
/// A logged nap needs nothing from the detector — it carries its own absolute
/// bounds — so it is published on its own. The day is still reported as
/// unjudged when the user logged nothing, because that is what it is.
static List<Map<String, dynamic>>? _napsWhenUnjudged(
Map<String, dynamic> bundle,
Map<String, dynamic>? scMap,
List<NapEdit> napEdits,
String note,
) {
final merged = applyNapEdits(const [], napEdits);
if (merged.isEmpty) {
_writeUnknownNaps(bundle, note);
return null;
}
bundle['naps'] = <String, dynamic>{
'value': merged,
'count': merged.length,
// No detection confidence, because there was no detection.
'confidence': null,
// AUTH is the closed vocabulary's "directly measured / definitional",
// which is what a self-report is: the user is not estimating that they
// napped, they are stating it. An invented fifth tier would be a string
// no reader knows how to rank.
'tier': ana.Tier.auth,
'inputs_used': const ['user'],
'note': '$note — showing what you logged',
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
scMap?['nap_min'] = napMinutes(merged).toDouble();
return [
for (final nap in merged)
{
'is_main': false,
'onset_ts': nap['start'],
'wake_ts': nap['end'],
'duration_min': nap['duration_min'],
'in_bed_min': nap['in_bed_min'],
'efficiency': null,
'confidence': null,
if (nap['source'] != null) 'source': nap['source'],
},
];
}

static void _writeUnknownNaps(
Map<String, dynamic> bundle,
String note,
Expand All @@ -4357,12 +4440,19 @@ class DerivationEngine {
int? attributionEndSec,
List<List<int>> wristOff = const [],
List<List<int>> charging = const [],
// Read on the main isolate and carried in, like every other DB-sourced
// input here — this runs inside the compute worker, which has no database.
List<NapEdit> napEdits = const [],
}) {
try {
final n = s.length;
if (n < 60) {
_writeUnknownNaps(bundle, 'too little 1 Hz data to assess naps');
return null;
return _napsWhenUnjudged(
bundle,
scMap,
napEdits,
'too little 1 Hz data to assess naps',
);
}
final accel = <ana.AccelSample>[
for (var i = 0; i < n; i++)
Expand All @@ -4388,15 +4478,12 @@ class DerivationEngine {
);

if (!m.present) {
bundle['naps'] = <String, dynamic>{
'value': null,
'count': null,
'confidence': 0,
'tier': m.tier,
'inputs_used': m.inputs_used,
'note': m.note,
};
return null;
return _napsWhenUnjudged(
bundle,
scMap,
napEdits,
m.note ?? 'naps could not be assessed for this day',
);
}

final t0 = s.tsSec.first;
Expand Down Expand Up @@ -4428,25 +4515,33 @@ class DerivationEngine {
return t0 + nap.startSec < attributionEndSec;
}).toList();

// The detector's answer is a PROPOSAL. The user's edits — a nap it
// missed, or one it invented — are stored separately and replayed over
// it here on every derivation, so a better detector later still respects
// "there was no nap here" instead of the edit being baked into a stale
// detection.
final detected = <Map<String, dynamic>>[
for (final nap in naps)
{
'start': t0 + nap.startSec,
'end': t0 + nap.endSec,
// Minutes ASLEEP. `duration_min` kept as the asleep figure so
// existing readers do not silently switch to in-bed minutes.
'duration_min': (nap.tstSec / 60).round(),
'in_bed_min': (nap.tibSec / 60).round(),
'efficiency': nap.efficiency,
'confidence': nap.confidence,
},
];
final merged = applyNapEdits(detected, napEdits);

bundle['naps'] = <String, dynamic>{
'value': [
for (final nap in naps)
{
'start': t0 + nap.startSec,
'end': t0 + nap.endSec,
// Minutes ASLEEP. `duration_min` kept as the asleep figure so
// existing readers do not silently switch to in-bed minutes.
'duration_min': (nap.tstSec / 60).round(),
'in_bed_min': (nap.tibSec / 60).round(),
'efficiency': nap.efficiency,
'confidence': nap.confidence,
},
],
'count': naps.length,
'value': merged,
'count': merged.length,
'confidence': m.confidence,
'tier': m.tier,
'inputs_used': m.inputs_used,
'note': m.note,
'note': napEdits.isEmpty ? m.note : '${m.note} (edited)',
};

// TST, never TIB. Crediting in-bed minutes against sleep need
Expand All @@ -4455,26 +4550,31 @@ class DerivationEngine {
// Rounded, matching the two display paths exactly. Truncating here while
// the cards round made the credit disagree with the sum of the minutes
// shown — up to a minute per nap, in a number the user can add up.
final napMin =
naps.fold<int>(0, (a, nap) => a + (nap.tstSec / 60).round());
scMap?['nap_min'] = napMin.toDouble();
// Summed over the MERGED list, so a logged nap counts toward sleep need
// and sleep debt exactly as a detected one does.
scMap?['nap_min'] = napMinutes(merged).toDouble();

return [
for (final nap in naps)
for (final nap in merged)
{
'is_main': false,
'onset_ts': t0 + nap.startSec,
'wake_ts': t0 + nap.endSec,
'duration_min': (nap.tstSec / 60).round(),
'in_bed_min': (nap.tibSec / 60).round(),
'efficiency': nap.efficiency,
'confidence': nap.confidence,
'onset_ts': nap['start'],
'wake_ts': nap['end'],
'duration_min': nap['duration_min'],
'in_bed_min': nap['in_bed_min'],
'efficiency': nap['efficiency'],
'confidence': nap['confidence'],
if (nap['source'] != null) 'source': nap['source'],
},
];
} catch (e) {
if (kDebugMode) debugPrint('[derive] naps FAILED/skipped: $e');
_writeUnknownNaps(bundle, 'nap detection failed for this day');
return null;
return _napsWhenUnjudged(
bundle,
scMap,
napEdits,
'nap detection failed for this day',
);
}
}

Expand Down Expand Up @@ -4711,6 +4811,7 @@ class DerivationEngine {
attributionEndSec: inp.dayEndSec,
wristOff: inp.wristOffSpans,
charging: inp.chargingSpans,
napEdits: inp.napEdits,
);
bundlePatch['sleep_periods'] = _sleepPeriods(
onset,
Expand Down Expand Up @@ -5089,6 +5190,7 @@ class DerivationEngine {
int? attributionEndSec,
List<List<int>> wristOff = const [],
List<List<int>> charging = const [],
List<NapEdit> napEdits = const [],
}) =>
_attachNaps(
bundle,
Expand All @@ -5100,6 +5202,7 @@ class DerivationEngine {
attributionEndSec: attributionEndSec,
wristOff: wristOff,
charging: charging,
napEdits: napEdits,
);

void _log(String m) {
Expand Down Expand Up @@ -5152,6 +5255,9 @@ class _DayBlocksInput {
final int dynHistoryDays;
final List<Map<String, dynamic>> savedSessions;

/// The user's nap edits for this day, replayed over the detector's output.
final List<NapEdit> napEdits;

/// Strap-reported off-wrist spans ([startSec, endSec]) over the nap window.
/// A band on a table is motionless and reads as deep rest — this is the
/// dominant nap false positive, and the strap already tells us about it.
Expand Down Expand Up @@ -5191,6 +5297,7 @@ class _DayBlocksInput {
required this.dynFloorG,
required this.dynHistoryDays,
required this.savedSessions,
this.napEdits = const [],
required this.wristOffSpans,
required this.chargingSpans,
required this.mainTstMin,
Expand Down
Loading
Loading