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
10 changes: 7 additions & 3 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,16 @@
total calories with it, and a missing read fails the whole read. -->
<uses-permission android:name="android.permission.health.READ_DISTANCE" />
<uses-permission android:name="android.permission.health.READ_TOTAL_CALORIES_BURNED" />
<!-- 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
<!-- Body metrics are read INTO the local profile (weight drifts, and the
calorie estimate depends on it); height is READ only. Weight has a
WRITE below, for a paired scale's own weighings only. 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" />
<!-- WRITE_WEIGHT: a paired scale's weighings are exported to Health Connect
(lib/health/health_weight_export.dart). Only the scale's own readings;
the weight read above never goes back out. -->
<uses-permission android:name="android.permission.health.WRITE_WEIGHT" />
<uses-permission android:name="android.permission.health.READ_HEIGHT" />
<!-- READ only, and there will never be a WRITE counterpart. These come
from devices CLEARED to take them — a blood-pressure cuff, a CGM, a
Expand Down
2 changes: 1 addition & 1 deletion ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@
<key>NSCameraUsageDescription</key>
<string>OpenStrap uses the camera to read the barcode on a food packet, so logging a meal does not mean typing its nutrition panel out by hand. It reads the barcode digits and nothing else — no photo is taken, kept, or sent anywhere.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>OpenStrap writes your sleep, resting heart rate, HRV, respiratory rate, energy and workouts into Apple Health.</string>
<string>OpenStrap writes your sleep, resting heart rate, HRV, respiratory rate, energy and workouts, and the weighings of a scale you have paired, into Apple Health.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>OpenStrap connects to your WHOOP band over Bluetooth to sync your health data.</string>
<key>NSLocationWhenInUseUsageDescription</key>
Expand Down
10 changes: 10 additions & 0 deletions lib/data/backup_extras.dart
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ const Map<String, String> kPrefsNotBackedUp = {
'alarm_probe_pending': 'legacy pending hardware probe; never resume on another band',
'alarm_fired_at': 'last fire event from the old band, not the chosen schedule',
'health_upload_last_ts': 'upload cursor for this installation',
'health_weight_perm_asked': 'WEIGHT write access is asked per install; '
'the new install has not asked yet',
'health_weight_grant_seen': 'the WEIGHT grant is per install; the new '
'install observes its own',
};

