Background + diagnostics fixes

This commit is contained in:
franzap
2026-07-12 19:53:04 -03:00
parent c8606c8fe3
commit 84c6799dee
4 changed files with 315 additions and 95 deletions
+154 -58
View File
@@ -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,
),
),
], ],
), ),
); );
+51 -4
View File
@@ -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.',
);
}
}
}());
}
}, },
); );
} }
+76 -14
View File
@@ -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 | [ ] |