8000 fix(app): release the live session on dispose and stop background polling by shroominic · Pull Request #177 · shroominic/OpenGlucose · GitHub
[go: up one dir, main page]

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
36 changes: 31 additions & 5 deletions openhealth/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -449,11 +449,20 @@ class _CgmHomePageState extends State<CgmHomePage> with WidgetsBindingObserver {
if (isPlatformProtocolCaptureEnabled) {
unawaited(_restoreLibreActivationResult());
}
_freshnessTimer = Timer.periodic(_foregroundFreshnessInterval, (_) {
_startForegroundFreshness();
}

void _startForegroundFreshness() {
_freshnessTimer ??= Timer.periodic(_foregroundFreshnessInterval, (_) {
unawaited(widget.controller.ensureFreshData());
});
}

void _stopForegroundFreshness() {
_freshnessTimer?.cancel();
_freshnessTimer = null;
}

Future<void> _restoreLibreActivationResult() async {
final verified = await readLastLibre2ActivationVerified();
if (mounted && verified) {
Expand All @@ -467,17 +476,34 @@ class _CgmHomePageState extends State<CgmHomePage> with WidgetsBindingObserver {

@override
void dispose() {
_freshnessTimer?.cancel();
_stopForegroundFreshness();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}

@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state != AppLifecycleState.resumed) {
return;
switch (state) {
case AppLifecycleState.resumed:
_startForegroundFreshness();
unawaited(widget.controller.ensureFreshData(force: true));
case AppLifecycleState.inactive:
// Transient on both platforms - a permission sheet, the app switcher,
// an incoming call. The user has not left the app and the session must
// not be disturbed.
break;
case AppLifecycleState.hidden:
case AppLifecycleState.paused:
case AppLifecycleState.detached:
// The host is no longer presenting the app, so the foreground-only
// poll stops. It polls on a timer, and a poll against a session that
// the platform has already dropped opens a new BLE link with nobody
// present to use it. The session itself keeps whatever link it holds:
// iOS is declared `bluetooth-central` and the lock-screen surface is
// fed from that same session, so leaving the foreground is not a
// reason to tear it down. The poll restarts on `resumed`.
_stopForegroundFreshness();
}
unawaited(widget.controller.ensureFreshData(force: true));
}

@override
Expand Down
14 changes: 14 additions & 0 deletions openhealth/lib/src/app_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1324,6 +1324,20 @@ class CgmAppController extends ChangeNotifier {
_cancelReconnect();
unawaited(_snapshotSubscription?.cancel());
unawaited(_logSubscription?.cancel());
// Nothing else tears a live session down: [disconnect] is reached only from
// an explicit user path. Without this the GATT client outlives the last
// owner that could ever service it, holding a radio and a connection slot
// for an app state that no longer exists.
final session = _session;
_session = null;
if (session != null) {
unawaited(
session.disconnect().then<void>(
(_) {},
onError: (Object _, StackTrace _) {},
),
);
}
super.dispose();
}

Expand Down
16 changes: 11 additions & 5 deletions openhealth/lib/src/live_activity_payload.dart
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,18 @@ LiveActivityPayload buildLiveActivityPayload({
}) {
final reading = currentReadingForSnapshot(snapshot, latestReading);
final effectiveNow = now ?? DateTime.now();
// The surface's staleness follows the youngest honest clock this snapshot
// has. For the CBio protocol that is the receipt time, which is used here
// and nowhere else: it is never published as a sensor timestamp.
final freshnessAt = liveSurfaceFreshnessAt(
snapshot: snapshot,
reading: reading,
now: effectiveNow,
);
final isStale = liveSurfaceIsStale(freshnessAt, now: effectiveNow);
if (reading?.isDisplayProvisional == true ||
reading?.source == CgmRecordSource.raw) {
return const LiveActivityPayload(
return LiveActivityPayload(
sensorName: liveSurfaceBrandName,
stageCode: 'progress',
stageLabel: 'VERIFYING',
Expand All @@ -103,7 +112,7 @@ LiveActivityPayload buildLiveActivityPayload({
detailText: 'Experimental readings are available in the app only.',
trendSymbol: '',
deltaText: '',
isStale: true,
isStale: isStale,
);
}
final warmup = computeWarmupStatus(
Expand Down Expand Up @@ -150,9 +159,6 @@ LiveActivityPayload buildLiveActivityPayload({
reading?.recordedAt,
now: effectiveNow,
);
final isStale =
displayRecordedAt == null ||
effectiveNow.difference(displayRecordedAt) > const Duration(minutes: 10);
final trend = glucoseTrendSummary(
isLibreGen1Snapshot(snapshot) && reading == null
? const <CgmReading>[]
Expand Down
46 changes: 46 additions & 0 deletions openhealth/lib/src/session_presentation.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,52 @@ String readingTimeText(CgmReading? reading, {DateTime? now}) {
return DateFormat('HH:mm').format(recordedAt);
}

/// How long a live surface may stay silent before it is honestly stale.
const Duration liveSurfaceStaleAfter = Duration(minutes: 10);

/// The youngest honest timestamp behind a snapshot's live surface.
///
/// Drivers that time their own readings are judged by the reading time, which
/// is what the surface shows as the record's own moment. The CBio protocol
/// carries no epoch: a GS1 record is positioned by the sensor's minute counter,
/// so [CgmReading.recordedAt] is null unless the session holds a clock anchor
/// for the position (the clock this app set on the sensor). An anchored record
/// is judged by that time; a record the anchor does not cover falls back to the
/// only clock left, when this phone received the data,
/// [CgmHistorySyncState.lastSyncAt]. A receipt time is returned here for
/// staleness only; it is never published as a sensor time.
DateTime? liveSurfaceFreshnessAt({
required CgmSessionSnapshot snapshot,
CgmReading? reading,
DateTime? now,
}) {
final effectiveNow = now ?? DateTime.now();
if (!isCbioSnapshot(snapshot)) {
return clampedDisplayRecordedAt(reading?.recordedAt, now: effectiveNow);
}
final anchored = clampedDisplayRecordedAt(
reading?.recordedAt,
now: effectiveNow,
);
if (anchored != null) {
return anchored;
}
final receivedAt = snapshot.historySync.lastSyncAt;
if (receivedAt == null) {
return null;
}
final localReceivedAt = receivedAt.toLocal();
return localReceivedAt.isAfter(effectiveNow) ? effectiveNow : localReceivedAt;
}

bool liveSurfaceIsStale(DateTime? freshnessAt, {DateTime? now}) {
if (freshnessAt == null) {
return true;
}
final effectiveNow = now ?? DateTime.now();
return effectiveNow.difference(freshnessAt) > liveSurfaceStaleAfter;
}

/// Local charts and explicit raw exports may retain provisional samples, with
/// their quality flag. They are not inputs to wellness summaries or messaging.
List<CgmReading> readingsForWellness(Iterable<CgmReading> readings) =>
Expand Down
100 changes: 100 additions & 0 deletions openhealth/test/app_controller_persistence_test.dart
10000
Original file line number Diff line number Diff line change
Expand Up @@ -2545,6 +2545,104 @@ void main() {
await scan.timeout(const Duration(seconds: 1));
});

test('disposing the controller disconnects its live session', () async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final preferences = await SharedPreferences.getInstance();
final sensor = _testSensor();
final session = _ControlledSession(
_testSnapshot(sensor, stage: CgmSyncStage.ready),
);
final driver = _ControlledDriver(<_ControlledSession>[session]);
final controller = CgmAppController(
preferences: preferences,
driver: driver,
healthStateStore: _ControllableHealthStateStore(),
);

await controller.initialize();
await controller.connect(sensor);
await _drainEventQueue();
expect(session.disconnectCalls, 0);

controller.dispose();
await _drainEventQueue();

expect(
session.disconnectCalls,
1,
reason: 'a disposed controller must not leave a GATT client open',
);
await driver.close();
});

testWidgets(
'a backgrounded app stops foreground polling and resumes it on return',
(tester) async {
SharedPreferences.setMockInitialValues(<String, Object>{
'openHealth.onboarding.completed': true,
});
final preferences = await SharedPreferences.getInstance();
final sensor = _testSensor();
final session = _ControlledSession(
_testSnapshot(sensor, stage: CgmSyncStage.ready),
);
final driver = _ControlledDriver(<_ControlledSession>[session]);
final controller = CgmAppController(
preferences: preferences,
driver: driver,
healthStateStore: _ControllableHealthStateStore(),
reconnectDelay: Duration.zero,
);

await controller.initialize();
await controller.connect(sensor);
await tester.pumpWidget(
OpenGlucoseApp(
controller: controller,
healthExport: HealthExportController(
preferences: preferences,
writesAllowed: false,
)..initialize(),
preferences: preferences,
),
);
await tester.pump();

await tester.pump(const Duration(seconds: 45));
final whileForeground = session.refreshLiveDataCalls;
expect(
whileForeground,
greaterThan(0),
reason: 'the declared foreground poll polls while presenting',
);

tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused);
await tester.pump();
final whilePaused = session.refreshLiveDataCalls;
await tester.pump(const Duration(seconds: 90));
expect(
session.refreshLiveDataCalls,
whilePaused,
reason: 'a backgrounded app must not keep driving its session',
);

tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pump();
expect(
session.refreshLiveDataCalls,
greaterThan(whilePaused),
reason: 'returning to the foreground refreshes immediately',
);
final >
await tester.pump(const Duration(seconds: 45));
expect(session.refreshLiveDataCalls, greaterThan(onReturn));

await tester.pumpWidget(const SizedBox.shrink());
controller.dispose();
await driver.close();
},
);

test('initial ready snapshot disables activation on reconnect', () async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final preferences = await SharedPreferences.getInstance();
Expand Down Expand Up @@ -3053,6 +3151,7 @@ class _ControlledSession implements CgmSession {
final Exception? disconnectError;
CgmSessionSnapshot? _snapshotOnSnapshotsAccess;
CgmSessionSnapshot? _snapshotOnRefreshLiveData;
int disconnectCalls = 0;
int refreshLiveDataCalls = 0;
int syncHistoryCalls = 0;
int refreshDiagnosticsCalls = 0;
Expand Down Expand Up @@ -3091,6 +3190,7 @@ class _ControlledSession implements CgmSession {

@override
Future<void> disconnect() async {
disconnectCalls += 1;
final error = disconnectError;
if (error != null) throw error;
}
Expand Down
Loading
Loading
0