Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -68,6 +69,9 @@ class PixelPlayApplication : Application(), ImageLoaderFactory, Configuration.Pr
@Inject
lateinit var userPreferencesRepository: dagger.Lazy<UserPreferencesRepository>

@Inject
lateinit var advancedPerformanceDiagnosticsController: dagger.Lazy<AdvancedPerformanceDiagnosticsController>

private val startupScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

// AÑADE EL COMPANION OBJECT
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, String> = emptyMap()
)

data class Snapshot(
val enabled: Boolean,
val sessionStartedEpochMs: Long?,
val expiresAtEpochMs: Long?,
val droppedEventCount: Long,
val events: List<DiagnosticEvent>
)

private val lock = Any()
private val events = ArrayDeque<DiagnosticEvent>(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<String, String> = 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<String, String> = { 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 <T> 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 <T> 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<String, String>.sanitizeDetails(): Map<String, String> {
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
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ data class DebugPerformanceReport(
val controllers: ControllerSection,
val timings: Map<String, ReportTiming>,
val offloadEvents: List<OffloadEventEntry>,
val advancedDiagnostics: AdvancedDiagnosticsSection = AdvancedDiagnosticsSection(),
val notes: List<String>
) {
fun toJson(): String = PRETTY_JSON.encodeToString(this)
Expand Down Expand Up @@ -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") }
Expand All @@ -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"
Expand Down Expand Up @@ -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<AdvancedDiagnosticEventEntry> = emptyList()
)

@Serializable
data class AdvancedDiagnosticEventEntry(
val elapsedRealtimeMs: Long,
val type: String,
val name: String,
val details: Map<String, String> = emptyMap()
)
Loading
Loading