const Map<String, String> kSecretsNotBackedUp = {
Expand Down Expand Up @@ -181,6 +185,12 @@ const List<(String, String, String)> kPrefTypeFamilies = [
('notif_osnext:', '', 'int'),
('nudge.', '.dismissed', 'bool'),
('nudge.', '.last_shown_ms', 'int'),
// Per-scale weighing export state. Travels: the Health store usually
// survives a restore, and re-sending every weighing would stack duplicates.
('health_weight_exported:', '', 'string'),
('health_weight_attempts:', '', 'string'),
('health_weight_maybe_landed:', '', 'string'),
('health_weight_trimmed:', '', 'string'),
];

/// The stored type the app reads [key] with, or null when it is not known.
Expand Down
103 changes: 101 additions & 2 deletions lib/health/health_export.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import '../data/series_codec.dart';
import 'health_heart_rate_batch.dart';
import 'health_nap_export.dart';
import 'health_sleep_session.dart';
import 'health_weight_export.dart';

/// What we can do with the health store right now.
enum HealthLinkState {
Expand Down Expand Up @@ -654,6 +655,10 @@ class HealthExporter {
for (final t in _sleepHealthTypes)
if (t != _foreignSleepEnvelope(isApple)) t,
HealthDataType.WORKOUT,
// Scale weighings (health_weight_export.dart). Authorization scope only:
// deliberately NOT in healthDeleteTypes / _rewriteTypes, because the
// per-day pass would delete weights it has no day_result to rewrite.
HealthDataType.WEIGHT,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
];

/// The types the per-day delete-then-write pass touches.
Expand Down Expand Up @@ -735,6 +740,52 @@ class HealthExporter {
}
}

/// WEIGHT write permission, for the weight export only. Existing users were
/// authorized before WEIGHT joined [_types] and are never re-prompted by
/// [request], so a plain attempt would no-op forever. HealthKit hides write
/// auth, so the one-time request runs there too (idempotent; shows the sheet
/// only for undetermined types); asked for at most once per install. The per-day pass keeps its
/// attempt-everything behaviour (see below).
Future<bool> _weightWriteAllowed() async {
final types = [HealthDataType.WEIGHT];
const access = [HealthDataAccess.WRITE];
final prefs = await SharedPreferences.getInstance();
return weightWritePermitted(
check: () async =>
await _health
.hasPermissions(types, permissions: access)
.timeout(_deleteTimeout) ==
true,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
request: () async {
try {
await _health
.requestAuthorization(types, permissions: access)
.timeout(_deleteTimeout);
} catch (e) {
if (e is TimeoutException) _storeHung = true;
rethrow;
}
},
asked: () async => prefs.getBool(kHealthWeightPermAskedPref) == true,
markAsked: () => prefs.setBool(kHealthWeightPermAskedPref, true),
);
}

/// Current WEIGHT grant for the cap-reset transition; null = unknown.
Future<bool?> _weightGranted() async {
try {
return await _health
.hasPermissions(
[HealthDataType.WEIGHT],
permissions: [HealthDataAccess.WRITE],
)
.timeout(_deleteTimeout);
} catch (e) {
debugPrint('[health] weight grant read: $e');
return null;
}
}

// We do NOT gate on a write-permission check: HealthKit hides write-auth by
// design, and Health Connect's hasPermissions(WRITE) frequently returns
// null/false even after the user grants everything — which would leave the UI
Expand Down Expand Up @@ -793,6 +844,11 @@ class HealthExporter {
_types,
permissions: _types.map((_) => HealthDataAccess.WRITE).toList(),
);
// WEIGHT is in _types: the scale path need not ask a second time.
await (await SharedPreferences.getInstance()).setBool(
kHealthWeightPermAskedPref,
true,
);
} catch (e) {
debugPrint('[health] requestAuthorization: $e');
}
Expand Down Expand Up @@ -899,6 +955,7 @@ class HealthExporter {
if (await _androidUnavailable() != null) return 0; // HC missing/outdated
return _workoutLock.run(() async {
_storeHung = false;
var days = 0;
try {
await ensureHealthSleepExportEpoch(
getCursor: LocalDb.getCursor,
Expand Down Expand Up @@ -1184,11 +1241,53 @@ class HealthExporter {
return done;
};

return exportPriorityOrBulk();
days = await exportPriorityOrBulk();
} catch (e) {
// Weighings do not depend on the day results: log and carry on.
debugPrint('[health] exportAll: $e');
return 0;
}
try {
if (!_storeHung) {
await exportScaleWeighings(
reset: reset,
forceRetry: forceRetry,
stopped: () => _storeHung,
permitted: _weightWriteAllowed,
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved
weightGranted: _weightGranted,
syncEnabled: () async =>
(await SharedPreferences.getInstance()).getBool(kHealthSyncPref) ==
true,
deleteOwn: (a, b) => _deleteOwnSamples(HealthDataType.WEIGHT, a, b),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
write: (kg, at) async {
if (_storeHung) return false;
try {
// A timeout is rethrown: the weighing export records the instant
// as possibly written (persisted) and clears it, safely, before
// the retry.
return await _health
.writeHealthData(
value: kg,
type: HealthDataType.WEIGHT,
startTime: at,
endTime: at,
unit: HealthDataUnit.KILOGRAM,
)
.timeout(_deleteTimeout);
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
} catch (e) {
debugPrint('[health] write WEIGHT: $e');
if (e is TimeoutException) {
_storeHung = true;
rethrow;
}
return false;
}
},
);
}
} catch (e) {
debugPrint('[health] exportAll weighings: $e');
}
return days;
});
}

Expand Down
Loading
Loading