More reasonable update notification logic

This commit is contained in:
franzap
2026-02-04 18:45:33 -03:00
parent 65049d2906
commit 2a9a192d02
6 changed files with 314 additions and 129 deletions
+13
View File
@@ -245,6 +245,9 @@ final appInitializationProvider = FutureProvider<void>((ref) async {
// Initialize device capabilities (used for dynamic download concurrency) // Initialize device capabilities (used for dynamic download concurrency)
await DeviceCapabilitiesCache.initialize(); await DeviceCapabilitiesCache.initialize();
// Record app open time for background notification throttling
await secureStorage.setLastAppOpenedTime(DateTime.now());
// These run in background - don't block UI // These run in background - don't block UI
final packageManager = ref.read(packageManagerProvider.notifier); final packageManager = ref.read(packageManagerProvider.notifier);
unawaited(packageManager.syncInstalledPackages()); unawaited(packageManager.syncInstalledPackages());
@@ -321,6 +324,9 @@ class _AppLifecycleObserver with WidgetsBindingObserver {
final packageManager = _ref.read(packageManagerProvider.notifier); final packageManager = _ref.read(packageManagerProvider.notifier);
if (state == AppLifecycleState.resumed) { if (state == AppLifecycleState.resumed) {
// Record app open time for background notification throttling
unawaited(_recordAppOpened());
// Sync installed packages to detect installs that completed while backgrounded // Sync installed packages to detect installs that completed while backgrounded
unawaited(packageManager.syncInstalledPackages()); unawaited(packageManager.syncInstalledPackages());
@@ -333,4 +339,11 @@ class _AppLifecycleObserver with WidgetsBindingObserver {
notifier.disconnect(); notifier.disconnect();
} }
} }
/// Record that the user opened the app.
/// This is used to check inactivity for background notifications.
Future<void> _recordAppOpened() async {
final secureStorage = SecureStorageService();
await secureStorage.setLastAppOpenedTime(DateTime.now());
}
} }
+82 -65
View File
@@ -4,6 +4,7 @@ import 'dart:ui' as ui;
import 'package:background_downloader/background_downloader.dart' hide Request; import 'package:background_downloader/background_downloader.dart' hide Request;
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart'; import 'package:models/models.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
@@ -11,6 +12,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:purplebase/purplebase.dart'; import 'package:purplebase/purplebase.dart';
import 'package:workmanager/workmanager.dart'; import 'package:workmanager/workmanager.dart';
import 'package:zapstore/router.dart';
import 'package:zapstore/services/package_manager/background_package_manager.dart'; import 'package:zapstore/services/package_manager/background_package_manager.dart';
import 'package:zapstore/services/package_manager/dummy_package_manager.dart'; import 'package:zapstore/services/package_manager/dummy_package_manager.dart';
import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/package_manager/package_manager.dart';
@@ -38,8 +40,11 @@ const kUpdateNotificationChannelDescription =
/// Stale download threshold for cleanup /// Stale download threshold for cleanup
const _staleDownloadThreshold = Duration(days: 7); const _staleDownloadThreshold = Duration(days: 7);
/// Threshold for re-notifying about the same updates (72 hours) /// How long user must be inactive before showing background notification
const _notificationReminderThreshold = Duration(hours: 72); const _inactivityThreshold = Duration(hours: 24);
/// Notification payload for deep linking to updates screen
const _kNotificationPayload = 'updates';
/// Input data key for AppCatalog relay URLs /// Input data key for AppCatalog relay URLs
const kAppCatalogRelaysKey = 'appCatalogRelays'; const kAppCatalogRelaysKey = 'appCatalogRelays';
@@ -134,7 +139,7 @@ Future<bool> _performWeeklyCleanup() async {
} }
/// Background update check logic - runs in a separate isolate. /// Background update check logic - runs in a separate isolate.
/// Note: Cannot directly reuse [CategorizedAppsNotifier] since this runs /// Note: Cannot directly reuse [CategorizedUpdatesNotifier] since this runs
/// in an isolated WorkManager context without the main app's Riverpod setup. /// in an isolated WorkManager context without the main app's Riverpod setup.
/// ///
/// [appCatalogRelays] - Relay URLs resolved from main isolate. Falls back to /// [appCatalogRelays] - Relay URLs resolved from main isolate. Falls back to
@@ -261,18 +266,49 @@ Future<bool> _checkForUpdatesInBackground(Set<String>? appCatalogRelays) async {
} }
} }
/// Show a local notification for available updates, throttled to once per 72h. /// Show a local notification for available updates.
/// Only notifies if:
/// 1. User hasn't opened app in 24+ hours
/// 2. There are updates with release.createdAt > seenUntil AND > lastOpened
/// (new since both last notification AND last time user saw the app)
Future<void> _showUpdateNotificationIfNeeded(List<App> updates) async { Future<void> _showUpdateNotificationIfNeeded(List<App> updates) async {
final secureStorage = SecureStorageService(); final secureStorage = SecureStorageService();
// Skip if notified recently // Skip if user recently opened the app
final lastNotified = await secureStorage.getLastUpdateNotificationTime(); final lastOpened = await secureStorage.getLastAppOpenedTime();
if (lastNotified != null && if (lastOpened != null &&
DateTime.now().difference(lastNotified) < DateTime.now().difference(lastOpened) < _inactivityThreshold) {
_notificationReminderThreshold) {
return; return;
} }
// Get the "seen until" timestamp - updates with release.createdAt > this are new
final seenUntil = await secureStorage.getSeenUntil();
// Filter to only updates that are genuinely new:
// - release.createdAt > seenUntil (not already notified via background)
// - release.createdAt > lastOpened (not already seen when user opened app)
// This prevents nagging about updates user saw in the app but chose to ignore
final newUpdates = updates.where((app) {
final releaseTime = app.latestRelease.value?.event.createdAt;
if (releaseTime == null) return false;
// Must be newer than last notification (if any)
if (seenUntil != null && !releaseTime.isAfter(seenUntil)) {
return false;
}
// Must be newer than last app open (if any) - user may have seen it in UI
if (lastOpened != null && !releaseTime.isAfter(lastOpened)) {
return false;
}
return true;
}).toList();
if (newUpdates.isEmpty) {
return; // No new updates to notify about
}
// Show the notification // Show the notification
final plugin = FlutterLocalNotificationsPlugin(); final plugin = FlutterLocalNotificationsPlugin();
const initSettings = InitializationSettings( const initSettings = InitializationSettings(
@@ -281,10 +317,10 @@ Future<void> _showUpdateNotificationIfNeeded(List<App> updates) async {
await plugin.initialize(initSettings); await plugin.initialize(initSettings);
await _ensureUpdateNotificationChannel(plugin); await _ensureUpdateNotificationChannel(plugin);
final appNames = updates.map((a) => a.name ?? a.identifier).toList(); final appNames = newUpdates.map((a) => a.name ?? a.identifier).toList();
final title = updates.length == 1 final title = newUpdates.length == 1
? '1 app update available' ? '1 app update available'
: '${updates.length} app updates available'; : '${newUpdates.length} app updates available';
final body = appNames.length <= 3 final body = appNames.length <= 3
? appNames.join(', ') ? appNames.join(', ')
: '${appNames.take(3).join(', ')} and ${appNames.length - 3} more'; : '${appNames.take(3).join(', ')} and ${appNames.length - 3} more';
@@ -304,9 +340,11 @@ Future<void> _showUpdateNotificationIfNeeded(List<App> updates) async {
autoCancel: true, autoCancel: true,
), ),
), ),
payload: _kNotificationPayload,
); );
await secureStorage.setLastUpdateNotificationTime(DateTime.now()); // Update seenUntil to now - future checks will only notify about releases after this
await secureStorage.setSeenUntil(DateTime.now());
} }
/// Service for managing background update checks /// Service for managing background update checks
@@ -334,18 +372,20 @@ class BackgroundUpdateService {
.resolveRelays('AppCatalog'); .resolveRelays('AppCatalog');
// Register periodic task (minimum 15 minutes on Android) // Register periodic task (minimum 15 minutes on Android)
// We use 6 hours for battery efficiency // We use 24 hours to match the inactivity threshold for notifications.
// More frequent checks would be wasted since we only notify users
// who haven't opened the app in 24+ hours.
await Workmanager().registerPeriodicTask( await Workmanager().registerPeriodicTask(
kBackgroundUpdateTaskId, kBackgroundUpdateTaskId,
kBackgroundUpdateTaskName, kBackgroundUpdateTaskName,
frequency: const Duration(hours: 6), frequency: const Duration(hours: 24),
constraints: Constraints( constraints: Constraints(
networkType: NetworkType.connected, networkType: NetworkType.connected,
requiresBatteryNotLow: true, requiresBatteryNotLow: true,
), ),
existingWorkPolicy: ExistingPeriodicWorkPolicy.keep, existingWorkPolicy: ExistingPeriodicWorkPolicy.keep,
backoffPolicy: BackoffPolicy.exponential, backoffPolicy: BackoffPolicy.exponential,
initialDelay: const Duration(minutes: 15), initialDelay: const Duration(hours: 1),
inputData: {kAppCatalogRelaysKey: appCatalogRelays.toList()}, inputData: {kAppCatalogRelaysKey: appCatalogRelays.toList()},
); );
@@ -385,63 +425,40 @@ class BackgroundUpdateService {
await flutterLocalNotificationsPlugin.initialize( await flutterLocalNotificationsPlugin.initialize(
initializationSettings, initializationSettings,
onDidReceiveNotificationResponse: (response) { onDidReceiveNotificationResponse: _handleNotificationTap,
// Notification tapped - app will open to main screen
// Navigation is handled by the app's normal launch flow
},
); );
// Check if app was launched from a notification (terminated state)
final launchDetails = await flutterLocalNotificationsPlugin
.getNotificationAppLaunchDetails();
if (launchDetails?.didNotificationLaunchApp == true &&
launchDetails?.notificationResponse?.payload == _kNotificationPayload) {
_navigateToUpdates();
}
await _ensureUpdateNotificationChannel(flutterLocalNotificationsPlugin); await _ensureUpdateNotificationChannel(flutterLocalNotificationsPlugin);
} }
/// Handle notification tap - navigate to updates screen
static void _handleNotificationTap(NotificationResponse response) {
if (response.payload == _kNotificationPayload) {
_navigateToUpdates();
}
}
/// Navigate to the updates screen
static void _navigateToUpdates() {
// Use the root navigator key to navigate
final context = rootNavigatorKey.currentContext;
if (context != null) {
GoRouter.of(context).go('/updates');
}
}
/// Cancel background update checks /// Cancel background update checks
Future<void> cancelBackgroundChecks() async { Future<void> cancelBackgroundChecks() async {
await Workmanager().cancelByUniqueName(kBackgroundUpdateTaskId); await Workmanager().cancelByUniqueName(kBackgroundUpdateTaskId);
} }
/// Trigger an immediate background check (for testing)
Future<void> triggerImmediateCheck() async {
final appCatalogRelays = await ref
.read(storageNotifierProvider.notifier)
.resolveRelays('AppCatalog');
await Workmanager().registerOneOffTask(
'${kBackgroundUpdateTaskId}_immediate',
kBackgroundUpdateTaskName,
constraints: Constraints(networkType: NetworkType.connected),
inputData: {kAppCatalogRelaysKey: appCatalogRelays.toList()},
);
}
/// Show a test notification directly (for testing)
Future<void> showTestNotification() async {
debugPrint('showTestNotification: starting');
final plugin = FlutterLocalNotificationsPlugin();
const initSettings = InitializationSettings(
android: AndroidInitializationSettings('@drawable/ic_notification'),
);
await plugin.initialize(initSettings);
debugPrint('showTestNotification: initialized');
await _ensureUpdateNotificationChannel(plugin);
debugPrint('showTestNotification: channel ensured');
await plugin.show(
0,
'2 app updates available',
'Test App, Another App',
const NotificationDetails(
android: AndroidNotificationDetails(
kUpdateNotificationChannelId,
kUpdateNotificationChannelName,
channelDescription: kUpdateNotificationChannelDescription,
importance: Importance.high,
priority: Priority.high,
showWhen: true,
autoCancel: true,
),
),
);
debugPrint('showTestNotification: done');
}
} }
Future<void> _ensureUpdateNotificationChannel( Future<void> _ensureUpdateNotificationChannel(
+35 -11
View File
@@ -15,9 +15,7 @@ class SecureStorageService {
// Use explicit options for reliability across platforms // Use explicit options for reliability across platforms
static final _storage = FlutterSecureStorage( static final _storage = FlutterSecureStorage(
aOptions: const AndroidOptions( aOptions: const AndroidOptions(encryptedSharedPreferences: true),
encryptedSharedPreferences: true,
),
iOptions: const IOSOptions( iOptions: const IOSOptions(
accessibility: KeychainAccessibility.first_unlock, accessibility: KeychainAccessibility.first_unlock,
), ),
@@ -49,24 +47,50 @@ class SecureStorageService {
} }
// ========================================================================= // =========================================================================
// Update Notification Throttling // App Open Tracking (for background notification throttling)
// ========================================================================= // =========================================================================
static const _lastUpdateNotificationKey = 'last_update_notification'; static const _lastAppOpenedKey = 'last_app_opened';
/// Get the last time an update notification was shown. /// Get the last time the user opened the app.
Future<DateTime?> getLastUpdateNotificationTime() async { Future<DateTime?> getLastAppOpenedTime() async {
final value = await _storage.read(key: _lastUpdateNotificationKey); final value = await _storage.read(key: _lastAppOpenedKey);
if (int.tryParse(value ?? '') case final ms?) { if (int.tryParse(value ?? '') case final ms?) {
return DateTime.fromMillisecondsSinceEpoch(ms); return DateTime.fromMillisecondsSinceEpoch(ms);
} }
return null; return null;
} }
/// Store the last update notification time. /// Store the last app opened time.
Future<void> setLastUpdateNotificationTime(DateTime time) async { Future<void> setLastAppOpenedTime(DateTime time) async {
await _storage.write( await _storage.write(
key: _lastUpdateNotificationKey, key: _lastAppOpenedKey,
value: '${time.millisecondsSinceEpoch}',
);
}
// =========================================================================
// Seen Until Timestamp (for background notification deduplication)
// =========================================================================
static const _seenUntilKey = 'seen_until';
/// Get the "seen until" timestamp.
/// Updates with release.createdAt <= this timestamp have already been notified.
Future<DateTime?> getSeenUntil() async {
final value = await _storage.read(key: _seenUntilKey);
if (int.tryParse(value ?? '') case final ms?) {
return DateTime.fromMillisecondsSinceEpoch(ms);
}
return null;
}
/// Store the "seen until" timestamp.
/// Called when a notification is shown, set to now() so future checks
/// only notify about releases created after this time.
Future<void> setSeenUntil(DateTime time) async {
await _storage.write(
key: _seenUntilKey,
value: '${time.millisecondsSinceEpoch}', value: '${time.millisecondsSinceEpoch}',
); );
} }
@@ -1,53 +0,0 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:zapstore/services/notification_service.dart';
import 'package:zapstore/services/updates_service.dart';
import 'package:zapstore/utils/extensions.dart';
final _zapstoreUpdatePromptHandledProvider = StateProvider<bool>(
(ref) => false,
);
class ZapstoreUpdatePromptListener extends ConsumerWidget {
const ZapstoreUpdatePromptListener({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.listen<CategorizedApps>(categorizedAppsProvider, (previous, next) {
final hasHandled = ref.read(_zapstoreUpdatePromptHandledProvider);
if (hasHandled) return;
if (next.isLoading) return;
final allUpdates = [...next.automaticUpdates, ...next.manualUpdates];
final zapstoreUpdate =
allUpdates.firstWhereOrNull((app) => app.isZapstoreApp);
if (zapstoreUpdate == null) return;
ref.read(_zapstoreUpdatePromptHandledProvider.notifier).state = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
context.showInfo(
'Update Zapstore',
description: 'A new Zapstore update is available.',
actions: [
(
'Update',
() async {
if (!context.mounted) return;
context.push('/updates/app/${zapstoreUpdate.identifier}');
},
),
],
);
});
});
return const SizedBox.shrink();
}
}
@@ -0,0 +1,41 @@
# FEAT-002 — Background Update Notifications
## Goal
Notify users about available app updates via background notifications, without overwhelming them with repeated notifications for the same updates they've already seen.
## Non-Goals
- In-app notification banners (out of scope, UI already shows updates)
- Per-app notification settings (all-or-nothing via Android system settings)
- "Smart" notification timing based on user behavior patterns
## User-Visible Behavior
- User receives a notification when new app updates are available AND they haven't opened the app recently (24+ hours)
- Tapping notification opens the app directly to the Updates screen
- User does NOT receive repeated notifications for updates they've already seen (either via previous notification OR in the app UI)
- Only genuinely new releases (created after user last saw the app) trigger notifications
## Edge Cases
- User opens app, sees updates, doesn't install → no re-notification for those same updates
- User dismisses notification without opening → no re-notification (release timestamp hasn't changed)
- No installed apps → no notifications ever sent
- All updates have release dates before last app open → no notification shown
- New release appears (newer timestamp) → notification sent for that release only
## Acceptance Criteria
- [ ] Background check runs every 24 hours (not 6 hours)
- [ ] Notification only shown if user hasn't opened app in 24+ hours
- [ ] Notification only includes releases with createdAt > seenUntil AND > lastAppOpened
- [ ] Tapping notification navigates to Updates screen
- [ ] seenUntil timestamp updated when notification is shown
## Notes
- Uses `seenUntil` timestamp approach — simpler than tracking individual app IDs
- Filters by both `seenUntil` (last notification time) AND `lastAppOpened` (last time user saw app)
- This prevents nagging about updates user already saw in the app UI but chose to ignore
- The foreground `Timer.periodic` in `updates_service.dart` does NOT run in the background — only WorkManager does
+143
View File
@@ -0,0 +1,143 @@
# WORK-004 — Background Notifications
**Feature:** FEAT-002-background-notifications.md
**Status:** Complete
## Tasks
- [x] 1. Add secure storage methods for notification state tracking
- Files: `lib/services/secure_storage_service.dart`
- Add `getLastAppOpenedTime()` / `setLastAppOpenedTime()`
- Add `getSeenUpdateIds()` / `setSeenUpdateIds()` / `clearSeenUpdateIds()`
- [x] 2. Record "last app opened" time when app resumes
- Files: `lib/main.dart`
- Update `_AppLifecycleObserver.didChangeAppLifecycleState()` to record timestamp on resume
- Also record on initial launch (in `appInitializationProvider`)
- [x] 3. Mark updates as "seen" when user opens app
- Files: `lib/main.dart`
- On app open, clear seen update IDs (so we can track new ones)
- Cleared in `_recordAppOpened()` and during initial launch
- [x] 4. Change background check frequency to 24 hours
- Files: `lib/services/background_update_service.dart`
- Changed `frequency: Duration(hours: 6)` to `Duration(hours: 24)`
- Updated `initialDelay` from 15 minutes to 1 hour
- [x] 5. Implement smart notification logic
- Files: `lib/services/background_update_service.dart`
- Check "last app opened" time — skip if < 24 hours ago
- Filter updates to only those not in "seen" list
- Only notify if filtered list is non-empty
- Removed old 72-hour throttle logic
- [x] 6. Configure notification tap to navigate to Updates screen
- Files: `lib/services/background_update_service.dart`
- Pass payload with notification (`_kNotificationPayload`)
- Handle `onDidReceiveNotificationResponse` via `_handleNotificationTap`
- Handle launch from terminated state via `getNotificationAppLaunchDetails()`
- [x] 7. Self-review against INVARIANTS.md
- ✅ UI Safety: All operations are async, no UI blocking
- ✅ Async Discipline: WorkManager is designed for periodic background work (same note as WORK-003)
- ✅ Local-First: Notifications enhance UX, don't gate functionality
- ✅ Lifecycle Safety: No resource leaks, all async
- ✅ UX Safety: Notifications provide clear information
## Test Coverage
| Scenario | Expected | Status |
|----------|----------|--------|
| App not opened in 24h, new updates exist | Notification shown | [ ] |
| App opened recently (< 24h), updates exist | No notification | [ ] |
| App not opened in 24h, but updates already seen | No notification | [ ] |
| Tap notification | Opens to Updates screen | [ ] |
| Dismiss notification, next cycle | May re-notify if still inactive | [ ] |
| Open app after notification | Updates marked as seen | [ ] |
## Decisions
### 2026-02-04 — "Seen" tracking on app open, not notification show
**Context:** How to track which updates user has been notified about without re-notifying for the same updates.
**Options:** (A) Mark seen when notification shown, (B) Mark seen when app opened.
**Decision:** Option B — mark seen when app opened.
**Rationale:** If user dismisses notification without opening, we want the option to re-notify. Marking on notification show would prevent re-notification even when user hasn't seen updates.
### 2026-02-04 — 24-hour check frequency
**Context:** How often should background checks run?
**Options:** (A) Keep 6h checks, (B) Change to 24h, (C) 12h middle ground.
**Decision:** Option B — 24 hours.
**Rationale:** Since we only notify users who haven't opened app in 24+ hours, checking more frequently adds battery cost with no benefit. When user opens app, the foreground `Timer.periodic` handles immediate updates.
### 2026-02-04 — Simplify "seen" tracking with timestamp (REVISED)
**Context:** Original design stored `Set<String>` of seen app IDs, cleared on app open. This had a flaw: clearing on app open means the next background check (24h later) would re-notify about the same updates the user already saw and chose to ignore.
**Options considered:**
- (A) Store `Set<appId>` — doesn't handle new versions of same app
- (B) Store `Set<appId:versionCode>` — handles new versions but unbounded growth
- (C) Store single `seenUntil` timestamp — simple, compare against release.createdAt
**Decision:** Option C — single `seenUntil` timestamp.
**Rationale:**
- Much simpler: one timestamp vs unbounded set
- No clearing needed on app open
- Filter: only notify about releases where `release.createdAt > seenUntil`
- When notification shown, set `seenUntil = now()`
- New releases (even for same app) have newer timestamps, so they'll trigger
- Old ignored updates won't re-notify (their timestamps stay old)
## Files Modified
| File | Change |
|------|--------|
| `lib/services/secure_storage_service.dart` | Add last app opened time and seen update IDs storage |
| `lib/main.dart` | Record app open time, mark updates as seen on launch |
| `lib/services/background_update_service.dart` | 24h frequency, smart notification logic, deep link to updates |
## Spec Issues
_None_
## Refactor Tasks (seenUntil timestamp approach)
- [x] 8. Replace seenUpdateIds with seenUntil timestamp in secure storage
- Files: `lib/services/secure_storage_service.dart`
- Removed `getSeenUpdateIds()` / `setSeenUpdateIds()` / `clearSeenUpdateIds()`
- Added `getSeenUntil()` / `setSeenUntil()`
- [x] 9. Remove clearSeenUpdateIds calls from main.dart
- Files: `lib/main.dart`
- Removed from `_recordAppOpened()` and `appInitializationProvider`
- Kept `setLastAppOpenedTime()` (still needed for 24h inactivity check)
- [x] 10. Update background notification logic to use seenUntil
- Files: `lib/services/background_update_service.dart`
- Filter: `release.createdAt > seenUntil AND > lastOpened`
- On notification shown: `setSeenUntil(now())`
- Handles null seenUntil gracefully (first run uses lastOpened as fallback)
## Progress Notes
**2026-02-04:** Design finalized. Starting implementation.
**2026-02-04:** Initial implementation complete.
- Replaced 72-hour notification throttle with smart logic based on app activity
- User must be inactive 24+ hours AND have unseen updates to receive notification
- Notification tap navigates directly to Updates screen
- Background check frequency changed from 6 hours to 24 hours to match inactivity threshold
**2026-02-04:** Design revision — seenUpdateIds approach had flaw.
- Problem: Clearing seenIds on app open meant user would be re-notified about same ignored updates
- Solution: Replace with single `seenUntil` timestamp
- Filter by `release.createdAt > seenUntil` instead of checking set membership
- Simpler, no unbounded storage growth, handles new versions naturally
**2026-02-04:** Refactor complete.
- Replaced seenUpdateIds with seenUntil timestamp
- Added extra filter: `release.createdAt > lastOpened` to prevent nagging about updates user saw in app UI
- Final logic: only notify if release is newer than BOTH last notification AND last app open
- This ensures user won't be nagged about updates they already saw and chose to ignore