Skip to content
Open
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
12 changes: 8 additions & 4 deletions lib/notify/fired_keys.dart
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ class FiredKeyStore {
// parsed at cold start and on every reload() above.
await _prunePrefs(p);
}
await LocalDb.pruneNotifFired(_cutoffLabel());
await LocalDb.pruneNotifFired(cutoffLabel());
} catch (_) {/* bookkeeping is best-effort — the claim stands */}
return true;
}
Expand Down Expand Up @@ -176,8 +176,12 @@ class FiredKeyStore {
Future<void> recordFired(String dedupeKey) async => claim(dedupeKey);

/// The oldest day label a dated claim may carry and still be retained.
static String _cutoffLabel() =>
dayLabelOf(DateTime.now().subtract(const Duration(days: retentionDays)));
///
/// Calendar days, not `subtract(Duration(days: 14))`: that is 14 x 24 h, and
/// across a DST change one of those days is 23 or 25 h long, so in the hour
/// either side of midnight the cutoff landed one label early or late.
static String cutoffLabel([DateTime? now]) =>
dayLabelBefore(todayLabel(now), retentionDays)!;

static Future<SharedPreferences?> _prefs() async {
try {
Expand Down Expand Up @@ -215,7 +219,7 @@ class FiredKeyStore {
/// prunes a little late. Keys without a leading YYYY-MM-DD are left untouched.
static Future<void> _prunePrefs(SharedPreferences p) async {
try {
final cutoff = _cutoffLabel();
final cutoff = cutoffLabel();
for (final k in p.getKeys()) {
if (!k.startsWith(_prefix)) continue;
final d = leadingDate(k.substring(_prefix.length));
Expand Down
4 changes: 1 addition & 3 deletions lib/notify/notification_ids.dart
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';

import '../data/day_label.dart';
import '../data/db.dart';
import 'fired_keys.dart';
import 'notification_event.dart';
Expand Down Expand Up @@ -204,8 +203,7 @@ class NotificationIds {
/// directions. Cheap and rare (only after a FRESH allocation).
Future<void> _prune(SharedPreferences p, {String? keep}) async {
try {
final cutoff =
dayLabelOf(DateTime.now().subtract(const Duration(days: retentionDays)));
final cutoff = FiredKeyStore.cutoffLabel();
for (final k in p.getKeys().toList(growable: false)) {
if (!k.startsWith(_kSlot) || k == keep) continue;
final rest = k.substring(_kSlot.length); // "<cat>:<dedupeKey>"
Expand Down
67 changes: 67 additions & 0 deletions test/notify_retention_dst_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// The fired-key / notification-slot retention cutoff is "14 calendar days
// before today". It used to be `now.subtract(Duration(days: 14))`, which is
// 336 h: across a DST change one of those days is 23 or 25 h, so in the hour
// either side of midnight the cutoff landed one day early or late. Real
// DST only bites if the PROCESS timezone has DST, so this moves it (libc
// setenv("TZ") + tzset(), as day_window_dst_test does). Windows has neither
// symbol, so the file is skipped there rather than failing in setUpAll.
@TestOn('!windows')
library;

import 'dart:ffi';
import 'dart:io';

import 'package:ffi/ffi.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:openstrap_edge/notify/fired_keys.dart';

typedef _SetenvNative = Int32 Function(Pointer<Utf8>, Pointer<Utf8>, Int32);
typedef _SetenvDart = int Function(Pointer<Utf8>, Pointer<Utf8>, int);
typedef _UnsetenvNative = Int32 Function(Pointer<Utf8>);
typedef _UnsetenvDart = int Function(Pointer<Utf8>);
typedef _TzsetNative = Void Function();
typedef _TzsetDart = void Function();

void _setProcessTz(String? tz) {
final lib = DynamicLibrary.process();
final key = 'TZ'.toNativeUtf8();
try {
if (tz == null) {
lib.lookupFunction<_UnsetenvNative, _UnsetenvDart>('unsetenv')(key);
} else {
final value = tz.toNativeUtf8();
lib.lookupFunction<_SetenvNative, _SetenvDart>('setenv')(key, value, 1);
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved
calloc.free(value);
}
lib.lookupFunction<_TzsetNative, _TzsetDart>('tzset')();
} finally {
calloc.free(key);
}
}

void main() {
final originalTz = Platform.environment['TZ'];
// America/New_York: 2026-03-08 is 23 h long, 2026-11-01 is 25 h long.
setUpAll(() => _setProcessTz('America/New_York'));
tearDownAll(() => _setProcessTz(originalTz));

test('the host really is on a DST zone (or this test proves nothing)', () {
expect(DateTime(2026, 3, 9).difference(DateTime(2026, 3, 8)).inHours, 23);
});

test('just after midnight, past a 23 h day: still 14 calendar days', () {
// 336 h before 00:30 lands at 23:30 on 02-22, a day too far back.
expect(FiredKeyStore.cutoffLabel(DateTime(2026, 3, 9, 0, 30)),
'2026-02-23');
});

test('just before midnight, past a 25 h day: still 14 calendar days', () {
// 336 h before 23:30 lands at 00:30 on 10-20, a day too recent.
expect(FiredKeyStore.cutoffLabel(DateTime(2026, 11, 2, 23, 30)),
'2026-10-19');
});

test('an ordinary day is unchanged', () {
expect(FiredKeyStore.cutoffLabel(DateTime(2026, 6, 20, 12)), '2026-06-06');
});
}
Loading