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.FlutterEngine
|
||||||
import io.flutter.embedding.engine.FlutterShellArgs
|
import io.flutter.embedding.engine.FlutterShellArgs
|
||||||
import io.flutter.plugin.common.MethodChannel
|
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
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
class MainActivity : FlutterActivity() {
|
class MainActivity : FlutterActivity() {
|
||||||
@@ -42,6 +45,21 @@ class MainActivity : FlutterActivity() {
|
|||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "MainActivity"
|
private const val TAG = "MainActivity"
|
||||||
private const val TEXT_INPUT_DIAGNOSTICS_ENABLED = false
|
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
|
// Mirrors DevicePerformance._lowMemThresholdBytes (2252 MiB): nominal
|
||||||
// "2GB" devices report totalMem slightly above 2 GiB after carve-outs.
|
// "2GB" devices report totalMem slightly above 2 GiB after carve-outs.
|
||||||
@@ -63,6 +81,7 @@ class MainActivity : FlutterActivity() {
|
|||||||
private var flutterSurfaceReconnectPending = false
|
private var flutterSurfaceReconnectPending = false
|
||||||
private var activityStarted = false
|
private var activityStarted = false
|
||||||
private val externalPlayerChannel = ExternalPlayerChannel(this)
|
private val externalPlayerChannel = ExternalPlayerChannel(this)
|
||||||
|
private val exitDiagnosticsRequested = AtomicBoolean(false)
|
||||||
|
|
||||||
private inline fun logTextInputDiag(message: () -> String) {
|
private inline fun logTextInputDiag(message: () -> String) {
|
||||||
if (TEXT_INPUT_DIAGNOSTICS_ENABLED) {
|
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
|
* Same triple DevicePerformance uses for the reduced tier on the Dart
|
||||||
* side — keep the two in sync. Evaluated here too because engine shell
|
* side — keep the two in sync. Evaluated here too because engine shell
|
||||||
@@ -208,6 +356,8 @@ class MainActivity : FlutterActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
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
|
// Apply persisted theme color to the window background before anything
|
||||||
// else renders. This prevents a white flash between the native splash
|
// else renders. This prevents a white flash between the native splash
|
||||||
// screen and Flutter's first frame for non-default themes (e.g. OLED).
|
// screen and Flutter's first frame for non-default themes (e.g. OLED).
|
||||||
@@ -428,6 +578,17 @@ class MainActivity : FlutterActivity() {
|
|||||||
"getTvDetection" -> result.success(getAndroidTvDetection())
|
"getTvDetection" -> result.success(getAndroidTvDetection())
|
||||||
"getDeviceName" -> result.success(getDeviceName())
|
"getDeviceName" -> result.success(getDeviceName())
|
||||||
"getPerformanceSignals" -> result.success(getPerformanceSignals())
|
"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()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import androidx.media3.extractor.ts.TsExtractor
|
|||||||
import androidx.media3.ui.AspectRatioFrameLayout
|
import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
import androidx.media3.ui.CaptionStyleCompat
|
import androidx.media3.ui.CaptionStyleCompat
|
||||||
import androidx.media3.ui.SubtitleView
|
import androidx.media3.ui.SubtitleView
|
||||||
|
import com.edde746.plezy.AndroidRuntimeDiagnostics
|
||||||
import com.edde746.plezy.libass.media.AssHandler
|
import com.edde746.plezy.libass.media.AssHandler
|
||||||
import com.edde746.plezy.libass.media.parser.AssSubtitleParserFactory
|
import com.edde746.plezy.libass.media.parser.AssSubtitleParserFactory
|
||||||
import com.edde746.plezy.libass.media.widget.AssSubtitleSurfaceView
|
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("channels=${format.channelCount}")
|
||||||
parts.add("sampleRate=${format.sampleRate}")
|
parts.add("sampleRate=${format.sampleRate}")
|
||||||
format.id?.let { parts.add("id=$it") }
|
format.id?.let { parts.add("id=$it") }
|
||||||
format.label?.let { parts.add("label=$it") }
|
|
||||||
format.language?.let { parts.add("lang=$it") }
|
|
||||||
return parts.joinToString(", ")
|
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 {
|
private fun describeAudioTrackConfig(config: AudioSink.AudioTrackConfig?): String {
|
||||||
if (config == null) return "none"
|
if (config == null) return "none"
|
||||||
return "encoding=${config.encoding}, channels=${Integer.bitCount(config.channelConfig)}, " +
|
return "encoding=${config.encoding}, channels=${Integer.bitCount(config.channelConfig)}, " +
|
||||||
@@ -2351,6 +2364,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
initializationDurationMs: Long
|
initializationDurationMs: Long
|
||||||
) {
|
) {
|
||||||
decoderInitName = decoderName
|
decoderInitName = decoderName
|
||||||
|
persistRuntimePlaybackDiagnostics(currentVideoFormat ?: exoPlayer?.videoFormat, decoderName)
|
||||||
firstFrameRendered = false
|
firstFrameRendered = false
|
||||||
emitLog("debug", "decoder-hang", "Decoder initialized: $decoderName (${initializationDurationMs}ms)")
|
emitLog("debug", "decoder-hang", "Decoder initialized: $decoderName (${initializationDurationMs}ms)")
|
||||||
logDolbyVisionPlaybackPathIfNeeded(decoderName)
|
logDolbyVisionPlaybackPathIfNeeded(decoderName)
|
||||||
@@ -2363,6 +2377,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
initializationDurationMs: Long
|
initializationDurationMs: Long
|
||||||
) {
|
) {
|
||||||
audioDecoderInitName = decoderName
|
audioDecoderInitName = decoderName
|
||||||
|
persistRuntimePlaybackDiagnostics(decoderName = decoderName)
|
||||||
emitLog("info", "audio", "Decoder initialized: $decoderName (${initializationDurationMs}ms)")
|
emitLog("info", "audio", "Decoder initialized: $decoderName (${initializationDurationMs}ms)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2371,6 +2386,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
format: Format,
|
format: Format,
|
||||||
decoderReuseEvaluation: DecoderReuseEvaluation?
|
decoderReuseEvaluation: DecoderReuseEvaluation?
|
||||||
) {
|
) {
|
||||||
|
persistRuntimePlaybackDiagnostics(format, audioDecoderInitName)
|
||||||
emitLog("info", "audio", "Input format: ${formatAudioSummary(format)}")
|
emitLog("info", "audio", "Input format: ${formatAudioSummary(format)}")
|
||||||
if (format.sampleMimeType == MimeTypes.AUDIO_TRUEHD) {
|
if (format.sampleMimeType == MimeTypes.AUDIO_TRUEHD) {
|
||||||
updateAudioDecoderPolicy("input format", format)
|
updateAudioDecoderPolicy("input format", format)
|
||||||
@@ -2412,6 +2428,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
) {
|
) {
|
||||||
lastAudioTrackConfig = audioTrackConfig
|
lastAudioTrackConfig = audioTrackConfig
|
||||||
val audioFormat = selectedAudioFormat()
|
val audioFormat = selectedAudioFormat()
|
||||||
|
persistRuntimePlaybackDiagnostics(audioFormat, audioDecoderInitName ?: "direct")
|
||||||
emitLog(
|
emitLog(
|
||||||
"info",
|
"info",
|
||||||
"audio",
|
"audio",
|
||||||
@@ -2802,6 +2819,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
// audioNormalizationEnabled persists across opens (user-level state, like
|
// audioNormalizationEnabled persists across opens (user-level state, like
|
||||||
// tunnelingUserEnabled); only the in-flight bounce is abandoned.
|
// tunnelingUserEnabled); only the in-flight bounce is abandoned.
|
||||||
pendingAudioRendererBounce = false
|
pendingAudioRendererBounce = false
|
||||||
|
AndroidRuntimeDiagnostics.clearPlayback(activity)
|
||||||
|
persistRuntimePlaybackDiagnostics()
|
||||||
handler.removeCallbacks(audioBounceTimeout)
|
handler.removeCallbacks(audioBounceTimeout)
|
||||||
pendingStartPositionMs = startPositionMs
|
pendingStartPositionMs = startPositionMs
|
||||||
pendingPlayWhenReady = autoPlay
|
pendingPlayWhenReady = autoPlay
|
||||||
@@ -2834,6 +2853,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
fun setAudioNormalization(enabled: Boolean) {
|
fun setAudioNormalization(enabled: Boolean) {
|
||||||
if (audioNormalizationEnabled == enabled) return
|
if (audioNormalizationEnabled == enabled) return
|
||||||
audioNormalizationEnabled = enabled
|
audioNormalizationEnabled = enabled
|
||||||
|
persistRuntimePlaybackDiagnostics(decoderName = audioDecoderInitName)
|
||||||
emitLog("info", "audio-normalization", "Loudness normalization ${if (enabled) "enabled" else "disabled"}")
|
emitLog("info", "audio-normalization", "Loudness normalization ${if (enabled) "enabled" else "disabled"}")
|
||||||
if (enabled) attachNormalizationEffect() else audioNormalization.release()
|
if (enabled) attachNormalizationEffect() else audioNormalization.release()
|
||||||
|
|
||||||
@@ -2862,6 +2882,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
fun setAudioPassthrough(enabled: Boolean) {
|
fun setAudioPassthrough(enabled: Boolean) {
|
||||||
if (audioPassthroughEnabled == enabled) return
|
if (audioPassthroughEnabled == enabled) return
|
||||||
audioPassthroughEnabled = enabled
|
audioPassthroughEnabled = enabled
|
||||||
|
persistRuntimePlaybackDiagnostics(decoderName = audioDecoderInitName)
|
||||||
emitLog("info", "audio", "Audio passthrough ${if (enabled) "enabled" else "disabled"}")
|
emitLog("info", "audio", "Audio passthrough ${if (enabled) "enabled" else "disabled"}")
|
||||||
|
|
||||||
if (exoPlayer == null) return
|
if (exoPlayer == null) return
|
||||||
@@ -2887,6 +2908,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
audioDownmixEnabled = enabled
|
audioDownmixEnabled = enabled
|
||||||
audioDownmixCenterBoostDb = boost
|
audioDownmixCenterBoostDb = boost
|
||||||
audioDownmixNormalize = normalize
|
audioDownmixNormalize = normalize
|
||||||
|
persistRuntimePlaybackDiagnostics(decoderName = audioDecoderInitName)
|
||||||
emitLog(
|
emitLog(
|
||||||
"info",
|
"info",
|
||||||
"audio-downmix",
|
"audio-downmix",
|
||||||
@@ -2916,7 +2938,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
if (coefficients != null) {
|
if (coefficients != null) {
|
||||||
ChannelMixingMatrix(count, 2, coefficients)
|
ChannelMixingMatrix(count, 2, coefficients)
|
||||||
} else {
|
} 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")
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
+298
-112
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
// ignore: depend_on_referenced_packages
|
// ignore: depend_on_referenced_packages
|
||||||
import 'package:shared_preferences_foundation/shared_preferences_foundation.dart';
|
import 'package:shared_preferences_foundation/shared_preferences_foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/material.dart' as material show ThemeMode;
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@@ -24,6 +25,7 @@ import 'profiles/profile_connection_cleanup.dart';
|
|||||||
import 'profiles/profile_connection_registry.dart';
|
import 'profiles/profile_connection_registry.dart';
|
||||||
import 'profiles/profile_registry.dart';
|
import 'profiles/profile_registry.dart';
|
||||||
import 'mixins/mounted_set_state_mixin.dart';
|
import 'mixins/mounted_set_state_mixin.dart';
|
||||||
|
import 'theme/mono_theme.dart';
|
||||||
import 'profiles/plex_home_service.dart';
|
import 'profiles/plex_home_service.dart';
|
||||||
import 'screens/auth_screen.dart';
|
import 'screens/auth_screen.dart';
|
||||||
import 'screens/profile/pin_entry_dialog.dart';
|
import 'screens/profile/pin_entry_dialog.dart';
|
||||||
@@ -72,11 +74,13 @@ import 'utils/watch_state_notifier.dart';
|
|||||||
import 'i18n/strings.g.dart';
|
import 'i18n/strings.g.dart';
|
||||||
import 'widgets/app_icon.dart';
|
import 'widgets/app_icon.dart';
|
||||||
import 'focus/input_mode_tracker.dart';
|
import 'focus/input_mode_tracker.dart';
|
||||||
|
import 'focus/focusable_button.dart';
|
||||||
import 'focus/key_event_utils.dart';
|
import 'focus/key_event_utils.dart';
|
||||||
import 'package:intl/date_symbol_data_local.dart';
|
import 'package:intl/date_symbol_data_local.dart';
|
||||||
import 'utils/navigation_transitions.dart';
|
import 'utils/navigation_transitions.dart';
|
||||||
import 'utils/log_redaction_manager.dart';
|
import 'utils/log_redaction_manager.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import 'utils/android_exit_diagnostics.dart';
|
||||||
|
|
||||||
const bool _enableSentry = bool.fromEnvironment('ENABLE_SENTRY', defaultValue: false);
|
const bool _enableSentry = bool.fromEnvironment('ENABLE_SENTRY', defaultValue: false);
|
||||||
const String _sentryDsn = 'https://6a1a6ef8c72140099b2798973c1bfb2f@bugs.plezy.app/1';
|
const String _sentryDsn = 'https://6a1a6ef8c72140099b2798973c1bfb2f@bugs.plezy.app/1';
|
||||||
@@ -112,8 +116,9 @@ void _registerTvosPlatformPlugins() {
|
|||||||
SharedPreferencesFoundation.registerWith();
|
SharedPreferencesFoundation.registerWith();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> main() async {
|
void main() {
|
||||||
final binding = WidgetsFlutterBinding.ensureInitialized();
|
final binding = WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.dartMain);
|
||||||
// Keep the accessibility tree available to Maestro and other UI automation
|
// Keep the accessibility tree available to Maestro and other UI automation
|
||||||
// without adding release-build overhead.
|
// without adding release-build overhead.
|
||||||
if (kDebugMode) binding.ensureSemantics();
|
if (kDebugMode) binding.ensureSemantics();
|
||||||
@@ -123,10 +128,47 @@ Future<void> main() async {
|
|||||||
// target in Flutter's tool), so register platform stores manually for
|
// target in Flutter's tool), so register platform stores manually for
|
||||||
// the plugins we use.
|
// the plugins we use.
|
||||||
_registerTvosPlatformPlugins();
|
_registerTvosPlatformPlugins();
|
||||||
|
_bootstrapApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _bootstrapApp() {
|
||||||
|
// In release mode, show a colored placeholder instead of a blank/white screen
|
||||||
|
// when a widget build() throws an unhandled exception.
|
||||||
|
ErrorWidget.builder = (FlutterErrorDetails details) {
|
||||||
|
if (kDebugMode) return ErrorWidget(details.exception);
|
||||||
|
return const ColoredBox(color: Color(0xFF000000));
|
||||||
|
};
|
||||||
|
|
||||||
|
AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.runApp);
|
||||||
|
runApp(
|
||||||
|
StartupBootstrap<_StartupDependencies>(
|
||||||
|
initialize: _initializeApplication,
|
||||||
|
buildApp: (context, dependencies) => MainApp(
|
||||||
|
settings: dependencies.settings,
|
||||||
|
storage: dependencies.storage,
|
||||||
|
appDatabase: dependencies.appDatabase,
|
||||||
|
databaseRecoveryOutcome: dependencies.databaseRecoveryOutcome,
|
||||||
|
),
|
||||||
|
discard: (dependencies) => dependencies.appDatabase.close(),
|
||||||
|
onCommitted: (dependencies) => _startNonessentialInitialization(dependencies.settings),
|
||||||
|
lightTheme: monoTheme(dark: false),
|
||||||
|
darkTheme: monoTheme(dark: true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<_StartupDependencies> _initializeApplication() async {
|
||||||
|
final settings = await SettingsService.getInstance();
|
||||||
|
setLoggerLevel(settings.read(SettingsService.enableDebugLogging));
|
||||||
|
|
||||||
|
_StartupDependencies? dependencies;
|
||||||
|
Future<void> initializeStartup() async {
|
||||||
|
AndroidExitDiagnostics.markTelemetryReady();
|
||||||
|
dependencies = await _initializeStartup(settings);
|
||||||
|
}
|
||||||
|
|
||||||
if (_enableSentry) {
|
if (_enableSentry) {
|
||||||
final packageInfo = await PackageInfo.fromPlatform();
|
final packageInfo = await PackageInfo.fromPlatform();
|
||||||
|
|
||||||
await SentryFlutter.init((options) {
|
await SentryFlutter.init((options) {
|
||||||
options.dsn = _sentryDsn;
|
options.dsn = _sentryDsn;
|
||||||
options.release = gitCommit.isNotEmpty
|
options.release = gitCommit.isNotEmpty
|
||||||
@@ -143,14 +185,170 @@ Future<void> main() async {
|
|||||||
options.appHangTimeoutInterval = const Duration(seconds: 3);
|
options.appHangTimeoutInterval = const Duration(seconds: 3);
|
||||||
options.beforeSend = _beforeSend;
|
options.beforeSend = _beforeSend;
|
||||||
options.beforeBreadcrumb = _beforeBreadcrumb;
|
options.beforeBreadcrumb = _beforeBreadcrumb;
|
||||||
}, appRunner: _bootstrapApp);
|
}, appRunner: initializeStartup);
|
||||||
return;
|
} else {
|
||||||
|
await initializeStartup();
|
||||||
}
|
}
|
||||||
|
return dependencies!;
|
||||||
await _bootstrapApp();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _bootstrapApp() async {
|
const startupBootstrapProgressKey = Key('startup-bootstrap-progress');
|
||||||
|
const startupBootstrapFailureKey = Key('startup-bootstrap-failure');
|
||||||
|
const startupBootstrapRetryKey = Key('startup-bootstrap-retry');
|
||||||
|
|
||||||
|
/// Mounts a Flutter-owned startup frame before invoking the asynchronous
|
||||||
|
/// initialization gate. The generic seam keeps frame ordering, failure, and
|
||||||
|
/// retry behavior testable without constructing platform services.
|
||||||
|
@visibleForTesting
|
||||||
|
class StartupBootstrap<T> extends StatefulWidget {
|
||||||
|
const StartupBootstrap({
|
||||||
|
super.key,
|
||||||
|
required this.initialize,
|
||||||
|
required this.buildApp,
|
||||||
|
this.discard,
|
||||||
|
this.onCommitted,
|
||||||
|
this.lightTheme,
|
||||||
|
this.darkTheme,
|
||||||
|
this.themeMode = material.ThemeMode.system,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Future<T> Function() initialize;
|
||||||
|
final Widget Function(BuildContext context, T value) buildApp;
|
||||||
|
final FutureOr<void> Function(T value)? discard;
|
||||||
|
final FutureOr<void> Function(T value)? onCommitted;
|
||||||
|
final ThemeData? lightTheme;
|
||||||
|
final ThemeData? darkTheme;
|
||||||
|
final material.ThemeMode themeMode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StartupBootstrap<T>> createState() => _StartupBootstrapState<T>();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StartupBootstrapState<T> extends State<StartupBootstrap<T>> {
|
||||||
|
T? _value;
|
||||||
|
Object? _error;
|
||||||
|
bool _completed = false;
|
||||||
|
bool _initializing = false;
|
||||||
|
int _generation = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.firstFrame);
|
||||||
|
if (mounted) unawaited(_initialize());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initialize() async {
|
||||||
|
if (_initializing) return;
|
||||||
|
|
||||||
|
final generation = ++_generation;
|
||||||
|
setState(() {
|
||||||
|
_error = null;
|
||||||
|
_initializing = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final value = await widget.initialize();
|
||||||
|
if (!mounted || generation != _generation) {
|
||||||
|
await _discard(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_value = value;
|
||||||
|
_completed = true;
|
||||||
|
_initializing = false;
|
||||||
|
});
|
||||||
|
unawaited(Future.sync(() => widget.onCommitted?.call(value)));
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
if (!mounted || generation != _generation) return;
|
||||||
|
appLogger.e('Startup initialization failed (${error.runtimeType})', stackTrace: stackTrace);
|
||||||
|
setState(() {
|
||||||
|
_error = error;
|
||||||
|
_initializing = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _discard(T value) async {
|
||||||
|
try {
|
||||||
|
await widget.discard?.call(value);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
appLogger.e('Failed to dispose an uncommitted startup result (${error.runtimeType})', stackTrace: stackTrace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_generation++;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_completed) return widget.buildApp(context, _value as T);
|
||||||
|
|
||||||
|
return TranslationProvider(
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) => InputModeTracker(
|
||||||
|
child: MaterialApp(
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
theme: widget.lightTheme,
|
||||||
|
darkTheme: widget.darkTheme,
|
||||||
|
themeMode: widget.themeMode,
|
||||||
|
home: Builder(builder: _buildBootstrapHome),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBootstrapHome(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: _error == null
|
||||||
|
? const CircularProgressIndicator(key: startupBootstrapProgressKey)
|
||||||
|
: Column(
|
||||||
|
key: startupBootstrapFailureKey,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const AppIcon(Symbols.error_rounded, size: 48),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(t.common.error, style: Theme.of(context).textTheme.titleLarge),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FocusableButton(
|
||||||
|
autofocus: true,
|
||||||
|
onPressed: _initializing ? null : () => unawaited(_initialize()),
|
||||||
|
child: FilledButton(
|
||||||
|
key: startupBootstrapRetryKey,
|
||||||
|
onPressed: _initializing ? null : () => unawaited(_initialize()),
|
||||||
|
child: Text(t.common.retry),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StartupDependencies {
|
||||||
|
const _StartupDependencies({
|
||||||
|
required this.settings,
|
||||||
|
required this.storage,
|
||||||
|
required this.appDatabase,
|
||||||
|
required this.databaseRecoveryOutcome,
|
||||||
|
});
|
||||||
|
|
||||||
|
final SettingsService settings;
|
||||||
|
final StorageService storage;
|
||||||
|
final AppDatabase appDatabase;
|
||||||
|
final TvosDatabaseRecoveryOutcome databaseRecoveryOutcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<_StartupDependencies> _initializeStartup(SettingsService settings) async {
|
||||||
final startupWatch = Stopwatch()..start();
|
final startupWatch = Stopwatch()..start();
|
||||||
var lastStartupMarkMs = 0;
|
var lastStartupMarkMs = 0;
|
||||||
void markStartupPhase(String phase) {
|
void markStartupPhase(String phase) {
|
||||||
@@ -160,140 +358,123 @@ Future<void> _bootstrapApp() async {
|
|||||||
lastStartupMarkMs = elapsedMs;
|
lastStartupMarkMs = elapsedMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
final settings = await SettingsService.getInstance();
|
AppDatabase? openedDatabase;
|
||||||
markStartupPhase('settings');
|
try {
|
||||||
final savedLocale = settings.read(SettingsService.appLocale);
|
final savedLocale = settings.read(SettingsService.appLocale);
|
||||||
|
await LocaleSettings.setLocale(savedLocale);
|
||||||
|
await initializeDateFormatting(savedLocale.languageCode, null);
|
||||||
|
markStartupPhase('locale');
|
||||||
|
|
||||||
unawaited(LocaleSettings.setLocale(savedLocale));
|
final futures = <Future<void>>[];
|
||||||
|
if (PlatformDetector.isDesktopOS()) {
|
||||||
|
if (Platform.isMacOS) {
|
||||||
|
futures.add(windowManager.ensureInitialized().then((_) => MacOSWindowService.setupCustomTitlebar()));
|
||||||
|
} else {
|
||||||
|
futures.add(windowManager.ensureInitialized());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await initializeDateFormatting(savedLocale.languageCode, null);
|
// MainApp reads both synchronous facades during its first build.
|
||||||
markStartupPhase('locale');
|
futures.add(TvDetectionService.getInstance(forceTv: settings.read(SettingsService.forceTvMode)));
|
||||||
|
futures.add(DevicePerformance.getInstance(override: settings.read(SettingsService.visualEffects)));
|
||||||
|
|
||||||
// One-time cleanup of the old flutter_cache_manager image cache directory
|
final storageFuture = StorageService.getInstance();
|
||||||
// (replaced by cached_network_image_ce in a prior refactor).
|
futures.add(storageFuture);
|
||||||
if (!settings.read(SettingsService.cleanedOldImageCache)) {
|
await Future.wait(futures);
|
||||||
|
final storage = await storageFuture;
|
||||||
|
markStartupPhase('platform-services');
|
||||||
|
|
||||||
|
AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.databaseOpenStarted);
|
||||||
|
final databaseBootstrap = await AppDatabase.open(isTvos: PlatformDetector.isAppleTV());
|
||||||
|
openedDatabase = databaseBootstrap.database;
|
||||||
|
AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.databaseReady);
|
||||||
|
markStartupPhase('database-recovery');
|
||||||
|
|
||||||
|
DevicePerformance.applyImageCacheBudget();
|
||||||
|
|
||||||
|
// DownloadManagerService reads this singleton synchronously in MainApp's
|
||||||
|
// initState, so its recoverable storage check remains in the explicit gate.
|
||||||
|
await DownloadStorageService.instance.initialize(settings);
|
||||||
|
markStartupPhase('download-storage');
|
||||||
|
|
||||||
|
return _StartupDependencies(
|
||||||
|
settings: settings,
|
||||||
|
storage: storage,
|
||||||
|
appDatabase: databaseBootstrap.database,
|
||||||
|
databaseRecoveryOutcome: databaseBootstrap.recoveryOutcome,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
await openedDatabase?.close();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startNonessentialInitialization(SettingsService settings) {
|
||||||
|
void bestEffort(String name, FutureOr<void> Function() action) {
|
||||||
|
unawaited(
|
||||||
|
Future.sync(action).catchError((Object error, StackTrace stackTrace) {
|
||||||
|
appLogger.e('$name startup task failed (${error.runtimeType})', stackTrace: stackTrace);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bestEffort('Legacy image cache cleanup', () async {
|
||||||
|
if (settings.read(SettingsService.cleanedOldImageCache)) return;
|
||||||
try {
|
try {
|
||||||
final tempDir = await getTemporaryDirectory();
|
final tempDir = await getTemporaryDirectory();
|
||||||
final oldCacheDir = Directory('${tempDir.path}/plexImageCache');
|
final oldCacheDir = Directory('${tempDir.path}/plexImageCache');
|
||||||
if (await oldCacheDir.exists()) {
|
if (await oldCacheDir.exists()) await oldCacheDir.delete(recursive: true);
|
||||||
await oldCacheDir.delete(recursive: true);
|
} finally {
|
||||||
}
|
await settings.write(SettingsService.cleanedOldImageCache, true);
|
||||||
} catch (_) {
|
|
||||||
// Best-effort; the directory may be locked or already partial.
|
|
||||||
}
|
}
|
||||||
await settings.write(SettingsService.cleanedOldImageCache, true);
|
});
|
||||||
}
|
|
||||||
|
|
||||||
final futures = <Future<void>>[];
|
bestEffort('Native window', () {
|
||||||
|
if (Platform.isAndroid) PipService();
|
||||||
|
NativeWindowService.initialize();
|
||||||
|
});
|
||||||
|
|
||||||
|
bestEffort('Fullscreen monitor', () async {
|
||||||
|
FullscreenStateManager().startMonitoring();
|
||||||
|
if (PlatformDetector.isDesktopOS() && settings.read(SettingsService.startInFullscreen)) {
|
||||||
|
await FullscreenStateManager().enterFullscreen();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
bestEffort('Gamepad', () {
|
||||||
|
GamepadService.instance.start();
|
||||||
|
if (PlatformDetector.isAppleTV()) AppleTvRemoteTouchService.instance.start();
|
||||||
|
});
|
||||||
|
|
||||||
if (PlatformDetector.isDesktopOS()) {
|
if (PlatformDetector.isDesktopOS()) {
|
||||||
if (Platform.isMacOS) {
|
bestEffort('Discord RPC', DiscordRPCService.instance.initialize);
|
||||||
futures.add(windowManager.ensureInitialized().then((_) => MacOSWindowService.setupCustomTitlebar()));
|
|
||||||
} else {
|
|
||||||
futures.add(windowManager.ensureInitialized());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize TV detection on every platform: auto-detect covers Android
|
if (settings.read(SettingsService.crashReporting)) {
|
||||||
// leanback and Apple TV; the force-TV setting applies anywhere, including
|
unawaited(AndroidExitDiagnostics.logPreviousExit());
|
||||||
// desktop home-theater setups.
|
|
||||||
futures.add(TvDetectionService.getInstance(forceTv: settings.read(SettingsService.forceTvMode)));
|
|
||||||
// Visual-effects tier (auto-detects low-end Android; full elsewhere).
|
|
||||||
futures.add(DevicePerformance.getInstance(override: settings.read(SettingsService.visualEffects)));
|
|
||||||
if (Platform.isAndroid) {
|
|
||||||
PipService();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hook Windows native fullscreen callback (no-op elsewhere).
|
bestEffort('Trakt scrobble', TraktScrobbleService.instance.initialize);
|
||||||
NativeWindowService.initialize();
|
bestEffort('Shader licenses', _registerShaderLicenses);
|
||||||
|
bestEffort('Environment diagnostics', _logEnvironmentDiagnostics);
|
||||||
final storageFuture = StorageService.getInstance();
|
}
|
||||||
futures.add(storageFuture);
|
|
||||||
|
|
||||||
await Future.wait(futures);
|
|
||||||
final storage = await storageFuture;
|
|
||||||
markStartupPhase('platform-services');
|
|
||||||
final databaseBootstrap = await AppDatabase.open(isTvos: PlatformDetector.isAppleTV());
|
|
||||||
markStartupPhase('database-recovery');
|
|
||||||
|
|
||||||
// Configure image cache — keep budget modest to leave headroom for Skia
|
|
||||||
// decode buffers. Runs after the futures so the effects tier is resolved.
|
|
||||||
DevicePerformance.applyImageCacheBudget();
|
|
||||||
|
|
||||||
// The PLEX_TOKEN dart-define (screenshot automation) is consumed by
|
|
||||||
// [ConnectionBootstrap.seedFromDevTokenDefine] later, when the registry
|
|
||||||
// is available — keeps the deprecated legacy slots out of runtime paths.
|
|
||||||
final debugEnabled = settings.read(SettingsService.enableDebugLogging);
|
|
||||||
setLoggerLevel(debugEnabled);
|
|
||||||
|
|
||||||
|
Future<void> _logEnvironmentDiagnostics() async {
|
||||||
final packageInfo = await PackageInfo.fromPlatform();
|
final packageInfo = await PackageInfo.fromPlatform();
|
||||||
final commitSuffix = gitCommit.isNotEmpty ? ' (${gitCommit.substring(0, 7)})' : '';
|
final commitSuffix = gitCommit.isNotEmpty ? ' (${gitCommit.substring(0, 7)})' : '';
|
||||||
String renderer = '';
|
String renderer = '';
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
final rendererName = await const MethodChannel('com.plezy/theme').invokeMethod<String>('getRenderer');
|
final rendererName = await const MethodChannel('com.plezy/theme').invokeMethod<String>('getRenderer');
|
||||||
renderer = ' [$rendererName]';
|
renderer = ' [$rendererName]';
|
||||||
// Tag crash reports with the active renderer while Impeller rolls back
|
await Future.sync(() => Sentry.configureScope((scope) => scope.setTag('renderer', rendererName ?? 'unknown')));
|
||||||
// out to Android TV, so device-specific regressions are attributable.
|
|
||||||
// configureScope returns FutureOr<void>; Future.sync flattens it for unawaited.
|
|
||||||
unawaited(Future.sync(() => Sentry.configureScope((scope) => scope.setTag('renderer', rendererName ?? 'unknown'))));
|
|
||||||
}
|
}
|
||||||
appLogger.i(
|
appLogger.i(
|
||||||
'Plezy v${packageInfo.version}+${packageInfo.buildNumber}$commitSuffix$renderer'
|
'Plezy v${packageInfo.version}+${packageInfo.buildNumber}$commitSuffix$renderer'
|
||||||
' [effects: ${DevicePerformance.describeSync()}]',
|
' [effects: ${DevicePerformance.describeSync()}]',
|
||||||
);
|
);
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
// Baseline for the RSS watchdog thresholds and a sanity anchor against
|
|
||||||
// `adb shell dumpsys meminfo` when tuning them.
|
|
||||||
appLogger.i('Startup RSS: ${ProcessInfo.currentRss >> 20}MB');
|
appLogger.i('Startup RSS: ${ProcessInfo.currentRss >> 20}MB');
|
||||||
}
|
}
|
||||||
markStartupPhase('environment');
|
|
||||||
|
|
||||||
await DownloadStorageService.instance.initialize(settings);
|
|
||||||
markStartupPhase('download-storage');
|
|
||||||
|
|
||||||
FullscreenStateManager().startMonitoring();
|
|
||||||
|
|
||||||
// Apply "start in fullscreen" preference on desktop. macOS does not restore
|
|
||||||
// fullscreen state on its own (frame autosave only persists windowed geometry),
|
|
||||||
// so it needs the same explicit handling as Windows/Linux.
|
|
||||||
if (PlatformDetector.isDesktopOS() && settings.read(SettingsService.startInFullscreen)) {
|
|
||||||
unawaited(FullscreenStateManager().enterFullscreen());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize gamepad service (all platforms — universal_gamepad auto-registers
|
|
||||||
// and intercepts input events, so we must listen to re-dispatch them)
|
|
||||||
GamepadService.instance.start();
|
|
||||||
if (PlatformDetector.isAppleTV()) {
|
|
||||||
AppleTvRemoteTouchService.instance.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (PlatformDetector.isDesktopOS()) {
|
|
||||||
unawaited(DiscordRPCService.instance.initialize());
|
|
||||||
}
|
|
||||||
|
|
||||||
await TraktScrobbleService.instance.initialize();
|
|
||||||
markStartupPhase('trakt-scrobble');
|
|
||||||
|
|
||||||
_registerShaderLicenses();
|
|
||||||
|
|
||||||
// In release mode, show a colored placeholder instead of a blank/white screen
|
|
||||||
// when a widget build() throws an unhandled exception.
|
|
||||||
ErrorWidget.builder = (FlutterErrorDetails details) {
|
|
||||||
if (kDebugMode) return ErrorWidget(details.exception);
|
|
||||||
return const ColoredBox(color: Color(0xFF000000));
|
|
||||||
};
|
|
||||||
|
|
||||||
final appDatabase = databaseBootstrap.database;
|
|
||||||
|
|
||||||
markStartupPhase('pre-runApp');
|
|
||||||
runApp(
|
|
||||||
MainApp(
|
|
||||||
settings: settings,
|
|
||||||
storage: storage,
|
|
||||||
appDatabase: appDatabase,
|
|
||||||
databaseRecoveryOutcome: databaseBootstrap.recoveryOutcome,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Breadcrumb? _beforeBreadcrumb(Breadcrumb? breadcrumb, Hint _) {
|
Breadcrumb? _beforeBreadcrumb(Breadcrumb? breadcrumb, Hint _) {
|
||||||
@@ -1160,6 +1341,7 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
|
|||||||
_setStatus(t.common.startingOfflineMode);
|
_setStatus(t.common.startingOfflineMode);
|
||||||
await context.read<DownloadProvider>().ensureInitialized();
|
await context.read<DownloadProvider>().ensureInitialized();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.mainScreen);
|
||||||
unawaited(Navigator.pushReplacement(context, fadeRoute(const ProfileSessionScreen(isOfflineMode: true))));
|
unawaited(Navigator.pushReplacement(context, fadeRoute(const ProfileSessionScreen(isOfflineMode: true))));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1260,6 +1442,7 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
|
|||||||
final List<Connection> allConnections;
|
final List<Connection> allConnections;
|
||||||
try {
|
try {
|
||||||
allConnections = await connectionRegistry.list();
|
allConnections = await connectionRegistry.list();
|
||||||
|
AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.credentialsLoaded);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
// Defence-in-depth: a DB-open failure here used to propagate
|
// Defence-in-depth: a DB-open failure here used to propagate
|
||||||
// uncaught and strand the splash forever (#1022). Route to auth so
|
// uncaught and strand the splash forever (#1022). Route to auth so
|
||||||
@@ -1341,6 +1524,7 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
|
|||||||
// Start only after network/offline startup has been decided and the
|
// Start only after network/offline startup has been decided and the
|
||||||
// active profile snapshot is hydrated. This prevents an eager binder
|
// active profile snapshot is hydrated. This prevents an eager binder
|
||||||
// microtask from racing the no-network/manual-offline fast path.
|
// microtask from racing the no-network/manual-offline fast path.
|
||||||
|
AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.bindingStarted);
|
||||||
binder.start();
|
binder.start();
|
||||||
|
|
||||||
// If "prompt for profile on launch" is on (or no profile is selected
|
// If "prompt for profile on launch" is on (or no profile is selected
|
||||||
@@ -1372,6 +1556,7 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
|
|||||||
bindingSucceeded = await activeProfile.awaitBindingSettle();
|
bindingSucceeded = await activeProfile.awaitBindingSettle();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
}
|
}
|
||||||
|
AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.bindingSettled);
|
||||||
|
|
||||||
if (shouldEnterOfflineModeAfterStartupBind(
|
if (shouldEnterOfflineModeAfterStartupBind(
|
||||||
bindingSucceeded: bindingSucceeded,
|
bindingSucceeded: bindingSucceeded,
|
||||||
@@ -1389,6 +1574,7 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
|
|||||||
await downloadProvider.refreshMetadataFromCache();
|
await downloadProvider.refreshMetadataFromCache();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
|
AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.mainScreen);
|
||||||
unawaited(Navigator.pushReplacement(context, fadeRoute(ProfileSessionScreen(initialPromptHandled: shouldPrompt))));
|
unawaited(Navigator.pushReplacement(context, fadeRoute(ProfileSessionScreen(initialPromptHandled: shouldPrompt))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ import '../utils/provider_extensions.dart';
|
|||||||
import '../utils/snackbar_helper.dart';
|
import '../utils/snackbar_helper.dart';
|
||||||
import '../utils/stream_buffer_sizing.dart';
|
import '../utils/stream_buffer_sizing.dart';
|
||||||
import '../utils/video_player_navigation.dart';
|
import '../utils/video_player_navigation.dart';
|
||||||
|
import '../utils/android_exit_diagnostics.dart';
|
||||||
import 'video_player/completion_latch.dart';
|
import 'video_player/completion_latch.dart';
|
||||||
import 'video_player/frame_rate_matcher.dart';
|
import 'video_player/frame_rate_matcher.dart';
|
||||||
import 'video_player/live_stream_retry.dart';
|
import 'video_player/live_stream_retry.dart';
|
||||||
@@ -684,6 +685,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
unawaited(AndroidExitDiagnostics.markUiState(AndroidUiState.player));
|
||||||
|
|
||||||
_playerNavigationCoordinator = PlayerNavigationCoordinator(
|
_playerNavigationCoordinator = PlayerNavigationCoordinator(
|
||||||
chromeController: _chromeController,
|
chromeController: _chromeController,
|
||||||
@@ -1428,6 +1430,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
unawaited(AndroidExitDiagnostics.markUiState(AndroidUiState.mainScreen));
|
||||||
_playerInitializationGeneration++;
|
_playerInitializationGeneration++;
|
||||||
_frameRate.dispose();
|
_frameRate.dispose();
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ abstract class BaseSharedPreferencesService {
|
|||||||
// Single shared cache across all subclasses so writes from one service are
|
// Single shared cache across all subclasses so writes from one service are
|
||||||
// visible to reads from another without per-instance cache divergence.
|
// visible to reads from another without per-instance cache divergence.
|
||||||
static Future<SharedPreferencesWithCache>? _cacheFuture;
|
static Future<SharedPreferencesWithCache>? _cacheFuture;
|
||||||
|
static Future<SharedPreferencesWithCache> Function() _cacheLoader = _loadSharedCache;
|
||||||
|
|
||||||
late SharedPreferencesWithCache _cache;
|
late SharedPreferencesWithCache _cache;
|
||||||
|
|
||||||
@@ -61,15 +62,38 @@ abstract class BaseSharedPreferencesService {
|
|||||||
/// migration on first call; subsequent calls return the same future.
|
/// migration on first call; subsequent calls return the same future.
|
||||||
/// Use this from services that don't extend [BaseSharedPreferencesService].
|
/// Use this from services that don't extend [BaseSharedPreferencesService].
|
||||||
static Future<SharedPreferencesWithCache> sharedCache() {
|
static Future<SharedPreferencesWithCache> sharedCache() {
|
||||||
return _cacheFuture ??= () async {
|
final cached = _cacheFuture;
|
||||||
final legacy = await SharedPreferences.getInstance();
|
if (cached != null) return cached;
|
||||||
await migrateLegacySharedPreferencesToSharedPreferencesAsyncIfNecessary(
|
|
||||||
legacySharedPreferencesInstance: legacy,
|
late final Future<SharedPreferencesWithCache> loading;
|
||||||
sharedPreferencesAsyncOptions: const SharedPreferencesOptions(),
|
loading = _cacheLoader().then(
|
||||||
migrationCompletedKey: 'plezy_legacy_prefs_migrated_v1',
|
(cache) => cache,
|
||||||
);
|
onError: (Object error, StackTrace stackTrace) {
|
||||||
return SharedPreferencesWithCache.create(cacheOptions: const SharedPreferencesWithCacheOptions());
|
// Do not poison every later startup with one transient plugin/storage
|
||||||
}();
|
// failure. Identity keeps a superseding/reset load intact while all
|
||||||
|
// concurrent callers continue to share this attempt.
|
||||||
|
if (identical(_cacheFuture, loading)) _cacheFuture = null;
|
||||||
|
Error.throwWithStackTrace(error, stackTrace);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
_cacheFuture = loading;
|
||||||
|
return loading;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<SharedPreferencesWithCache> _loadSharedCache() async {
|
||||||
|
final legacy = await SharedPreferences.getInstance();
|
||||||
|
await migrateLegacySharedPreferencesToSharedPreferencesAsyncIfNecessary(
|
||||||
|
legacySharedPreferencesInstance: legacy,
|
||||||
|
sharedPreferencesAsyncOptions: const SharedPreferencesOptions(),
|
||||||
|
migrationCompletedKey: 'plezy_legacy_prefs_migrated_v1',
|
||||||
|
);
|
||||||
|
return SharedPreferencesWithCache.create(cacheOptions: const SharedPreferencesWithCacheOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
static void setCacheLoaderForTesting(Future<SharedPreferencesWithCache> Function() loader) {
|
||||||
|
_cacheFuture = null;
|
||||||
|
_cacheLoader = loader;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drop all cached singleton instances and the shared cache future so the
|
/// Drop all cached singleton instances and the shared cache future so the
|
||||||
@@ -81,6 +105,7 @@ abstract class BaseSharedPreferencesService {
|
|||||||
_initializations.clear();
|
_initializations.clear();
|
||||||
_instances.clear();
|
_instances.clear();
|
||||||
_cacheFuture = null;
|
_cacheFuture = null;
|
||||||
|
_cacheLoader = _loadSharedCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Typed read helpers — return the stored value or [defaultValue] when missing.
|
/// Typed read helpers — return the stored value or [defaultValue] when missing.
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||||
|
|
||||||
|
import 'app_logger.dart';
|
||||||
|
|
||||||
|
enum AndroidStartupPhase {
|
||||||
|
nativeOnCreate('native_on_create'),
|
||||||
|
dartMain('dart_main'),
|
||||||
|
runApp('runApp'),
|
||||||
|
firstFrame('first_frame'),
|
||||||
|
databaseOpenStarted('database_open_started'),
|
||||||
|
databaseReady('database_ready'),
|
||||||
|
credentialsLoaded('credentials_loaded'),
|
||||||
|
bindingStarted('binding_started'),
|
||||||
|
bindingSettled('binding_settled'),
|
||||||
|
mainScreen('main_screen');
|
||||||
|
|
||||||
|
const AndroidStartupPhase(this.id);
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AndroidUiState {
|
||||||
|
mainScreen('main_screen'),
|
||||||
|
player('player');
|
||||||
|
|
||||||
|
const AndroidUiState(this.id);
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort bridge for the newest Android 11+ historical process exit.
|
||||||
|
abstract final class AndroidExitDiagnostics {
|
||||||
|
static const _channel = MethodChannel('com.plezy/device');
|
||||||
|
static const _allowedReasons = {'crash', 'native_crash', 'anr', 'low_memory', 'user_requested', 'other'};
|
||||||
|
static const _allowedAbis = {'arm64-v8a', 'armeabi-v7a', 'x86_64', 'x86', 'unknown'};
|
||||||
|
static const _allowedCodecContexts = {
|
||||||
|
'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',
|
||||||
|
};
|
||||||
|
static const _allowedUiStates = {'startup', 'authentication', 'main_screen', 'player', 'player_disposed'};
|
||||||
|
static const _allowedStartupPhases = {
|
||||||
|
'native_on_create',
|
||||||
|
'dart_main',
|
||||||
|
'runApp',
|
||||||
|
'first_frame',
|
||||||
|
'database_open_started',
|
||||||
|
'database_ready',
|
||||||
|
'credentials_loaded',
|
||||||
|
'binding_started',
|
||||||
|
'binding_settled',
|
||||||
|
'main_screen',
|
||||||
|
};
|
||||||
|
static final _decoderNamePattern = RegExp(r'^[A-Za-z0-9_.:-]{1,96}$');
|
||||||
|
static final _startupWatch = Stopwatch()..start();
|
||||||
|
static final List<({String phase, int elapsedMs})> _pendingBreadcrumbs = [];
|
||||||
|
static var _telemetryReady = false;
|
||||||
|
static var _nativeOnCreateRecorded = false;
|
||||||
|
static var _lastElapsedMs = 0;
|
||||||
|
|
||||||
|
/// Flushes phase breadcrumbs recorded before Sentry's app runner started.
|
||||||
|
static void markTelemetryReady() {
|
||||||
|
if (_telemetryReady) return;
|
||||||
|
_telemetryReady = true;
|
||||||
|
final pending = List.of(_pendingBreadcrumbs);
|
||||||
|
_pendingBreadcrumbs.clear();
|
||||||
|
for (final mark in pending) {
|
||||||
|
_sendBreadcrumb(mark.phase, mark.elapsedMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persists and records one fixed, privacy-safe startup phase.
|
||||||
|
static void markStartupPhase(AndroidStartupPhase phase) {
|
||||||
|
try {
|
||||||
|
if (Platform.isAndroid && !_nativeOnCreateRecorded) {
|
||||||
|
_nativeOnCreateRecorded = true;
|
||||||
|
_recordPhase(AndroidStartupPhase.nativeOnCreate.id, 0);
|
||||||
|
}
|
||||||
|
final measuredMs = _startupWatch.elapsedMilliseconds;
|
||||||
|
final elapsedMs = measuredMs < _lastElapsedMs ? _lastElapsedMs : measuredMs;
|
||||||
|
_lastElapsedMs = elapsedMs;
|
||||||
|
_recordPhase(phase.id, elapsedMs);
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
unawaited(_persistStartupPhase(phase.id));
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Startup diagnostics must never affect the startup path.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _recordPhase(String phase, int elapsedMs) {
|
||||||
|
try {
|
||||||
|
appLogger.i('Startup phase: phase=$phase elapsedMs=$elapsedMs');
|
||||||
|
} catch (_) {
|
||||||
|
// Local logging is best-effort.
|
||||||
|
}
|
||||||
|
if (_telemetryReady) {
|
||||||
|
_sendBreadcrumb(phase, elapsedMs);
|
||||||
|
} else {
|
||||||
|
_pendingBreadcrumbs.add((phase: phase, elapsedMs: elapsedMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void _sendBreadcrumb(String phase, int elapsedMs) {
|
||||||
|
try {
|
||||||
|
unawaited(
|
||||||
|
Sentry.addBreadcrumb(
|
||||||
|
Breadcrumb(
|
||||||
|
message: 'Startup phase $phase',
|
||||||
|
category: 'startup.phase',
|
||||||
|
data: {'phase': phase, 'elapsedMs': elapsedMs},
|
||||||
|
),
|
||||||
|
).catchError((_) {}),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
// Breadcrumb emission is best-effort.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _persistStartupPhase(String phase) async {
|
||||||
|
try {
|
||||||
|
await _channel.invokeMethod<bool>('setStartupPhase', phase);
|
||||||
|
} catch (_) {
|
||||||
|
// Native phase persistence is best-effort.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> markUiState(AndroidUiState state) async {
|
||||||
|
if (!Platform.isAndroid) return;
|
||||||
|
try {
|
||||||
|
await _channel.invokeMethod<bool>('setRuntimeUiState', state.id);
|
||||||
|
} catch (_) {
|
||||||
|
// Runtime diagnostics are best-effort and must never affect navigation.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records a native-sanitized previous-exit report in local logs and Sentry.
|
||||||
|
///
|
||||||
|
/// Native persistence makes this one-shot across launches. Every failure is
|
||||||
|
/// intentionally contained because historical diagnostics must not affect
|
||||||
|
/// startup.
|
||||||
|
static Future<void> logPreviousExit() async {
|
||||||
|
if (!Platform.isAndroid) return;
|
||||||
|
try {
|
||||||
|
final raw = await _channel.invokeMapMethod<String, Object?>('getPreviousExit');
|
||||||
|
final report = _validate(raw);
|
||||||
|
if (report == null) return;
|
||||||
|
|
||||||
|
appLogger.w(
|
||||||
|
'Previous Android application exit: '
|
||||||
|
'reason=${report['reason']} status=${report['status']} '
|
||||||
|
'importance=${report['importance']} timestamp=${report['timestamp']} '
|
||||||
|
'deviceModel=${report['deviceModel']} apiLevel=${report['apiLevel']} '
|
||||||
|
'abi=${report['abi']} lowRam=${report['lowRam']} '
|
||||||
|
'startupPhase=${report['startupPhase'] ?? 'omitted'} '
|
||||||
|
'codecContext=${report['codecContext'] ?? 'omitted'} '
|
||||||
|
'channels=${report['channelCount'] ?? 'omitted'} sampleRate=${report['sampleRate'] ?? 'omitted'} '
|
||||||
|
'decoder=${report['selectedDecoder'] ?? 'omitted'} '
|
||||||
|
'passthrough=${report['passthroughEnabled'] ?? 'omitted'} '
|
||||||
|
'downmix=${report['downmixEnabled'] ?? 'omitted'} '
|
||||||
|
'normalization=${report['normalizationEnabled'] ?? 'omitted'} '
|
||||||
|
'uiState=${report['uiState'] ?? 'omitted'}',
|
||||||
|
);
|
||||||
|
await Sentry.captureMessage(
|
||||||
|
'Previous Android application exit',
|
||||||
|
level: SentryLevel.warning,
|
||||||
|
withScope: (scope) => scope.setContexts('android_previous_exit', report),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
// Historical diagnostics are best-effort and must never escape startup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, Object?>? _validate(Map<String, Object?>? raw) {
|
||||||
|
if (raw == null) return null;
|
||||||
|
final reason = raw['reason'];
|
||||||
|
final status = raw['status'];
|
||||||
|
final importance = raw['importance'];
|
||||||
|
final timestamp = raw['timestamp'];
|
||||||
|
final deviceModel = raw['deviceModel'];
|
||||||
|
final apiLevel = raw['apiLevel'];
|
||||||
|
final abi = raw['abi'];
|
||||||
|
final lowRam = raw['lowRam'];
|
||||||
|
final startupPhase = raw['startupPhase'];
|
||||||
|
final codecContext = raw['codecContext'];
|
||||||
|
final channelCount = raw['channelCount'];
|
||||||
|
final sampleRate = raw['sampleRate'];
|
||||||
|
final selectedDecoder = raw['selectedDecoder'];
|
||||||
|
final passthroughEnabled = raw['passthroughEnabled'];
|
||||||
|
final downmixEnabled = raw['downmixEnabled'];
|
||||||
|
final normalizationEnabled = raw['normalizationEnabled'];
|
||||||
|
final uiState = raw['uiState'];
|
||||||
|
if (reason is! String || !_allowedReasons.contains(reason)) return null;
|
||||||
|
if (status is! int || importance is! int || timestamp is! int) return null;
|
||||||
|
if (deviceModel is! String ||
|
||||||
|
deviceModel.isEmpty ||
|
||||||
|
deviceModel.length > 80 ||
|
||||||
|
deviceModel.runes.any(_isControlCharacter)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (apiLevel is! int || apiLevel < 1 || abi is! String || !_allowedAbis.contains(abi) || lowRam is! bool) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (startupPhase != null && (startupPhase is! String || !_allowedStartupPhases.contains(startupPhase))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (codecContext != null && (codecContext is! String || !_allowedCodecContexts.contains(codecContext))) return null;
|
||||||
|
if (channelCount != null && (channelCount is! int || channelCount < 1 || channelCount > 32)) return null;
|
||||||
|
if (sampleRate != null && (sampleRate is! int || sampleRate < 1 || sampleRate > 768000)) return null;
|
||||||
|
if (selectedDecoder != null && (selectedDecoder is! String || !_decoderNamePattern.hasMatch(selectedDecoder))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (passthroughEnabled != null && passthroughEnabled is! bool) return null;
|
||||||
|
if (downmixEnabled != null && downmixEnabled is! bool) return null;
|
||||||
|
if (normalizationEnabled != null && normalizationEnabled is! bool) return null;
|
||||||
|
if (uiState != null && (uiState is! String || !_allowedUiStates.contains(uiState))) return null;
|
||||||
|
return <String, Object?>{
|
||||||
|
'reason': reason,
|
||||||
|
'status': status,
|
||||||
|
'importance': importance,
|
||||||
|
'timestamp': timestamp,
|
||||||
|
'deviceModel': deviceModel,
|
||||||
|
'apiLevel': apiLevel,
|
||||||
|
'abi': abi,
|
||||||
|
'lowRam': lowRam,
|
||||||
|
if (startupPhase case final String safeStartupPhase) 'startupPhase': safeStartupPhase,
|
||||||
|
if (codecContext case final String safeCodecContext) 'codecContext': safeCodecContext,
|
||||||
|
if (channelCount case final int safeChannelCount) 'channelCount': safeChannelCount,
|
||||||
|
if (sampleRate case final int safeSampleRate) 'sampleRate': safeSampleRate,
|
||||||
|
if (selectedDecoder case final String safeSelectedDecoder) 'selectedDecoder': safeSelectedDecoder,
|
||||||
|
if (passthroughEnabled case final bool safePassthroughEnabled) 'passthroughEnabled': safePassthroughEnabled,
|
||||||
|
if (downmixEnabled case final bool safeDownmixEnabled) 'downmixEnabled': safeDownmixEnabled,
|
||||||
|
if (normalizationEnabled case final bool safeNormalizationEnabled)
|
||||||
|
'normalizationEnabled': safeNormalizationEnabled,
|
||||||
|
if (uiState case final String safeUiState) 'uiState': safeUiState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _isControlCharacter(int rune) => rune < 0x20 || rune == 0x7f;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:plezy/services/base_shared_preferences_service.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import '../test_helpers/prefs.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUp(resetSharedPreferencesForTest);
|
||||||
|
|
||||||
|
tearDown(BaseSharedPreferencesService.resetForTesting);
|
||||||
|
|
||||||
|
test('failed shared cache load is coalesced but a later call retries', () async {
|
||||||
|
final firstLoad = Completer<SharedPreferencesWithCache>();
|
||||||
|
final originalError = StateError('preferences unavailable');
|
||||||
|
final originalStackTrace = StackTrace.current;
|
||||||
|
var loadCount = 0;
|
||||||
|
|
||||||
|
BaseSharedPreferencesService.setCacheLoaderForTesting(() {
|
||||||
|
loadCount++;
|
||||||
|
if (loadCount == 1) return firstLoad.future;
|
||||||
|
return SharedPreferencesWithCache.create(cacheOptions: const SharedPreferencesWithCacheOptions());
|
||||||
|
});
|
||||||
|
|
||||||
|
final firstCaller = BaseSharedPreferencesService.sharedCache();
|
||||||
|
final concurrentCaller = BaseSharedPreferencesService.sharedCache();
|
||||||
|
expect(identical(firstCaller, concurrentCaller), isTrue);
|
||||||
|
expect(loadCount, 1);
|
||||||
|
|
||||||
|
firstLoad.completeError(originalError, originalStackTrace);
|
||||||
|
Object? caughtError;
|
||||||
|
StackTrace? caughtStackTrace;
|
||||||
|
try {
|
||||||
|
await firstCaller;
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
caughtError = error;
|
||||||
|
caughtStackTrace = stackTrace;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(identical(caughtError, originalError), isTrue);
|
||||||
|
expect(caughtStackTrace.toString(), originalStackTrace.toString());
|
||||||
|
|
||||||
|
final recovered = await BaseSharedPreferencesService.sharedCache();
|
||||||
|
expect(loadCount, 2);
|
||||||
|
await recovered.setBool('recovered', true);
|
||||||
|
expect(recovered.getBool('recovered'), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reset restores the production cache loader', () async {
|
||||||
|
BaseSharedPreferencesService.setCacheLoaderForTesting(
|
||||||
|
() => Future<SharedPreferencesWithCache>.error(StateError('injected failure')),
|
||||||
|
);
|
||||||
|
|
||||||
|
BaseSharedPreferencesService.resetForTesting();
|
||||||
|
|
||||||
|
final cache = await BaseSharedPreferencesService.sharedCache();
|
||||||
|
await cache.setString('loader', 'production');
|
||||||
|
expect(cache.getString('loader'), 'production');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:plezy/main.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('renders a Flutter frame before starting the initialization gate', (tester) async {
|
||||||
|
final completion = Completer<int>();
|
||||||
|
var bootstrapWasMounted = false;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
StartupBootstrap<int>(
|
||||||
|
initialize: () {
|
||||||
|
bootstrapWasMounted = find.byKey(startupBootstrapProgressKey).evaluate().isNotEmpty;
|
||||||
|
return completion.future;
|
||||||
|
},
|
||||||
|
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(bootstrapWasMounted, isTrue);
|
||||||
|
expect(find.byKey(startupBootstrapProgressKey), findsOneWidget);
|
||||||
|
|
||||||
|
completion.complete(1);
|
||||||
|
await tester.pump();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('replaces bootstrap UI with the initialized app on success', (tester) async {
|
||||||
|
final completion = Completer<int>();
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
StartupBootstrap<int>(
|
||||||
|
initialize: () => completion.future,
|
||||||
|
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
completion.complete(7);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('ready 7'), findsOneWidget);
|
||||||
|
expect(find.byKey(startupBootstrapProgressKey), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows a localized recoverable failure instead of removing Flutter UI', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
StartupBootstrap<int>(
|
||||||
|
initialize: () async => throw StateError('database unavailable'),
|
||||||
|
buildApp: (_, value) => Text('ready $value'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.byKey(startupBootstrapFailureKey), findsOneWidget);
|
||||||
|
expect(find.text('Error'), findsOneWidget);
|
||||||
|
expect(find.text('Retry'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('retry clears the failed generation and can commit a later success', (tester) async {
|
||||||
|
final retryCompletion = Completer<int>();
|
||||||
|
var attempts = 0;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
StartupBootstrap<int>(
|
||||||
|
initialize: () {
|
||||||
|
attempts++;
|
||||||
|
if (attempts == 1) return Future<int>.error(StateError('first attempt'));
|
||||||
|
return retryCompletion.future;
|
||||||
|
},
|
||||||
|
buildApp: (_, value) => MaterialApp(home: Text('ready $value')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(startupBootstrapRetryKey));
|
||||||
|
await tester.pump();
|
||||||
|
expect(attempts, 2);
|
||||||
|
expect(find.byKey(startupBootstrapProgressKey), findsOneWidget);
|
||||||
|
|
||||||
|
retryCompletion.complete(42);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.text('ready 42'), findsOneWidget);
|
||||||
|
expect(find.byKey(startupBootstrapFailureKey), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('discards a completion from a disposed bootstrap generation', (tester) async {
|
||||||
|
final completion = Completer<int>();
|
||||||
|
final discarded = <int>[];
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
StartupBootstrap<int>(
|
||||||
|
initialize: () => completion.future,
|
||||||
|
buildApp: (_, value) => Text('ready $value'),
|
||||||
|
discard: discarded.add,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
completion.complete(9);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(discarded, [9]);
|
||||||
|
expect(find.text('ready 9'), findsNothing);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:plezy/utils/android_exit_diagnostics.dart';
|
||||||
|
import 'package:plezy/utils/app_logger.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
test('startup phase vocabulary is fixed and elapsed logging is monotonic', () async {
|
||||||
|
expect(AndroidStartupPhase.values.map((phase) => phase.id), [
|
||||||
|
'native_on_create',
|
||||||
|
'dart_main',
|
||||||
|
'runApp',
|
||||||
|
'first_frame',
|
||||||
|
'database_open_started',
|
||||||
|
'database_ready',
|
||||||
|
'credentials_loaded',
|
||||||
|
'binding_started',
|
||||||
|
'binding_settled',
|
||||||
|
'main_screen',
|
||||||
|
]);
|
||||||
|
expect(AndroidUiState.values.map((state) => state.id), ['main_screen', 'player']);
|
||||||
|
|
||||||
|
MemoryLogOutput.clearLogs();
|
||||||
|
AndroidExitDiagnostics.markTelemetryReady();
|
||||||
|
AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.databaseOpenStarted);
|
||||||
|
AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.databaseReady);
|
||||||
|
AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.credentialsLoaded);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
final messages = MemoryLogOutput.getLogs()
|
||||||
|
.map((entry) => entry.message)
|
||||||
|
.where((message) => message.startsWith('Startup phase:'))
|
||||||
|
.toList()
|
||||||
|
.reversed
|
||||||
|
.toList();
|
||||||
|
expect(messages, hasLength(3));
|
||||||
|
expect(messages[0], contains('phase=database_open_started'));
|
||||||
|
expect(messages[1], contains('phase=database_ready'));
|
||||||
|
expect(messages[2], contains('phase=credentials_loaded'));
|
||||||
|
|
||||||
|
final elapsed = messages
|
||||||
|
.map((message) => int.parse(RegExp(r'elapsedMs=(\d+)').firstMatch(message)!.group(1)!))
|
||||||
|
.toList();
|
||||||
|
expect(elapsed[1], greaterThanOrEqualTo(elapsed[0]));
|
||||||
|
expect(elapsed[2], greaterThanOrEqualTo(elapsed[1]));
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user