fix(android): persist startup and runtime exit diagnostics
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
package com.edde746.plezy
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
internal data class HistoricalExitRecord(
|
||||
val reason: Int,
|
||||
val status: Int,
|
||||
val importance: Int,
|
||||
val timestamp: Long
|
||||
)
|
||||
|
||||
internal object AndroidStartupPhases {
|
||||
const val NATIVE_ON_CREATE = "native_on_create"
|
||||
private val allowed = setOf(
|
||||
NATIVE_ON_CREATE,
|
||||
"dart_main",
|
||||
"runApp",
|
||||
"first_frame",
|
||||
"database_open_started",
|
||||
"database_ready",
|
||||
"credentials_loaded",
|
||||
"binding_started",
|
||||
"binding_settled",
|
||||
"main_screen"
|
||||
)
|
||||
|
||||
fun sanitize(raw: String?): String? = raw?.takeIf(allowed::contains)
|
||||
}
|
||||
|
||||
internal class StartupPhaseStore(
|
||||
readPhase: () -> String?,
|
||||
private val persistPhase: (String) -> Boolean
|
||||
) {
|
||||
val previousPhase: String? = AndroidStartupPhases.sanitize(readPhase())
|
||||
|
||||
@Synchronized
|
||||
fun mark(raw: String?): Boolean {
|
||||
val phase = AndroidStartupPhases.sanitize(raw) ?: return false
|
||||
return persistPhase(phase)
|
||||
}
|
||||
}
|
||||
|
||||
internal data class RuntimeDiagnosticSnapshot(
|
||||
val codecContext: String? = null,
|
||||
val channelCount: Int? = null,
|
||||
val sampleRate: Int? = null,
|
||||
val selectedDecoder: String? = null,
|
||||
val passthroughEnabled: Boolean? = null,
|
||||
val downmixEnabled: Boolean? = null,
|
||||
val normalizationEnabled: Boolean? = null,
|
||||
val uiState: String? = null
|
||||
)
|
||||
|
||||
internal object AndroidRuntimeDiagnostics {
|
||||
const val UI_STARTUP = "startup"
|
||||
const val UI_AUTHENTICATION = "authentication"
|
||||
const val UI_MAIN_SCREEN = "main_screen"
|
||||
const val UI_PLAYER = "player"
|
||||
const val UI_PLAYER_DISPOSED = "player_disposed"
|
||||
|
||||
private const val PREFERENCES_NAME = "plezy_runtime_diagnostics"
|
||||
private const val KEY_CODEC_CONTEXT = "codec_context"
|
||||
private const val KEY_CHANNEL_COUNT = "channel_count"
|
||||
private const val KEY_SAMPLE_RATE = "sample_rate"
|
||||
private const val KEY_SELECTED_DECODER = "selected_decoder"
|
||||
private const val KEY_PASSTHROUGH_ENABLED = "passthrough_enabled"
|
||||
private const val KEY_DOWNMIX_ENABLED = "downmix_enabled"
|
||||
private const val KEY_NORMALIZATION_ENABLED = "normalization_enabled"
|
||||
private const val KEY_UI_STATE = "ui_state"
|
||||
private val allowedCodecContexts = setOf(
|
||||
"audio:aac",
|
||||
"audio:ac3",
|
||||
"audio:eac3",
|
||||
"audio:dts",
|
||||
"audio:truehd",
|
||||
"audio:flac",
|
||||
"audio:pcm",
|
||||
"audio:other",
|
||||
"video:dolby_vision",
|
||||
"video:hevc",
|
||||
"video:avc",
|
||||
"video:other"
|
||||
)
|
||||
private val allowedUiStates = setOf(
|
||||
UI_STARTUP,
|
||||
UI_AUTHENTICATION,
|
||||
UI_MAIN_SCREEN,
|
||||
UI_PLAYER,
|
||||
UI_PLAYER_DISPOSED
|
||||
)
|
||||
private val decoderNamePattern = Regex("[A-Za-z0-9_.:-]{1,96}")
|
||||
private val executor by lazy {
|
||||
Executors.newSingleThreadExecutor { task ->
|
||||
Thread(task, "plezy-runtime-diagnostics").apply { isDaemon = true }
|
||||
}
|
||||
}
|
||||
|
||||
fun codecContextForMime(mimeType: String?): String? {
|
||||
val normalized = mimeType?.lowercase() ?: return null
|
||||
return when (normalized) {
|
||||
"audio/mp4a-latm" -> "audio:aac"
|
||||
"audio/ac3" -> "audio:ac3"
|
||||
"audio/eac3", "audio/eac3-joc" -> "audio:eac3"
|
||||
"audio/vnd.dts", "audio/vnd.dts.hd" -> "audio:dts"
|
||||
"audio/true-hd" -> "audio:truehd"
|
||||
"audio/flac" -> "audio:flac"
|
||||
"audio/raw" -> "audio:pcm"
|
||||
"video/dolby-vision" -> "video:dolby_vision"
|
||||
"video/hevc" -> "video:hevc"
|
||||
"video/avc" -> "video:avc"
|
||||
else -> when {
|
||||
normalized.startsWith("audio/") -> "audio:other"
|
||||
normalized.startsWith("video/") -> "video:other"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sanitizeDecoderName(raw: String?): String? {
|
||||
if (raw == null) return null
|
||||
return raw.takeIf(decoderNamePattern::matches) ?: "unknown"
|
||||
}
|
||||
|
||||
fun sanitizeUiState(raw: String?): String? = raw?.takeIf(allowedUiStates::contains)
|
||||
fun sanitize(snapshot: RuntimeDiagnosticSnapshot): RuntimeDiagnosticSnapshot = RuntimeDiagnosticSnapshot(
|
||||
codecContext = snapshot.codecContext?.takeIf(allowedCodecContexts::contains),
|
||||
channelCount = snapshot.channelCount?.takeIf { it in 1..32 },
|
||||
sampleRate = snapshot.sampleRate?.takeIf { it in 1..768_000 },
|
||||
selectedDecoder = sanitizeDecoderName(snapshot.selectedDecoder),
|
||||
passthroughEnabled = snapshot.passthroughEnabled,
|
||||
downmixEnabled = snapshot.downmixEnabled,
|
||||
normalizationEnabled = snapshot.normalizationEnabled,
|
||||
uiState = sanitizeUiState(snapshot.uiState)
|
||||
)
|
||||
|
||||
fun update(
|
||||
context: Context,
|
||||
codecContext: String? = null,
|
||||
channelCount: Int? = null,
|
||||
sampleRate: Int? = null,
|
||||
selectedDecoder: String? = null,
|
||||
passthroughEnabled: Boolean? = null,
|
||||
downmixEnabled: Boolean? = null,
|
||||
normalizationEnabled: Boolean? = null,
|
||||
uiState: String? = null
|
||||
) {
|
||||
val safeCodecContext = codecContext?.takeIf(allowedCodecContexts::contains)
|
||||
val safeChannelCount = channelCount?.takeIf { it in 1..32 }
|
||||
val safeSampleRate = sampleRate?.takeIf { it in 1..768_000 }
|
||||
val safeDecoder = sanitizeDecoderName(selectedDecoder)
|
||||
val safeUiState = sanitizeUiState(uiState)
|
||||
val applicationContext = context.applicationContext
|
||||
executor.execute {
|
||||
runCatching {
|
||||
applicationContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE).edit().apply {
|
||||
safeCodecContext?.let { putString(KEY_CODEC_CONTEXT, it) }
|
||||
safeChannelCount?.let { putInt(KEY_CHANNEL_COUNT, it) }
|
||||
safeSampleRate?.let { putInt(KEY_SAMPLE_RATE, it) }
|
||||
safeDecoder?.let { putString(KEY_SELECTED_DECODER, it) }
|
||||
passthroughEnabled?.let { putBoolean(KEY_PASSTHROUGH_ENABLED, it) }
|
||||
downmixEnabled?.let { putBoolean(KEY_DOWNMIX_ENABLED, it) }
|
||||
normalizationEnabled?.let { putBoolean(KEY_NORMALIZATION_ENABLED, it) }
|
||||
safeUiState?.let { putString(KEY_UI_STATE, it) }
|
||||
}.commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearPlayback(context: Context) {
|
||||
val applicationContext = context.applicationContext
|
||||
executor.execute {
|
||||
runCatching {
|
||||
applicationContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE).edit()
|
||||
.remove(KEY_CODEC_CONTEXT)
|
||||
.remove(KEY_CHANNEL_COUNT)
|
||||
.remove(KEY_SAMPLE_RATE)
|
||||
.remove(KEY_SELECTED_DECODER)
|
||||
.remove(KEY_PASSTHROUGH_ENABLED)
|
||||
.remove(KEY_DOWNMIX_ENABLED)
|
||||
.remove(KEY_NORMALIZATION_ENABLED)
|
||||
.putString(KEY_UI_STATE, UI_PLAYER_DISPOSED)
|
||||
.commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun read(context: Context): RuntimeDiagnosticSnapshot {
|
||||
val preferences = context.applicationContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
|
||||
return RuntimeDiagnosticSnapshot(
|
||||
codecContext = runCatching { preferences.getString(KEY_CODEC_CONTEXT, null) }
|
||||
.getOrNull()
|
||||
?.takeIf(allowedCodecContexts::contains),
|
||||
channelCount = runCatching { preferences.getInt(KEY_CHANNEL_COUNT, -1) }.getOrNull()?.takeIf { it in 1..32 },
|
||||
sampleRate = runCatching { preferences.getInt(KEY_SAMPLE_RATE, -1) }.getOrNull()?.takeIf { it in 1..768_000 },
|
||||
selectedDecoder = sanitizeDecoderName(runCatching { preferences.getString(KEY_SELECTED_DECODER, null) }.getOrNull()),
|
||||
passthroughEnabled = readBoolean(preferences, KEY_PASSTHROUGH_ENABLED),
|
||||
downmixEnabled = readBoolean(preferences, KEY_DOWNMIX_ENABLED),
|
||||
normalizationEnabled = readBoolean(preferences, KEY_NORMALIZATION_ENABLED),
|
||||
uiState = sanitizeUiState(runCatching { preferences.getString(KEY_UI_STATE, null) }.getOrNull())
|
||||
)
|
||||
}
|
||||
|
||||
private fun readBoolean(preferences: SharedPreferences, key: String): Boolean? {
|
||||
if (!preferences.contains(key)) return null
|
||||
return runCatching { preferences.getBoolean(key, false) }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
internal data class PreviousExitReport(
|
||||
val reason: String,
|
||||
val status: Int,
|
||||
val importance: Int,
|
||||
val timestamp: Long,
|
||||
val deviceModel: String,
|
||||
val apiLevel: Int,
|
||||
val abi: String,
|
||||
val lowRam: Boolean,
|
||||
val startupPhase: String?,
|
||||
val runtime: RuntimeDiagnosticSnapshot,
|
||||
val dedupeKey: String
|
||||
) {
|
||||
fun toMap(): Map<String, Any> = buildMap {
|
||||
put("reason", reason)
|
||||
put("status", status)
|
||||
put("importance", importance)
|
||||
put("timestamp", timestamp)
|
||||
put("deviceModel", deviceModel)
|
||||
put("apiLevel", apiLevel)
|
||||
put("abi", abi)
|
||||
put("lowRam", lowRam)
|
||||
startupPhase?.let { put("startupPhase", it) }
|
||||
runtime.codecContext?.let { put("codecContext", it) }
|
||||
runtime.channelCount?.let { put("channelCount", it) }
|
||||
runtime.sampleRate?.let { put("sampleRate", it) }
|
||||
runtime.selectedDecoder?.let { put("selectedDecoder", it) }
|
||||
runtime.passthroughEnabled?.let { put("passthroughEnabled", it) }
|
||||
runtime.downmixEnabled?.let { put("downmixEnabled", it) }
|
||||
runtime.normalizationEnabled?.let { put("normalizationEnabled", it) }
|
||||
runtime.uiState?.let { put("uiState", it) }
|
||||
}
|
||||
}
|
||||
|
||||
internal object AndroidExitReportMapper {
|
||||
private const val REASON_LOW_MEMORY = 3
|
||||
private const val REASON_CRASH = 4
|
||||
private const val REASON_CRASH_NATIVE = 5
|
||||
private const val REASON_ANR = 6
|
||||
private const val REASON_USER_REQUESTED = 10
|
||||
private const val REASON_USER_STOPPED = 11
|
||||
private const val MAX_DEVICE_MODEL_LENGTH = 80
|
||||
private val supportedAbis = setOf("arm64-v8a", "armeabi-v7a", "x86_64", "x86")
|
||||
|
||||
fun map(
|
||||
record: HistoricalExitRecord,
|
||||
deviceModel: String,
|
||||
apiLevel: Int,
|
||||
abi: String,
|
||||
lowRam: Boolean,
|
||||
startupPhase: String? = null,
|
||||
runtime: RuntimeDiagnosticSnapshot = RuntimeDiagnosticSnapshot()
|
||||
): PreviousExitReport {
|
||||
val dedupeKey = sha256(
|
||||
listOf(
|
||||
record.timestamp.toString(),
|
||||
record.reason.toString(),
|
||||
record.status.toString(),
|
||||
record.importance.toString()
|
||||
)
|
||||
)
|
||||
return PreviousExitReport(
|
||||
reason = mapReason(record.reason),
|
||||
status = record.status,
|
||||
importance = record.importance,
|
||||
timestamp = record.timestamp,
|
||||
deviceModel = sanitizeDeviceModel(deviceModel),
|
||||
apiLevel = apiLevel,
|
||||
abi = abi.takeIf(supportedAbis::contains) ?: "unknown",
|
||||
lowRam = lowRam,
|
||||
startupPhase = AndroidStartupPhases.sanitize(startupPhase),
|
||||
runtime = AndroidRuntimeDiagnostics.sanitize(runtime),
|
||||
dedupeKey = dedupeKey
|
||||
)
|
||||
}
|
||||
|
||||
fun mapReason(reason: Int): String = when (reason) {
|
||||
REASON_CRASH -> "crash"
|
||||
REASON_CRASH_NATIVE -> "native_crash"
|
||||
REASON_ANR -> "anr"
|
||||
REASON_LOW_MEMORY -> "low_memory"
|
||||
REASON_USER_REQUESTED, REASON_USER_STOPPED -> "user_requested"
|
||||
else -> "other"
|
||||
}
|
||||
|
||||
fun sanitizeDeviceModel(raw: String): String {
|
||||
val sanitized = buildString(raw.length.coerceAtMost(MAX_DEVICE_MODEL_LENGTH)) {
|
||||
var pendingSpace = false
|
||||
raw.forEach { character ->
|
||||
if (length >= MAX_DEVICE_MODEL_LENGTH) return@forEach
|
||||
if (character.isWhitespace() || Character.isISOControl(character)) {
|
||||
pendingSpace = isNotEmpty()
|
||||
} else {
|
||||
if (pendingSpace && length < MAX_DEVICE_MODEL_LENGTH) append(' ')
|
||||
if (length < MAX_DEVICE_MODEL_LENGTH) append(character)
|
||||
pendingSpace = false
|
||||
}
|
||||
}
|
||||
}.trim()
|
||||
return sanitized.ifEmpty { "unknown" }
|
||||
}
|
||||
|
||||
private fun sha256(fields: List<String>): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
fields.forEach { field ->
|
||||
digest.update(field.length.toString().toByteArray(Charsets.UTF_8))
|
||||
digest.update(':'.code.toByte())
|
||||
digest.update(field.toByteArray(Charsets.UTF_8))
|
||||
digest.update(';'.code.toByte())
|
||||
}
|
||||
return digest.digest().joinToString("") { byte ->
|
||||
(byte.toInt() and 0xff).toString(16).padStart(2, '0')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class PreviousExitReportStore(
|
||||
private val readDedupeKey: () -> String?,
|
||||
private val persistDedupeKey: (String) -> Boolean
|
||||
) {
|
||||
@Synchronized
|
||||
fun takeIfNew(report: PreviousExitReport): Map<String, Any>? {
|
||||
if (readDedupeKey() == report.dedupeKey) return null
|
||||
if (!persistDedupeKey(report.dedupeKey)) return null
|
||||
return report.toMap()
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,9 @@ import io.flutter.embedding.android.TransparencyMode
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.FlutterShellArgs
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.RejectedExecutionException
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
@@ -42,6 +45,21 @@ class MainActivity : FlutterActivity() {
|
||||
companion object {
|
||||
private const val TAG = "MainActivity"
|
||||
private const val TEXT_INPUT_DIAGNOSTICS_ENABLED = false
|
||||
private const val EXIT_DIAGNOSTICS_PREFS = "plezy_exit_diagnostics"
|
||||
private const val LAST_EXIT_DEDUPE_KEY = "last_reported_exit"
|
||||
private const val LAST_STARTUP_PHASE_KEY = "last_startup_phase"
|
||||
private val startupPhaseLock = Any()
|
||||
|
||||
@Volatile private var startupPhaseInitializationAttempted = false
|
||||
|
||||
@Volatile private var startupPhaseStore: StartupPhaseStore? = null
|
||||
|
||||
@Volatile private var previousRuntimeDiagnostics = RuntimeDiagnosticSnapshot()
|
||||
private val exitDiagnosticsExecutor by lazy {
|
||||
Executors.newSingleThreadExecutor { runnable ->
|
||||
Thread(runnable, "plezy-exit-diagnostics").apply { isDaemon = true }
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors DevicePerformance._lowMemThresholdBytes (2252 MiB): nominal
|
||||
// "2GB" devices report totalMem slightly above 2 GiB after carve-outs.
|
||||
@@ -63,6 +81,7 @@ class MainActivity : FlutterActivity() {
|
||||
private var flutterSurfaceReconnectPending = false
|
||||
private var activityStarted = false
|
||||
private val externalPlayerChannel = ExternalPlayerChannel(this)
|
||||
private val exitDiagnosticsRequested = AtomicBoolean(false)
|
||||
|
||||
private inline fun logTextInputDiag(message: () -> String) {
|
||||
if (TEXT_INPUT_DIAGNOSTICS_ENABLED) {
|
||||
@@ -184,6 +203,135 @@ class MainActivity : FlutterActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
private fun initializeStartupPhaseStore() {
|
||||
var shouldMarkNativeOnCreate = false
|
||||
synchronized(startupPhaseLock) {
|
||||
if (startupPhaseInitializationAttempted) return
|
||||
startupPhaseInitializationAttempted = true
|
||||
try {
|
||||
previousRuntimeDiagnostics = AndroidRuntimeDiagnostics.read(this)
|
||||
val preferences = getSharedPreferences(EXIT_DIAGNOSTICS_PREFS, Context.MODE_PRIVATE)
|
||||
startupPhaseStore = StartupPhaseStore(
|
||||
readPhase = { preferences.getString(LAST_STARTUP_PHASE_KEY, null) },
|
||||
persistPhase = { phase ->
|
||||
preferences.edit().putString(LAST_STARTUP_PHASE_KEY, phase).commit()
|
||||
}
|
||||
)
|
||||
shouldMarkNativeOnCreate = true
|
||||
} catch (_: Throwable) {
|
||||
Log.w(TAG, "Startup phase persistence unavailable")
|
||||
}
|
||||
}
|
||||
if (shouldMarkNativeOnCreate) {
|
||||
queueStartupPhase(AndroidStartupPhases.NATIVE_ON_CREATE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun queueStartupPhase(raw: String?, result: MethodChannel.Result? = null) {
|
||||
val phase = AndroidStartupPhases.sanitize(raw)
|
||||
if (phase == null) {
|
||||
result?.let { completeStartupPhase(it, false) }
|
||||
return
|
||||
}
|
||||
AndroidRuntimeDiagnostics.update(this, uiState = uiStateForStartupPhase(phase))
|
||||
try {
|
||||
exitDiagnosticsExecutor.execute {
|
||||
val persisted = try {
|
||||
startupPhaseStore?.mark(phase) == true
|
||||
} catch (_: Throwable) {
|
||||
Log.w(TAG, "Startup phase update failed")
|
||||
false
|
||||
}
|
||||
result?.let { reply ->
|
||||
runOnUiThread { completeStartupPhase(reply, persisted) }
|
||||
}
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
Log.w(TAG, "Startup phase update could not start")
|
||||
result?.let { completeStartupPhase(it, false) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun uiStateForStartupPhase(phase: String): String = when (phase) {
|
||||
"credentials_loaded", "binding_started", "binding_settled" -> AndroidRuntimeDiagnostics.UI_AUTHENTICATION
|
||||
"main_screen" -> AndroidRuntimeDiagnostics.UI_MAIN_SCREEN
|
||||
else -> AndroidRuntimeDiagnostics.UI_STARTUP
|
||||
}
|
||||
|
||||
private fun completeStartupPhase(result: MethodChannel.Result, persisted: Boolean) {
|
||||
try {
|
||||
result.success(persisted)
|
||||
} catch (_: Throwable) {
|
||||
Log.w(TAG, "Startup phase reply failed")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePreviousExit(result: MethodChannel.Result) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
completePreviousExit(result, null)
|
||||
return
|
||||
}
|
||||
if (!exitDiagnosticsRequested.compareAndSet(false, true)) {
|
||||
completePreviousExit(result, null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
exitDiagnosticsExecutor.execute {
|
||||
val report = try {
|
||||
readPreviousExit()
|
||||
} catch (_: Throwable) {
|
||||
Log.w(TAG, "Previous exit diagnostics failed")
|
||||
null
|
||||
}
|
||||
runOnUiThread { completePreviousExit(result, report) }
|
||||
}
|
||||
} catch (_: RejectedExecutionException) {
|
||||
completePreviousExit(result, null)
|
||||
} catch (_: Throwable) {
|
||||
Log.w(TAG, "Previous exit diagnostics could not start")
|
||||
completePreviousExit(result, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun completePreviousExit(result: MethodChannel.Result, report: Map<String, Any>?) {
|
||||
try {
|
||||
result.success(report)
|
||||
} catch (_: Throwable) {
|
||||
Log.w(TAG, "Previous exit diagnostics reply failed")
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
private fun readPreviousExit(): Map<String, Any>? {
|
||||
val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
val exitInfo = activityManager
|
||||
.getHistoricalProcessExitReasons(packageName, 0, 1)
|
||||
.firstOrNull()
|
||||
?: return null
|
||||
val report = AndroidExitReportMapper.map(
|
||||
record = HistoricalExitRecord(
|
||||
reason = exitInfo.reason,
|
||||
status = exitInfo.status,
|
||||
importance = exitInfo.importance,
|
||||
timestamp = exitInfo.timestamp
|
||||
),
|
||||
deviceModel = Build.MODEL,
|
||||
apiLevel = Build.VERSION.SDK_INT,
|
||||
abi = Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown",
|
||||
lowRam = activityManager.isLowRamDevice,
|
||||
startupPhase = startupPhaseStore?.previousPhase,
|
||||
runtime = previousRuntimeDiagnostics
|
||||
)
|
||||
val preferences = getSharedPreferences(EXIT_DIAGNOSTICS_PREFS, Context.MODE_PRIVATE)
|
||||
return PreviousExitReportStore(
|
||||
readDedupeKey = { preferences.getString(LAST_EXIT_DEDUPE_KEY, null) },
|
||||
persistDedupeKey = { key ->
|
||||
preferences.edit().putString(LAST_EXIT_DEDUPE_KEY, key).commit()
|
||||
}
|
||||
).takeIfNew(report)
|
||||
}
|
||||
|
||||
/**
|
||||
* Same triple DevicePerformance uses for the reduced tier on the Dart
|
||||
* side — keep the two in sync. Evaluated here too because engine shell
|
||||
@@ -208,6 +356,8 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Snapshot the previous process phase before this launch can overwrite it.
|
||||
initializeStartupPhaseStore()
|
||||
// Apply persisted theme color to the window background before anything
|
||||
// else renders. This prevents a white flash between the native splash
|
||||
// screen and Flutter's first frame for non-default themes (e.g. OLED).
|
||||
@@ -428,6 +578,17 @@ class MainActivity : FlutterActivity() {
|
||||
"getTvDetection" -> result.success(getAndroidTvDetection())
|
||||
"getDeviceName" -> result.success(getDeviceName())
|
||||
"getPerformanceSignals" -> result.success(getPerformanceSignals())
|
||||
"getPreviousExit" -> handlePreviousExit(result)
|
||||
"setStartupPhase" -> queueStartupPhase(call.arguments as? String, result)
|
||||
"setRuntimeUiState" -> {
|
||||
val uiState = AndroidRuntimeDiagnostics.sanitizeUiState(call.arguments as? String)
|
||||
if (uiState == null) {
|
||||
result.success(false)
|
||||
} else {
|
||||
AndroidRuntimeDiagnostics.update(this, uiState = uiState)
|
||||
result.success(true)
|
||||
}
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ import androidx.media3.extractor.ts.TsExtractor
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.CaptionStyleCompat
|
||||
import androidx.media3.ui.SubtitleView
|
||||
import com.edde746.plezy.AndroidRuntimeDiagnostics
|
||||
import com.edde746.plezy.libass.media.AssHandler
|
||||
import com.edde746.plezy.libass.media.parser.AssSubtitleParserFactory
|
||||
import com.edde746.plezy.libass.media.widget.AssSubtitleSurfaceView
|
||||
@@ -2327,11 +2328,23 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
parts.add("channels=${format.channelCount}")
|
||||
parts.add("sampleRate=${format.sampleRate}")
|
||||
format.id?.let { parts.add("id=$it") }
|
||||
format.label?.let { parts.add("label=$it") }
|
||||
format.language?.let { parts.add("lang=$it") }
|
||||
return parts.joinToString(", ")
|
||||
}
|
||||
|
||||
private fun persistRuntimePlaybackDiagnostics(format: Format? = selectedAudioFormat(), decoderName: String? = null) {
|
||||
AndroidRuntimeDiagnostics.update(
|
||||
context = activity,
|
||||
codecContext = AndroidRuntimeDiagnostics.codecContextForMime(format?.sampleMimeType),
|
||||
channelCount = format?.channelCount,
|
||||
sampleRate = format?.sampleRate,
|
||||
selectedDecoder = decoderName,
|
||||
passthroughEnabled = audioPassthroughEnabled,
|
||||
downmixEnabled = audioDownmixEnabled,
|
||||
normalizationEnabled = audioNormalizationEnabled,
|
||||
uiState = AndroidRuntimeDiagnostics.UI_PLAYER
|
||||
)
|
||||
}
|
||||
|
||||
private fun describeAudioTrackConfig(config: AudioSink.AudioTrackConfig?): String {
|
||||
if (config == null) return "none"
|
||||
return "encoding=${config.encoding}, channels=${Integer.bitCount(config.channelConfig)}, " +
|
||||
@@ -2351,6 +2364,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
initializationDurationMs: Long
|
||||
) {
|
||||
decoderInitName = decoderName
|
||||
persistRuntimePlaybackDiagnostics(currentVideoFormat ?: exoPlayer?.videoFormat, decoderName)
|
||||
firstFrameRendered = false
|
||||
emitLog("debug", "decoder-hang", "Decoder initialized: $decoderName (${initializationDurationMs}ms)")
|
||||
logDolbyVisionPlaybackPathIfNeeded(decoderName)
|
||||
@@ -2363,6 +2377,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
initializationDurationMs: Long
|
||||
) {
|
||||
audioDecoderInitName = decoderName
|
||||
persistRuntimePlaybackDiagnostics(decoderName = decoderName)
|
||||
emitLog("info", "audio", "Decoder initialized: $decoderName (${initializationDurationMs}ms)")
|
||||
}
|
||||
|
||||
@@ -2371,6 +2386,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
format: Format,
|
||||
decoderReuseEvaluation: DecoderReuseEvaluation?
|
||||
) {
|
||||
persistRuntimePlaybackDiagnostics(format, audioDecoderInitName)
|
||||
emitLog("info", "audio", "Input format: ${formatAudioSummary(format)}")
|
||||
if (format.sampleMimeType == MimeTypes.AUDIO_TRUEHD) {
|
||||
updateAudioDecoderPolicy("input format", format)
|
||||
@@ -2412,6 +2428,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
) {
|
||||
lastAudioTrackConfig = audioTrackConfig
|
||||
val audioFormat = selectedAudioFormat()
|
||||
persistRuntimePlaybackDiagnostics(audioFormat, audioDecoderInitName ?: "direct")
|
||||
emitLog(
|
||||
"info",
|
||||
"audio",
|
||||
@@ -2802,6 +2819,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
// audioNormalizationEnabled persists across opens (user-level state, like
|
||||
// tunnelingUserEnabled); only the in-flight bounce is abandoned.
|
||||
pendingAudioRendererBounce = false
|
||||
AndroidRuntimeDiagnostics.clearPlayback(activity)
|
||||
persistRuntimePlaybackDiagnostics()
|
||||
handler.removeCallbacks(audioBounceTimeout)
|
||||
pendingStartPositionMs = startPositionMs
|
||||
pendingPlayWhenReady = autoPlay
|
||||
@@ -2834,6 +2853,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
fun setAudioNormalization(enabled: Boolean) {
|
||||
if (audioNormalizationEnabled == enabled) return
|
||||
audioNormalizationEnabled = enabled
|
||||
persistRuntimePlaybackDiagnostics(decoderName = audioDecoderInitName)
|
||||
emitLog("info", "audio-normalization", "Loudness normalization ${if (enabled) "enabled" else "disabled"}")
|
||||
if (enabled) attachNormalizationEffect() else audioNormalization.release()
|
||||
|
||||
@@ -2862,6 +2882,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
fun setAudioPassthrough(enabled: Boolean) {
|
||||
if (audioPassthroughEnabled == enabled) return
|
||||
audioPassthroughEnabled = enabled
|
||||
persistRuntimePlaybackDiagnostics(decoderName = audioDecoderInitName)
|
||||
emitLog("info", "audio", "Audio passthrough ${if (enabled) "enabled" else "disabled"}")
|
||||
|
||||
if (exoPlayer == null) return
|
||||
@@ -2887,6 +2908,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
audioDownmixEnabled = enabled
|
||||
audioDownmixCenterBoostDb = boost
|
||||
audioDownmixNormalize = normalize
|
||||
persistRuntimePlaybackDiagnostics(decoderName = audioDecoderInitName)
|
||||
emitLog(
|
||||
"info",
|
||||
"audio-downmix",
|
||||
@@ -2916,7 +2938,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
if (coefficients != null) {
|
||||
ChannelMixingMatrix(count, 2, coefficients)
|
||||
} else {
|
||||
ChannelMixingMatrix.create(count, count)
|
||||
renderersFactory?.identityChannelMixingMatrix(count) ?: return
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -3652,6 +3674,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
}
|
||||
}
|
||||
|
||||
AndroidRuntimeDiagnostics.clearPlayback(activity)
|
||||
Log.d(TAG, "Disposed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package com.edde746.plezy
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AndroidExitDiagnosticsTest {
|
||||
|
||||
@Test
|
||||
fun mapsExitReasonsDeterministically() {
|
||||
assertEquals("low_memory", AndroidExitReportMapper.mapReason(3))
|
||||
assertEquals("crash", AndroidExitReportMapper.mapReason(4))
|
||||
assertEquals("native_crash", AndroidExitReportMapper.mapReason(5))
|
||||
assertEquals("anr", AndroidExitReportMapper.mapReason(6))
|
||||
assertEquals("user_requested", AndroidExitReportMapper.mapReason(10))
|
||||
assertEquals("user_requested", AndroidExitReportMapper.mapReason(11))
|
||||
assertEquals("other", AndroidExitReportMapper.mapReason(1))
|
||||
assertEquals("other", AndroidExitReportMapper.mapReason(999))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun suppressesDuplicateAndReportsNewerRecord() {
|
||||
var storedKey: String? = null
|
||||
val store = PreviousExitReportStore(
|
||||
readDedupeKey = { storedKey },
|
||||
persistDedupeKey = { key ->
|
||||
storedKey = key
|
||||
true
|
||||
}
|
||||
)
|
||||
val first = mappedRecord(timestamp = 100)
|
||||
|
||||
assertNotNull(store.takeIfNew(first))
|
||||
assertNull(store.takeIfNew(first))
|
||||
assertNotNull(store.takeIfNew(mappedRecord(timestamp = 101)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun doesNotReportWhenDedupeKeyCannotBePersisted() {
|
||||
val report = mappedRecord(timestamp = 100)
|
||||
val store = PreviousExitReportStore(
|
||||
readDedupeKey = { null },
|
||||
persistDedupeKey = { false }
|
||||
)
|
||||
|
||||
assertNull(store.takeIfNew(report))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun snapshotsPreviousPhaseBeforeMarkingCurrentLaunch() {
|
||||
var persistedPhase: String? = "database_ready"
|
||||
val store = StartupPhaseStore(
|
||||
readPhase = { persistedPhase },
|
||||
persistPhase = { phase ->
|
||||
persistedPhase = phase
|
||||
true
|
||||
}
|
||||
)
|
||||
|
||||
assertEquals("database_ready", store.previousPhase)
|
||||
assertTrue(store.mark(AndroidStartupPhases.NATIVE_ON_CREATE))
|
||||
assertEquals("native_on_create", persistedPhase)
|
||||
assertEquals("database_ready", store.previousPhase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reportsStartupPhaseCommitFailure() {
|
||||
var attempts = 0
|
||||
val store = StartupPhaseStore(
|
||||
readPhase = { "database_ready" },
|
||||
persistPhase = {
|
||||
attempts++
|
||||
false
|
||||
}
|
||||
)
|
||||
|
||||
assertFalse(store.mark("first_frame"))
|
||||
assertEquals(1, attempts)
|
||||
assertEquals("database_ready", store.previousPhase)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptsOnlyFixedStartupPhaseVocabulary() {
|
||||
val written = mutableListOf<String>()
|
||||
val store = StartupPhaseStore(
|
||||
readPhase = { null },
|
||||
persistPhase = {
|
||||
written += it
|
||||
true
|
||||
}
|
||||
)
|
||||
val phases = listOf(
|
||||
"native_on_create",
|
||||
"dart_main",
|
||||
"runApp",
|
||||
"first_frame",
|
||||
"database_open_started",
|
||||
"database_ready",
|
||||
"credentials_loaded",
|
||||
"binding_started",
|
||||
"binding_settled",
|
||||
"main_screen"
|
||||
)
|
||||
|
||||
phases.forEach { phase -> assertTrue(store.mark(phase)) }
|
||||
assertEquals(phases, written)
|
||||
assertFalse(store.mark("server_name"))
|
||||
assertEquals(phases, written)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun attachesPhaseWithoutChangingExitDedupeKey() {
|
||||
val firstFrame = mappedRecord(timestamp = 100, startupPhase = "first_frame")
|
||||
val mainScreen = mappedRecord(timestamp = 100, startupPhase = "main_screen")
|
||||
val invalid = mappedRecord(timestamp = 100, startupPhase = "profile-name")
|
||||
|
||||
assertEquals(firstFrame.dedupeKey, mainScreen.dedupeKey)
|
||||
assertEquals("first_frame", firstFrame.toMap()["startupPhase"])
|
||||
assertEquals("main_screen", mainScreen.toMap()["startupPhase"])
|
||||
assertNull(invalid.startupPhase)
|
||||
assertFalse(invalid.toMap().containsKey("startupPhase"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dedupeKeyUsesStableMappedFieldsWithoutBeingReported() {
|
||||
val first = mappedRecord(timestamp = 100)
|
||||
val same = mappedRecord(timestamp = 100)
|
||||
val newer = mappedRecord(timestamp = 101)
|
||||
|
||||
assertEquals(first.dedupeKey, same.dedupeKey)
|
||||
assertNotEquals(first.dedupeKey, newer.dedupeKey)
|
||||
assertEquals(
|
||||
setOf("reason", "status", "importance", "timestamp", "deviceModel", "apiLevel", "abi", "lowRam"),
|
||||
first.toMap().keys
|
||||
)
|
||||
assertFalse(first.toMap().containsKey("dedupeKey"))
|
||||
assertFalse(first.toMap().containsKey("description"))
|
||||
assertFalse(first.toMap().containsKey("processName"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sanitizesDeviceFieldsWithoutAcceptingArbitraryAbiValues() {
|
||||
val mapped = AndroidExitReportMapper.map(
|
||||
record = HistoricalExitRecord(reason = 6, status = 0, importance = 100, timestamp = 100),
|
||||
deviceModel = " Shield\u0000 Pro ",
|
||||
apiLevel = 35,
|
||||
abi = "user-supplied",
|
||||
lowRam = false
|
||||
)
|
||||
|
||||
assertEquals("Shield Pro", mapped.deviceModel)
|
||||
assertEquals("unknown", mapped.abi)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapsOnlyFixedRuntimeDiagnosticFields() {
|
||||
val mapped = AndroidExitReportMapper.map(
|
||||
record = HistoricalExitRecord(reason = 5, status = 0, importance = 100, timestamp = 100),
|
||||
deviceModel = "NVIDIA Shield",
|
||||
apiLevel = 35,
|
||||
abi = "arm64-v8a",
|
||||
lowRam = false,
|
||||
runtime = RuntimeDiagnosticSnapshot(
|
||||
codecContext = "audio:truehd",
|
||||
channelCount = 8,
|
||||
sampleRate = 48000,
|
||||
selectedDecoder = "c2.android.truehd.decoder",
|
||||
passthroughEnabled = true,
|
||||
downmixEnabled = false,
|
||||
normalizationEnabled = false,
|
||||
uiState = "player"
|
||||
)
|
||||
).toMap()
|
||||
|
||||
assertEquals("audio:truehd", mapped["codecContext"])
|
||||
assertEquals(8, mapped["channelCount"])
|
||||
assertEquals(48000, mapped["sampleRate"])
|
||||
assertEquals("c2.android.truehd.decoder", mapped["selectedDecoder"])
|
||||
assertEquals(true, mapped["passthroughEnabled"])
|
||||
assertEquals(false, mapped["downmixEnabled"])
|
||||
assertEquals(false, mapped["normalizationEnabled"])
|
||||
assertEquals("player", mapped["uiState"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reducesRemoteCodecMetadataToFixedContextValues() {
|
||||
assertEquals("audio:eac3", AndroidRuntimeDiagnostics.codecContextForMime("audio/eac3-joc"))
|
||||
assertEquals("video:dolby_vision", AndroidRuntimeDiagnostics.codecContextForMime("video/dolby-vision"))
|
||||
assertEquals("audio:other", AndroidRuntimeDiagnostics.codecContextForMime("audio/user-defined"))
|
||||
assertNull(AndroidRuntimeDiagnostics.codecContextForMime("https://example.test/private"))
|
||||
assertEquals("unknown", AndroidRuntimeDiagnostics.sanitizeDecoderName("decoder with a remote label"))
|
||||
assertNull(AndroidRuntimeDiagnostics.sanitizeUiState("server-name"))
|
||||
}
|
||||
|
||||
private fun mappedRecord(timestamp: Long, startupPhase: String? = null): PreviousExitReport = AndroidExitReportMapper.map(
|
||||
record = HistoricalExitRecord(
|
||||
reason = 4,
|
||||
status = 1,
|
||||
importance = 100,
|
||||
timestamp = timestamp
|
||||
),
|
||||
deviceModel = "NVIDIA Shield",
|
||||
apiLevel = 35,
|
||||
abi = "arm64-v8a",
|
||||
lowRam = false,
|
||||
startupPhase = startupPhase
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user