mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
811 lines
27 KiB
Dart
811 lines
27 KiB
Dart
import 'dart:async';
|
|
import 'dart:io' show Directory, File, Platform;
|
|
import 'dart:isolate';
|
|
import 'dart:ui' as ui;
|
|
import 'dart:ui' show PlatformDispatcher;
|
|
|
|
import 'package:background_downloader/background_downloader.dart' hide Request;
|
|
import 'package:flutter/foundation.dart' show FlutterError, kDebugMode;
|
|
import 'package:flutter_local_notifications/flutter_local_notifications.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:models/models.dart';
|
|
import 'package:path/path.dart' as path;
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:purplebase/purplebase.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_native_installer.dart';
|
|
import 'package:zapstore/services/background_pending_install_store.dart';
|
|
import 'package:zapstore/services/log_service.dart';
|
|
import 'package:zapstore/services/package_manager/background_package_manager.dart';
|
|
import 'package:zapstore/services/package_manager/dummy_package_manager.dart';
|
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
|
import 'package:zapstore/services/catalog_fetcher.dart';
|
|
import 'package:zapstore/services/settings_service.dart';
|
|
import 'package:zapstore/utils/extensions.dart';
|
|
|
|
/// Unique task name for background update checking
|
|
const kBackgroundUpdateTaskName = 'dev.zapstore.backgroundUpdateCheck';
|
|
|
|
/// Unique task name for unmetered background auto-updates
|
|
const kBackgroundAutoUpdateTaskName = 'dev.zapstore.backgroundAutoUpdate';
|
|
|
|
/// Unique task name for weekly cleanup
|
|
const kWeeklyCleanupTaskName = 'dev.zapstore.weeklyCleanup';
|
|
|
|
/// Unique task identifier
|
|
const kBackgroundUpdateTaskId = 'backgroundUpdateCheck';
|
|
|
|
/// Unique task identifier for unmetered background auto-updates
|
|
const kBackgroundAutoUpdateTaskId = 'backgroundAutoUpdate';
|
|
|
|
/// Unique one-off task identifier for the first opt-in auto-update run
|
|
const kBackgroundAutoUpdateImmediateTaskId = 'backgroundAutoUpdateImmediate';
|
|
|
|
/// Unique task identifier for weekly cleanup
|
|
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
|
|
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
|
|
const kAppCatalogRelaysKey = 'appCatalogRelays';
|
|
|
|
/// Holds the isolate error port for the workmanager background
|
|
/// isolate. Top-level so the GC cannot collect the port while the
|
|
/// task is running.
|
|
// ignore: unused_element
|
|
RawReceivePort? _workmanagerErrorPort;
|
|
|
|
/// The entry point for WorkManager background tasks.
|
|
/// This MUST be a top-level function (not a class method).
|
|
@pragma('vm:entry-point')
|
|
void callbackDispatcher() {
|
|
WidgetsFlutterBinding.ensureInitialized();
|
|
ui.DartPluginRegistrant.ensureInitialized();
|
|
|
|
// Wire all four error sinks for this background isolate.
|
|
// LogService.init is fire-and-forget — pre-init writes go to the
|
|
// ring buffer and are flushed once disk is ready.
|
|
unawaited(LogService.I.init(isolateName: 'workmanager'));
|
|
|
|
FlutterError.onError = (details) {
|
|
FlutterError.presentError(details);
|
|
LogService.I.fatal(
|
|
'uncaught error',
|
|
tag: 'crash',
|
|
fields: const {'source': 'flutter'},
|
|
err: details.exception,
|
|
stack: details.stack,
|
|
);
|
|
LogService.I.flushSync();
|
|
};
|
|
|
|
PlatformDispatcher.instance.onError = (error, stack) {
|
|
LogService.I.fatal(
|
|
'uncaught error',
|
|
tag: 'crash',
|
|
fields: const {'source': 'platform_dispatcher'},
|
|
err: error,
|
|
stack: stack,
|
|
);
|
|
LogService.I.flushSync();
|
|
return true;
|
|
};
|
|
|
|
final port = RawReceivePort((dynamic pair) {
|
|
if (pair is List && pair.length == 2) {
|
|
final err = pair[0]?.toString() ?? 'unknown';
|
|
final stack = pair[1] == null
|
|
? null
|
|
: StackTrace.fromString(pair[1].toString());
|
|
LogService.I.fatal(
|
|
'uncaught error',
|
|
tag: 'crash',
|
|
fields: const {'source': 'isolate'},
|
|
err: err,
|
|
stack: stack,
|
|
);
|
|
LogService.I.flushSync();
|
|
}
|
|
});
|
|
Isolate.current.addErrorListener(port.sendPort);
|
|
_workmanagerErrorPort = port;
|
|
|
|
runZonedGuarded(
|
|
() {
|
|
Workmanager().executeTask((task, inputData) async {
|
|
try {
|
|
switch (task) {
|
|
case kBackgroundUpdateTaskName:
|
|
final relayUrls =
|
|
(inputData?[kAppCatalogRelaysKey] as List<dynamic>?)
|
|
?.cast<String>()
|
|
.toSet();
|
|
return await _checkForUpdatesInBackground(
|
|
relayUrls,
|
|
autoUpdateWorker: false,
|
|
);
|
|
case kBackgroundAutoUpdateTaskName:
|
|
final relayUrls =
|
|
(inputData?[kAppCatalogRelaysKey] as List<dynamic>?)
|
|
?.cast<String>()
|
|
.toSet();
|
|
return await _checkForUpdatesInBackground(
|
|
relayUrls,
|
|
autoUpdateWorker: true,
|
|
);
|
|
case kBackgroundAutoUpdateImmediateTaskId:
|
|
final relayUrls =
|
|
(inputData?[kAppCatalogRelaysKey] as List<dynamic>?)
|
|
?.cast<String>()
|
|
.toSet();
|
|
return await _checkForUpdatesInBackground(
|
|
relayUrls,
|
|
autoUpdateWorker: true,
|
|
);
|
|
case kWeeklyCleanupTaskName:
|
|
return await _performWeeklyCleanup();
|
|
default:
|
|
return false;
|
|
}
|
|
} catch (e, st) {
|
|
LogService.I.error(
|
|
'background task failed',
|
|
tag: 'workmanager',
|
|
fields: {'task': task},
|
|
err: e,
|
|
stack: st,
|
|
);
|
|
return false;
|
|
} finally {
|
|
// Ensure entries from this task hit disk before the isolate
|
|
// tears down.
|
|
await LogService.I.flush();
|
|
}
|
|
});
|
|
},
|
|
(error, stack) {
|
|
LogService.I.fatal(
|
|
'uncaught error',
|
|
tag: 'crash',
|
|
fields: const {'source': 'zone'},
|
|
err: error,
|
|
stack: stack,
|
|
);
|
|
LogService.I.flushSync();
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Perform weekly cleanup of stale downloads
|
|
Future<bool> _performWeeklyCleanup() async {
|
|
try {
|
|
final downloader = FileDownloader();
|
|
|
|
// Get all tracked records from background_downloader's database
|
|
final trackedRecords = await downloader.database.allRecords(
|
|
group: FileDownloader.defaultGroup,
|
|
);
|
|
|
|
for (final record in trackedRecords) {
|
|
final task = record.task;
|
|
if (task is! DownloadTask) continue;
|
|
|
|
final taskAge = DateTime.now().difference(record.task.creationTime);
|
|
if (taskAge > _staleDownloadThreshold) {
|
|
try {
|
|
// Cancel if active
|
|
await downloader.cancelTaskWithId(task.taskId);
|
|
} catch (_) {}
|
|
|
|
try {
|
|
// Delete the file if it exists
|
|
final filePath = await task.filePath();
|
|
final file = File(filePath);
|
|
if (await file.exists()) {
|
|
await file.delete();
|
|
}
|
|
} catch (_) {}
|
|
|
|
try {
|
|
// Remove from database
|
|
await downloader.database.deleteRecordWithId(task.taskId);
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
|
|
// Also clean up orphaned APK files in download directory
|
|
try {
|
|
final cacheDir = await getApplicationCacheDirectory();
|
|
final downloadDir = Directory(
|
|
path.join(cacheDir.path, 'flutter_background_downloader'),
|
|
);
|
|
|
|
if (await downloadDir.exists()) {
|
|
final entities = downloadDir.listSync();
|
|
final cutoff = DateTime.now().subtract(_staleDownloadThreshold);
|
|
|
|
for (final entity in entities) {
|
|
if (entity is File) {
|
|
final stat = await entity.stat();
|
|
if (stat.modified.isBefore(cutoff)) {
|
|
await entity.delete();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (_) {
|
|
// Ignore cleanup failures for orphaned files
|
|
}
|
|
|
|
return true;
|
|
} catch (e) {
|
|
// Cleanup failed - return false for retry
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Background update check logic - runs in a separate isolate via WorkManager.
|
|
///
|
|
/// [appCatalogRelays] - Relay URLs resolved from main isolate. Falls back to
|
|
/// default relay if not provided.
|
|
Future<bool> _checkForUpdatesInBackground(
|
|
Set<String>? appCatalogRelays, {
|
|
required bool autoUpdateWorker,
|
|
}) async {
|
|
try {
|
|
final relays = appCatalogRelays ?? {'wss://relay.zapstore.dev'};
|
|
final settings = await SettingsService().load();
|
|
|
|
// Notification-only checks keep their existing connected-network schedule.
|
|
// 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;
|
|
}
|
|
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
storageNotifierProvider.overrideWith(PurplebaseStorageNotifier.new),
|
|
packageManagerProvider.overrideWith(
|
|
(ref) => Platform.isAndroid
|
|
? BackgroundPackageManager(ref)
|
|
: DummyPackageManager(ref),
|
|
),
|
|
],
|
|
);
|
|
|
|
try {
|
|
final dir = await getApplicationSupportDirectory();
|
|
final dbPath = path.join(dir.path, 'zapstore.db');
|
|
|
|
await container.read(
|
|
initializationProvider(
|
|
StorageConfiguration(
|
|
databasePath: dbPath,
|
|
defaultRelays: {'AppCatalog': relays},
|
|
),
|
|
).future,
|
|
);
|
|
|
|
final packageManager = container.read(packageManagerProvider.notifier);
|
|
await packageManager.syncInstalledPackages();
|
|
|
|
final pmState = container.read(packageManagerProvider);
|
|
if (pmState.installed.isEmpty) {
|
|
return true;
|
|
}
|
|
|
|
final storage = container.read(storageNotifierProvider.notifier);
|
|
final catalog = await fetchCatalog(
|
|
storage: storage,
|
|
installedIds: pmState.installed.keys.toSet(),
|
|
platform: packageManager.platform,
|
|
subscriptionPrefix: 'app-bg',
|
|
);
|
|
|
|
final updatableInstallables = <String, Installable>{};
|
|
for (final entry in catalog.installableByApp.entries) {
|
|
if (packageManager.hasUpdate(entry.key, entry.value)) {
|
|
updatableInstallables[entry.key] = entry.value;
|
|
}
|
|
}
|
|
|
|
if (updatableInstallables.isNotEmpty) {
|
|
final updatableApps = await storage.query(
|
|
RequestFilter<App>(
|
|
tags: {
|
|
'#d': updatableInstallables.keys.toSet(),
|
|
'#f': {packageManager.platform},
|
|
},
|
|
).toRequest(),
|
|
source: const LocalSource(),
|
|
);
|
|
|
|
if (autoUpdateWorker) {
|
|
final displayNames = {
|
|
for (final app in updatableApps)
|
|
app.identifier: app.name ?? app.identifier,
|
|
};
|
|
final result = await BackgroundAutoUpdateExecutor.run(
|
|
updatableInstallables: updatableInstallables,
|
|
installed: pmState.installed,
|
|
displayNames: displayNames,
|
|
);
|
|
if (result.hasWork) {
|
|
await _showAutoUpdateResultNotification(result, updatableApps);
|
|
}
|
|
} else {
|
|
await _showUpdateNotificationIfNeeded(
|
|
updatableApps,
|
|
updatableInstallables,
|
|
);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
} finally {
|
|
container.dispose();
|
|
}
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
class BackgroundUpdateService {
|
|
BackgroundUpdateService(this.ref);
|
|
|
|
final Ref ref;
|
|
Future<void>? _initializeFuture;
|
|
|
|
/// Initialize WorkManager and register periodic task
|
|
Future<void> initialize() => _initializeFuture ??= _initialize();
|
|
|
|
Future<void> _initialize() async {
|
|
if (!Platform.isAndroid) {
|
|
// WorkManager only works on Android/iOS, skip on other platforms
|
|
return;
|
|
}
|
|
|
|
// Initialize WorkManager
|
|
await Workmanager().initialize(callbackDispatcher);
|
|
|
|
// Initialize local notifications
|
|
await _initializeNotifications();
|
|
|
|
// Resolve AppCatalog relays from main isolate to pass to background task
|
|
final appCatalogRelays = await ref
|
|
.read(storageNotifierProvider.notifier)
|
|
.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(
|
|
appCatalogRelays,
|
|
initialDelay: const Duration(hours: 1),
|
|
);
|
|
|
|
// Register weekly cleanup task
|
|
await Workmanager().registerPeriodicTask(
|
|
kWeeklyCleanupTaskId,
|
|
kWeeklyCleanupTaskName,
|
|
frequency: const Duration(days: 7),
|
|
constraints: Constraints(
|
|
requiresBatteryNotLow: true,
|
|
requiresCharging: true, // Only run when charging for cleanup
|
|
),
|
|
existingWorkPolicy: ExistingPeriodicWorkPolicy.keep,
|
|
backoffPolicy: BackoffPolicy.exponential,
|
|
initialDelay: const Duration(hours: 24), // Start first cleanup after 24h
|
|
);
|
|
}
|
|
|
|
/// Queue the first opted-in auto-update run.
|
|
///
|
|
/// WorkManager starts it immediately when its unmetered-network constraint
|
|
/// is met. If Wi-Fi is unavailable, it remains queued instead of polling or
|
|
/// waking the app on a timer.
|
|
Future<void> scheduleImmediateAutoUpdate() async {
|
|
if (!Platform.isAndroid) return;
|
|
|
|
final settings = await ref.read(settingsServiceProvider).load();
|
|
if (!settings.backgroundAutoUpdatesEnabled) return;
|
|
|
|
await initialize();
|
|
|
|
final appCatalogRelays = await ref
|
|
.read(storageNotifierProvider.notifier)
|
|
.resolveRelays('AppCatalog');
|
|
|
|
await Workmanager().registerOneOffTask(
|
|
kBackgroundAutoUpdateImmediateTaskId,
|
|
kBackgroundAutoUpdateImmediateTaskId,
|
|
constraints: Constraints(
|
|
networkType: NetworkType.unmetered,
|
|
requiresBatteryNotLow: true,
|
|
),
|
|
existingWorkPolicy: ExistingWorkPolicy.replace,
|
|
backoffPolicy: BackoffPolicy.exponential,
|
|
inputData: {kAppCatalogRelaysKey: appCatalogRelays.toList()},
|
|
);
|
|
|
|
// Start the recurring 24-hour cadence after this first run instead of
|
|
// allowing the task registered during app startup to run a duplicate
|
|
// check shortly afterward.
|
|
await Workmanager().cancelByUniqueName(kBackgroundAutoUpdateTaskId);
|
|
await _registerAutoUpdatePeriodicTask(
|
|
appCatalogRelays,
|
|
initialDelay: const Duration(hours: 24),
|
|
);
|
|
}
|
|
|
|
Future<void> _registerAutoUpdatePeriodicTask(
|
|
Set<String> appCatalogRelays, {
|
|
required Duration initialDelay,
|
|
}) {
|
|
return Workmanager().registerPeriodicTask(
|
|
kBackgroundAutoUpdateTaskId,
|
|
kBackgroundAutoUpdateTaskName,
|
|
frequency: const Duration(hours: 24),
|
|
constraints: Constraints(
|
|
networkType: NetworkType.unmetered,
|
|
requiresBatteryNotLow: true,
|
|
),
|
|
existingWorkPolicy: ExistingPeriodicWorkPolicy.keep,
|
|
backoffPolicy: BackoffPolicy.exponential,
|
|
initialDelay: initialDelay,
|
|
inputData: {kAppCatalogRelaysKey: appCatalogRelays.toList()},
|
|
);
|
|
}
|
|
|
|
/// 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
|
|
Future<void> cancelBackgroundChecks() async {
|
|
await Future.wait([
|
|
Workmanager().cancelByUniqueName(kBackgroundUpdateTaskId),
|
|
Workmanager().cancelByUniqueName(kBackgroundAutoUpdateTaskId),
|
|
]);
|
|
}
|
|
}
|
|
|
|
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
|
|
final backgroundUpdateServiceProvider = Provider<BackgroundUpdateService>(
|
|
BackgroundUpdateService.new,
|
|
);
|