mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 20:48:24 +00:00
Remove unused notifications, reintroduce later
This commit is contained in:
@@ -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
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user