Remove unused notifications, reintroduce later

This commit is contained in:
franzap
2026-07-17 16:24:56 -03:00
parent 130134d50d
commit 0490a3459c
10 changed files with 70 additions and 514 deletions
-1
View File
@@ -7,7 +7,6 @@
<uses-permission android:name="android.permission.ENFORCE_UPDATE_OWNERSHIP" /> <uses-permission android:name="android.permission.ENFORCE_UPDATE_OWNERSHIP" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" /> <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="18dp"
android:height="24dp"
android:viewportWidth="19"
android:viewportHeight="32">
<!-- Zapstore icon for notifications -->
<path
android:fillColor="#FFFFFF"
android:pathData="M18.8379,13.9711L8.84956,0.356086C8.30464,-0.386684 7.10438,0.128479 7.30103,1.02073L9.04686,8.94232C9.16268,9.46783 8.74887,9.96266 8.19641,9.9593L0.871032,9.91477C0.194934,9.91066 -0.223975,10.6293 0.126748,11.1916L7.69743,23.3297C7.99957,23.8141 7.73264,24.4447 7.16744,24.5816L5.40958,25.0076C4.70199,25.179 4.51727,26.0734 5.10186,26.4974L12.4572,31.8326C12.9554,32.194 13.6711,31.9411 13.8147,31.3529L15.8505,23.0152C16.0137,22.3465 15.3281,21.7801 14.6762,22.0452L13.0661,22.7001C12.5619,22.9052 11.991,22.6092 11.8849,22.0877L10.7521,16.5224C10.6486,16.014 11.038,15.5365 11.5704,15.5188L18.1639,15.2998C18.8529,15.2769 19.2383,14.517 18.8379,13.9711Z"/>
</vector>
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="18dp"
android:height="24dp"
android:viewportWidth="19"
android:viewportHeight="32">
<!-- Zapstore icon for notifications -->
<path
android:fillColor="#FFFFFF"
android:pathData="M18.8379,13.9711L8.84956,0.356086C8.30464,-0.386684 7.10438,0.128479 7.30103,1.02073L9.04686,8.94232C9.16268,9.46783 8.74887,9.96266 8.19641,9.9593L0.871032,9.91477C0.194934,9.91066 -0.223975,10.6293 0.126748,11.1916L7.69743,23.3297C7.99957,23.8141 7.73264,24.4447 7.16744,24.5816L5.40958,25.0076C4.70199,25.179 4.51727,26.0734 5.10186,26.4974L12.4572,31.8326C12.9554,32.194 13.6711,31.9411 13.8147,31.3529L15.8505,23.0152C16.0137,22.3465 15.3281,21.7801 14.6762,22.0452L13.0661,22.7001C12.5619,22.9052 11.991,22.6092 11.8849,22.0877L10.7521,16.5224C10.6486,16.014 11.038,15.5365 11.5704,15.5188L18.1639,15.2998C18.8529,15.2769 19.2383,14.517 18.8379,13.9711Z"/>
</vector>
+2 -3
View File
@@ -431,7 +431,7 @@ 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 // Record app open time (retained for settings / device-key restore)
await ref await ref
.read(settingsServiceProvider) .read(settingsServiceProvider)
.update((s) => s.copyWith(lastAppOpened: DateTime.now())); .update((s) => s.copyWith(lastAppOpened: DateTime.now()));
@@ -574,7 +574,7 @@ 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 // Record app open time (retained for settings / device-key restore)
unawaited(_recordAppOpened()); unawaited(_recordAppOpened());
// Sync installed packages to detect installs that completed while backgrounded // Sync installed packages to detect installs that completed while backgrounded
@@ -606,7 +606,6 @@ class _AppLifecycleObserver with WidgetsBindingObserver {
} }
/// Record that the user opened the app. /// Record that the user opened the app.
/// This is used to check inactivity for background notifications.
Future<void> _recordAppOpened() async { Future<void> _recordAppOpened() async {
await SettingsService().update( await SettingsService().update(
(s) => s.copyWith(lastAppOpened: DateTime.now()), (s) => s.copyWith(lastAppOpened: DateTime.now()),
@@ -52,8 +52,7 @@ class BackgroundAutoUpdateExecutor {
final pending = pendingInstalls[appId]; final pending = pendingInstalls[appId];
if (!pkg.canInstallSilently && pending?.hash == target.hash) { if (!pkg.canInstallSilently && pending?.hash == target.hash) {
// This exact manual update is already downloaded and verified. It was // This exact manual update is already downloaded and verified.
// reported when first staged, so avoid downloading and notifying again.
continue; continue;
} }
@@ -79,7 +79,7 @@ class PendingBackgroundInstall {
} }
} }
/// Persists manual background installs until the user taps the notification. /// Persists manual background installs until the user installs from Updates.
class BackgroundPendingInstallStore { class BackgroundPendingInstallStore {
static const _fileName = 'background_pending_installs.json'; static const _fileName = 'background_pending_installs.json';
+23 -395
View File
@@ -5,30 +5,24 @@ import 'dart:ui' as ui;
import 'dart:ui' show PlatformDispatcher; import 'dart:ui' show PlatformDispatcher;
import 'package:background_downloader/background_downloader.dart' hide Request; import 'package:background_downloader/background_downloader.dart' hide Request;
import 'package:flutter/foundation.dart' show FlutterError, kDebugMode; import 'package:flutter/foundation.dart' show FlutterError;
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:permission_handler/permission_handler.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;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.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/background_auto_update_executor.dart'; import 'package:zapstore/services/background_auto_update_executor.dart';
import 'package:zapstore/services/background_native_installer.dart'; import 'package:zapstore/services/background_native_installer.dart';
import 'package:zapstore/services/background_pending_install_store.dart';
import 'package:zapstore/services/log_service.dart'; import 'package:zapstore/services/log_service.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';
import 'package:zapstore/services/catalog_fetcher.dart'; import 'package:zapstore/services/catalog_fetcher.dart';
import 'package:zapstore/services/settings_service.dart'; import 'package:zapstore/services/settings_service.dart';
import 'package:zapstore/utils/extensions.dart';
/// Unique task name for background update checking /// Legacy notification-only task name (cancelled on init; no longer registered).
const kBackgroundUpdateTaskName = 'dev.zapstore.backgroundUpdateCheck'; const kBackgroundUpdateTaskName = 'dev.zapstore.backgroundUpdateCheck';
/// Unique task name for unmetered background auto-updates /// Unique task name for unmetered background auto-updates
@@ -37,7 +31,7 @@ const kBackgroundAutoUpdateTaskName = 'dev.zapstore.backgroundAutoUpdate';
/// Unique task name for weekly cleanup /// Unique task name for weekly cleanup
const kWeeklyCleanupTaskName = 'dev.zapstore.weeklyCleanup'; const kWeeklyCleanupTaskName = 'dev.zapstore.weeklyCleanup';
/// Unique task identifier /// Legacy notification-only task id (cancelled on init; no longer registered).
const kBackgroundUpdateTaskId = 'backgroundUpdateCheck'; const kBackgroundUpdateTaskId = 'backgroundUpdateCheck';
/// Unique task identifier for unmetered background auto-updates /// Unique task identifier for unmetered background auto-updates
@@ -49,28 +43,9 @@ const kBackgroundAutoUpdateImmediateTaskId = 'backgroundAutoUpdateImmediate';
/// Unique task identifier for weekly cleanup /// Unique task identifier for weekly cleanup
const kWeeklyCleanupTaskId = 'weeklyCleanup'; const kWeeklyCleanupTaskId = 'weeklyCleanup';
/// Notification channel for update notifications
const kUpdateNotificationChannelId = 'zapstore_updates';
const kUpdateNotificationChannelName = 'App Updates';
const kUpdateNotificationChannelDescription =
'Notifications for available app updates';
/// Stale download threshold for cleanup /// Stale download threshold for cleanup
const _staleDownloadThreshold = Duration(days: 7); const _staleDownloadThreshold = Duration(days: 7);
/// 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';
/// Notification payload prefix for ready-to-install manual background updates.
/// Format: `background-ready:appId1,appId2`
const _kNotificationPayloadReadyPrefix = 'background-ready:';
/// Notification payload when auto-update ran but needs no user install action.
const _kNotificationPayloadResults = 'background-results';
/// Input data key for AppCatalog relay URLs /// Input data key for AppCatalog relay URLs
const kAppCatalogRelaysKey = 'appCatalogRelays'; const kAppCatalogRelaysKey = 'appCatalogRelays';
@@ -141,32 +116,15 @@ void callbackDispatcher() {
try { try {
switch (task) { switch (task) {
case kBackgroundUpdateTaskName: case kBackgroundUpdateTaskName:
final relayUrls = // Legacy notification-only worker — no longer does work.
(inputData?[kAppCatalogRelaysKey] as List<dynamic>?) return true;
?.cast<String>()
.toSet();
return await _checkForUpdatesInBackground(
relayUrls,
autoUpdateWorker: false,
);
case kBackgroundAutoUpdateTaskName: case kBackgroundAutoUpdateTaskName:
final relayUrls =
(inputData?[kAppCatalogRelaysKey] as List<dynamic>?)
?.cast<String>()
.toSet();
return await _checkForUpdatesInBackground(
relayUrls,
autoUpdateWorker: true,
);
case kBackgroundAutoUpdateImmediateTaskId: case kBackgroundAutoUpdateImmediateTaskId:
final relayUrls = final relayUrls =
(inputData?[kAppCatalogRelaysKey] as List<dynamic>?) (inputData?[kAppCatalogRelaysKey] as List<dynamic>?)
?.cast<String>() ?.cast<String>()
.toSet(); .toSet();
return await _checkForUpdatesInBackground( return await _checkForUpdatesInBackground(relayUrls);
relayUrls,
autoUpdateWorker: true,
);
case kWeeklyCleanupTaskName: case kWeeklyCleanupTaskName:
return await _performWeeklyCleanup(); return await _performWeeklyCleanup();
default: default:
@@ -269,22 +227,16 @@ Future<bool> _performWeeklyCleanup() async {
} }
} }
/// Background update check logic - runs in a separate isolate via WorkManager. /// Background auto-update check — runs in a separate isolate via WorkManager.
/// ///
/// [appCatalogRelays] - Relay URLs resolved from main isolate. Falls back to /// [appCatalogRelays] - Relay URLs resolved from main isolate. Falls back to
/// default relay if not provided. /// default relay if not provided.
Future<bool> _checkForUpdatesInBackground( Future<bool> _checkForUpdatesInBackground(Set<String>? appCatalogRelays) async {
Set<String>? appCatalogRelays, {
required bool autoUpdateWorker,
}) async {
try { try {
final relays = appCatalogRelays ?? {'wss://relay.zapstore.dev'}; final relays = appCatalogRelays ?? {'wss://relay.zapstore.dev'};
final settings = await SettingsService().load(); final settings = await SettingsService().load();
// Notification-only checks keep their existing connected-network schedule. if (!settings.backgroundAutoUpdatesEnabled) {
// Auto-update work runs in a separate worker constrained to unmetered
// networks. Exactly one worker proceeds for the current setting.
if (settings.backgroundAutoUpdatesEnabled != autoUpdateWorker) {
return true; return true;
} }
@@ -346,28 +298,18 @@ Future<bool> _checkForUpdatesInBackground(
source: const LocalSource(), source: const LocalSource(),
); );
if (autoUpdateWorker) { final displayNames = {
final displayNames = { for (final app in updatableApps)
for (final app in updatableApps) app.identifier: app.name ?? app.identifier,
app.identifier: app.name ?? app.identifier, };
}; final result = await BackgroundAutoUpdateExecutor.run(
final result = await BackgroundAutoUpdateExecutor.run( updatableInstallables: updatableInstallables,
updatableInstallables: updatableInstallables, installed: pmState.installed,
installed: pmState.installed, displayNames: displayNames,
displayNames: displayNames, );
); await BackgroundNativeInstaller.notifyBackgroundUpdatesCompleted(
await BackgroundNativeInstaller.notifyBackgroundUpdatesCompleted( result.updatedAppIds,
result.updatedAppIds, );
);
if (result.hasWork) {
await _showAutoUpdateResultNotification(result, updatableApps);
}
} else {
await _showUpdateNotificationIfNeeded(
updatableApps,
updatableInstallables,
);
}
} }
return true; return true;
@@ -379,184 +321,6 @@ Future<bool> _checkForUpdatesInBackground(
} }
} }
/// Show a local notification for available updates.
/// Only notifies if:
/// 1. User hasn't opened app in 24+ hours
/// 2. There are updates with installable.createdAt > seenUntil AND > lastOpened
/// (new since both last notification AND last time user saw the app)
///
/// [installables] maps app identifier → the installable (SoftwareAsset or
/// FileMetadata) that represents the available update. Its `createdAt` is used
/// for freshness checks instead of `app.latestRelease.value` which is not
/// populated in the background isolate context.
Future<void> _showUpdateNotificationIfNeeded(
List<App> updates,
Map<String, Installable> installables,
) async {
final settingsService = SettingsService();
final settings = await settingsService.load();
// Skip if user recently opened the app
if (settings.lastAppOpened != null &&
DateTime.now().difference(settings.lastAppOpened!) <
_inactivityThreshold) {
return;
}
// Get the "seen until" timestamp - updates with createdAt > this are new
final seenUntil = settings.seenUntil;
// Filter to only updates that are genuinely new:
// - installable.createdAt > seenUntil (not already notified via background)
// - installable.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 installable = installables[app.identifier];
if (installable == null) return false;
final releaseTime = installable.createdAt;
// 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 (settings.lastAppOpened != null &&
!releaseTime.isAfter(settings.lastAppOpened!)) {
return false;
}
return true;
}).toList();
if (newUpdates.isEmpty) {
return; // No new updates to notify about
}
// Show the notification
final plugin = FlutterLocalNotificationsPlugin();
const initSettings = InitializationSettings(
android: AndroidInitializationSettings('@drawable/ic_notification'),
);
await plugin.initialize(initSettings);
await _ensureUpdateNotificationChannel(plugin);
final appNames = newUpdates.map((a) => a.name ?? a.identifier).toList();
final title = newUpdates.length == 1
? '1 app update available'
: '${newUpdates.length} app updates available';
final body = appNames.length <= 3
? appNames.join(', ')
: '${appNames.take(3).join(', ')} and ${appNames.length - 3} more';
await plugin.show(
0,
title,
body,
const NotificationDetails(
android: AndroidNotificationDetails(
kUpdateNotificationChannelId,
kUpdateNotificationChannelName,
channelDescription: kUpdateNotificationChannelDescription,
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
showWhen: true,
autoCancel: true,
),
),
payload: _kNotificationPayload,
);
// Update seenUntil to now - future checks will only notify about releases after this
await settingsService.update((s) => s.copyWith(seenUntil: DateTime.now()));
}
/// Show a notification summarizing background auto-update results.
Future<void> _showAutoUpdateResultNotification(
BackgroundAutoUpdateResult result,
List<App> updatableApps,
) async {
final plugin = FlutterLocalNotificationsPlugin();
const initSettings = InitializationSettings(
android: AndroidInitializationSettings('@drawable/ic_notification'),
);
await plugin.initialize(initSettings);
await _ensureUpdateNotificationChannel(plugin);
final nameById = {
for (final app in updatableApps) app.identifier: app.name ?? app.identifier,
};
String name(String appId) => nameById[appId] ?? appId;
final parts = <String>[];
if (result.updatedAppIds.isNotEmpty) {
final names = result.updatedAppIds.map(name).toList();
parts.add(
result.updatedAppIds.length == 1
? 'Updated ${names.first}'
: 'Updated ${names.length} apps: ${_formatNameList(names)}',
);
}
if (result.readyAppIds.isNotEmpty) {
final names = result.readyAppIds.map(name).toList();
parts.add(
result.readyAppIds.length == 1
? '${names.first} is ready to update — tap to install'
: '${names.length} apps ready to update — tap to install',
);
}
if (result.failedAppIds.isNotEmpty) {
parts.add(
result.failedAppIds.length == 1
? 'Failed to update ${name(result.failedAppIds.first)}'
: 'Failed to update ${result.failedAppIds.length} apps',
);
}
final body = parts.join('\n');
final title = switch ((
result.updatedAppIds.isNotEmpty,
result.readyAppIds.isNotEmpty,
)) {
(true, true) => 'Updates applied',
(true, false) =>
result.updatedAppIds.length == 1 ? 'App updated' : 'Apps updated',
(false, true) =>
result.readyAppIds.length == 1 ? 'Update ready' : 'Updates ready',
_ => 'Update check finished',
};
final payload = result.readyAppIds.isNotEmpty
? '$_kNotificationPayloadReadyPrefix${result.readyAppIds.join(',')}'
: _kNotificationPayloadResults;
await plugin.show(
0,
title,
body,
NotificationDetails(
android: AndroidNotificationDetails(
kUpdateNotificationChannelId,
kUpdateNotificationChannelName,
channelDescription: kUpdateNotificationChannelDescription,
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
showWhen: true,
autoCancel: true,
styleInformation: BigTextStyleInformation(body),
),
),
payload: payload,
);
}
String _formatNameList(List<String> names) {
if (names.length <= 3) return names.join(', ');
return '${names.take(3).join(', ')} and ${names.length - 3} more';
}
/// Service for managing background update checks /// Service for managing background update checks
class BackgroundUpdateService { class BackgroundUpdateService {
BackgroundUpdateService(this.ref); BackgroundUpdateService(this.ref);
@@ -576,32 +340,14 @@ class BackgroundUpdateService {
// Initialize WorkManager // Initialize WorkManager
await Workmanager().initialize(callbackDispatcher); await Workmanager().initialize(callbackDispatcher);
// Initialize local notifications // Drop any previously scheduled notification-only checks.
await _initializeNotifications(); await Workmanager().cancelByUniqueName(kBackgroundUpdateTaskId);
// Resolve AppCatalog relays from main isolate to pass to background task // Resolve AppCatalog relays from main isolate to pass to background task
final appCatalogRelays = await ref final appCatalogRelays = await ref
.read(storageNotifierProvider.notifier) .read(storageNotifierProvider.notifier)
.resolveRelays('AppCatalog'); .resolveRelays('AppCatalog');
// Register periodic task (minimum 15 minutes on Android)
// 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: 24),
constraints: Constraints(
networkType: NetworkType.connected,
requiresBatteryNotLow: true,
),
existingWorkPolicy: ExistingPeriodicWorkPolicy.keep,
backoffPolicy: BackoffPolicy.exponential,
initialDelay: const Duration(hours: 1),
inputData: {kAppCatalogRelaysKey: appCatalogRelays.toList()},
);
await _registerAutoUpdatePeriodicTask( await _registerAutoUpdatePeriodicTask(
appCatalogRelays, appCatalogRelays,
initialDelay: const Duration(hours: 1), initialDelay: const Duration(hours: 1),
@@ -680,104 +426,6 @@ class BackgroundUpdateService {
); );
} }
/// Initialize local notifications plugin and request permission (release/profile only).
Future<void> _initializeNotifications() async {
// Request notification permission on Android 13+ (API 33+); skip in debug / dev runs.
if (Platform.isAndroid && !kDebugMode) {
final status = await Permission.notification.status;
if (!status.isGranted) {
await Permission.notification.request();
}
}
final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
const initializationSettingsAndroid = AndroidInitializationSettings(
'@drawable/ic_notification',
);
const initializationSettings = InitializationSettings(
android: initializationSettingsAndroid,
);
await flutterLocalNotificationsPlugin.initialize(
initializationSettings,
onDidReceiveNotificationResponse: _handleNotificationTap,
);
// Check if app was launched from a notification (terminated state)
final launchDetails = await flutterLocalNotificationsPlugin
.getNotificationAppLaunchDetails();
if (launchDetails?.didNotificationLaunchApp == true) {
final payload = launchDetails?.notificationResponse?.payload;
if (payload != null) {
_handleNotificationPayload(payload);
}
}
await _ensureUpdateNotificationChannel(flutterLocalNotificationsPlugin);
}
/// Handle notification tap - navigate or launch pending installs
static void _handleNotificationTap(NotificationResponse response) {
final payload = response.payload;
if (payload == null || payload.isEmpty) return;
_handleNotificationPayload(payload);
}
static void _handleNotificationPayload(String payload) {
if (payload.startsWith(_kNotificationPayloadReadyPrefix)) {
final raw = payload.substring(_kNotificationPayloadReadyPrefix.length);
final appIds = raw.split(',').where((id) => id.isNotEmpty).toList();
// A notification can cold-start the app before its Activity, method
// channels, and router have finished attaching. Defer to the next frame
// so the prepared install can safely launch Android system UI.
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(launchPendingBackgroundInstalls(appIds));
});
return;
}
if (payload == _kNotificationPayload ||
payload == _kNotificationPayloadResults) {
_navigateToUpdates();
}
}
/// Launch Android install dialogs for manual updates prepared in the background.
static Future<void> launchPendingBackgroundInstalls(
List<String> appIds,
) async {
final pending = await BackgroundPendingInstallStore.loadAll();
final targets = appIds.isEmpty ? pending.keys.toList() : appIds;
for (final appId in targets) {
final install = pending[appId];
if (install == null) continue;
await BackgroundNativeInstaller.launchPreparedInstall(
appId: install.appId,
filePath: install.filePath,
expectedHash: install.hash,
expectedSize: install.size,
expectedCertHashes: install.certificateHashes,
);
}
final context = rootNavigatorKey.currentContext;
if (context != null && context.mounted) {
GoRouter.of(context).go('/updates');
}
}
/// 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 Future.wait([ await Future.wait([
@@ -787,26 +435,6 @@ class BackgroundUpdateService {
} }
} }
Future<void> _ensureUpdateNotificationChannel(
FlutterLocalNotificationsPlugin plugin,
) async {
final androidPlugin = plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
if (androidPlugin != null) {
await androidPlugin.createNotificationChannel(
const AndroidNotificationChannel(
kUpdateNotificationChannelId,
kUpdateNotificationChannelName,
description: kUpdateNotificationChannelDescription,
importance: Importance.defaultImportance,
),
);
}
}
/// Provider for the background update service /// Provider for the background update service
final backgroundUpdateServiceProvider = Provider<BackgroundUpdateService>( final backgroundUpdateServiceProvider = Provider<BackgroundUpdateService>(
BackgroundUpdateService.new, BackgroundUpdateService.new,
-88
View File
@@ -383,38 +383,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.0.0" version: "5.0.0"
flutter_local_notifications:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875"
url: "https://pub.dev"
source: hosted
version: "19.5.0"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe"
url: "https://pub.dev"
source: hosted
version: "9.1.0"
flutter_local_notifications_windows:
dependency: transitive
description:
name: flutter_local_notifications_windows
sha256: "8d658f0d367c48bd420e7cf2d26655e2d1130147bca1eea917e576ca76668aaf"
url: "https://pub.dev"
source: hosted
version: "1.0.3"
flutter_markdown: flutter_markdown:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -791,54 +759,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.2.5" version: "4.2.5"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1
url: "https://pub.dev"
source: hosted
version: "12.0.1"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
url: "https://pub.dev"
source: hosted
version: "13.0.1"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
url: "https://pub.dev"
source: hosted
version: "9.4.7"
permission_handler_html:
dependency: transitive
description:
name: permission_handler_html
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
url: "https://pub.dev"
source: hosted
version: "0.1.3+5"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
url: "https://pub.dev"
source: hosted
version: "4.3.0"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
petitparser: petitparser:
dependency: transitive dependency: transitive
description: description:
@@ -1163,14 +1083,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.7" version: "0.7.7"
timezone:
dependency: transitive
description:
name: timezone
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
url: "https://pub.dev"
source: hosted
version: "0.10.1"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
-2
View File
@@ -41,7 +41,6 @@ dependencies:
auto_size_text: ^3.0.0 auto_size_text: ^3.0.0
percent_indicator: ^4.2.5 percent_indicator: ^4.2.5
flutter_layout_grid: ^2.0.8 flutter_layout_grid: ^2.0.8
permission_handler: ^12.0.1
equatable: ^2.0.7 equatable: ^2.0.7
state_notifier: ^1.0.0 state_notifier: ^1.0.0
gap: ^3.0.1 gap: ^3.0.1
@@ -56,7 +55,6 @@ dependencies:
flutter_secure_storage: ^9.2.4 # DO NOT UPDATE TO BROKEN ^10.0.0 flutter_secure_storage: ^9.2.4 # DO NOT UPDATE TO BROKEN ^10.0.0
markdown: ^7.3.0 markdown: ^7.3.0
workmanager: ^0.9.0+3 workmanager: ^0.9.0+3
flutter_local_notifications: ^19.5.0
device_info_plus: ^12.0.0 device_info_plus: ^12.0.0
archive: ^4.0.7 archive: ^4.0.7
flutter_slidable: ^4.0.3 flutter_slidable: ^4.0.3
@@ -0,0 +1,43 @@
# WORK-028 — Remove Android System Notifications
**Feature:** FEAT-002-background-notifications.md, FEAT-007-background-auto-updates.md
**Status:** Complete
## Tasks
- [x] 1. Remove `POST_NOTIFICATIONS` from AndroidManifest
- Files: `android/app/src/main/AndroidManifest.xml`
- [x] 2. Remove runtime notification permission request and local-notification plumbing
- Files: `lib/services/background_update_service.dart`
- [x] 3. Drop unused packages and notification icons
- Files: `pubspec.yaml`, `android/app/src/main/res/drawable*/ic_notification.xml`
- [x] 4. Stop registering the notification-only background check worker
- Notes: With no notifications, the FEAT-002 worker has no user-visible effect
- [x] 5. Self-review against INVARIANTS.md
## Test Coverage
| Scenario | Expected | Status |
|----------|----------|--------|
| Cold start (release) | No Android notification permission dialog | [ ] |
| Background auto-updates ON | Silent installs still run; no result notification | [ ] |
| Background auto-updates OFF | No availability notification | [ ] |
## Decisions
### 2026-07-17 — Remove system notifications entirely
**Context:** Product request to remove the Android “Allow notifications?” prompt.
**Options:** (A) Stop requesting only, keep permission + show paths; (B) Remove permission + request + show paths.
**Decision:** B — remove both the runtime request and `POST_NOTIFICATIONS`, and delete show/tap plumbing so failures are not silent.
**Rationale:** Without the permission, posts cannot succeed on Android 13+; leaving show paths would violate UX Safety (silent failures).
## Spec Issues
- **Issue:** FEAT-002 and FEAT-007 require system notifications (availability alerts, result summaries, tap-to-install for staged manual updates).
- **Question:** Product chose removal anyway. Feature specs remain human-owned and still describe the old behavior until updated.
- **Mitigation in code:** Keep WorkManager auto-update execution and pending-install staging; drop notification-only checks and all `flutter_local_notifications` usage. Staged manual updates remain on disk for the Updates screen / next user-driven install (per FEAT-007 edge case when notification is never tapped).
## Progress Notes
**2026-07-17:** Removed `POST_NOTIFICATIONS`, runtime request, `flutter_local_notifications` / `permission_handler`, notification show/tap paths, and notification-only WorkManager registration. Background auto-update execution kept.