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)
|
// 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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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