mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
More reasonable update notification logic
This commit is contained in:
@@ -245,6 +245,9 @@ final appInitializationProvider = FutureProvider<void>((ref) async {
|
||||
// Initialize device capabilities (used for dynamic download concurrency)
|
||||
await DeviceCapabilitiesCache.initialize();
|
||||
|
||||
// Record app open time for background notification throttling
|
||||
await secureStorage.setLastAppOpenedTime(DateTime.now());
|
||||
|
||||
// These run in background - don't block UI
|
||||
final packageManager = ref.read(packageManagerProvider.notifier);
|
||||
unawaited(packageManager.syncInstalledPackages());
|
||||
@@ -321,6 +324,9 @@ class _AppLifecycleObserver with WidgetsBindingObserver {
|
||||
final packageManager = _ref.read(packageManagerProvider.notifier);
|
||||
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
// Record app open time for background notification throttling
|
||||
unawaited(_recordAppOpened());
|
||||
|
||||
// Sync installed packages to detect installs that completed while backgrounded
|
||||
unawaited(packageManager.syncInstalledPackages());
|
||||
|
||||
@@ -333,4 +339,11 @@ class _AppLifecycleObserver with WidgetsBindingObserver {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'dart:ui' as ui;
|
||||
import 'package:background_downloader/background_downloader.dart' hide Request;
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:models/models.dart';
|
||||
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:purplebase/purplebase.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/dummy_package_manager.dart';
|
||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||
@@ -38,8 +40,11 @@ const kUpdateNotificationChannelDescription =
|
||||
/// Stale download threshold for cleanup
|
||||
const _staleDownloadThreshold = Duration(days: 7);
|
||||
|
||||
/// Threshold for re-notifying about the same updates (72 hours)
|
||||
const _notificationReminderThreshold = Duration(hours: 72);
|
||||
/// How long user must be inactive before showing background notification
|
||||
const _inactivityThreshold = Duration(hours: 24);
|
||||
|
||||
/// Notification payload for deep linking to updates screen
|
||||
const _kNotificationPayload = 'updates';
|
||||
|
||||
/// Input data key for AppCatalog relay URLs
|
||||
const kAppCatalogRelaysKey = 'appCatalogRelays';
|
||||
@@ -134,7 +139,7 @@ Future<bool> _performWeeklyCleanup() async {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// [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 {
|
||||
final secureStorage = SecureStorageService();
|
||||
|
||||
// Skip if notified recently
|
||||
final lastNotified = await secureStorage.getLastUpdateNotificationTime();
|
||||
if (lastNotified != null &&
|
||||
DateTime.now().difference(lastNotified) <
|
||||
_notificationReminderThreshold) {
|
||||
// Skip if user recently opened the app
|
||||
final lastOpened = await secureStorage.getLastAppOpenedTime();
|
||||
if (lastOpened != null &&
|
||||
DateTime.now().difference(lastOpened) < _inactivityThreshold) {
|
||||
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
|
||||
final plugin = FlutterLocalNotificationsPlugin();
|
||||
const initSettings = InitializationSettings(
|
||||
@@ -281,10 +317,10 @@ Future<void> _showUpdateNotificationIfNeeded(List<App> updates) async {
|
||||
await plugin.initialize(initSettings);
|
||||
await _ensureUpdateNotificationChannel(plugin);
|
||||
|
||||
final appNames = updates.map((a) => a.name ?? a.identifier).toList();
|
||||
final title = updates.length == 1
|
||||
final appNames = newUpdates.map((a) => a.name ?? a.identifier).toList();
|
||||
final title = newUpdates.length == 1
|
||||
? '1 app update available'
|
||||
: '${updates.length} app updates available';
|
||||
: '${newUpdates.length} app updates available';
|
||||
final body = appNames.length <= 3
|
||||
? appNames.join(', ')
|
||||
: '${appNames.take(3).join(', ')} and ${appNames.length - 3} more';
|
||||
@@ -304,9 +340,11 @@ Future<void> _showUpdateNotificationIfNeeded(List<App> updates) async {
|
||||
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
|
||||
@@ -334,18 +372,20 @@ class BackgroundUpdateService {
|
||||
.resolveRelays('AppCatalog');
|
||||
|
||||
// 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(
|
||||
kBackgroundUpdateTaskId,
|
||||
kBackgroundUpdateTaskName,
|
||||
frequency: const Duration(hours: 6),
|
||||
frequency: const Duration(hours: 24),
|
||||
constraints: Constraints(
|
||||
networkType: NetworkType.connected,
|
||||
requiresBatteryNotLow: true,
|
||||
),
|
||||
existingWorkPolicy: ExistingPeriodicWorkPolicy.keep,
|
||||
backoffPolicy: BackoffPolicy.exponential,
|
||||
initialDelay: const Duration(minutes: 15),
|
||||
initialDelay: const Duration(hours: 1),
|
||||
inputData: {kAppCatalogRelaysKey: appCatalogRelays.toList()},
|
||||
);
|
||||
|
||||
@@ -385,63 +425,40 @@ class BackgroundUpdateService {
|
||||
|
||||
await flutterLocalNotificationsPlugin.initialize(
|
||||
initializationSettings,
|
||||
onDidReceiveNotificationResponse: (response) {
|
||||
// Notification tapped - app will open to main screen
|
||||
// Navigation is handled by the app's normal launch flow
|
||||
},
|
||||
onDidReceiveNotificationResponse: _handleNotificationTap,
|
||||
);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// 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
|
||||
Future<void> cancelBackgroundChecks() async {
|
||||
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(
|
||||
|
||||
@@ -15,9 +15,7 @@ class SecureStorageService {
|
||||
|
||||
// Use explicit options for reliability across platforms
|
||||
static final _storage = FlutterSecureStorage(
|
||||
aOptions: const AndroidOptions(
|
||||
encryptedSharedPreferences: true,
|
||||
),
|
||||
aOptions: const AndroidOptions(encryptedSharedPreferences: true),
|
||||
iOptions: const IOSOptions(
|
||||
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.
|
||||
Future<DateTime?> getLastUpdateNotificationTime() async {
|
||||
final value = await _storage.read(key: _lastUpdateNotificationKey);
|
||||
/// Get the last time the user opened the app.
|
||||
Future<DateTime?> getLastAppOpenedTime() async {
|
||||
final value = await _storage.read(key: _lastAppOpenedKey);
|
||||
if (int.tryParse(value ?? '') case final ms?) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(ms);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Store the last update notification time.
|
||||
Future<void> setLastUpdateNotificationTime(DateTime time) async {
|
||||
/// Store the last app opened time.
|
||||
Future<void> setLastAppOpenedTime(DateTime time) async {
|
||||
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}',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user