diff --git a/analysis_options.yaml b/analysis_options.yaml index 0ca8f46c..8619a760 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,12 +1,3 @@ -# This file configures the analyzer, which statically analyzes Dart code to -# check for errors, warnings, and lints. -# -# The issues identified by the analyzer are surfaced in the UI of Dart-enabled -# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be -# invoked from the command line by running `flutter analyze`. - -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. include: package:flutter_lints/flutter.yaml plugins: @@ -29,19 +20,7 @@ analyzer: strict-inference: true strict-raw-types: true linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at https://dart.dev/lints. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. rules: - # avoid_print: false # Uncomment to disable the `avoid_print` rule - # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule always_declare_return_types: true avoid_dynamic_calls: true avoid_types_as_parameter_names: true @@ -52,6 +31,3 @@ linter: # Catches dead cross-layer chains (Dart wrapper kept alive only by its own # declaration) before they accumulate into another dedup campaign. unreachable_from_main: true - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 30a8462e..6780afe6 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -8,7 +8,6 @@ plugins { id("dev.flutter.flutter-gradle-plugin") } -// Load keystore properties for local builds val keystorePropertiesFile = rootProject.file("key.properties") val keystoreProperties = Properties() if (keystorePropertiesFile.exists()) { @@ -119,7 +118,6 @@ repositories { dependencies { coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5") - // Include all AAR and JAR files from libs folder implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar")))) implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0") diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index c68c9e98..783d2ddd 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -321,8 +321,6 @@ class MainActivity: FlutterFragmentActivity() { android.util.Log.w("SpotiFLAC", "Device: ${Build.MANUFACTURER} ${Build.MODEL}, SDK: ${Build.VERSION.SDK_INT}") android.util.Log.w("SpotiFLAC", "Hardware: ${Build.HARDWARE}, Board: ${Build.BOARD}") args.add("--enable-impeller=false") - } else { - android.util.Log.i("SpotiFLAC", "Using Impeller renderer for ${Build.MODEL}") } return args } diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt index 0178bc0e..e3e67b3c 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivitySafScan.kt @@ -774,10 +774,6 @@ internal fun MainActivity.scanSafTree( putResult(cueArray.getJSONObject(j)) } - android.util.Log.d( - "SpotiFLAC", - "SAF scan: CUE $cueName -> ${cueArray.length()} tracks" - ) } catch (e: Exception) { errors++ android.util.Log.w("SpotiFLAC", "SAF scan: error processing CUE $cueName: ${e.message}") @@ -1154,10 +1150,6 @@ internal fun MainActivity.scanSafTreeIncremental( } } - android.util.Log.d( - "SpotiFLAC", - "SAF incremental scan: CUE $cueName -> ${cueArray.length()} tracks" - ) } catch (e: Exception) { errors++ android.util.Log.w("SpotiFLAC", "SAF incremental scan: error processing CUE $cueName: ${e.message}") diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerSafPublish.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerSafPublish.kt index 2af92cd0..1d3d88c2 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerSafPublish.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeFinalizerSafPublish.kt @@ -135,7 +135,6 @@ internal fun NativeDownloadFinalizer.publishDeferredSafOutput( val newUri = published.uri val publishedName = published.fileName - Log.i(TAG, "Published deferred SAF output once: file=$publishedName bytes=${outputFile.length()}") outputFile.delete() state.filePath = newUri state.fileName = publishedName diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt index 1f6209be..efcffb01 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/SafDownloadHandler.kt @@ -2,7 +2,6 @@ package com.zarz.spotiflac import android.content.Context import android.net.Uri -import android.util.Log import androidx.documentfile.provider.DocumentFile import org.json.JSONObject import java.io.File @@ -117,7 +116,6 @@ object SafDownloadHandler { deleteStaleStagedFiles(targetDir, fileName, outputExt) val workingExt = outputExt.ifBlank { ".tmp" } val workingFile = File.createTempFile("native_saf_work_", workingExt, context.cacheDir) - Log.i("SpotiFLAC", "SAF deferred native output: target=$fileName working=${workingFile.name}") return try { req.put("output_path", workingFile.absolutePath) req.put("output_ext", outputExt) diff --git a/android/build.gradle.kts b/android/build.gradle.kts index b6fe0ea2..cb700cd2 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -15,13 +15,11 @@ subprojects { targetCompatibility = JavaVersion.VERSION_25 } - // Enable multidex for all subprojects defaultConfig { multiDexEnabled = true } } - // Add desugaring dependency to all Android subprojects project.dependencies.add("coreLibraryDesugaring", "com.android.tools:desugar_jdk_libs:2.1.5") } diff --git a/android/gradle.properties b/android/gradle.properties index d7a85c66..87ae5497 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,6 +1,4 @@ org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true -# This builtInKotlin flag was added automatically by Flutter migrator android.builtInKotlin=false -# This newDsl flag was added automatically by Flutter migrator android.newDsl=false diff --git a/go_backend/cover.go b/go_backend/cover.go index e6818ecd..7899acb7 100644 --- a/go_backend/cover.go +++ b/go_backend/cover.go @@ -24,7 +24,6 @@ func downloadCoverToMemory(coverURL string) ([]byte, error) { return nil, fmt.Errorf("no cover URL provided") } - GoLog("[Cover] Provider URL: %s", coverURL) data, err := fetchCoverCached(coverURL) if err != nil { return nil, err diff --git a/go_backend/cover_test.go b/go_backend/cover_test.go index 31131559..f9d60866 100644 --- a/go_backend/cover_test.go +++ b/go_backend/cover_test.go @@ -110,7 +110,6 @@ func TestFetchCoverCachedTTLExpiry(t *testing.T) { if _, err := fetchCoverCached(url); err != nil { t.Fatalf("first fetch error: %v", err) } - // second call served from cache if _, err := fetchCoverCached(url); err != nil { t.Fatalf("second fetch error: %v", err) } @@ -118,7 +117,6 @@ func TestFetchCoverCachedTTLExpiry(t *testing.T) { t.Fatalf("expected cache hit, got %d fetches", got) } - // expire the entry and confirm a refetch coverMu.Lock() coverCache[url].expiresAt = time.Now().Add(-time.Minute) coverMu.Unlock() diff --git a/go_backend/exports_metadata.go b/go_backend/exports_metadata.go index 82fe26db..cc1a664b 100644 --- a/go_backend/exports_metadata.go +++ b/go_backend/exports_metadata.go @@ -551,7 +551,6 @@ func DownloadCoverToFileSized(coverURL string, outputPath string, maxDimension i return fmt.Errorf("failed to write cover file: %w", err) } - GoLog("[Cover] Downloaded cover to: %s (%d KB)\n", outputPath, len(data)/1024) return nil } @@ -586,6 +585,5 @@ func ExtractCoverToFile(audioPath string, outputPath string) error { return fmt.Errorf("failed to write cover file: %w", err) } - GoLog("[Cover] Extracted cover art to: %s (%d KB)\n", outputPath, len(coverData)/1024) return nil } diff --git a/go_backend/exports_reenrich.go b/go_backend/exports_reenrich.go index e929eb51..2e622f7b 100644 --- a/go_backend/exports_reenrich.go +++ b/go_backend/exports_reenrich.go @@ -651,7 +651,7 @@ func ReEnrichFile(requestJSON string) (string, error) { return "", fmt.Errorf("file_path is required") } - GoLog("[ReEnrich] Starting re-enrichment for: %s\n", req.FilePath) + GoLog("[ReEnrich] Starting re-enrichment\n") if req.SearchOnline { found := false @@ -659,21 +659,19 @@ func ReEnrichFile(requestJSON string) (string, error) { GoLog("[ReEnrich] Trying metadata providers in configured priority...\n") manager := getExtensionManager() if identifierTrack, err := resolveReEnrichTrackFromIdentifiers(req); err == nil && identifierTrack != nil { - GoLog("[ReEnrich] Identifier-first metadata match (%s): %s - %s (album: %s, date: %s)\n", - identifierTrack.ProviderID, identifierTrack.Name, identifierTrack.Artists, identifierTrack.AlbumName, identifierTrack.ReleaseDate) + GoLog("[ReEnrich] Identifier-first metadata match via %s\n", identifierTrack.ProviderID) applyReEnrichTrackMetadata(&req, *identifierTrack) found = true } searchQuery := buildReEnrichSearchQuery(req) if searchQuery != "" { - GoLog("[ReEnrich] Searching online metadata for query: %s\n", searchQuery) + GoLog("[ReEnrich] Searching online metadata\n") tracks, searchErr := manager.SearchTracksWithMetadataProviders(searchQuery, 5, true) if searchErr == nil && len(tracks) > 0 { track := selectBestReEnrichTrack(req, tracks) if track != nil { - GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s, date: %s)\n", - track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate) + GoLog("[ReEnrich] Metadata match via %s\n", track.ProviderID) applyReEnrichTrackMetadata(&req, *track) found = true } @@ -690,7 +688,7 @@ func ReEnrichFile(requestJSON string) (string, error) { GoLog("[ReEnrich] Failed to get album artist from MusicBrainz: %v\n", err) } else if strings.TrimSpace(albumArtist) != "" { req.AlbumArtist = strings.TrimSpace(albumArtist) - GoLog("[ReEnrich] Album artist fallback from MusicBrainz: %s\n", req.AlbumArtist) + GoLog("[ReEnrich] Applied album artist fallback from MusicBrainz\n") found = true } } @@ -705,11 +703,6 @@ func ReEnrichFile(requestJSON string) (string, error) { } } - GoLog("[ReEnrich] Metadata to embed: title=%s, artist=%s, album=%s, albumArtist=%s\n", - req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist) - GoLog("[ReEnrich] track=%d, disc=%d, date=%s, isrc=%s, genre=%s, label=%s\n", - req.TrackNumber, req.DiscNumber, req.ReleaseDate, req.ISRC, req.Genre, req.Label) - enrichedMeta := buildReEnrichResultMetadata(&req) if req.PreviewOnly { result := map[string]any{ diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index e65b2010..0d07a8ce 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -1045,7 +1045,17 @@ func (m *extensionManager) InvokeAction(extensionID string, actionName string) ( exported := result.Export() if resultMap, ok := exported.(map[string]any); ok { - GoLog("[Extension] InvokeAction %s.%s result: %v\n", extensionID, actionName, resultMap) + status := "unspecified" + if success, present := resultMap["success"].(bool); present { + status = strconv.FormatBool(success) + } + GoLog( + "[Extension] InvokeAction %s.%s completed (success=%s, fields=%d)\n", + extensionID, + actionName, + status, + len(resultMap), + ) return resultMap, nil } diff --git a/go_backend/extension_manager_runtime.go b/go_backend/extension_manager_runtime.go index de6243ff..3b321cf6 100644 --- a/go_backend/extension_manager_runtime.go +++ b/go_backend/extension_manager_runtime.go @@ -82,11 +82,7 @@ func initializeVMLocked(ext *loadedExtension) error { console := vm.NewObject() console.Set("log", func(call goja.FunctionCall) goja.Value { - args := make([]any, len(call.Arguments)) - for i, arg := range call.Arguments { - args[i] = arg.Export() - } - GoLog("[Extension:%s] %v\n", ext.ID, args) + GoLog("[Extension:%s] %s\n", ext.ID, formatExtensionLogArgs(call.Arguments)) return goja.Undefined() }) vm.Set("console", console) @@ -153,11 +149,7 @@ func newIsolatedExtensionRuntime(ext *loadedExtension) (*goja.Runtime, *extensio console := vm.NewObject() console.Set("log", func(call goja.FunctionCall) goja.Value { - args := make([]any, len(call.Arguments)) - for i, arg := range call.Arguments { - args[i] = arg.Export() - } - GoLog("[Extension:%s] %v\n", ext.ID, args) + GoLog("[Extension:%s] %s\n", ext.ID, formatExtensionLogArgs(call.Arguments)) return goja.Undefined() }) vm.Set("console", console) diff --git a/go_backend/extension_runtime_supplement_test.go b/go_backend/extension_runtime_supplement_test.go index 586cf88b..29095c6b 100644 --- a/go_backend/extension_runtime_supplement_test.go +++ b/go_backend/extension_runtime_supplement_test.go @@ -1145,6 +1145,12 @@ func TestExtensionRuntimeUtilityAPIs(t *testing.T) { if msg := runtime.formatLogArgs([]goja.Value{vm.ToValue("a"), vm.ToValue(1)}); msg != "a 1" { t.Fatalf("formatLogArgs = %q", msg) } + objectLog := runtime.formatLogArgs([]goja.Value{ + vm.ToValue(map[string]any{"access_token": "must-not-be-exported"}), + }) + if strings.Contains(objectLog, "must-not-be-exported") || !strings.HasPrefix(objectLog, "<") { + t.Fatalf("object log was not summarized: %q", objectLog) + } runtime.logDebug(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("debug")}}) runtime.logInfo(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("info")}}) runtime.logWarn(goja.FunctionCall{Arguments: []goja.Value{vm.ToValue("warn")}}) diff --git a/go_backend/extension_runtime_utils.go b/go_backend/extension_runtime_utils.go index 29a39f05..83dff906 100644 --- a/go_backend/extension_runtime_utils.go +++ b/go_backend/extension_runtime_utils.go @@ -11,6 +11,7 @@ import ( "encoding/json" "fmt" "math" + "reflect" "strings" "time" @@ -365,11 +366,43 @@ func (r *extensionRuntime) logError(call goja.FunctionCall) goja.Value { } func (r *extensionRuntime) formatLogArgs(args []goja.Value) string { - parts := make([]string, len(args)) - for i, arg := range args { - parts[i] = fmt.Sprintf("%v", arg.Export()) + return formatExtensionLogArgs(args) +} + +const ( + maxExtensionLogArgs = 8 + maxExtensionLogArgLength = 512 +) + +func formatExtensionLogArgs(args []goja.Value) string { + limit := len(args) + if limit > maxExtensionLogArgs { + limit = maxExtensionLogArgs } - return strings.Join(parts, " ") + parts := make([]string, 0, limit+1) + for _, arg := range args[:limit] { + value := "" + if exportType := arg.ExportType(); exportType != nil { + switch exportType.Kind() { + case reflect.Bool, + reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Float32, reflect.Float64, + reflect.String: + value = arg.String() + default: + value = "<" + exportType.String() + ">" + } + } + if len(value) > maxExtensionLogArgLength { + value = value[:maxExtensionLogArgLength] + "...[truncated]" + } + parts = append(parts, value) + } + if len(args) > limit { + parts = append(parts, fmt.Sprintf("...[%d more args]", len(args)-limit)) + } + return truncateLogMessage(sanitizeSensitiveLogText(strings.Join(parts, " "))) } func (r *extensionRuntime) RegisterGoBackendAPIs(vm *goja.Runtime) { diff --git a/go_backend/log_progress_timeout_supplement_test.go b/go_backend/log_progress_timeout_supplement_test.go index 6322824a..e613ad11 100644 --- a/go_backend/log_progress_timeout_supplement_test.go +++ b/go_backend/log_progress_timeout_supplement_test.go @@ -28,6 +28,8 @@ func TestLogBufferExportedHelpersAndRedaction(t *testing.T) { GoLog("[GoTag] success token=abc") LogError("json", `{"access_token":"json-secret","session_secret":"session-secret"}`) LogError("query", "https://example.test/?X-Amz-Signature=signed-secret&X-Amz-Security-Token=session-token") + LogError("ffmpeg", "-decryption_key raw-media-key -i https://example.test/audio") + LogError("bounded", "%s", strings.Repeat("x", maxLogMessageLength+500)) var entries []LogEntry if err := json.Unmarshal([]byte(GetLogBuffer().GetAll()), &entries); err != nil { @@ -37,9 +39,12 @@ func TestLogBufferExportedHelpersAndRedaction(t *testing.T) { t.Fatalf("expected log entries, got %#v", entries) } for _, entry := range entries { - if strings.Contains(entry.Message, "secret-token") || strings.Contains(entry.Message, "api_key=value") || strings.Contains(entry.Message, "password=secret") || strings.Contains(entry.Message, "json-secret") || strings.Contains(entry.Message, "session-secret") || strings.Contains(entry.Message, "signed-secret") || strings.Contains(entry.Message, "session-token") { + if strings.Contains(entry.Message, "secret-token") || strings.Contains(entry.Message, "api_key=value") || strings.Contains(entry.Message, "password=secret") || strings.Contains(entry.Message, "json-secret") || strings.Contains(entry.Message, "session-secret") || strings.Contains(entry.Message, "signed-secret") || strings.Contains(entry.Message, "session-token") || strings.Contains(entry.Message, "raw-media-key") { t.Fatalf("log was not redacted: %#v", entry) } + if len(entry.Message) > maxLogMessageLength+len("...[truncated]") { + t.Fatalf("log was not bounded: %d bytes", len(entry.Message)) + } } sinceJSON := GetLogsSince(1) diff --git a/go_backend/logbuffer.go b/go_backend/logbuffer.go index 53570f6b..8a8c3104 100644 --- a/go_backend/logbuffer.go +++ b/go_backend/logbuffer.go @@ -7,6 +7,7 @@ import ( "strings" "sync" "time" + "unicode/utf8" ) type LogEntry struct { @@ -28,6 +29,7 @@ type LogBuffer struct { const ( defaultLogBufferSize = 500 + maxLogMessageLength = 4000 ) var ( @@ -35,9 +37,10 @@ var ( logBufferOnce sync.Once authorizationBearerPattern = regexp.MustCompile(`(?i)\bAuthorization\b\s*[:=]\s*Bearer\s+[A-Za-z0-9._~+/\-]+=*`) - genericKeyValuePattern = regexp.MustCompile(`(?i)("?(?: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,;}\]]+)`) + genericKeyValuePattern = regexp.MustCompile(`(?i)("?(?: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,;}\]]+)`) queryTokenPattern = regexp.MustCompile(`(?i)([?&](?:access_token|refresh_token|id_token|token|client_secret|api_key|apikey|password|code|grant|sig|signature|x-amz-signature|x-amz-credential|x-amz-security-token|awsaccesskeyid|googleaccessid|policy|key-pair-id)=)[^&\s]+`) bearerTokenPattern = regexp.MustCompile(`(?i)\bBearer\s+[A-Za-z0-9._~+/\-]+=*`) + decryptionKeyFlagPattern = regexp.MustCompile(`(?i)(-decryption_key\s+)[^\s]+`) ) func sanitizeSensitiveLogText(message string) string { @@ -46,9 +49,21 @@ func sanitizeSensitiveLogText(message string) string { redacted = genericKeyValuePattern.ReplaceAllString(redacted, `${1}${2}[REDACTED]`) redacted = queryTokenPattern.ReplaceAllString(redacted, `${1}[REDACTED]`) redacted = bearerTokenPattern.ReplaceAllString(redacted, "Bearer [REDACTED]") + redacted = decryptionKeyFlagPattern.ReplaceAllString(redacted, `${1}[REDACTED]`) return redacted } +func truncateLogMessage(message string) string { + if len(message) <= maxLogMessageLength { + return message + } + truncated := message[:maxLogMessageLength] + for !utf8.ValidString(truncated) { + truncated = truncated[:len(truncated)-1] + } + return truncated + "...[truncated]" +} + func GetLogBuffer() *LogBuffer { logBufferOnce.Do(func() { globalLogBuffer = &LogBuffer{ @@ -80,7 +95,7 @@ func (lb *LogBuffer) Add(level, tag, message string) { return } - message = sanitizeSensitiveLogText(message) + message = truncateLogMessage(sanitizeSensitiveLogText(message)) entry := LogEntry{ Timestamp: time.Now().Format("15:04:05.000"), diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index 7ac29929..94ce1b63 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -370,7 +370,6 @@ func (c *LyricsClient) fetchLyricsAllSourcesUncoalesced(spotifyID, trackName, ar } } if (!isExtensionCache && selectedExtensionCount == 0) || cachedProviderSelected { - fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName) cachedCopy := *cached cachedCopy.Source = cached.Source + " (cached)" return &cachedCopy, nil diff --git a/go_backend/metadata.go b/go_backend/metadata.go index 89274001..dd859ece 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -287,14 +287,12 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error { if fileExists(coverPath) { coverData, err := os.ReadFile(coverPath) if err != nil { - fmt.Printf("[Metadata] Warning: Failed to read cover file %s: %v\n", coverPath, err) + LogWarn("Metadata", "Failed to read cover file: %v", err) } else if err := replaceFlacPictures(f, coverPath, coverData); err != nil { - fmt.Printf("[Metadata] Warning: skipping cover art: %v\n", err) - } else { - fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData)) + LogWarn("Metadata", "Skipping cover art: %v", err) } } else { - fmt.Printf("[Metadata] Warning: Cover file does not exist: %s\n", coverPath) + LogWarn("Metadata", "Cover file does not exist") } } return nil @@ -307,9 +305,7 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData [] if len(coverData) > 0 { if err := replaceFlacPictures(f, "", coverData); err != nil { - fmt.Printf("[Metadata] Warning: skipping cover art: %v\n", err) - } else { - fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData)) + LogWarn("Metadata", "Skipping cover art: %v", err) } } return nil diff --git a/lib/providers/download_history_provider.dart b/lib/providers/download_history_provider.dart index d9bf4640..caa2deab 100644 --- a/lib/providers/download_history_provider.dart +++ b/lib/providers/download_history_provider.dart @@ -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, HistoryBatchLookupRequest>((ref, request) async { ref.watch( diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 88398a82..c22dd86c 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -328,7 +328,7 @@ class DownloadQueueNotifier extends Notifier { '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 { ..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 { 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 { 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 { _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.delayed(_queueSchedulingInterval), diff --git a/lib/providers/download_queue_provider_embedding.dart b/lib/providers/download_queue_provider_embedding.dart index 83c15322..0b24a6b5 100644 --- a/lib/providers/download_queue_provider_embedding.dart +++ b/lib/providers/download_queue_provider_embedding.dart @@ -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'); diff --git a/lib/providers/download_queue_provider_single_item.dart b/lib/providers/download_queue_provider_single_item.dart index 572d68be..44ddf5ed 100644 --- a/lib/providers/download_queue_provider_single_item.dart +++ b/lib/providers/download_queue_provider_single_item.dart @@ -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') || diff --git a/lib/providers/explore_provider.dart b/lib/providers/explore_provider.dart index 02340070..17e710ef 100644 --- a/lib/providers/explore_provider.dart +++ b/lib/providers/explore_provider.dart @@ -357,18 +357,14 @@ class ExploreNotifier extends Notifier { }); 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 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 { 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 { 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 { } 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? ?? []; final normalizedSectionsWithoutProvider = await compute( _normalizeExploreSectionsPayload, @@ -450,17 +436,11 @@ class ExploreNotifier extends Notifier { 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, diff --git a/lib/providers/preview_player_provider.dart b/lib/providers/preview_player_provider.dart index 2e8a0b4a..d5110953 100644 --- a/lib/providers/preview_player_provider.dart +++ b/lib/providers/preview_player_provider.dart @@ -110,7 +110,6 @@ class PreviewPlayerController extends Notifier { ); _subscriptions.add( player.onPlayerComplete.listen((_) { - _log.d('Preview playback completed'); state = const PreviewPlayerState(); }), ); @@ -178,7 +177,6 @@ class PreviewPlayerController extends Notifier { ); try { - _log.i('Starting preview playback'); await _playOnPlayer(_ensurePlayer(), trimmed); } catch (e) { _log.w('Preview playback failed, recreating player and retrying: $e'); diff --git a/lib/providers/runtime_profile_provider.dart b/lib/providers/runtime_profile_provider.dart index 2bbf5cf0..1bfde69c 100644 --- a/lib/providers/runtime_profile_provider.dart +++ b/lib/providers/runtime_profile_provider.dart @@ -11,8 +11,7 @@ final lowEndDeviceProvider = Provider((ref) => false); final deviceSupportsBackdropBlurProvider = Provider((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((ref) { return ref.watch(deviceSupportsBackdropBlurProvider) || ref.watch(settingsProvider.select((s) => s.forceBackdropBlur)); diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 8f86ce6d..457a8ac0 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -175,7 +175,7 @@ class TrackNotifier extends Notifier { return; } - _log.i('Found extension URL handler: $extensionHandler for URL: $url'); + _log.i('Found extension URL handler: $extensionHandler'); Map? result; for (int attempt = 1; attempt <= 3; attempt++) { @@ -368,21 +368,12 @@ class TrackNotifier extends Notifier { 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 { } } - _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 { ); 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 { return; } - _log.i('Custom search returned ${results.length} tracks'); - final tracks = []; for (int i = 0; i < results.length; i++) { final t = results[i]; @@ -466,13 +457,8 @@ class TrackNotifier extends Notifier { } _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( diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index e96168c6..07152c73 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -258,13 +258,11 @@ class _MainShellState extends ConsumerState 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 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 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 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 } 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 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 } 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( diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 85911f3a..f244f345 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -961,10 +961,6 @@ class _DownloadSettingsPageState extends ConsumerState { } /// 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, diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 44271df9..ed838b55 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -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); diff --git a/lib/services/music_player_service.dart b/lib/services/music_player_service.dart index ea09aea2..f09a2ec1 100644 --- a/lib/services/music_player_service.dart +++ b/lib/services/music_player_service.dart @@ -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.delayed(const Duration(milliseconds: 300)); } @@ -1220,7 +1218,6 @@ class MusicPlayerHandler extends BaseAudioHandler Future _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 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 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() diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 1f4eebb1..5bcc068b 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -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 setLibraryCoverCacheDir(String cacheDir) async { - _log.i('setLibraryCoverCacheDir: $cacheDir'); await _channel.invokeMethod('setLibraryCoverCacheDir', { 'cache_dir': cacheDir, }); @@ -2066,7 +2052,6 @@ class PlatformBridge { static Future>> 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 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>> 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 existingFiles, ) async { - _log.i( - 'scanSafTreeIncremental: $treeUri (${existingFiles.length} existing files)', - ); final result = await _channel.invokeMethod('scanSafTreeIncremental', { 'tree_uri': treeUri, 'existing_files': jsonEncode(existingFiles), diff --git a/lib/services/share_intent_service.dart b/lib/services/share_intent_service.dart index 3e911e67..3c97b567 100644 --- a/lib/services/share_intent_service.dart +++ b/lib/services/share_intent_service.dart @@ -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; } diff --git a/lib/theme/app_tokens.dart b/lib/theme/app_tokens.dart index 3153bf04..9f45fb53 100644 --- a/lib/theme/app_tokens.dart +++ b/lib/theme/app_tokens.dart @@ -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()`, so a widget rendered outside a @@ -118,9 +114,7 @@ class AppTokens extends ThemeExtension { 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; diff --git a/lib/theme/cover_palette.dart b/lib/theme/cover_palette.dart index 2ca03cce..9c4781aa 100644 --- a/lib/theme/cover_palette.dart +++ b/lib/theme/cover_palette.dart @@ -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._(); diff --git a/lib/utils/adaptive_layout.dart b/lib/utils/adaptive_layout.dart index 02ea3417..69b5a7c6 100644 --- a/lib/utils/adaptive_layout.dart +++ b/lib/utils/adaptive_layout.dart @@ -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, diff --git a/lib/utils/logger.dart b/lib/utils/logger.dart index 32359598..fd2d0b87 100644 --- a/lib/utils/logger.dart +++ b/lib/utils/logger.dart @@ -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))); } diff --git a/lib/widgets/app_bottom_sheet.dart b/lib/widgets/app_bottom_sheet.dart index ac1e57b3..c104d483 100644 --- a/lib/widgets/app_bottom_sheet.dart +++ b/lib/widgets/app_bottom_sheet.dart @@ -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}); diff --git a/lib/widgets/app_sliver_header.dart b/lib/widgets/app_sliver_header.dart index 0e4288cd..43e61de7 100644 --- a/lib/widgets/app_sliver_header.dart +++ b/lib/widgets/app_sliver_header.dart @@ -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. diff --git a/lib/widgets/collection_scaffold.dart b/lib/widgets/collection_scaffold.dart index eb8cd3c8..4b42dc1e 100644 --- a/lib/widgets/collection_scaffold.dart +++ b/lib/widgets/collection_scaffold.dart @@ -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, diff --git a/lib/widgets/error_card.dart b/lib/widgets/error_card.dart index 1eb91875..67157af0 100644 --- a/lib/widgets/error_card.dart +++ b/lib/widgets/error_card.dart @@ -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, diff --git a/lib/widgets/extension_row.dart b/lib/widgets/extension_row.dart index 4c2a4166..df9bceb6 100644 --- a/lib/widgets/extension_row.dart +++ b/lib/widgets/extension_row.dart @@ -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, diff --git a/lib/widgets/settings_group.dart b/lib/widgets/settings_group.dart index 6a5ad2fc..a55b81d8 100644 --- a/lib/widgets/settings_group.dart +++ b/lib/widgets/settings_group.dart @@ -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, diff --git a/lib/widgets/track_card.dart b/lib/widgets/track_card.dart index e9f2b8c5..9f474681 100644 --- a/lib/widgets/track_card.dart +++ b/lib/widgets/track_card.dart @@ -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}); diff --git a/test/log_selection_test.dart b/test/log_selection_test.dart index fe1ccf45..fec7dc0a 100644 --- a/test/log_selection_test.dart +++ b/test/log_selection_test.dart @@ -31,6 +31,26 @@ void main() { ); }); + test('log buffer redacts media keys and bounds exported payloads', () { + final buffer = LogBuffer()..clear(); + addTearDown(buffer.clear); + buffer.add( + LogEntry( + timestamp: DateTime(2026, 8, 31), + level: 'ERROR', + tag: 'FFmpeg', + message: + '-decryption_key raw-media-key ${List.filled(5000, 'x').join()}', + ), + ); + + final stored = buffer.entries.single.message; + expect(stored, isNot(contains('raw-media-key'))); + expect(stored, contains('[REDACTED]')); + expect(stored, endsWith('...[truncated]')); + expect(stored.length, lessThan(4100)); + }); + testWidgets('long press selects multiple log rows for copying', ( tester, ) async {