From cae047bfe342b0fd5d6859581c2aa91cd1416568 Mon Sep 17 00:00:00 2001 From: theov Date: Tue, 9 Jun 2026 18:03:24 -0300 Subject: [PATCH] Added lag diagnostic tool --- .../pixelplay/PixelPlayApplication.kt | 5 + .../AdvancedPerformanceDiagnostics.kt | 238 ++++++++++++++++++ ...dvancedPerformanceDiagnosticsController.kt | 50 ++++ .../diagnostics/DebugPerformanceReport.kt | 39 ++- .../DebugPerformanceReportCollector.kt | 27 +- .../diagnostics/MainThreadStallMonitor.kt | 50 ++++ .../data/diagnostics/PerformanceMetrics.kt | 30 +++ .../data/equalizer/EqualizerManager.kt | 109 ++++++++ .../preferences/UserPreferencesRepository.kt | 55 ++++ .../data/repository/ArtistImageRepository.kt | 52 ++-- .../data/service/player/DualPlayerEngine.kt | 65 ++++- .../data/worker/NavidromeSyncWorker.kt | 30 +++ .../pixelplay/data/worker/SyncWorker.kt | 53 +++- .../components/UnifiedPlayerSheetV2.kt | 15 ++ .../components/player/FullPlayerContent.kt | 10 + .../screens/DeviceCapabilitiesScreen.kt | 88 ++++++- .../viewmodel/DeviceCapabilitiesViewModel.kt | 37 +++ .../values/strings_presentation_batch_g.xml | 5 + .../AdvancedPerformanceDiagnosticsTest.kt | 141 +++++++++++ .../diagnostics/DebugPerformanceReportTest.kt | 29 +++ 20 files changed, 1106 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/com/theveloper/pixelplay/data/diagnostics/AdvancedPerformanceDiagnostics.kt create mode 100644 app/src/main/java/com/theveloper/pixelplay/data/diagnostics/AdvancedPerformanceDiagnosticsController.kt create mode 100644 app/src/main/java/com/theveloper/pixelplay/data/diagnostics/MainThreadStallMonitor.kt create mode 100644 app/src/test/java/com/theveloper/pixelplay/data/diagnostics/AdvancedPerformanceDiagnosticsTest.kt diff --git a/app/src/main/java/com/theveloper/pixelplay/PixelPlayApplication.kt b/app/src/main/java/com/theveloper/pixelplay/PixelPlayApplication.kt index cc9c42531..d1485e4e5 100644 --- a/app/src/main/java/com/theveloper/pixelplay/PixelPlayApplication.kt +++ b/app/src/main/java/com/theveloper/pixelplay/PixelPlayApplication.kt @@ -14,6 +14,7 @@ import androidx.work.Configuration import coil.ImageLoader import coil.ImageLoaderFactory import com.theveloper.pixelplay.data.preferences.UserPreferencesRepository +import com.theveloper.pixelplay.data.diagnostics.AdvancedPerformanceDiagnosticsController import com.theveloper.pixelplay.data.repository.ArtistImageRepository import com.theveloper.pixelplay.data.telegram.TelegramRepository import com.theveloper.pixelplay.presentation.viewmodel.LibraryStateHolder @@ -68,6 +69,9 @@ class PixelPlayApplication : Application(), ImageLoaderFactory, Configuration.Pr @Inject lateinit var userPreferencesRepository: dagger.Lazy + @Inject + lateinit var advancedPerformanceDiagnosticsController: dagger.Lazy + private val startupScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) // AÑADE EL COMPANION OBJECT @@ -112,6 +116,7 @@ class PixelPlayApplication : Application(), ImageLoaderFactory, Configuration.Pr } ProcessLifecycleOwner.get().lifecycle.addObserver(appLifecycleObserver) + advancedPerformanceDiagnosticsController.get().start(startupScope) startupScope.launch { AlbumArtUtils.migrateLegacyCacheLocation(this@PixelPlayApplication) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/diagnostics/AdvancedPerformanceDiagnostics.kt b/app/src/main/java/com/theveloper/pixelplay/data/diagnostics/AdvancedPerformanceDiagnostics.kt new file mode 100644 index 000000000..6bf33706f --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/diagnostics/AdvancedPerformanceDiagnostics.kt @@ -0,0 +1,238 @@ +package com.theveloper.pixelplay.data.diagnostics + +import android.os.Trace +import java.util.ArrayDeque + +/** + * Opt-in recorder for beta lag investigations. + * + * Normal users keep this disabled. Hot call sites should call [recordEvent] freely: + * the disabled path is one volatile read and an immediate return. + */ +object AdvancedPerformanceDiagnostics { + const val MAX_EVENTS = 120 + const val DEFAULT_SESSION_DURATION_MS = 24L * 60L * 60L * 1_000L + const val FRAME_STALL_THRESHOLD_MS = 100L + + object EventTypes { + const val USER_MARK = "user_mark" + const val FRAME_STALL = "frame_stall" + const val PLAYBACK = "playback" + const val AUDIO_EFFECT = "audio_effect" + const val OFFLOAD = "offload" + const val WORKER = "worker" + const val ARTWORK = "artwork" + const val UI = "ui" + } + + data class DiagnosticEvent( + val elapsedRealtimeMs: Long, + val type: String, + val name: String, + val details: Map = emptyMap() + ) + + data class Snapshot( + val enabled: Boolean, + val sessionStartedEpochMs: Long?, + val expiresAtEpochMs: Long?, + val droppedEventCount: Long, + val events: List + ) + + private val lock = Any() + private val events = ArrayDeque(MAX_EVENTS) + + @Volatile + private var enabled = false + + @Volatile + private var sessionStartedEpochMs: Long? = null + + @Volatile + private var expiresAtEpochMs: Long? = null + + private var droppedEventCount = 0L + + val isEnabled: Boolean + get() = enabled + + fun startSession( + startedAtEpochMs: Long = System.currentTimeMillis(), + durationMs: Long = DEFAULT_SESSION_DURATION_MS + ) { + val expiresAt = startedAtEpochMs + durationMs.coerceAtLeast(1L) + synchronized(lock) { + events.clear() + droppedEventCount = 0L + sessionStartedEpochMs = startedAtEpochMs + expiresAtEpochMs = expiresAt + enabled = true + } + } + + fun configureSession( + enabled: Boolean, + startedAtEpochMs: Long?, + expiresAtEpochMs: Long?, + nowEpochMs: Long = System.currentTimeMillis() + ): Boolean { + synchronized(lock) { + val active = enabled && + startedAtEpochMs != null && + expiresAtEpochMs != null && + nowEpochMs < expiresAtEpochMs + if (!active) { + clearLocked() + return false + } + + val sessionChanged = sessionStartedEpochMs != startedAtEpochMs || + this.expiresAtEpochMs != expiresAtEpochMs + if (sessionChanged) { + events.clear() + droppedEventCount = 0L + } + sessionStartedEpochMs = startedAtEpochMs + this.expiresAtEpochMs = expiresAtEpochMs + this.enabled = true + return true + } + } + + fun stopSession() { + synchronized(lock) { + clearLocked() + } + } + + fun recordEvent( + type: String, + name: String, + details: Map = emptyMap(), + elapsedRealtimeMs: Long = android.os.SystemClock.elapsedRealtime(), + nowEpochMs: Long = System.currentTimeMillis() + ) { + if (!enabled) return + synchronized(lock) { + if (!isActiveLocked(nowEpochMs)) { + clearLocked() + return + } + if (events.size >= MAX_EVENTS) { + events.pollFirst() + droppedEventCount += 1 + } + events.addLast( + DiagnosticEvent( + elapsedRealtimeMs = elapsedRealtimeMs, + type = type.take(MAX_FIELD_CHARS), + name = name.take(MAX_FIELD_CHARS), + details = details.sanitizeDetails() + ) + ) + } + } + + inline fun recordEventIfEnabled( + type: String, + name: String, + elapsedRealtimeMs: Long? = null, + details: () -> Map = { emptyMap() } + ) { + if (!isEnabled) return + if (elapsedRealtimeMs == null) { + recordEvent(type = type, name = name, details = details()) + } else { + recordEvent( + type = type, + name = name, + details = details(), + elapsedRealtimeMs = elapsedRealtimeMs + ) + } + } + + fun markLagNow( + note: String? = null, + elapsedRealtimeMs: Long = android.os.SystemClock.elapsedRealtime(), + nowEpochMs: Long = System.currentTimeMillis() + ) { + recordEvent( + type = EventTypes.USER_MARK, + name = "lag_mark", + details = note + ?.takeIf { it.isNotBlank() } + ?.let { mapOf("note" to it) } + ?: emptyMap(), + elapsedRealtimeMs = elapsedRealtimeMs, + nowEpochMs = nowEpochMs + ) + } + + fun trace(sectionName: String, block: () -> T): T { + if (!enabled) return block() + Trace.beginSection(sectionName.take(MAX_TRACE_SECTION_CHARS)) + return try { + block() + } finally { + Trace.endSection() + } + } + + suspend fun traceSuspend(sectionName: String, block: suspend () -> T): T { + if (!enabled) return block() + Trace.beginSection(sectionName.take(MAX_TRACE_SECTION_CHARS)) + return try { + block() + } finally { + Trace.endSection() + } + } + + fun snapshot(nowEpochMs: Long = System.currentTimeMillis()): Snapshot { + synchronized(lock) { + if (!isActiveLocked(nowEpochMs)) { + clearLocked() + } + return Snapshot( + enabled = enabled, + sessionStartedEpochMs = sessionStartedEpochMs, + expiresAtEpochMs = expiresAtEpochMs, + droppedEventCount = droppedEventCount, + events = events.toList() + ) + } + } + + fun resetForTest() { + synchronized(lock) { + clearLocked() + } + } + + private fun isActiveLocked(nowEpochMs: Long): Boolean = + enabled && expiresAtEpochMs?.let { nowEpochMs < it } == true + + private fun clearLocked() { + enabled = false + sessionStartedEpochMs = null + expiresAtEpochMs = null + droppedEventCount = 0L + events.clear() + } + + private fun Map.sanitizeDetails(): Map { + if (isEmpty()) return emptyMap() + return entries + .take(MAX_DETAIL_ENTRIES) + .associate { (key, value) -> + key.take(MAX_FIELD_CHARS) to value.take(MAX_DETAIL_VALUE_CHARS) + } + } + + private const val MAX_TRACE_SECTION_CHARS = 120 + private const val MAX_FIELD_CHARS = 80 + private const val MAX_DETAIL_VALUE_CHARS = 240 + private const val MAX_DETAIL_ENTRIES = 8 +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/diagnostics/AdvancedPerformanceDiagnosticsController.kt b/app/src/main/java/com/theveloper/pixelplay/data/diagnostics/AdvancedPerformanceDiagnosticsController.kt new file mode 100644 index 000000000..d1d396f74 --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/diagnostics/AdvancedPerformanceDiagnosticsController.kt @@ -0,0 +1,50 @@ +package com.theveloper.pixelplay.data.diagnostics + +import com.theveloper.pixelplay.data.preferences.UserPreferencesRepository +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Singleton +class AdvancedPerformanceDiagnosticsController @Inject constructor( + private val userPreferencesRepository: UserPreferencesRepository +) { + private val stallMonitor = MainThreadStallMonitor() + private var observerJob: Job? = null + private var expiryJob: Job? = null + + fun start(scope: CoroutineScope) { + if (observerJob != null) return + observerJob = scope.launch { + userPreferencesRepository.disableExpiredAdvancedPerformanceDiagnostics() + userPreferencesRepository.advancedPerformanceDiagnosticsSettingsFlow.collectLatest { settings -> + val active = AdvancedPerformanceDiagnostics.configureSession( + enabled = settings.enabled, + startedAtEpochMs = settings.sessionStartedEpochMs, + expiresAtEpochMs = settings.expiresAtEpochMs + ) + expiryJob?.cancel() + if (active && settings.expiresAtEpochMs != null) { + expiryJob = scope.launch { + val delayMs = settings.expiresAtEpochMs - System.currentTimeMillis() + if (delayMs > 0L) delay(delayMs) + userPreferencesRepository.disableExpiredAdvancedPerformanceDiagnostics() + } + } + withContext(Dispatchers.Main.immediate) { + if (active) { + stallMonitor.start() + } else { + stallMonitor.stop() + } + } + } + } + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/diagnostics/DebugPerformanceReport.kt b/app/src/main/java/com/theveloper/pixelplay/data/diagnostics/DebugPerformanceReport.kt index 5811c0ea3..a46266de1 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/diagnostics/DebugPerformanceReport.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/diagnostics/DebugPerformanceReport.kt @@ -31,6 +31,7 @@ data class DebugPerformanceReport( val controllers: ControllerSection, val timings: Map, val offloadEvents: List, + val advancedDiagnostics: AdvancedDiagnosticsSection = AdvancedDiagnosticsSection(), val notes: List ) { fun toJson(): String = PRETTY_JSON.encodeToString(this) @@ -134,6 +135,24 @@ data class DebugPerformanceReport( offloadEvents.forEach { appendLine(" [+${it.elapsedRealtimeMs} ms] ${it.reason}") } } + if (advancedDiagnostics.enabled || advancedDiagnostics.events.isNotEmpty()) { + section("ADVANCED DIAGNOSTICS") + kv("Enabled", advancedDiagnostics.enabled.toString()) + kv("Started", advancedDiagnostics.sessionStartedIso ?: NOT_OBSERVED) + kv("Expires", advancedDiagnostics.expiresAtIso ?: NOT_OBSERVED) + kv("Events retained", advancedDiagnostics.eventCount.toString()) + kv("Events dropped", advancedDiagnostics.droppedEventCount.toString()) + advancedDiagnostics.events.forEach { event -> + val details = event.details.entries.joinToString(", ") { (key, value) -> "$key=$value" } + appendLine( + buildString { + append(" [+${event.elapsedRealtimeMs} ms] ${event.type}/${event.name}") + if (details.isNotBlank()) append(" ($details)") + } + ) + } + } + if (notes.isNotEmpty()) { section("NOTES") notes.forEach { appendLine(" - $it") } @@ -150,7 +169,7 @@ data class DebugPerformanceReport( } companion object { - const val SCHEMA_VERSION = 1 + const val SCHEMA_VERSION = 2 private const val UNKNOWN = "unknown" private const val NOT_OBSERVED = "not observed" private const val NOT_PLAYING = "not playing" @@ -313,3 +332,21 @@ data class OffloadEventEntry( val elapsedRealtimeMs: Long, val reason: String ) + +@Serializable +data class AdvancedDiagnosticsSection( + val enabled: Boolean = false, + val sessionStartedIso: String? = null, + val expiresAtIso: String? = null, + val eventCount: Int = 0, + val droppedEventCount: Long = 0, + val events: List = emptyList() +) + +@Serializable +data class AdvancedDiagnosticEventEntry( + val elapsedRealtimeMs: Long, + val type: String, + val name: String, + val details: Map = emptyMap() +) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/diagnostics/DebugPerformanceReportCollector.kt b/app/src/main/java/com/theveloper/pixelplay/data/diagnostics/DebugPerformanceReportCollector.kt index 45d716fd2..5086b756c 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/diagnostics/DebugPerformanceReportCollector.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/diagnostics/DebugPerformanceReportCollector.kt @@ -114,6 +114,7 @@ class DebugPerformanceReportCollector @Inject constructor( offloadEvents = metrics.offloadEvents.map { OffloadEventEntry(it.elapsedRealtimeMs, it.reason) }, + advancedDiagnostics = collectAdvancedDiagnostics(), notes = buildNotes() ) } @@ -234,21 +235,43 @@ class DebugPerformanceReportCollector @Inject constructor( } ) + private fun collectAdvancedDiagnostics(): AdvancedDiagnosticsSection { + val snapshot = AdvancedPerformanceDiagnostics.snapshot() + return AdvancedDiagnosticsSection( + enabled = snapshot.enabled, + sessionStartedIso = snapshot.sessionStartedEpochMs?.let(::isoFromEpochMs), + expiresAtIso = snapshot.expiresAtEpochMs?.let(::isoFromEpochMs), + eventCount = snapshot.events.size, + droppedEventCount = snapshot.droppedEventCount, + events = snapshot.events.map { event -> + AdvancedDiagnosticEventEntry( + elapsedRealtimeMs = event.elapsedRealtimeMs, + type = event.type, + name = event.name, + details = event.details + ) + } + ) + } + private fun buildNotes(): List = listOf( "Hi-res = sample rate > 48 kHz; ultra-hi-res = sample rate >= 176.4 kHz.", "File sizes are estimated from bitrate × duration (raw sizes are not stored).", "Channel count, bit depth, multichannel and embedded-artwork figures are observed " + "while the app works (scan / playback); they reflect this session, not an exhaustive library probe.", "Timings are in milliseconds and only appear once at least one sample was collected.", + "Advanced diagnostics are opt-in and only appear when enabled before reproducing lag.", "This report contains no file paths, titles, or artists and is safe to share." ) private fun PerformanceMetrics.TimingSnapshot.toReportTiming() = ReportTiming(count = count, minMs = minMs, avgMs = avgMs, maxMs = maxMs, lastMs = lastMs) - private fun isoNow(): String { + private fun isoNow(): String = isoFromEpochMs(System.currentTimeMillis()) + + private fun isoFromEpochMs(epochMs: Long): String { val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US) format.timeZone = TimeZone.getTimeZone("UTC") - return format.format(Date()) + return format.format(Date(epochMs)) } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/diagnostics/MainThreadStallMonitor.kt b/app/src/main/java/com/theveloper/pixelplay/data/diagnostics/MainThreadStallMonitor.kt new file mode 100644 index 000000000..34ee8c63e --- /dev/null +++ b/app/src/main/java/com/theveloper/pixelplay/data/diagnostics/MainThreadStallMonitor.kt @@ -0,0 +1,50 @@ +package com.theveloper.pixelplay.data.diagnostics + +import android.os.SystemClock +import android.view.Choreographer + +/** + * Records large frame gaps while advanced diagnostics are enabled. + * + * Must be started/stopped on the main thread because [Choreographer] is thread-confined. + */ +class MainThreadStallMonitor( + private val thresholdMs: Long = AdvancedPerformanceDiagnostics.FRAME_STALL_THRESHOLD_MS +) { + private var running = false + private var lastFrameNanos = 0L + + private val callback = object : Choreographer.FrameCallback { + override fun doFrame(frameTimeNanos: Long) { + if (!running) return + val previous = lastFrameNanos + if (previous > 0L) { + val gapMs = (frameTimeNanos - previous) / 1_000_000L + if (gapMs >= thresholdMs) { + AdvancedPerformanceDiagnostics.recordEvent( + type = AdvancedPerformanceDiagnostics.EventTypes.FRAME_STALL, + name = "main_thread_frame_gap", + details = mapOf("gapMs" to gapMs.toString()), + elapsedRealtimeMs = SystemClock.elapsedRealtime() + ) + } + } + lastFrameNanos = frameTimeNanos + Choreographer.getInstance().postFrameCallback(this) + } + } + + fun start() { + if (running) return + running = true + lastFrameNanos = 0L + Choreographer.getInstance().postFrameCallback(callback) + } + + fun stop() { + if (!running) return + running = false + lastFrameNanos = 0L + Choreographer.getInstance().removeFrameCallback(callback) + } +} diff --git a/app/src/main/java/com/theveloper/pixelplay/data/diagnostics/PerformanceMetrics.kt b/app/src/main/java/com/theveloper/pixelplay/data/diagnostics/PerformanceMetrics.kt index bca3b488d..049441f99 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/diagnostics/PerformanceMetrics.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/diagnostics/PerformanceMetrics.kt @@ -129,6 +129,15 @@ object PerformanceMetrics { private val maxes = ConcurrentHashMap() private val offloadEvents = ArrayDeque(MAX_OFFLOAD_EVENTS) private val controllers = ConcurrentHashMap() + private val advancedTimelineTimingNames = setOf( + Timings.FULL_SCAN, + Timings.ARTWORK_EXTRACT, + Timings.ARTWORK_DECODE, + Timings.PLAYBACK_PREPARE, + Timings.AUDIO_DECODER_INIT, + Timings.TRANSITION, + Timings.MEDIASESSION_ITEM_BUILD + ) @Volatile var widgetActive: Boolean = false @@ -139,6 +148,20 @@ object PerformanceMetrics { fun recordTiming(name: String, durationMs: Long) { if (durationMs < 0) return timings.computeIfAbsent(name) { TimingStat() }.record(durationMs.toDouble()) + if (AdvancedPerformanceDiagnostics.isEnabled && name in advancedTimelineTimingNames) { + AdvancedPerformanceDiagnostics.recordEvent( + type = when (name) { + Timings.ARTWORK_EXTRACT, Timings.ARTWORK_DECODE -> + AdvancedPerformanceDiagnostics.EventTypes.ARTWORK + Timings.FULL_SCAN -> + AdvancedPerformanceDiagnostics.EventTypes.WORKER + else -> + AdvancedPerformanceDiagnostics.EventTypes.PLAYBACK + }, + name = "timing_$name", + details = mapOf("durationMs" to durationMs.toString()) + ) + } } /** Times [block], records the elapsed wall time under [name], and returns its result. */ @@ -192,6 +215,13 @@ object PerformanceMetrics { fun recordOffloadFallback(reason: String, elapsedRealtimeMs: Long) { increment(Counters.OFFLOAD_FALLBACKS) + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.OFFLOAD, + name = "offload_fallback", + elapsedRealtimeMs = elapsedRealtimeMs + ) { + mapOf("reason" to reason) + } synchronized(offloadEvents) { if (offloadEvents.size >= MAX_OFFLOAD_EVENTS) offloadEvents.pollFirst() offloadEvents.addLast(OffloadEvent(elapsedRealtimeMs, reason)) diff --git a/app/src/main/java/com/theveloper/pixelplay/data/equalizer/EqualizerManager.kt b/app/src/main/java/com/theveloper/pixelplay/data/equalizer/EqualizerManager.kt index 0d092d6e0..3afe4d1b4 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/equalizer/EqualizerManager.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/equalizer/EqualizerManager.kt @@ -4,6 +4,8 @@ package com.theveloper.pixelplay.data.equalizer import android.media.audiofx.Equalizer import android.media.audiofx.BassBoost import android.media.audiofx.Virtualizer +import android.os.SystemClock +import com.theveloper.pixelplay.data.diagnostics.AdvancedPerformanceDiagnostics import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -130,21 +132,67 @@ class EqualizerManager @Inject constructor() { * Call this when the player is created or swapped during crossfade. */ suspend fun attachToAudioSession(audioSessionId: Int) { + if (AdvancedPerformanceDiagnostics.isEnabled) { + AdvancedPerformanceDiagnostics.traceSuspend("Equalizer.attachToAudioSession") { + attachToAudioSessionInternal(audioSessionId) + } + } else { + attachToAudioSessionInternal(audioSessionId) + } + } + + private suspend fun attachToAudioSessionInternal(audioSessionId: Int) { + val attachStartedMs = if (AdvancedPerformanceDiagnostics.isEnabled) { + SystemClock.elapsedRealtime() + } else { + 0L + } + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.AUDIO_EFFECT, + name = "equalizer_attach_start", + elapsedRealtimeMs = attachStartedMs + ) { + mapOf("audioSessionId" to audioSessionId.toString()) + } if (effectsDisabledForProcess) { Timber.tag(TAG).d( "Skipping attachToAudioSession($audioSessionId): audio effects disabled (%s)", effectsDisableReason ?: "unknown reason" ) + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.AUDIO_EFFECT, + name = "equalizer_attach_skipped" + ) { + mapOf( + "audioSessionId" to audioSessionId.toString(), + "reason" to (effectsDisableReason ?: "effects_disabled_for_process") + ) + } return } if (audioSessionId == 0) { Timber.tag(TAG).w("Invalid audio session ID: 0") + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.AUDIO_EFFECT, + name = "equalizer_attach_skipped" + ) { + mapOf("reason" to "invalid_audio_session") + } return } if (currentAudioSessionId == audioSessionId && equalizer != null) { Timber.tag(TAG).d("Already attached to session $audioSessionId") + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.AUDIO_EFFECT, + name = "equalizer_attach_skipped" + ) { + mapOf( + "audioSessionId" to audioSessionId.toString(), + "reason" to "already_attached" + ) + } return } @@ -174,6 +222,15 @@ class EqualizerManager @Inject constructor() { e, "Audio effects unavailable on this device/audio route. Disabling EQ stack for this process." ) + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.AUDIO_EFFECT, + name = "equalizer_init_failed" + ) { + mapOf( + "audioSessionId" to audioSessionId.toString(), + "error" to (effectsDisableReason ?: e.javaClass.simpleName) + ) + } release() return } @@ -193,6 +250,16 @@ class EqualizerManager @Inject constructor() { if (bassBoost != null) Timber.tag(TAG).d("BassBoost initialized on attempt ${retryCount + 1}") } catch (e: Exception) { Timber.tag(TAG).w("BassBoost init failed (attempt ${retryCount + 1}): ${e.message}") + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.AUDIO_EFFECT, + name = "bass_boost_init_failed" + ) { + mapOf( + "audioSessionId" to audioSessionId.toString(), + "attempt" to (retryCount + 1).toString(), + "error" to (e.message ?: e.javaClass.simpleName) + ) + } if (retryCount < maxRetries - 1) kotlinx.coroutines.delay(300) } retryCount++ @@ -213,6 +280,16 @@ class EqualizerManager @Inject constructor() { if (virtualizer != null) Timber.tag(TAG).d("Virtualizer initialized on attempt ${retryCount + 1}") } catch (e: Exception) { Timber.tag(TAG).w("Virtualizer init failed (attempt ${retryCount + 1}): ${e.message}") + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.AUDIO_EFFECT, + name = "virtualizer_init_failed" + ) { + mapOf( + "audioSessionId" to audioSessionId.toString(), + "attempt" to (retryCount + 1).toString(), + "error" to (e.message ?: e.javaClass.simpleName) + ) + } if (retryCount < maxRetries - 1) kotlinx.coroutines.delay(300) } retryCount++ @@ -229,6 +306,15 @@ class EqualizerManager @Inject constructor() { } } catch (e: Exception) { Timber.tag(TAG).w(e, "LoudnessEnhancer not supported on this device") + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.AUDIO_EFFECT, + name = "loudness_enhancer_init_failed" + ) { + mapOf( + "audioSessionId" to audioSessionId.toString(), + "error" to (e.message ?: e.javaClass.simpleName) + ) + } null } @@ -241,9 +327,32 @@ class EqualizerManager @Inject constructor() { applyCurrentEffectStateToAttachedEffects() Timber.tag(TAG).d("Effects attached successfully. EQ bands: ${equalizer?.numberOfBands}, Range: $minEqLevel to $maxEqLevel") + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.AUDIO_EFFECT, + name = "equalizer_attach_success" + ) { + mapOf( + "audioSessionId" to audioSessionId.toString(), + "durationMs" to (SystemClock.elapsedRealtime() - attachStartedMs).toString(), + "eqBands" to (equalizer?.numberOfBands?.toString() ?: "unknown"), + "bassBoostAvailable" to (bassBoost != null).toString(), + "virtualizerAvailable" to (virtualizer != null).toString(), + "loudnessAvailable" to (loudnessEnhancer != null).toString() + ) + } } catch (e: Exception) { Timber.tag(TAG).e(e, "Failed to initialize audio effects") + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.AUDIO_EFFECT, + name = "equalizer_attach_failed" + ) { + mapOf( + "audioSessionId" to audioSessionId.toString(), + "durationMs" to (SystemClock.elapsedRealtime() - attachStartedMs).toString(), + "error" to (e.message ?: e.javaClass.simpleName) + ) + } release() } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt index b743d4f4e..9efca552a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/preferences/UserPreferencesRepository.kt @@ -14,6 +14,7 @@ import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.preferencesDataStore import androidx.media3.common.Player import com.theveloper.pixelplay.data.equalizer.EqualizerPreset +import com.theveloper.pixelplay.data.diagnostics.AdvancedPerformanceDiagnostics import com.theveloper.pixelplay.data.model.FolderSource import com.theveloper.pixelplay.data.model.LyricsSourcePreference import com.theveloper.pixelplay.data.model.PlaybackQueueSnapshot @@ -67,6 +68,15 @@ enum class AlbumArtQuality(val maxSize: Int, val label: String) { ORIGINAL(0, "Original - Maximum quality") } +data class AdvancedPerformanceDiagnosticsSettings( + val enabled: Boolean, + val sessionStartedEpochMs: Long?, + val expiresAtEpochMs: Long? +) { + fun isActive(nowEpochMs: Long = System.currentTimeMillis()): Boolean = + enabled && expiresAtEpochMs?.let { nowEpochMs < it } == true +} + @Singleton class UserPreferencesRepository @Inject constructor( private val dataStore: DataStore, @@ -203,6 +213,12 @@ class UserPreferencesRepository @Inject constructor( val ALBUM_ART_CACHE_LIMIT_MB = intPreferencesKey("album_art_cache_limit_mb") val TAP_BACKGROUND_CLOSES_PLAYER = booleanPreferencesKey("tap_background_closes_player") val HAPTICS_ENABLED = booleanPreferencesKey("haptics_enabled") + val ADVANCED_PERFORMANCE_DIAGNOSTICS_ENABLED = + booleanPreferencesKey("advanced_performance_diagnostics_enabled") + val ADVANCED_PERFORMANCE_DIAGNOSTICS_STARTED_AT = + longPreferencesKey("advanced_performance_diagnostics_started_at_epoch_ms") + val ADVANCED_PERFORMANCE_DIAGNOSTICS_EXPIRES_AT = + longPreferencesKey("advanced_performance_diagnostics_expires_at_epoch_ms") val IMMERSIVE_LYRICS_ENABLED = booleanPreferencesKey("immersive_lyrics_enabled") val IMMERSIVE_LYRICS_TIMEOUT = longPreferencesKey("immersive_lyrics_timeout") val USE_ANIMATED_LYRICS = booleanPreferencesKey("use_animated_lyrics") @@ -1212,6 +1228,45 @@ suspend fun markDirectoryRulesVersionApplied(version: Int) { // ─── Backup / restore ───────────────────────────────────────────────────── + val advancedPerformanceDiagnosticsSettingsFlow: Flow = + pref { preferences -> + AdvancedPerformanceDiagnosticsSettings( + enabled = preferences[PreferencesKeys.ADVANCED_PERFORMANCE_DIAGNOSTICS_ENABLED] ?: false, + sessionStartedEpochMs = + preferences[PreferencesKeys.ADVANCED_PERFORMANCE_DIAGNOSTICS_STARTED_AT], + expiresAtEpochMs = + preferences[PreferencesKeys.ADVANCED_PERFORMANCE_DIAGNOSTICS_EXPIRES_AT] + ) + }.distinctUntilChanged() + + suspend fun setAdvancedPerformanceDiagnosticsEnabled(enabled: Boolean) { + dataStore.edit { preferences -> + if (enabled) { + val now = System.currentTimeMillis() + preferences[PreferencesKeys.ADVANCED_PERFORMANCE_DIAGNOSTICS_ENABLED] = true + preferences[PreferencesKeys.ADVANCED_PERFORMANCE_DIAGNOSTICS_STARTED_AT] = now + preferences[PreferencesKeys.ADVANCED_PERFORMANCE_DIAGNOSTICS_EXPIRES_AT] = + now + AdvancedPerformanceDiagnostics.DEFAULT_SESSION_DURATION_MS + } else { + preferences[PreferencesKeys.ADVANCED_PERFORMANCE_DIAGNOSTICS_ENABLED] = false + preferences.remove(PreferencesKeys.ADVANCED_PERFORMANCE_DIAGNOSTICS_STARTED_AT) + preferences.remove(PreferencesKeys.ADVANCED_PERFORMANCE_DIAGNOSTICS_EXPIRES_AT) + } + } + } + + suspend fun disableExpiredAdvancedPerformanceDiagnostics(nowEpochMs: Long = System.currentTimeMillis()) { + dataStore.edit { preferences -> + val enabled = preferences[PreferencesKeys.ADVANCED_PERFORMANCE_DIAGNOSTICS_ENABLED] ?: false + val expiresAt = preferences[PreferencesKeys.ADVANCED_PERFORMANCE_DIAGNOSTICS_EXPIRES_AT] + if (enabled && (expiresAt == null || nowEpochMs >= expiresAt)) { + preferences[PreferencesKeys.ADVANCED_PERFORMANCE_DIAGNOSTICS_ENABLED] = false + preferences.remove(PreferencesKeys.ADVANCED_PERFORMANCE_DIAGNOSTICS_STARTED_AT) + preferences.remove(PreferencesKeys.ADVANCED_PERFORMANCE_DIAGNOSTICS_EXPIRES_AT) + } + } + } + suspend fun clearPreferencesByKeys(keyNames: Set) { if (keyNames.isEmpty()) return dataStore.edit { preferences -> diff --git a/app/src/main/java/com/theveloper/pixelplay/data/repository/ArtistImageRepository.kt b/app/src/main/java/com/theveloper/pixelplay/data/repository/ArtistImageRepository.kt index af65cb9d5..add155cd1 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/repository/ArtistImageRepository.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/repository/ArtistImageRepository.kt @@ -7,6 +7,7 @@ import android.net.Uri import android.util.Log import android.util.LruCache import com.theveloper.pixelplay.data.database.MusicDao +import com.theveloper.pixelplay.data.diagnostics.AdvancedPerformanceDiagnostics import com.theveloper.pixelplay.data.network.deezer.DeezerApiService import com.theveloper.pixelplay.utils.NetworkRetryUtils import com.theveloper.pixelplay.utils.isRetryableNetworkError @@ -124,28 +125,47 @@ class ArtistImageRepository @Inject constructor( * Useful for batch loading when displaying artist lists. */ suspend fun prefetchArtistImages(artists: List>) = withContext(Dispatchers.IO) { + val startedAt = if (AdvancedPerformanceDiagnostics.isEnabled) System.currentTimeMillis() else 0L + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.WORKER, + name = "artist_image_prefetch_start" + ) { + mapOf("artistCount" to artists.size.toString()) + } // Process in small chunks to avoid creating hundreds of coroutines simultaneously. // Without this, a library with 500 artists creates 500 coroutine objects at once, all // suspended at the semaphore, exhausting the heap and triggering OOM in coroutine machinery. - artists.chunked(PREFETCH_CONCURRENCY * 4).forEach { chunk -> - chunk.map { (artistId, artistName) -> - async { - try { - val normalizedName = artistName.trim().lowercase() - if (memoryCache.get(normalizedName) == null && !failedFetches.contains(normalizedName)) { - prefetchSemaphore.withPermit { - getArtistImageUrl(artistName, artistId) + try { + artists.chunked(PREFETCH_CONCURRENCY * 4).forEach { chunk -> + chunk.map { (artistId, artistName) -> + async { + try { + val normalizedName = artistName.trim().lowercase() + if (memoryCache.get(normalizedName) == null && !failedFetches.contains(normalizedName)) { + prefetchSemaphore.withPermit { + getArtistImageUrl(artistName, artistId) + } + } else { + Timber.tag(TAG).d("Skipping prefetch for $artistName") //check } - } else { - Timber.tag(TAG).d("Skipping prefetch for $artistName") //check + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.tag(TAG).w("Failed to prefetch image for $artistName: ${e.message}") } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Timber.tag(TAG).w("Failed to prefetch image for $artistName: ${e.message}") } - } - }.awaitAll() + }.awaitAll() + } + } finally { + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.WORKER, + name = "artist_image_prefetch_end" + ) { + mapOf( + "artistCount" to artists.size.toString(), + "durationMs" to (System.currentTimeMillis() - startedAt).toString() + ) + } } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt b/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt index fdd506da3..c8f05dc0a 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt @@ -62,6 +62,7 @@ import com.theveloper.pixelplay.data.netease.NeteaseStreamProxy import com.theveloper.pixelplay.data.navidrome.NavidromeStreamProxy import com.theveloper.pixelplay.data.qqmusic.QqMusicStreamProxy import androidx.core.net.toUri +import com.theveloper.pixelplay.data.diagnostics.AdvancedPerformanceDiagnostics data class ActiveDecoderInfo( val name: String, @@ -455,6 +456,16 @@ class DualPlayerEngine @Inject constructor( PerformanceMetrics.Timings.AUDIO_DECODER_INIT, initializationDurationMs ) + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.PLAYBACK, + name = "audio_decoder_initialized" + ) { + mapOf( + "decoderName" to decoderName, + "isHardware" to isHardware.toString(), + "initializationDurationMs" to initializationDurationMs.toString() + ) + } Timber.tag("DualPlayerEngine").d("Audio decoder initialized: %s (Hardware: %b)", decoderName, isHardware) } @@ -470,11 +481,29 @@ class DualPlayerEngine @Inject constructor( sampleRate = if (format.sampleRate == Format.NO_VALUE) 0 else format.sampleRate, pcmEncoding = if (format.pcmEncoding == Format.NO_VALUE) 0 else format.pcmEncoding ) + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.PLAYBACK, + name = "audio_format_changed" + ) { + mapOf( + "mime" to (format.sampleMimeType ?: "unknown"), + "sampleRate" to format.sampleRate.toString(), + "channels" to format.channelCount.toString(), + "pcmEncoding" to format.pcmEncoding.toString(), + "bitrate" to format.bitrate.toString() + ) + } } override fun onAudioSessionIdChanged(audioSessionId: Int) { if (audioSessionId != 0 && _activeAudioSessionId.value != audioSessionId) { _activeAudioSessionId.value = audioSessionId + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.PLAYBACK, + name = "audio_session_changed" + ) { + mapOf("audioSessionId" to audioSessionId.toString()) + } Timber.tag("TransitionDebug").d("Master audio session changed: %d", audioSessionId) } } @@ -482,6 +511,16 @@ class DualPlayerEngine @Inject constructor( override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { lastMediaItemTransitionAtMs = SystemClock.elapsedRealtime() cancelAudioOffloadFallback() + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.PLAYBACK, + name = "media_item_transition", + elapsedRealtimeMs = lastMediaItemTransitionAtMs + ) { + mapOf( + "reason" to reason.toString(), + "scheme" to (mediaItem?.localConfiguration?.uri?.scheme ?: "unknown") + ) + } // If the transition was not automatic (e.g. user skip or playlist change), // immediately cancel any background crossfade logic to ensure responsiveness. @@ -539,6 +578,11 @@ class DualPlayerEngine @Inject constructor( Player.STATE_BUFFERING -> { val now = SystemClock.elapsedRealtime() if (bufferingStartedAtMs == 0L) bufferingStartedAtMs = now + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.PLAYBACK, + name = "playback_buffering", + elapsedRealtimeMs = now + ) val timeSincePlayingMs = now - lastPlayingAtMs val timeSinceSeekMs = now - lastSeekAtMs val timeSinceTransitionMs = now - lastTransitionFinishedAtMs @@ -567,10 +611,17 @@ class DualPlayerEngine @Inject constructor( } Player.STATE_READY -> { if (bufferingStartedAtMs > 0L) { + val prepareDurationMs = SystemClock.elapsedRealtime() - bufferingStartedAtMs PerformanceMetrics.recordTiming( PerformanceMetrics.Timings.PLAYBACK_PREPARE, - SystemClock.elapsedRealtime() - bufferingStartedAtMs + prepareDurationMs ) + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.PLAYBACK, + name = "playback_ready_after_buffering" + ) { + mapOf("prepareDurationMs" to prepareDurationMs.toString()) + } bufferingStartedAtMs = 0L } scheduleAudioOffloadFallbackIfNeeded(playerA) @@ -878,6 +929,12 @@ class DualPlayerEngine @Inject constructor( private fun rebuildPlayersPreservingMasterState(logMessage: String) { cancelAudioOffloadFallback() + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.PLAYBACK, + name = "player_rebuild_start" + ) { + mapOf("reason" to logMessage) + } val desiredPlayWhenReady = playerA.playWhenReady // Guard against snapshotting a position that landed during a bad early-startup seek @@ -920,6 +977,12 @@ class DualPlayerEngine @Inject constructor( _activeAudioSessionId.value = playerA.audioSessionId onPlayerSwappedListeners.forEach { it(playerA) } + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.PLAYBACK, + name = "player_rebuild_end" + ) { + mapOf("audioSessionId" to playerA.audioSessionId.toString()) + } Timber.tag("DualPlayerEngine").d(logMessage) } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/worker/NavidromeSyncWorker.kt b/app/src/main/java/com/theveloper/pixelplay/data/worker/NavidromeSyncWorker.kt index 30b640acc..917aad182 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/worker/NavidromeSyncWorker.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/worker/NavidromeSyncWorker.kt @@ -6,6 +6,7 @@ import androidx.work.CoroutineWorker import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkerParameters import androidx.work.workDataOf +import com.theveloper.pixelplay.data.diagnostics.AdvancedPerformanceDiagnostics import com.theveloper.pixelplay.data.navidrome.NavidromeRepository import dagger.assisted.Assisted import dagger.assisted.AssistedInject @@ -23,6 +24,16 @@ class NavidromeSyncWorker @AssistedInject constructor( val playlistId = inputData.getString(KEY_PLAYLIST_ID) Timber.d("NavidromeSyncWorker: Starting sync (type=$syncType, playlistId=$playlistId)") + val startedAt = if (AdvancedPerformanceDiagnostics.isEnabled) System.currentTimeMillis() else 0L + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.WORKER, + name = "navidrome_sync_start" + ) { + mapOf( + "syncType" to syncType, + "playlistScoped" to (playlistId != null).toString() + ) + } return try { when (syncType) { @@ -46,9 +57,28 @@ class NavidromeSyncWorker @AssistedInject constructor( } } } + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.WORKER, + name = "navidrome_sync_success" + ) { + mapOf( + "syncType" to syncType, + "durationMs" to (System.currentTimeMillis() - startedAt).toString() + ) + } Result.success() } catch (e: Exception) { Timber.e(e, "NavidromeSyncWorker: Sync failed") + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.WORKER, + name = "navidrome_sync_failure" + ) { + mapOf( + "syncType" to syncType, + "durationMs" to (System.currentTimeMillis() - startedAt).toString(), + "error" to (e.message ?: e.javaClass.simpleName) + ) + } Result.failure(workDataOf(ERROR_MESSAGE to e.message)) } } diff --git a/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt b/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt index 667143cfb..02d160a44 100644 --- a/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt +++ b/app/src/main/java/com/theveloper/pixelplay/data/worker/SyncWorker.kt @@ -26,6 +26,7 @@ import com.theveloper.pixelplay.data.database.SourceType import com.theveloper.pixelplay.data.database.TelegramDao // Added import com.theveloper.pixelplay.data.database.resolveAlbumArtUri import com.theveloper.pixelplay.data.database.serializeArtistRefs +import com.theveloper.pixelplay.data.diagnostics.AdvancedPerformanceDiagnostics import com.theveloper.pixelplay.data.diagnostics.PerformanceMetrics import com.theveloper.pixelplay.data.model.ArtistRef import com.theveloper.pixelplay.data.navidrome.NavidromeRepository @@ -92,6 +93,18 @@ constructor( val syncMode = SyncMode.valueOf(syncModeName) val forceMetadata = inputData.getBoolean(INPUT_FORCE_METADATA, false) val runMaintenance = inputData.getBoolean(INPUT_RUN_MAINTENANCE, true) + val workerStartedAt = System.currentTimeMillis() + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.WORKER, + name = "sync_worker_start" + ) { + mapOf( + "mode" to syncMode.name, + "forceMetadata" to forceMetadata.toString(), + "runMaintenance" to runMaintenance.toString(), + "attempt" to runAttemptCount.toString() + ) + } // Battery / thermal: defer background INCREMENTAL syncs while // music is playing. FULL and REBUILD are skipped from this @@ -107,12 +120,22 @@ constructor( Timber.tag(TAG).d( "SyncWorker deferring INCREMENTAL sync (playback active, attempt=$runAttemptCount)" ) + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.WORKER, + name = "sync_worker_deferred" + ) { + mapOf( + "mode" to syncMode.name, + "reason" to "playback_active", + "attempt" to runAttemptCount.toString() + ) + } return@withContext Result.retry() } Timber.tag(TAG) .i("Starting MediaStore synchronization (Mode: $syncMode, ForceMetadata: $forceMetadata)...") - val startTime = System.currentTimeMillis() + val startTime = workerStartedAt val artistDelimiters = userPreferencesRepository.artistDelimitersFlow.first() val artistWordDelimiters = userPreferencesRepository.artistWordDelimitersFlow.first() @@ -313,6 +336,17 @@ constructor( val totalSongs = musicDao.getSongCount().first() if (!runMaintenance) { Timber.tag(TAG).d("Skipping library maintenance phases for local-only sync.") + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.WORKER, + name = "sync_worker_success" + ) { + mapOf( + "mode" to syncMode.name, + "durationMs" to (endTime - startTime).toString(), + "totalSongs" to totalSongs.toString(), + "runMaintenance" to "false" + ) + } return@withContext Result.success( workDataOf(OUTPUT_TOTAL_SONGS to totalSongs.toLong()) ) @@ -433,9 +467,26 @@ constructor( // Recalculate total val finalTotalSongs = musicDao.getSongCount().first() + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.WORKER, + name = "sync_worker_success" + ) { + mapOf( + "mode" to syncMode.name, + "durationMs" to (System.currentTimeMillis() - startTime).toString(), + "totalSongs" to finalTotalSongs.toString(), + "runMaintenance" to runMaintenance.toString() + ) + } Result.success(workDataOf(OUTPUT_TOTAL_SONGS to finalTotalSongs.toLong())) } catch (e: Exception) { Log.e(TAG, "Error during MediaStore synchronization", e) + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.WORKER, + name = "sync_worker_failure" + ) { + mapOf("error" to (e.message ?: e.javaClass.simpleName)) + } Result.failure() } finally { Trace.endSection() // End SyncWorker.doWork diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetV2.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetV2.kt index dfd3cd1b2..d692ed9fd 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetV2.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/UnifiedPlayerSheetV2.kt @@ -61,6 +61,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState +import com.theveloper.pixelplay.data.diagnostics.AdvancedPerformanceDiagnostics import com.theveloper.pixelplay.data.model.Song import com.theveloper.pixelplay.data.preferences.sanitizeNavBarCornerRadius import com.theveloper.pixelplay.presentation.components.scoped.PlayerAlbumNavigationEffect @@ -336,6 +337,20 @@ fun UnifiedPlayerSheetV2( showPlayerContentArea && previousSheetState == PlayerSheetState.EXPANDED && currentSheetContentState == PlayerSheetState.COLLAPSED + if (previousSheetState != currentSheetContentState) { + val fromState = previousSheetState + val toState = currentSheetContentState + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.UI, + name = "player_sheet_state_changed" + ) { + mapOf( + "from" to fromState.name, + "to" to toState.name, + "showPlayerContentArea" to showPlayerContentArea.toString() + ) + } + } previousSheetState = currentSheetContentState scope.launch { animatePlayerSheet(targetExpanded = targetExpanded) diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt index 573573a9c..87c5ca688 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/components/player/FullPlayerContent.kt @@ -113,6 +113,7 @@ import androidx.compose.ui.res.stringResource import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import com.theveloper.pixelplay.R +import com.theveloper.pixelplay.data.diagnostics.AdvancedPerformanceDiagnostics import com.theveloper.pixelplay.data.model.Artist import com.theveloper.pixelplay.data.model.Song import com.theveloper.pixelplay.data.preferences.AlbumArtQuality @@ -1810,6 +1811,15 @@ private fun PlayerProgressBarSection( val targetMs = (finalValue * durationForCalc).roundToLong() targetSeekFraction = finalValue lastSeekFinishedTime = System.currentTimeMillis() + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.UI, + name = "player_seek_commit" + ) { + mapOf( + "targetMs" to targetMs.toString(), + "durationMs" to displayDurationValue.toString() + ) + } onSeek(targetMs) sliderDragValue = null }, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/DeviceCapabilitiesScreen.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/DeviceCapabilitiesScreen.kt index 3f54da494..26891c927 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/screens/DeviceCapabilitiesScreen.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/screens/DeviceCapabilitiesScreen.kt @@ -56,6 +56,7 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -103,6 +104,9 @@ import com.theveloper.pixelplay.presentation.viewmodel.LocalMusicStorageSummary import com.theveloper.pixelplay.presentation.viewmodel.MemorySummary import com.theveloper.pixelplay.presentation.viewmodel.PlaybackCompatibilitySummary import com.theveloper.pixelplay.presentation.viewmodel.PlayerViewModel +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale import kotlinx.coroutines.launch import racra.compose.smooth_corner_rect_library.AbsoluteSmoothCornerShape import kotlin.math.roundToInt @@ -191,6 +195,8 @@ fun DeviceCapabilitiesScreen( lazyListState = lazyListState, topPadding = currentTopBarHeightDp, onGenerateReport = viewModel::generatePerformanceReport, + onAdvancedDiagnosticsChange = viewModel::setAdvancedPerformanceDiagnosticsEnabled, + onMarkLagNow = viewModel::markLagNow, modifier = Modifier.fillMaxSize() ) } @@ -213,6 +219,8 @@ private fun DeviceCapabilitiesContent( lazyListState: LazyListState, topPadding: Dp, onGenerateReport: () -> Unit, + onAdvancedDiagnosticsChange: (Boolean) -> Unit, + onMarkLagNow: () -> Unit, modifier: Modifier = Modifier ) { LazyColumn( @@ -274,7 +282,11 @@ private fun DeviceCapabilitiesContent( PerformanceReportCard( report = state.performanceReport, isGenerating = state.isGeneratingReport, - onGenerate = onGenerateReport + advancedDiagnosticsEnabled = state.advancedDiagnosticsEnabled, + advancedDiagnosticsExpiresAtEpochMs = state.advancedDiagnosticsExpiresAtEpochMs, + onGenerate = onGenerateReport, + onAdvancedDiagnosticsChange = onAdvancedDiagnosticsChange, + onMarkLagNow = onMarkLagNow ) } } @@ -284,13 +296,18 @@ private fun DeviceCapabilitiesContent( private fun PerformanceReportCard( report: String?, isGenerating: Boolean, + advancedDiagnosticsEnabled: Boolean, + advancedDiagnosticsExpiresAtEpochMs: Long?, onGenerate: () -> Unit, + onAdvancedDiagnosticsChange: (Boolean) -> Unit, + onMarkLagNow: () -> Unit, modifier: Modifier = Modifier ) { val context = LocalContext.current val clipboardManager = LocalClipboardManager.current val copiedMessage = stringResource(R.string.device_capabilities_report_copied) val shareTitle = stringResource(R.string.device_capabilities_report_share_title) + val lagMarkedMessage = stringResource(R.string.device_capabilities_advanced_diagnostics_marked) CapabilityCard( title = stringResource(R.string.device_capabilities_report_title), @@ -303,6 +320,24 @@ private fun PerformanceReportCard( color = MaterialTheme.colorScheme.onSurfaceVariant ) + AdvancedDiagnosticsToggleRow( + enabled = advancedDiagnosticsEnabled, + expiresAtEpochMs = advancedDiagnosticsExpiresAtEpochMs, + onEnabledChange = onAdvancedDiagnosticsChange + ) + + if (advancedDiagnosticsEnabled) { + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + onClick = { + onMarkLagNow() + Toast.makeText(context, lagMarkedMessage, Toast.LENGTH_SHORT).show() + } + ) { + Text(stringResource(R.string.device_capabilities_advanced_diagnostics_mark_lag)) + } + } + Button( modifier = Modifier.fillMaxWidth(), onClick = onGenerate, @@ -391,6 +426,54 @@ private fun PerformanceReportCard( } } +@Composable +private fun AdvancedDiagnosticsToggleRow( + enabled: Boolean, + expiresAtEpochMs: Long?, + onEnabledChange: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = AbsoluteSmoothCornerShape(18.dp, 60), + color = MaterialTheme.colorScheme.surfaceContainerLow + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = stringResource(R.string.device_capabilities_advanced_diagnostics_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = if (enabled && expiresAtEpochMs != null) { + stringResource( + R.string.device_capabilities_advanced_diagnostics_expires, + formatDiagnosticsExpiry(expiresAtEpochMs) + ) + } else { + stringResource(R.string.device_capabilities_advanced_diagnostics_description) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = enabled, + onCheckedChange = onEnabledChange + ) + } + } +} + @Composable private fun PlaybackReadinessCard( deviceInfo: Map, @@ -1152,6 +1235,9 @@ private fun Float.visibleProgress(): Float { return if (clamped > 0f && clamped < 0.01f) 0.01f else clamped } +private fun formatDiagnosticsExpiry(epochMs: Long): String = + SimpleDateFormat("MMM d, HH:mm", Locale.getDefault()).format(Date(epochMs)) + @Composable private fun OutputRouteRow( name: String, diff --git a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/DeviceCapabilitiesViewModel.kt b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/DeviceCapabilitiesViewModel.kt index d55b4a9e6..69cb6573e 100644 --- a/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/DeviceCapabilitiesViewModel.kt +++ b/app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/DeviceCapabilitiesViewModel.kt @@ -18,6 +18,8 @@ import androidx.media3.common.util.UnstableApi import com.theveloper.pixelplay.data.database.DeviceCapabilitySongRow import com.theveloper.pixelplay.data.database.MusicDao import com.theveloper.pixelplay.data.database.SourceType +import com.theveloper.pixelplay.data.diagnostics.AdvancedPerformanceDiagnostics +import com.theveloper.pixelplay.data.preferences.UserPreferencesRepository import com.theveloper.pixelplay.data.service.player.ActiveDecoderInfo import com.theveloper.pixelplay.data.service.player.DualPlayerEngine import com.theveloper.pixelplay.data.service.player.HiFiCapabilityChecker @@ -29,6 +31,7 @@ import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -136,6 +139,8 @@ data class DeviceCapabilitiesState( val decoderInfo: ActiveDecoderInfo? = null, val isLoading: Boolean = true, val isGeneratingReport: Boolean = false, + val advancedDiagnosticsEnabled: Boolean = false, + val advancedDiagnosticsExpiresAtEpochMs: Long? = null, val performanceReport: String? = null ) @@ -149,6 +154,7 @@ private data class AudioFormatCandidate( class DeviceCapabilitiesViewModel @Inject constructor( @param:ApplicationContext private val context: Context, private val engine: DualPlayerEngine, + private val userPreferencesRepository: UserPreferencesRepository, private val musicDao: MusicDao, private val reportCollector: com.theveloper.pixelplay.data.diagnostics.DebugPerformanceReportCollector ) : ViewModel() { @@ -157,6 +163,7 @@ class DeviceCapabilitiesViewModel @Inject constructor( val state = _state.asStateFlow() init { + observeAdvancedDiagnostics() loadCapabilities() } @@ -186,6 +193,32 @@ class DeviceCapabilitiesViewModel @Inject constructor( } } + fun setAdvancedPerformanceDiagnosticsEnabled(enabled: Boolean) { + viewModelScope.launch { + userPreferencesRepository.setAdvancedPerformanceDiagnosticsEnabled(enabled) + } + } + + fun markLagNow() { + AdvancedPerformanceDiagnostics.markLagNow(note = "Marked from Device capabilities screen") + } + + private fun observeAdvancedDiagnostics() { + viewModelScope.launch { + userPreferencesRepository.disableExpiredAdvancedPerformanceDiagnostics() + userPreferencesRepository.advancedPerformanceDiagnosticsSettingsFlow.collect { settings -> + val active = settings.isActive() + if (!active && settings.enabled) { + userPreferencesRepository.disableExpiredAdvancedPerformanceDiagnostics() + } + _state.value = _state.value.copy( + advancedDiagnosticsEnabled = active, + advancedDiagnosticsExpiresAtEpochMs = settings.expiresAtEpochMs.takeIf { active } + ) + } + } + } + private fun loadCapabilities() { viewModelScope.launch { val exoInfo = getExoPlayerInfo() @@ -199,6 +232,7 @@ class DeviceCapabilitiesViewModel @Inject constructor( val memorySummary = getMemorySummary() val decoderInfo = engine.activeDecoderInfo.value + val current = _state.value DeviceCapabilitiesState( deviceInfo = deviceInfo, audioCapabilities = audioCaps, @@ -208,6 +242,9 @@ class DeviceCapabilitiesViewModel @Inject constructor( formatSupport = formatSupport, memorySummary = memorySummary, decoderInfo = decoderInfo, + advancedDiagnosticsEnabled = current.advancedDiagnosticsEnabled, + advancedDiagnosticsExpiresAtEpochMs = current.advancedDiagnosticsExpiresAtEpochMs, + performanceReport = current.performanceReport, isLoading = false ) } diff --git a/app/src/main/res/values/strings_presentation_batch_g.xml b/app/src/main/res/values/strings_presentation_batch_g.xml index 692ebf5bc..bc32ba102 100644 --- a/app/src/main/res/values/strings_presentation_batch_g.xml +++ b/app/src/main/res/values/strings_presentation_batch_g.xml @@ -155,6 +155,11 @@ Share Report copied to clipboard PixelPlay performance report + Advanced performance diagnostics + Off by default. Records a short lag timeline for beta troubleshooting. + Active until %1$s + Mark lag now + Lag moment marked Compatibility Findings No major incompatibilities Your indexed tracks match the decoders Android reports on this device. diff --git a/app/src/test/java/com/theveloper/pixelplay/data/diagnostics/AdvancedPerformanceDiagnosticsTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/diagnostics/AdvancedPerformanceDiagnosticsTest.kt new file mode 100644 index 000000000..140402c9c --- /dev/null +++ b/app/src/test/java/com/theveloper/pixelplay/data/diagnostics/AdvancedPerformanceDiagnosticsTest.kt @@ -0,0 +1,141 @@ +package com.theveloper.pixelplay.data.diagnostics + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class AdvancedPerformanceDiagnosticsTest { + + @BeforeEach + fun reset() { + AdvancedPerformanceDiagnostics.resetForTest() + } + + @Test + fun recordEvent_whenDisabled_dropsEventAndKeepsNoTimeline() { + AdvancedPerformanceDiagnostics.recordEvent( + type = AdvancedPerformanceDiagnostics.EventTypes.PLAYBACK, + name = "buffering", + details = mapOf("state" to "BUFFERING"), + elapsedRealtimeMs = 100L, + nowEpochMs = 1_000L + ) + + val snapshot = AdvancedPerformanceDiagnostics.snapshot(nowEpochMs = 1_000L) + + assertThat(snapshot.enabled).isFalse() + assertThat(snapshot.events).isEmpty() + assertThat(snapshot.droppedEventCount).isEqualTo(0L) + } + + @Test + fun recordEventIfEnabled_whenDisabled_doesNotEvaluateDetails() { + var detailsEvaluated = false + + AdvancedPerformanceDiagnostics.recordEventIfEnabled( + type = AdvancedPerformanceDiagnostics.EventTypes.PLAYBACK, + name = "buffering" + ) { + detailsEvaluated = true + mapOf("state" to "BUFFERING") + } + + assertThat(detailsEvaluated).isFalse() + assertThat(AdvancedPerformanceDiagnostics.snapshot(nowEpochMs = 1_000L).events).isEmpty() + } + + @Test + fun enabledSession_recordsEventsWithSessionBounds() { + AdvancedPerformanceDiagnostics.startSession( + startedAtEpochMs = 1_000L, + durationMs = 60_000L + ) + + AdvancedPerformanceDiagnostics.recordEvent( + type = AdvancedPerformanceDiagnostics.EventTypes.AUDIO_EFFECT, + name = "equalizer_attach_start", + details = mapOf("audioSessionId" to "42"), + elapsedRealtimeMs = 200L, + nowEpochMs = 2_000L + ) + + val snapshot = AdvancedPerformanceDiagnostics.snapshot(nowEpochMs = 2_000L) + + assertThat(snapshot.enabled).isTrue() + assertThat(snapshot.sessionStartedEpochMs).isEqualTo(1_000L) + assertThat(snapshot.expiresAtEpochMs).isEqualTo(61_000L) + assertThat(snapshot.events).hasSize(1) + assertThat(snapshot.events.single().type).isEqualTo(AdvancedPerformanceDiagnostics.EventTypes.AUDIO_EFFECT) + assertThat(snapshot.events.single().details).containsEntry("audioSessionId", "42") + } + + @Test + fun eventTimeline_isBoundedAndCountsDroppedEvents() { + AdvancedPerformanceDiagnostics.startSession( + startedAtEpochMs = 1_000L, + durationMs = 60_000L + ) + + repeat(AdvancedPerformanceDiagnostics.MAX_EVENTS + 3) { index -> + AdvancedPerformanceDiagnostics.recordEvent( + type = AdvancedPerformanceDiagnostics.EventTypes.WORKER, + name = "event_$index", + elapsedRealtimeMs = index.toLong(), + nowEpochMs = 2_000L + ) + } + + val snapshot = AdvancedPerformanceDiagnostics.snapshot(nowEpochMs = 2_000L) + + assertThat(snapshot.events).hasSize(AdvancedPerformanceDiagnostics.MAX_EVENTS) + assertThat(snapshot.events.first().name).isEqualTo("event_3") + assertThat(snapshot.events.last().name).isEqualTo("event_${AdvancedPerformanceDiagnostics.MAX_EVENTS + 2}") + assertThat(snapshot.droppedEventCount).isEqualTo(3L) + } + + @Test + fun expiredSession_disablesAndClearsTimeline() { + AdvancedPerformanceDiagnostics.startSession( + startedAtEpochMs = 1_000L, + durationMs = 1_000L + ) + AdvancedPerformanceDiagnostics.recordEvent( + type = AdvancedPerformanceDiagnostics.EventTypes.USER_MARK, + name = "lag_mark", + elapsedRealtimeMs = 50L, + nowEpochMs = 1_500L + ) + + val snapshot = AdvancedPerformanceDiagnostics.snapshot(nowEpochMs = 2_000L) + + assertThat(snapshot.enabled).isFalse() + assertThat(snapshot.events).isEmpty() + assertThat(snapshot.sessionStartedEpochMs).isNull() + assertThat(snapshot.expiresAtEpochMs).isNull() + } + + @Test + fun markLagNow_recordsUserMarkerOnlyWhenEnabled() { + AdvancedPerformanceDiagnostics.markLagNow( + note = "player stuttered", + elapsedRealtimeMs = 50L, + nowEpochMs = 1_000L + ) + assertThat(AdvancedPerformanceDiagnostics.snapshot(nowEpochMs = 1_000L).events).isEmpty() + + AdvancedPerformanceDiagnostics.startSession( + startedAtEpochMs = 1_000L, + durationMs = 60_000L + ) + AdvancedPerformanceDiagnostics.markLagNow( + note = "player stuttered", + elapsedRealtimeMs = 75L, + nowEpochMs = 1_500L + ) + + val event = AdvancedPerformanceDiagnostics.snapshot(nowEpochMs = 1_500L).events.single() + assertThat(event.type).isEqualTo(AdvancedPerformanceDiagnostics.EventTypes.USER_MARK) + assertThat(event.name).isEqualTo("lag_mark") + assertThat(event.details).containsEntry("note", "player stuttered") + } +} diff --git a/app/src/test/java/com/theveloper/pixelplay/data/diagnostics/DebugPerformanceReportTest.kt b/app/src/test/java/com/theveloper/pixelplay/data/diagnostics/DebugPerformanceReportTest.kt index 51ced401f..88ee8a572 100644 --- a/app/src/test/java/com/theveloper/pixelplay/data/diagnostics/DebugPerformanceReportTest.kt +++ b/app/src/test/java/com/theveloper/pixelplay/data/diagnostics/DebugPerformanceReportTest.kt @@ -160,4 +160,33 @@ class DebugPerformanceReportTest { assertThat(DebugPerformanceReport.pcmEncodingLabel(4)).isEqualTo("32-bit float") assertThat(DebugPerformanceReport.pcmEncodingLabel(999)).contains("999") } + + @Test + fun advancedDiagnosticsTimeline_roundTripsAndRendersWhenPresent() { + val report = sampleReport().copy( + advancedDiagnostics = AdvancedDiagnosticsSection( + enabled = true, + sessionStartedIso = "2026-06-03T11:00:00Z", + expiresAtIso = "2026-06-04T11:00:00Z", + eventCount = 1, + droppedEventCount = 0, + events = listOf( + AdvancedDiagnosticEventEntry( + elapsedRealtimeMs = 123_456L, + type = AdvancedPerformanceDiagnostics.EventTypes.USER_MARK, + name = "lag_mark", + details = mapOf("note" to "Player UI stuttered") + ) + ) + ) + ) + + val decoded = Json.decodeFromString(DebugPerformanceReport.serializer(), report.toJson()) + val text = report.toPlainText() + + assertThat(decoded).isEqualTo(report) + assertThat(text).contains("== ADVANCED DIAGNOSTICS ==") + assertThat(text).contains("lag_mark") + assertThat(text).contains("Player UI stuttered") + } }