From 84c6799dee5cc61aa276c398b9fe5df9d952534a Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Sun, 12 Jul 2026 19:53:04 -0300 Subject: [PATCH] Background + diagnostics fixes --- lib/screens/diagnostics_screen.dart | 250 ++++++++++++------ lib/screens/profile_screen.dart | 55 +++- lib/services/background_update_service.dart | 90 ++++++- spec/work/WORK-013-background-auto-updates.md | 15 ++ 4 files changed, 315 insertions(+), 95 deletions(-) diff --git a/lib/screens/diagnostics_screen.dart b/lib/screens/diagnostics_screen.dart index ab6f126..7f4ad7d 100644 --- a/lib/screens/diagnostics_screen.dart +++ b/lib/screens/diagnostics_screen.dart @@ -23,6 +23,7 @@ class DiagnosticsScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final tickRefresh = useState(0); + final scrollController = useScrollController(); // Re-read disk tail every time the screen is opened so the user // sees recent entries that may have been flushed asynchronously. @@ -37,17 +38,40 @@ class DiagnosticsScreen extends HookConsumerWidget { final settingsAsync = ref.watch(localSettingsProvider); final currentLevel = settingsAsync.valueOrNull?.logLevel ?? LogLevel.info; 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( - 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( children: [ _Toolbar( level: currentLevel, onLevelChanged: (level) async { - await ref.read(settingsServiceProvider).update( - (s) => s.copyWith(logLevel: level), - ); + await ref + .read(settingsServiceProvider) + .update((s) => s.copyWith(logLevel: level)); LogService.I.level = level; ref.invalidate(localSettingsProvider); }, @@ -58,11 +82,10 @@ class DiagnosticsScreen extends HookConsumerWidget { }), entryCount: filtered.length, ), - const Divider(height: 1), Expanded( child: filtered.isEmpty ? const _EmptyState() - : _LogList(entries: filtered), + : _LogList(entries: filtered, controller: scrollController), ), ], ), @@ -99,6 +122,12 @@ class DiagnosticsScreen extends HookConsumerWidget { .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 _exportLogs(BuildContext context) async { final files = LogService.I.currentFiles(); if (files.isEmpty) { @@ -133,12 +162,13 @@ class DiagnosticsScreen extends HookConsumerWidget { // Surface size before sharing so the user can cancel a large transfer. context.showInfo('Exported ${_humanBytes(size)}'); - await SharePlus.instance.share(ShareParams( - files: [XFile(zipPath, mimeType: 'application/zip')], - subject: 'Zapstore diagnostic logs', - text: - 'Zapstore diagnostic logs (local export, no telemetry).', - )); + await SharePlus.instance.share( + ShareParams( + files: [XFile(zipPath, mimeType: 'application/zip')], + subject: 'Zapstore diagnostic logs', + text: 'Zapstore diagnostic logs (local export, no telemetry).', + ), + ); } catch (e, st) { LogService.I.error( 'log export failed', @@ -215,27 +245,23 @@ class _Toolbar extends StatelessWidget { @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; return Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.fromLTRB(16, 10, 8, 10), child: Row( children: [ + Icon(Icons.terminal_rounded, size: 18, color: colors.primary), + const SizedBox(width: 8), DropdownButtonHideUnderline( child: DropdownButton( value: level, isDense: true, + style: theme.textTheme.labelLarge, items: const [ - DropdownMenuItem( - value: LogLevel.debug, - child: Text('Debug'), - ), - DropdownMenuItem( - value: LogLevel.info, - child: Text('Info'), - ), - DropdownMenuItem( - value: LogLevel.warn, - child: Text('Warn'), - ), + DropdownMenuItem(value: LogLevel.debug, child: Text('Debug')), + DropdownMenuItem(value: LogLevel.info, child: Text('Info')), + DropdownMenuItem(value: LogLevel.warn, child: Text('Warn')), ], onChanged: (v) { if (v != null) onLevelChanged(v); @@ -243,10 +269,18 @@ class _Toolbar extends StatelessWidget { ), ), const Spacer(), - Text( - '$entryCount', - style: Theme.of(context).textTheme.labelSmall, + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + '$entryCount entries', + style: theme.textTheme.labelSmall, + ), ), + const SizedBox(width: 4), IconButton( tooltip: 'Refresh', onPressed: onRefresh, @@ -283,19 +317,24 @@ class _Toolbar extends StatelessWidget { } class _LogList extends StatelessWidget { - const _LogList({required this.entries}); + const _LogList({required this.entries, required this.controller}); final List entries; + final ScrollController controller; @override Widget build(BuildContext context) { return ListView.separated( - reverse: true, + controller: controller, + padding: const EdgeInsets.symmetric(vertical: 8), itemCount: entries.length, - separatorBuilder: (_, __) => const Divider(height: 1), + separatorBuilder: (_, __) => const SizedBox(height: 4), itemBuilder: (context, 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 Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; final time = '${entry.ts.toLocal().hour.toString().padLeft(2, '0')}:' '${entry.ts.toLocal().minute.toString().padLeft(2, '0')}:' '${entry.ts.toLocal().second.toString().padLeft(2, '0')}'; final fields = entry.fields; - return ListTile( - dense: true, - onTap: () => _copyToClipboard(context), - title: Row( - children: [ - Text( - entry.level.short, - style: TextStyle( - fontWeight: FontWeight.bold, - color: _levelColor(context), - fontFamily: 'monospace', - ), + final levelColor = _levelColor(context); + return Material( + color: colors.surfaceContainerLow, + borderRadius: BorderRadius.circular(10), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => _copyToClipboard(context), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: levelColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Text( + entry.level.short.toUpperCase(), + style: theme.textTheme.labelSmall?.copyWith( + color: levelColor, + fontFamily: 'monospace', + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + ), + ), + const SizedBox(width: 10), + Text( + time, + style: theme.textTheme.labelSmall?.copyWith( + color: colors.onSurfaceVariant, + fontFamily: 'monospace', + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + entry.tag, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelSmall?.copyWith( + color: colors.secondary, + fontFamily: 'monospace', + ), + ), + ), + Icon( + Icons.copy_rounded, + size: 14, + color: colors.onSurfaceVariant, + ), + ], + ), + const SizedBox(height: 8), + Text(entry.msg, style: theme.textTheme.bodyMedium), + if (fields != null && fields.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + fields.toString(), + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + fontFamily: 'monospace', + ), + ), + ], + if (entry.err != null) ...[ + const SizedBox(height: 6), + Text( + entry.err!, + style: theme.textTheme.bodySmall?.copyWith( + color: colors.error, + fontFamily: 'monospace', + ), + ), + ], + ], ), - const SizedBox(width: 8), - Text(time, style: const TextStyle(fontFamily: 'monospace')), - const SizedBox(width: 8), - Flexible( - child: Text( - entry.tag, - overflow: TextOverflow.ellipsis, - style: - TextStyle(color: Theme.of(context).colorScheme.secondary), - ), - ), - ], - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(entry.msg), - if (fields != null && fields.isNotEmpty) - Text( - fields.toString(), - style: const TextStyle(fontFamily: 'monospace', fontSize: 11), - ), - if (entry.err != null) - Text( - entry.err!, - style: - TextStyle(color: Theme.of(context).colorScheme.error), - ), - ], + ), ), ); } @@ -392,11 +474,25 @@ class _EmptyState extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.notes, - size: 48, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4)), + Icon( + Icons.terminal_rounded, + size: 40, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.4), + ), 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, + ), + ), ], ), ); diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index 0e438ac..0f76276 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -11,10 +11,12 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.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:purplebase/purplebase.dart'; import 'package:zapstore/main.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/settings_service.dart'; import 'package:zapstore/utils/extensions.dart'; @@ -1536,17 +1538,62 @@ class _BackgroundAutoUpdatesToggle extends ConsumerWidget { ), ), 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, contentPadding: EdgeInsets.zero, onChanged: (value) async { + if (value && !enabled) { + final confirmed = await showDialog( + 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 .read(settingsServiceProvider) .update((s) => s.copyWith(backgroundAutoUpdatesEnabled: value)); 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.', + ); + } + } + }()); + } }, ); } diff --git a/lib/services/background_update_service.dart b/lib/services/background_update_service.dart index 34956eb..797d9cb 100644 --- a/lib/services/background_update_service.dart +++ b/lib/services/background_update_service.dart @@ -43,6 +43,9 @@ 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'; @@ -155,6 +158,15 @@ void callbackDispatcher() { relayUrls, autoUpdateWorker: true, ); + case kBackgroundAutoUpdateImmediateTaskId: + final relayUrls = + (inputData?[kAppCatalogRelaysKey] as List?) + ?.cast() + .toSet(); + return await _checkForUpdatesInBackground( + relayUrls, + autoUpdateWorker: true, + ); case kWeeklyCleanupTaskName: return await _performWeeklyCleanup(); default: @@ -547,9 +559,12 @@ class BackgroundUpdateService { BackgroundUpdateService(this.ref); final Ref ref; + Future? _initializeFuture; /// Initialize WorkManager and register periodic task - Future initialize() async { + Future initialize() => _initializeFuture ??= _initialize(); + + Future _initialize() async { if (!Platform.isAndroid) { // WorkManager only works on Android/iOS, skip on other platforms return; @@ -584,20 +599,9 @@ class BackgroundUpdateService { inputData: {kAppCatalogRelaysKey: appCatalogRelays.toList()}, ); - // Auto-update downloads and installs must only run on an unmetered network. - // This worker exits immediately while the setting is disabled. - await Workmanager().registerPeriodicTask( - kBackgroundAutoUpdateTaskId, - kBackgroundAutoUpdateTaskName, - frequency: const Duration(hours: 24), - constraints: Constraints( - networkType: NetworkType.unmetered, - requiresBatteryNotLow: true, - ), - existingWorkPolicy: ExistingPeriodicWorkPolicy.keep, - backoffPolicy: BackoffPolicy.exponential, + await _registerAutoUpdatePeriodicTask( + appCatalogRelays, initialDelay: const Duration(hours: 1), - inputData: {kAppCatalogRelaysKey: appCatalogRelays.toList()}, ); // 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 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 _registerAutoUpdatePeriodicTask( + Set 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 _initializeNotifications() async { // Request notification permission on Android 13+ (API 33+); skip in debug / dev runs. diff --git a/spec/work/WORK-013-background-auto-updates.md b/spec/work/WORK-013-background-auto-updates.md index 9c47e12..3af4e16 100644 --- a/spec/work/WORK-013-background-auto-updates.md +++ b/spec/work/WORK-013-background-auto-updates.md @@ -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] Reuse already-staged manual updates instead of downloading them again - [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 - [ ] Manual UAT on device @@ -36,3 +38,16 @@ apply updates, with a result notification instead of "updates available". declared; otherwise staging fails closed. - Use a generated plugin registrant bridge so activity and headless Flutter 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 | [ ] |