mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Background + diagnostics fixes
This commit is contained in:
@@ -23,6 +23,7 @@ class DiagnosticsScreen extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final tickRefresh = useState(0);
|
final tickRefresh = useState(0);
|
||||||
|
final scrollController = useScrollController();
|
||||||
|
|
||||||
// Re-read disk tail every time the screen is opened so the user
|
// Re-read disk tail every time the screen is opened so the user
|
||||||
// sees recent entries that may have been flushed asynchronously.
|
// sees recent entries that may have been flushed asynchronously.
|
||||||
@@ -37,17 +38,40 @@ class DiagnosticsScreen extends HookConsumerWidget {
|
|||||||
final settingsAsync = ref.watch(localSettingsProvider);
|
final settingsAsync = ref.watch(localSettingsProvider);
|
||||||
final currentLevel = settingsAsync.valueOrNull?.logLevel ?? LogLevel.info;
|
final currentLevel = settingsAsync.valueOrNull?.logLevel ?? LogLevel.info;
|
||||||
final filtered = _filterEntries(entries, level: currentLevel);
|
final filtered = _filterEntries(entries, level: currentLevel);
|
||||||
|
final newestEntryKey = filtered.isEmpty ? null : _entryKey(filtered.last);
|
||||||
|
|
||||||
|
// The newest log entry is rendered at offset zero. Keep that position
|
||||||
|
// sticky when a refresh adds entries, but never pull someone reading
|
||||||
|
// older entries back to the top.
|
||||||
|
useEffect(() {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (scrollController.hasClients &&
|
||||||
|
scrollController.offset <= _kStickyTopThreshold) {
|
||||||
|
scrollController.jumpTo(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}, [newestEntryKey, scrollController]);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Diagnostics')),
|
appBar: AppBar(
|
||||||
|
title: const Text('Diagnostics'),
|
||||||
|
bottom: PreferredSize(
|
||||||
|
preferredSize: const Size.fromHeight(1),
|
||||||
|
child: Divider(
|
||||||
|
height: 1,
|
||||||
|
color: Theme.of(context).colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
_Toolbar(
|
_Toolbar(
|
||||||
level: currentLevel,
|
level: currentLevel,
|
||||||
onLevelChanged: (level) async {
|
onLevelChanged: (level) async {
|
||||||
await ref.read(settingsServiceProvider).update(
|
await ref
|
||||||
(s) => s.copyWith(logLevel: level),
|
.read(settingsServiceProvider)
|
||||||
);
|
.update((s) => s.copyWith(logLevel: level));
|
||||||
LogService.I.level = level;
|
LogService.I.level = level;
|
||||||
ref.invalidate(localSettingsProvider);
|
ref.invalidate(localSettingsProvider);
|
||||||
},
|
},
|
||||||
@@ -58,11 +82,10 @@ class DiagnosticsScreen extends HookConsumerWidget {
|
|||||||
}),
|
}),
|
||||||
entryCount: filtered.length,
|
entryCount: filtered.length,
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: filtered.isEmpty
|
child: filtered.isEmpty
|
||||||
? const _EmptyState()
|
? const _EmptyState()
|
||||||
: _LogList(entries: filtered),
|
: _LogList(entries: filtered, controller: scrollController),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -99,6 +122,12 @@ class DiagnosticsScreen extends HookConsumerWidget {
|
|||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static const _kStickyTopThreshold = 24.0;
|
||||||
|
|
||||||
|
static String _entryKey(LogEntry entry) =>
|
||||||
|
'${entry.ts.microsecondsSinceEpoch}|${entry.isolate}|${entry.level}|'
|
||||||
|
'${entry.tag}|${entry.msg}';
|
||||||
|
|
||||||
Future<void> _exportLogs(BuildContext context) async {
|
Future<void> _exportLogs(BuildContext context) async {
|
||||||
final files = LogService.I.currentFiles();
|
final files = LogService.I.currentFiles();
|
||||||
if (files.isEmpty) {
|
if (files.isEmpty) {
|
||||||
@@ -133,12 +162,13 @@ class DiagnosticsScreen extends HookConsumerWidget {
|
|||||||
// Surface size before sharing so the user can cancel a large transfer.
|
// Surface size before sharing so the user can cancel a large transfer.
|
||||||
context.showInfo('Exported ${_humanBytes(size)}');
|
context.showInfo('Exported ${_humanBytes(size)}');
|
||||||
|
|
||||||
await SharePlus.instance.share(ShareParams(
|
await SharePlus.instance.share(
|
||||||
|
ShareParams(
|
||||||
files: [XFile(zipPath, mimeType: 'application/zip')],
|
files: [XFile(zipPath, mimeType: 'application/zip')],
|
||||||
subject: 'Zapstore diagnostic logs',
|
subject: 'Zapstore diagnostic logs',
|
||||||
text:
|
text: 'Zapstore diagnostic logs (local export, no telemetry).',
|
||||||
'Zapstore diagnostic logs (local export, no telemetry).',
|
),
|
||||||
));
|
);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
LogService.I.error(
|
LogService.I.error(
|
||||||
'log export failed',
|
'log export failed',
|
||||||
@@ -215,27 +245,23 @@ class _Toolbar extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colors = theme.colorScheme;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
padding: const EdgeInsets.fromLTRB(16, 10, 8, 10),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
Icon(Icons.terminal_rounded, size: 18, color: colors.primary),
|
||||||
|
const SizedBox(width: 8),
|
||||||
DropdownButtonHideUnderline(
|
DropdownButtonHideUnderline(
|
||||||
child: DropdownButton<LogLevel>(
|
child: DropdownButton<LogLevel>(
|
||||||
value: level,
|
value: level,
|
||||||
isDense: true,
|
isDense: true,
|
||||||
|
style: theme.textTheme.labelLarge,
|
||||||
items: const [
|
items: const [
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(value: LogLevel.debug, child: Text('Debug')),
|
||||||
value: LogLevel.debug,
|
DropdownMenuItem(value: LogLevel.info, child: Text('Info')),
|
||||||
child: Text('Debug'),
|
DropdownMenuItem(value: LogLevel.warn, child: Text('Warn')),
|
||||||
),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: LogLevel.info,
|
|
||||||
child: Text('Info'),
|
|
||||||
),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: LogLevel.warn,
|
|
||||||
child: Text('Warn'),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
onChanged: (v) {
|
onChanged: (v) {
|
||||||
if (v != null) onLevelChanged(v);
|
if (v != null) onLevelChanged(v);
|
||||||
@@ -243,10 +269,18 @@ class _Toolbar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Text(
|
Container(
|
||||||
'$entryCount',
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
style: Theme.of(context).textTheme.labelSmall,
|
decoration: BoxDecoration(
|
||||||
|
color: colors.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
|
child: Text(
|
||||||
|
'$entryCount entries',
|
||||||
|
style: theme.textTheme.labelSmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Refresh',
|
tooltip: 'Refresh',
|
||||||
onPressed: onRefresh,
|
onPressed: onRefresh,
|
||||||
@@ -283,19 +317,24 @@ class _Toolbar extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _LogList extends StatelessWidget {
|
class _LogList extends StatelessWidget {
|
||||||
const _LogList({required this.entries});
|
const _LogList({required this.entries, required this.controller});
|
||||||
|
|
||||||
final List<LogEntry> entries;
|
final List<LogEntry> entries;
|
||||||
|
final ScrollController controller;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListView.separated(
|
return ListView.separated(
|
||||||
reverse: true,
|
controller: controller,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
itemCount: entries.length,
|
itemCount: entries.length,
|
||||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
separatorBuilder: (_, __) => const SizedBox(height: 4),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final e = entries[entries.length - 1 - index];
|
final e = entries[entries.length - 1 - index];
|
||||||
return _LogTile(entry: e);
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: _LogTile(entry: e),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -324,53 +363,96 @@ class _LogTile extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colors = theme.colorScheme;
|
||||||
final time =
|
final time =
|
||||||
'${entry.ts.toLocal().hour.toString().padLeft(2, '0')}:'
|
'${entry.ts.toLocal().hour.toString().padLeft(2, '0')}:'
|
||||||
'${entry.ts.toLocal().minute.toString().padLeft(2, '0')}:'
|
'${entry.ts.toLocal().minute.toString().padLeft(2, '0')}:'
|
||||||
'${entry.ts.toLocal().second.toString().padLeft(2, '0')}';
|
'${entry.ts.toLocal().second.toString().padLeft(2, '0')}';
|
||||||
final fields = entry.fields;
|
final fields = entry.fields;
|
||||||
return ListTile(
|
final levelColor = _levelColor(context);
|
||||||
dense: true,
|
return Material(
|
||||||
|
color: colors.surfaceContainerLow,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: InkWell(
|
||||||
onTap: () => _copyToClipboard(context),
|
onTap: () => _copyToClipboard(context),
|
||||||
title: Row(
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: levelColor,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
entry.level.short,
|
entry.level.short.toUpperCase(),
|
||||||
style: TextStyle(
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
color: levelColor,
|
||||||
color: _levelColor(context),
|
fontFamily: 'monospace',
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
time,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 10),
|
||||||
Text(time, style: const TextStyle(fontFamily: 'monospace')),
|
Expanded(
|
||||||
const SizedBox(width: 8),
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
child: Text(
|
||||||
entry.tag,
|
entry.tag,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style:
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
TextStyle(color: Theme.of(context).colorScheme.secondary),
|
color: colors.secondary,
|
||||||
|
fontFamily: 'monospace',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
Icons.copy_rounded,
|
||||||
|
size: 14,
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
subtitle: Column(
|
const SizedBox(height: 8),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Text(entry.msg, style: theme.textTheme.bodyMedium),
|
||||||
children: [
|
if (fields != null && fields.isNotEmpty) ...[
|
||||||
Text(entry.msg),
|
const SizedBox(height: 6),
|
||||||
if (fields != null && fields.isNotEmpty)
|
|
||||||
Text(
|
Text(
|
||||||
fields.toString(),
|
fields.toString(),
|
||||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colors.onSurfaceVariant,
|
||||||
|
fontFamily: 'monospace',
|
||||||
),
|
),
|
||||||
if (entry.err != null)
|
|
||||||
Text(
|
|
||||||
entry.err!,
|
|
||||||
style:
|
|
||||||
TextStyle(color: Theme.of(context).colorScheme.error),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
if (entry.err != null) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
entry.err!,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colors.error,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -392,11 +474,25 @@ class _EmptyState extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.notes,
|
Icon(
|
||||||
size: 48,
|
Icons.terminal_rounded,
|
||||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4)),
|
size: 40,
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurface.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
const Text('No logs match the current filter'),
|
Text(
|
||||||
|
'No matching log entries',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Try a lower log level or refresh the log tail.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ 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:zapstore/services/app_restart_service.dart';
|
import 'package:zapstore/services/app_restart_service.dart';
|
||||||
|
import 'package:zapstore/services/background_update_service.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:purplebase/purplebase.dart';
|
import 'package:purplebase/purplebase.dart';
|
||||||
import 'package:zapstore/main.dart';
|
import 'package:zapstore/main.dart';
|
||||||
import 'package:zapstore/services/device_key_service.dart';
|
import 'package:zapstore/services/device_key_service.dart';
|
||||||
|
import 'package:zapstore/services/log_service.dart' as app_logs;
|
||||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||||
import 'package:zapstore/services/settings_service.dart';
|
import 'package:zapstore/services/settings_service.dart';
|
||||||
import 'package:zapstore/utils/extensions.dart';
|
import 'package:zapstore/utils/extensions.dart';
|
||||||
@@ -1536,17 +1538,62 @@ class _BackgroundAutoUpdatesToggle extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: const Text('Background auto-updates'),
|
title: const Text('Background auto-updates'),
|
||||||
subtitle: const Text(
|
|
||||||
'When on, periodic background checks download and apply updates. '
|
|
||||||
'Manual updates are downloaded and shown as ready to install.',
|
|
||||||
),
|
|
||||||
value: enabled,
|
value: enabled,
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
onChanged: (value) async {
|
onChanged: (value) async {
|
||||||
|
if (value && !enabled) {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text('Turn on background auto-updates?'),
|
||||||
|
content: const Text(
|
||||||
|
'Zapstore will check for updates and apply them in the '
|
||||||
|
'background. The first check will start immediately when '
|
||||||
|
'Wi-Fi is available. After that, checks run approximately '
|
||||||
|
'every 24 hours. You can turn this off at any time.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext, true),
|
||||||
|
child: const Text('Turn on'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true || !context.mounted) return;
|
||||||
|
}
|
||||||
|
|
||||||
await ref
|
await ref
|
||||||
.read(settingsServiceProvider)
|
.read(settingsServiceProvider)
|
||||||
.update((s) => s.copyWith(backgroundAutoUpdatesEnabled: value));
|
.update((s) => s.copyWith(backgroundAutoUpdatesEnabled: value));
|
||||||
ref.invalidate(localSettingsProvider);
|
ref.invalidate(localSettingsProvider);
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
unawaited(() async {
|
||||||
|
try {
|
||||||
|
await ref
|
||||||
|
.read(backgroundUpdateServiceProvider)
|
||||||
|
.scheduleImmediateAutoUpdate();
|
||||||
|
} catch (error, stack) {
|
||||||
|
app_logs.LogService.I.warn(
|
||||||
|
'initial background auto-update scheduling failed',
|
||||||
|
tag: 'background_updates',
|
||||||
|
err: error,
|
||||||
|
stack: stack,
|
||||||
|
);
|
||||||
|
if (context.mounted) {
|
||||||
|
context.showError(
|
||||||
|
'Background auto-updates are enabled, but the first check '
|
||||||
|
'could not be scheduled.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}());
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ const kBackgroundUpdateTaskId = 'backgroundUpdateCheck';
|
|||||||
/// Unique task identifier for unmetered background auto-updates
|
/// Unique task identifier for unmetered background auto-updates
|
||||||
const kBackgroundAutoUpdateTaskId = 'backgroundAutoUpdate';
|
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
|
/// Unique task identifier for weekly cleanup
|
||||||
const kWeeklyCleanupTaskId = 'weeklyCleanup';
|
const kWeeklyCleanupTaskId = 'weeklyCleanup';
|
||||||
|
|
||||||
@@ -155,6 +158,15 @@ void callbackDispatcher() {
|
|||||||
relayUrls,
|
relayUrls,
|
||||||
autoUpdateWorker: true,
|
autoUpdateWorker: true,
|
||||||
);
|
);
|
||||||
|
case kBackgroundAutoUpdateImmediateTaskId:
|
||||||
|
final relayUrls =
|
||||||
|
(inputData?[kAppCatalogRelaysKey] as List<dynamic>?)
|
||||||
|
?.cast<String>()
|
||||||
|
.toSet();
|
||||||
|
return await _checkForUpdatesInBackground(
|
||||||
|
relayUrls,
|
||||||
|
autoUpdateWorker: true,
|
||||||
|
);
|
||||||
case kWeeklyCleanupTaskName:
|
case kWeeklyCleanupTaskName:
|
||||||
return await _performWeeklyCleanup();
|
return await _performWeeklyCleanup();
|
||||||
default:
|
default:
|
||||||
@@ -547,9 +559,12 @@ class BackgroundUpdateService {
|
|||||||
BackgroundUpdateService(this.ref);
|
BackgroundUpdateService(this.ref);
|
||||||
|
|
||||||
final Ref ref;
|
final Ref ref;
|
||||||
|
Future<void>? _initializeFuture;
|
||||||
|
|
||||||
/// Initialize WorkManager and register periodic task
|
/// Initialize WorkManager and register periodic task
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() => _initializeFuture ??= _initialize();
|
||||||
|
|
||||||
|
Future<void> _initialize() async {
|
||||||
if (!Platform.isAndroid) {
|
if (!Platform.isAndroid) {
|
||||||
// WorkManager only works on Android/iOS, skip on other platforms
|
// WorkManager only works on Android/iOS, skip on other platforms
|
||||||
return;
|
return;
|
||||||
@@ -584,20 +599,9 @@ class BackgroundUpdateService {
|
|||||||
inputData: {kAppCatalogRelaysKey: appCatalogRelays.toList()},
|
inputData: {kAppCatalogRelaysKey: appCatalogRelays.toList()},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Auto-update downloads and installs must only run on an unmetered network.
|
await _registerAutoUpdatePeriodicTask(
|
||||||
// This worker exits immediately while the setting is disabled.
|
appCatalogRelays,
|
||||||
await Workmanager().registerPeriodicTask(
|
|
||||||
kBackgroundAutoUpdateTaskId,
|
|
||||||
kBackgroundAutoUpdateTaskName,
|
|
||||||
frequency: const Duration(hours: 24),
|
|
||||||
constraints: Constraints(
|
|
||||||
networkType: NetworkType.unmetered,
|
|
||||||
requiresBatteryNotLow: true,
|
|
||||||
),
|
|
||||||
existingWorkPolicy: ExistingPeriodicWorkPolicy.keep,
|
|
||||||
backoffPolicy: BackoffPolicy.exponential,
|
|
||||||
initialDelay: const Duration(hours: 1),
|
initialDelay: const Duration(hours: 1),
|
||||||
inputData: {kAppCatalogRelaysKey: appCatalogRelays.toList()},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Register weekly cleanup task
|
// Register weekly cleanup task
|
||||||
@@ -615,6 +619,64 @@ class BackgroundUpdateService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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).
|
/// Initialize local notifications plugin and request permission (release/profile only).
|
||||||
Future<void> _initializeNotifications() async {
|
Future<void> _initializeNotifications() async {
|
||||||
// Request notification permission on Android 13+ (API 33+); skip in debug / dev runs.
|
// Request notification permission on Android 13+ (API 33+); skip in debug / dev runs.
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ apply updates, with a result notification instead of "updates available".
|
|||||||
- [x] Refresh installed package versions natively before each background run
|
- [x] Refresh installed package versions natively before each background run
|
||||||
- [x] Reuse already-staged manual updates instead of downloading them again
|
- [x] Reuse already-staged manual updates instead of downloading them again
|
||||||
- [x] Register the native package manager in headless WorkManager engines
|
- [x] Register the native package manager in headless WorkManager engines
|
||||||
|
- [x] Confirm opt-in before enabling and explain first-run timing and cadence
|
||||||
|
- [x] Queue the first auto-update run immediately with an unmetered-network constraint
|
||||||
- [x] Emulator UAT: schedule constraints, staging, notification, and install prompt
|
- [x] Emulator UAT: schedule constraints, staging, notification, and install prompt
|
||||||
- [ ] Manual UAT on device
|
- [ ] Manual UAT on device
|
||||||
|
|
||||||
@@ -36,3 +38,16 @@ apply updates, with a result notification instead of "updates available".
|
|||||||
declared; otherwise staging fails closed.
|
declared; otherwise staging fails closed.
|
||||||
- Use a generated plugin registrant bridge so activity and headless Flutter
|
- Use a generated plugin registrant bridge so activity and headless Flutter
|
||||||
engines share the same app-owned Android package manager implementation.
|
engines share the same app-owned Android package manager implementation.
|
||||||
|
- The first opt-in run is a one-off WorkManager task constrained to an
|
||||||
|
unmetered network. It runs as soon as Wi-Fi is available and does not poll
|
||||||
|
while offline; the existing periodic auto-update task remains approximately
|
||||||
|
every 24 hours.
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
|
||||||
|
| Scenario | Expected | Status |
|
||||||
|
|----------|----------|--------|
|
||||||
|
| Enable and confirm | Setting is saved and first unmetered run is queued | [ ] |
|
||||||
|
| Enable and cancel | Dialog closes and setting remains off | [ ] |
|
||||||
|
| Enable without Wi-Fi | First run stays deferred until the network constraint is met | [ ] |
|
||||||
|
| Disable | No new auto-update work is scheduled | [ ] |
|
||||||
|
|||||||
Reference in New Issue
Block a user