chore(logging): sanitize diagnostics and reduce noise

This commit is contained in:
zarzet
2026-08-31 01:49:49 +07:00
parent 8f17cd1682
commit a1ee346f2f
46 changed files with 173 additions and 301 deletions
+2 -6
View File
@@ -1132,12 +1132,8 @@ final downloadHistoryExistsProvider = FutureProvider.autoDispose
);
});
// Deliberately no per-row verifyOrRepairHistoryItem here (issue #495): on a
// >500-track playlist that verify pass meant one SAF stat round-trip per
// already-downloaded track, and any loadedIndexVersion bump mid-pass restarted
// it from zero — above ~500 tracks the future never settled and "Download all"
// silently did nothing. Stale rows are reconciled by the startup repair and
// orphan-cleanup passes; the single-track provider above keeps the verify.
// Batch lookups deliberately avoid per-row SAF verification. Startup repair
// reconciles stale rows; the single-track provider above keeps strict checks.
final downloadHistoryBatchExistsProvider = FutureProvider.autoDispose
.family<Set<String>, HistoryBatchLookupRequest>((ref, request) async {
ref.watch(
+2 -12
View File
@@ -328,7 +328,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
'download_queue_native_worker_run_id';
static const _userPausedQueuePrefsKey = 'download_queue_user_paused_v1';
static const _bytesUiStep = 104857; // ~0.1 MiB, matches one-decimal MB UI.
static const _progressLogStepPercent = 5;
static const _progressLogStepPercent = 10;
static const _serviceProgressStepPercent = 2;
static const _decryptStageSafAccess = 'safAccess';
static const _decryptStageDecrypt = 'decrypt';
@@ -735,10 +735,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
..clear()
..addAll(currentItemsById);
_nonCanonicalPersistedQueueIds.clear();
_log.d(
'Persisted ${upserts.length} changed and removed '
'${deletedIds.length} queue items',
);
} catch (e) {
_log.e('Failed to save queue to storage: $e');
}
@@ -1525,7 +1521,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
await failedDir.create(recursive: true);
}
// Use date-only format for daily grouping (YYYY-MM-DD)
final now = DateTime.now();
final dateStr =
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
@@ -1816,11 +1811,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
state = state.copyWith(outputDir: musicDir.path);
}
if (!isSafMode) {
_log.d('Output directory: ${state.outputDir}');
} else {
_log.d('Output directory: SAF (tree_uri=${settings.downloadTreeUri})');
}
_log.d('Download storage mode: ${isSafMode ? 'SAF' : 'filesystem'}');
if (!isSafMode &&
Platform.isIOS &&
@@ -1957,7 +1948,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.d('Queue is paused and no active download remains');
break;
}
_log.d('Queue is paused, waiting for active download...');
await Future.any([
Future.wait(activeDownloads.values),
Future<void>.delayed(_queueSchedulingInterval),
@@ -1057,7 +1057,6 @@ extension _DownloadQueueEmbedding on DownloadQueueNotifier {
_log.w('Failed to download cover: ${result['error']}');
return null;
}
_log.d('Cover downloaded for embedding: $coverPath');
return coverPath;
} catch (e) {
_log.e('Failed to download cover for embedding: $e');
@@ -76,11 +76,8 @@ extension _SingleItemDownload on DownloadQueueNotifier {
/// One download attempt for a single queue item.
///
/// What used to be the ~35 locals of a 1,700-line method live here as fields
/// so the pipeline reads as stage methods: enrich -> resolve output ->
/// resolve identifiers -> download (with SAF fallback) -> decrypt / convert /
/// embed -> publish to history. `n` is the owning notifier; queue state and
/// shared helpers stay there.
/// The stage order is enrich -> resolve output -> resolve identifiers ->
/// download -> decrypt/convert/embed -> publish. `n` owns shared queue state.
class _DownloadRun {
_DownloadRun(this.n, this.item);
@@ -157,7 +154,6 @@ class _DownloadRun {
}
_log.d('Processing: ${item.track.name} by ${item.track.artistName}');
_log.d('Cover URL: ${item.track.coverUrl}');
final currentItem = n._findItemById(item.id) ?? item;
if (n._isLocallyCancelled(item.id, item: currentItem)) {
@@ -210,7 +206,12 @@ class _DownloadRun {
if (!await _downloadAndMaybeFallback()) return;
_log.d('Result: $result');
_log.d(
'Native download result: success=${result['success'] == true}, '
'service=${result['service'] ?? item.service}, '
'errorType=${result['error_type'] ?? 'none'}, '
'filePresent=${(result['file_path'] as String?)?.isNotEmpty == true}',
);
final extendedMetadata = await extendedMetadataFuture;
if (extendedMetadata != null) {
@@ -663,11 +664,7 @@ class _DownloadRun {
}
wasExisting = result['already_exists'] == true;
if (wasExisting) {
_log.i('File already exists in library: $filePath');
}
_log.i('Download success, file: $filePath');
_log.i('Download completed (existing=$wasExisting)');
final actualBitDepth = result['actual_bit_depth'] as int?;
final actualSampleRate = result['actual_sample_rate'] as int?;
@@ -716,8 +713,6 @@ class _DownloadRun {
result,
resolvedAlbumArtist,
);
_log.d('Track coverUrl after download result: ${trackToDownload.coverUrl}');
if (!await _decryptIfNeeded()) {
return false;
}
@@ -1805,8 +1800,6 @@ class _DownloadRun {
}
}
_log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}');
final isLossyOutput =
isLossyAudioFormat(finalFormat) ||
lowerFilePath.endsWith('.mp3') ||
+4 -24
View File
@@ -357,18 +357,14 @@ class ExploreNotifier extends Notifier<ExploreState> {
});
await prefs.setString(_cacheKey, encoded);
await prefs.setInt(_cacheTsKey, DateTime.now().millisecondsSinceEpoch);
_log.d('Saved ${normalizedSections.length} explore sections to cache');
} catch (e) {
_log.w('Failed to save explore cache: $e');
}
}
Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
if (ref.read(settingsProvider).homeFeedProvider ==
AppSettings.homeFeedProviderOff) {
_log.d('Home feed disabled by user setting');
_homeFeedRequestId++;
PlatformBridge.cancelExtensionHomeFeedRequests();
state = const ExploreState();
@@ -393,13 +389,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
state = state.copyWith(isLoading: showLoading, error: null);
try {
final extState = ref.read(extensionProvider);
final settings = ref.read(settingsProvider);
final preferredId = settings.homeFeedProvider;
_log.d(
'Extensions count: ${extState.extensions.length}, preferred home feed: $preferredId',
);
final targetExt = _resolveHomeFeedExtension();
if (targetExt == null) {
@@ -412,7 +401,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
return;
}
_log.i('Fetching home feed from ${targetExt.id}...');
final result = await PlatformBridge.getExtensionHomeFeed(
targetExt.id,
cancelPrevious: forceRefresh,
@@ -428,14 +416,12 @@ class ExploreNotifier extends Notifier<ExploreState> {
}
final success = result['success'] as bool? ?? false;
_log.d('getExtensionHomeFeed success=$success');
if (!success) {
final error = result['error'] as String? ?? 'Unknown error';
state = state.copyWith(isLoading: false, error: error);
return;
}
final greeting = result['greeting'] as String?;
final sectionsData = result['sections'] as List<dynamic>? ?? [];
final normalizedSectionsWithoutProvider = await compute(
_normalizeExploreSectionsPayload,
@@ -450,17 +436,11 @@ class ExploreNotifier extends Notifier<ExploreState> {
normalizedSections,
);
_log.i('Fetched ${sections.length} sections');
if (sections.isNotEmpty && sections.first.items.isNotEmpty) {
final firstItem = sections.first.items.first;
_log.d(
'First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}',
);
}
final localGreeting = _getLocalGreeting();
_log.d('Greeting from extension: $greeting, using local: $localGreeting');
_log.i(
'Home feed updated: provider=${targetExt.id}, '
'sections=${sections.length}',
);
state = ExploreState(
isLoading: false,
@@ -110,7 +110,6 @@ class PreviewPlayerController extends Notifier<PreviewPlayerState> {
);
_subscriptions.add(
player.onPlayerComplete.listen((_) {
_log.d('Preview playback completed');
state = const PreviewPlayerState();
}),
);
@@ -178,7 +177,6 @@ class PreviewPlayerController extends Notifier<PreviewPlayerState> {
);
try {
_log.i('Starting preview playback');
await _playOnPlayer(_ensurePlayer(), trimmed);
} catch (e) {
_log.w('Preview playback failed, recreating player and retrying: $e');
+1 -2
View File
@@ -11,8 +11,7 @@ final lowEndDeviceProvider = Provider<bool>((ref) => false);
final deviceSupportsBackdropBlurProvider = Provider<bool>((ref) => false);
/// Whether backdrop blur effects should render: the device default, or the
/// user's manual override from appearance settings (issue #488 — let lower
/// tiers opt back in).
/// user's manual override from appearance settings.
final backdropBlurEnabledProvider = Provider<bool>((ref) {
return ref.watch(deviceSupportsBackdropBlurProvider) ||
ref.watch(settingsProvider.select((s) => s.forceBackdropBlur));
+8 -22
View File
@@ -175,7 +175,7 @@ class TrackNotifier extends Notifier<TrackState> {
return;
}
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
_log.i('Found extension URL handler: $extensionHandler');
Map<String, dynamic>? result;
for (int attempt = 1; attempt <= 3; attempt++) {
@@ -368,21 +368,12 @@ class TrackNotifier extends Notifier<TrackState> {
try {
final includeExtensions = settings.useExtensionProviders;
_log.i(
'Search started: provider=metadata_extensions, query="$query", includeExtensions=$includeExtensions, filter=$requestFilter',
);
_log.d('Calling metadata provider track search API...');
final metadataTrackResults =
await PlatformBridge.searchTracksWithMetadataProviders(
query,
limit: 20,
includeExtensions: includeExtensions,
);
_log.i(
'metadata_extensions returned ${metadataTrackResults.length} tracks',
);
if (!_isRequestValid(requestId)) {
_log.w('Search request cancelled (requestId=$requestId)');
return;
@@ -398,7 +389,11 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
_log.i('Search complete: ${tracks.length} tracks parsed successfully');
_log.i(
'Search completed: provider=metadata_extensions, '
'tracks=${tracks.length}, extensions=$includeExtensions, '
'filter=$requestFilter',
);
state = TrackState(
tracks: tracks,
@@ -439,8 +434,6 @@ class TrackNotifier extends Notifier<TrackState> {
);
try {
_log.i('Custom search started: extension=$extensionId, query="$query"');
final results = await PlatformBridge.customSearchWithExtension(
extensionId,
query,
@@ -453,8 +446,6 @@ class TrackNotifier extends Notifier<TrackState> {
return;
}
_log.i('Custom search returned ${results.length} tracks');
final tracks = <Track>[];
for (int i = 0; i < results.length; i++) {
final t = results[i];
@@ -466,13 +457,8 @@ class TrackNotifier extends Notifier<TrackState> {
}
_log.i(
'Custom search complete: ${tracks.length} tracks parsed (source=$extensionId)',
);
final previewCount = tracks.where((t) => t.hasPreview).length;
_log.d(
'Custom search preview availability: $previewCount/${tracks.length} tracks have preview_url'
'${results.isNotEmpty ? '; first raw keys=${(results.first).keys.toList()}' : ''}',
'Custom search completed: extension=$extensionId, '
'tracks=${tracks.length}',
);
state = TrackState(
-27
View File
@@ -258,13 +258,11 @@ class _MainShellState extends ConsumerState<MainShell>
void _setupShareListener() {
final pendingUrl = ShareIntentService().consumePendingUrl();
if (pendingUrl != null) {
_log.d('Processing pending shared URL: $pendingUrl');
_handleSharedUrl(pendingUrl);
}
_shareSubscription = ShareIntentService().sharedUrlStream.listen(
(url) {
_log.d('Received shared URL from stream: $url');
_handleSharedUrl(url);
},
onError: (Object error) {
@@ -540,7 +538,6 @@ class _MainShellState extends ConsumerState<MainShell>
final rootNavigator = Navigator.of(context, rootNavigator: true);
final handledByRootNavigator = await rootNavigator.maybePop();
if (handledByRootNavigator) {
_log.i('Back: step 1 - root navigator handled back');
_lastBackPress = null;
return;
}
@@ -552,7 +549,6 @@ class _MainShellState extends ConsumerState<MainShell>
final handledByCurrentNavigator =
await currentNavigator?.maybePop() ?? false;
if (handledByCurrentNavigator) {
_log.i('Back: step 2 - tab navigator handled back (tab=$_currentIndex)');
_lastBackPress = null;
return;
}
@@ -563,23 +559,10 @@ class _MainShellState extends ConsumerState<MainShell>
final isKeyboardVisible = MediaQuery.viewInsetsOf(context).bottom > 0;
_log.d(
'Back: state check - tab=$_currentIndex, '
'isShowingRecentAccess=${trackState.isShowingRecentAccess}, '
'hasSearchText=${trackState.hasSearchText}, '
'hasContent=${trackState.hasContent}, '
'isLoading=${trackState.isLoading}, '
'isKeyboardVisible=$isKeyboardVisible',
);
if (_currentIndex == 0 &&
trackState.isShowingRecentAccess &&
!trackState.isLoading &&
(trackState.hasSearchText || trackState.hasContent)) {
_log.i(
'Back: step 3a - dismiss recent access + clear search/content '
'(hasSearchText=${trackState.hasSearchText}, hasContent=${trackState.hasContent})',
);
FocusManager.instance.primaryFocus?.unfocus();
ref.read(previewPlayerProvider.notifier).stop();
ref.read(trackProvider.notifier).clear();
@@ -588,7 +571,6 @@ class _MainShellState extends ConsumerState<MainShell>
}
if (_currentIndex == 0 && trackState.isShowingRecentAccess) {
_log.i('Back: step 3b - dismiss recent access only');
ref.read(trackProvider.notifier).setShowingRecentAccess(false);
FocusManager.instance.primaryFocus?.unfocus();
_lastBackPress = null;
@@ -598,10 +580,6 @@ class _MainShellState extends ConsumerState<MainShell>
if (_currentIndex == 0 &&
!trackState.isLoading &&
(trackState.hasSearchText || trackState.hasContent)) {
_log.i(
'Back: step 4 - clear search/content '
'(hasSearchText=${trackState.hasSearchText}, hasContent=${trackState.hasContent})',
);
// Unfocus BEFORE clear so _onTrackStateChanged can properly
// clear _urlController (it checks !_searchFocusNode.hasFocus)
FocusManager.instance.primaryFocus?.unfocus();
@@ -612,31 +590,26 @@ class _MainShellState extends ConsumerState<MainShell>
}
if (_currentIndex == 0 && isKeyboardVisible) {
_log.i('Back: step 5 - dismiss keyboard');
FocusManager.instance.primaryFocus?.unfocus();
_lastBackPress = null;
return;
}
if (_currentIndex != 0) {
_log.i('Back: step 6 - switch to home tab from tab=$_currentIndex');
_onNavTap(0);
_lastBackPress = null;
return;
}
if (trackState.isLoading) {
_log.i('Back: blocked - loading in progress');
return;
}
final now = DateTime.now();
if (_lastBackPress != null &&
now.difference(_lastBackPress!) < const Duration(seconds: 2)) {
_log.i('Back: step 8 - double-tap exit');
unawaited(PlatformBridge.exitApp());
} else {
_log.i('Back: step 7 - first tap, showing exit snackbar');
_lastBackPress = now;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -961,10 +961,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
}
/// Searchable region list.
///
/// The picker previously rendered ~190 ISO codes as a flat list with no filter,
/// so finding a country meant scrolling blind — only a handful of codes have a
/// localized name to recognize them by.
class _RegionPickerSheet extends StatefulWidget {
const _RegionPickerSheet({
required this.regions,
+1 -3
View File
@@ -1335,9 +1335,7 @@ class FFmpegService {
localUrl,
];
_log.d(
'Starting live decrypt tunnel: ${_previewCommandForLog(commandArguments.join(' '))}',
);
_log.d('Starting live decrypt tunnel (format=$ext)');
final session = await FFmpegKit.executeWithArgumentsAsync(commandArguments);
final isReady = await _awaitLiveTunnelReady(session);
+2 -7
View File
@@ -329,7 +329,6 @@ class MusicPlayerHandler extends BaseAudioHandler
(state == PlayerState.stopped ||
state == PlayerState.completed ||
state == PlayerState.disposed)) {
_log.d('Ignoring transient $state event while switching tracks');
return;
}
if (state == PlayerState.completed && _shouldIgnoreComplete) {
@@ -1135,7 +1134,6 @@ class MusicPlayerHandler extends BaseAudioHandler
_broadcastState(playerState: PlayerState.playing);
_lastPeriodicPersistAt = DateTime.now();
unawaited(_persistSession(position: effectiveStartPosition));
_log.i('Playing: ${media.title}');
// Some files do not emit onDurationChanged reliably (stuck at 0:00);
// poll the engine for the real duration as a fallback.
unawaited(_ensureDurationKnown(index, generation));
@@ -1175,7 +1173,7 @@ class MusicPlayerHandler extends BaseAudioHandler
return;
}
} catch (_) {
// ignore and retry
// Duration probing is best-effort; retry until the bounded loop ends.
}
await Future<void>.delayed(const Duration(milliseconds: 300));
}
@@ -1220,7 +1218,6 @@ class MusicPlayerHandler extends BaseAudioHandler
Future<void> _handlePlayerComplete() async {
if (_shouldIgnoreComplete) {
_log.d('Ignoring non-terminal player complete event');
if (_userPaused || _interruptionActive) {
_broadcastState(playerState: PlayerState.paused);
}
@@ -1273,7 +1270,6 @@ class MusicPlayerHandler extends BaseAudioHandler
@override
Future<void> click([MediaButton button = MediaButton.media]) async {
_log.d('Hardware media button: ${button.name}');
switch (button) {
case MediaButton.media:
if (playbackState.value.playing) {
@@ -1293,7 +1289,6 @@ class MusicPlayerHandler extends BaseAudioHandler
@override
Future<void> pause() async {
_log.i('Pausing internal player by user/control request');
_playRequestGeneration++;
_switchingGeneration = 0;
_userPaused = true;
@@ -1450,7 +1445,7 @@ class MusicPlayerHandler extends BaseAudioHandler
kept.add(_media[i]);
}
if (kept.length == _media.length) return; // nothing matched
if (kept.length == _media.length) return;
_media
..clear()
+1 -23
View File
@@ -638,24 +638,11 @@ class PlatformBridge {
useExtensions: useExtensions,
useFallback: useFallback,
);
_log.i(
'downloadByStrategy: "${payload.trackName}" by ${payload.artistName} '
'(service: ${payload.service}, ext: ${routedPayload.useExtensions}, fallback: ${routedPayload.useFallback})',
);
final response = await _invokeDownloadMethod(
'downloadByStrategy',
routedPayload,
);
if (response['success'] == true) {
final service = response['service'] ?? payload.service;
final filePath = response['file_path'] ?? '';
final bitDepth = response['actual_bit_depth'] as num?;
final sampleRate = response['actual_sample_rate'] as num?;
final qualityStr = bitDepth != null && sampleRate != null
? ' ($bitDepth-bit/${(sampleRate / 1000).toStringAsFixed(1)}kHz)'
: '';
_log.i('Download success via $service$qualityStr: $filePath');
} else {
if (response['success'] != true) {
final error = response['error'] ?? 'Unknown error';
final errorType = response['error_type'] ?? '';
_log.e('Download failed: $error (type: $errorType)');
@@ -2057,7 +2044,6 @@ class PlatformBridge {
}
static Future<void> setLibraryCoverCacheDir(String cacheDir) async {
_log.i('setLibraryCoverCacheDir: $cacheDir');
await _channel.invokeMethod('setLibraryCoverCacheDir', {
'cache_dir': cacheDir,
});
@@ -2066,7 +2052,6 @@ class PlatformBridge {
static Future<List<Map<String, dynamic>>> scanLibraryFolder(
String folderPath,
) async {
_log.i('scanLibraryFolder: $folderPath');
final result = await _channel.invokeMethod('scanLibraryFolder', {
'folder_path': folderPath,
});
@@ -2088,9 +2073,6 @@ class PlatformBridge {
String folderPath,
Map<String, int> existingFiles,
) async {
_log.i(
'scanLibraryFolderIncremental: $folderPath (${existingFiles.length} existing files)',
);
final result = await _channel.invokeMethod('scanLibraryFolderIncremental', {
'folder_path': folderPath,
'existing_files': jsonEncode(existingFiles),
@@ -2116,7 +2098,6 @@ class PlatformBridge {
}
static Future<List<Map<String, dynamic>>> scanSafTree(String treeUri) async {
_log.i('scanSafTree: $treeUri');
final result = await _channel.invokeMethod('scanSafTree', {
'tree_uri': treeUri,
});
@@ -2188,9 +2169,6 @@ class PlatformBridge {
String treeUri,
Map<String, int> existingFiles,
) async {
_log.i(
'scanSafTreeIncremental: $treeUri (${existingFiles.length} existing files)',
);
final result = await _channel.invokeMethod('scanSafTreeIncremental', {
'tree_uri': treeUri,
'existing_files': jsonEncode(existingFiles),
+1 -1
View File
@@ -68,7 +68,7 @@ class ShareIntentService {
for (final textToCheck in textsToCheck) {
final url = _extractMusicUrl(textToCheck);
if (url != null) {
_log.i('Received music URL: $url (initial: $isInitial)');
_log.i('Received supported music link (initial=$isInitial)');
if (isInitial) {
_pendingUrl = url;
}
+2 -8
View File
@@ -2,11 +2,7 @@ import 'package:flutter/material.dart';
/// Single source of truth for the app's visual scale.
///
/// Before this existed, radii, cover sizes, badge metrics and motion durations
/// were written literally at every call site (300+ `BorderRadius.circular`
/// calls across 70 files, with five different radii for what is visually the
/// same thumbnail). Anything reused across more than one screen belongs here so
/// a design change is a one-line edit instead of a grep-and-replace campaign.
/// Reusable radii, spacing, artwork metrics and motion durations belong here.
///
/// Read it through [AppTokensContext.tokens] rather than
/// `Theme.of(context).extension<AppTokens>()`, so a widget rendered outside a
@@ -118,9 +114,7 @@ class AppTokens extends ThemeExtension<AppTokens> {
final double headerCollapsedTitleSize;
/// Expanded title size for every collapsing header in the app. Tab roots used
/// to expand to 34 while sub-pages expanded to 28; both now follow this one
/// value, which matches the Material 3 large top app bar headline.
/// Material 3 large top app bar headline size used by collapsing headers.
final double headerExpandedTitleSize;
final Duration motionFast;
+2 -6
View File
@@ -5,12 +5,8 @@ import 'package:spotiflac_android/widgets/cached_cover_image.dart';
/// Colour scheme derived from cover art, used to theme detail-screen headers.
///
/// The headers used to hardcode `Colors.white` text over a `Colors.black`
/// scrim, so they looked identical in light and dark mode and ignored dynamic
/// colour entirely — and white-on-pale-cover was hard to read. Deriving a
/// scheme from the artwork keeps the header tinted by the album while still
/// following the app's brightness, and guarantees the on-colours contrast with
/// whatever surface ends up behind them.
/// The generated scheme follows app brightness and supplies contrasting
/// on-colours for the artwork-derived surface.
class CoverPalette {
const CoverPalette._();
+1 -2
View File
@@ -2,8 +2,7 @@ import 'package:flutter/widgets.dart';
/// Widest content span for a surface of [maxWidth]: content is never narrower
/// than [contentMaxWidth], and the centering margin never exceeds 80dp per
/// side so tablets keep near-full-width rows (issue #493) instead of a
/// fixed 720dp column floating in whitespace.
/// side so tablets retain near-full-width rows.
double adaptiveContentMaxWidth(
double maxWidth, {
double contentMaxWidth = 720,
+24 -4
View File
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
const int _maxLogMessageLength = 500;
const int _maxBufferedLogMessageLength = 4000;
const String _redactedValue = '[REDACTED]';
final RegExp _authorizationBearerPattern = RegExp(
@@ -16,7 +17,7 @@ final RegExp _authorizationBearerPattern = RegExp(
);
final RegExp _genericSensitiveKeyValuePattern = RegExp(
r'("?(?:access[_\s-]?token|refresh[_\s-]?token|id[_\s-]?token|client[_\s-]?secret|authorization|password|api[_\s-]?key|session[_\s-]?secret|cookie|set-cookie)"?)(\s*[:=]\s*)("(?:\\.|[^"\\])*"|[^\s,;}\]]+)',
r'("?(?:access[_\s-]?token|refresh[_\s-]?token|id[_\s-]?token|client[_\s-]?secret|authorization|password|api[_\s-]?key|session[_\s-]?secret|decryption[_\s-]?key|cookie|set-cookie)"?)(\s*[:=]\s*)("(?:\\.|[^"\\])*"|[^\s,;}\]]+)',
caseSensitive: false,
);
@@ -30,6 +31,11 @@ final RegExp _bearerTokenPattern = RegExp(
caseSensitive: false,
);
final RegExp _decryptionKeyFlagPattern = RegExp(
r'(-decryption_key\s+)[^\s]+',
caseSensitive: false,
);
String _truncateLogText(String value, {int maxLength = _maxLogMessageLength}) {
if (value.length <= maxLength) {
return value;
@@ -61,6 +67,10 @@ String _redactSensitiveText(String value) {
return 'Bearer $_redactedValue';
});
redacted = redacted.replaceAllMapped(_decryptionKeyFlagPattern, (match) {
return '${match.group(1) ?? '-decryption_key '}$_redactedValue';
});
return redacted;
}
@@ -136,9 +146,15 @@ class LogBuffer extends ChangeNotifier {
return;
}
final sanitizedMessage = _redactSensitiveText(entry.message);
final sanitizedMessage = _truncateLogText(
_redactSensitiveText(entry.message),
maxLength: _maxBufferedLogMessageLength,
);
final sanitizedError = entry.error != null
? _redactSensitiveText(entry.error!)
? _truncateLogText(
_redactSensitiveText(entry.error!),
maxLength: _maxBufferedLogMessageLength,
)
: null;
final sanitizedEntry =
(sanitizedMessage == entry.message && sanitizedError == entry.error)
@@ -380,7 +396,11 @@ class BufferedOutput extends LogOutput {
@override
void output(OutputEvent event) {
if (kDebugMode) {
if (kDebugMode &&
(LogBuffer.loggingEnabled ||
event.level == Level.warning ||
event.level == Level.error ||
event.level == Level.fatal)) {
for (final line in event.lines) {
debugPrint(_truncateLogText(_redactSensitiveText(line)));
}
+1 -4
View File
@@ -3,10 +3,7 @@ import 'package:spotiflac_android/theme/app_tokens.dart';
/// The drag handle shown at the top of every modal sheet.
///
/// Seventeen copies of this container existed across the app in two sizes
/// (40x4 tinted `onSurfaceVariant`, 32x4 tinted `outlineVariant`) with four
/// different margins. Sheets that opt into Material's own `showDragHandle`
/// get an equivalent affordance from the framework and should not add this.
/// Sheets using Material's `showDragHandle` should not add this handle.
class AppSheetHandle extends StatelessWidget {
const AppSheetHandle({super.key, this.margin});
+1 -5
View File
@@ -5,11 +5,7 @@ import 'package:spotiflac_android/utils/app_bar_layout.dart';
/// The collapsing header used by every top-level tab and every settings-style
/// sub-page.
///
/// This replaces five hand-rolled copies of the same `SliverAppBar` +
/// `LayoutBuilder` + `FlexibleSpaceBar` block. Those copies had drifted into two
/// type ramps (tab roots expanded the title to 34pt, sub-pages to 28pt); both
/// now expand to [AppTokens.headerExpandedTitleSize], which matches the
/// Material 3 large top app bar headline.
/// Expanded titles use [AppTokens.headerExpandedTitleSize].
class AppSliverHeader extends StatelessWidget {
/// Root of a navigation tab: no back button, and the title stays aligned with
/// the content margin at every collapse ratio.
-6
View File
@@ -4,12 +4,6 @@ import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
/// Shared shell for every track-collection screen: album, playlist, local
/// album, downloaded album and library folders.
///
/// Before this existed each of those screens built its own `Scaffold` +
/// `PopScope` + selection plumbing, which is why the same four screens had three
/// different selection-bar mechanisms and why the playlist screen ended up with
/// none at all. Screens now supply their header, their content slivers and the
/// bar contents; everything else is shared:
///
/// * back gesture exits selection mode instead of popping the route,
/// * the selection bar is mounted in the root overlay so it floats above the
/// shell navigation bar,
+1 -4
View File
@@ -94,10 +94,7 @@ class ErrorCard extends StatelessWidget {
}
/// Empty-state block with an optional call to action.
///
/// Screens used to render a bare icon plus a sentence, leaving the user with
/// nothing to tap. [action] is the way forward (search, pick a folder, install
/// an extension, ...).
/// [action] provides an optional recovery path.
class EmptyState extends StatelessWidget {
const EmptyState({
super.key,
-4
View File
@@ -4,10 +4,6 @@ import 'package:flutter/material.dart';
import 'package:spotiflac_android/theme/app_tokens.dart';
/// Square extension icon with a tinted fallback.
///
/// Both the store and the installed-extensions page drew this by hand with the
/// same 44dp box, radius and fallback-icon logic, differing only in whether the
/// image came from a file or the network.
class ExtensionAvatar extends StatelessWidget {
const ExtensionAvatar({
super.key,
-8
View File
@@ -472,11 +472,6 @@ enum SettingsChipLayout {
}
/// Single-select chip used across the settings pages.
///
/// Five private copies of this existed (theme mode, view mode, update channel,
/// download service, generic choice), each re-deriving the same unselected
/// fill and re-declaring radius 12. They now share one implementation, so the
/// selected/unselected treatment is identical everywhere.
class SettingsChoiceChip extends StatelessWidget {
const SettingsChoiceChip({
super.key,
@@ -604,9 +599,6 @@ class SettingsChoiceGrid extends StatelessWidget {
enum SettingsInfoTone { neutral, warning, error }
/// Inline explanatory or warning callout inside a settings page.
///
/// The `Container` + icon + text combination was inlined at a dozen call sites
/// with three different radii and four different container colours.
class SettingsInfoCard extends StatelessWidget {
const SettingsInfoCard({
super.key,
+2 -13
View File
@@ -17,12 +17,8 @@ enum TrackCardStyle {
/// The one track row in the app.
///
/// Six near-identical implementations existed before this
/// (`TrackListTile`, `AlbumTrackTile`, the queue item, the bridge item, the
/// unified library item and the folder tile), each with its own radius,
/// padding, title style and selection treatment. Screens now supply only the
/// parts that genuinely differ: [leading], [subtitle], [trailing] and an
/// optional [background] layer for download progress.
/// Screens provide [leading], [subtitle], [trailing], and an optional
/// [background] layer for download progress.
class TrackCard extends StatelessWidget {
const TrackCard({
super.key,
@@ -172,10 +168,6 @@ class TrackCard extends StatelessWidget {
/// Grid counterpart of [TrackCard]: square artwork with overlays, then the
/// title and subtitle underneath.
///
/// Replaces three copies that each re-declared the radius, the overlay stack
/// and the label typography, and used a bare `GestureDetector` (no ripple, no
/// semantics).
class TrackGridCard extends StatelessWidget {
const TrackGridCard({
super.key,
@@ -316,9 +308,6 @@ class TrackGridPlayButton extends StatelessWidget {
}
/// Square artwork placeholder shared by every track row and grid cell.
///
/// The `Container` + `surfaceContainerHighest` + `music_note` combination was
/// repeated at roughly a dozen call sites with four different radii.
class TrackCoverPlaceholder extends StatelessWidget {
const TrackCoverPlaceholder({super.key, this.size, this.borderRadius});