mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-03 16:50:40 +02:00
chore(logging): sanitize diagnostics and reduce noise
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")}})
|
||||
|
||||
@@ -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 := "<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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
+17
-2
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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') ||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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._();
|
||||
|
||||
|
||||
@@ -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
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user