diff --git a/android/app/src/main/kotlin/com/edde746/plezy/AndroidExitDiagnostics.kt b/android/app/src/main/kotlin/com/edde746/plezy/AndroidExitDiagnostics.kt new file mode 100644 index 00000000..bfa838e1 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/AndroidExitDiagnostics.kt @@ -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 = 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 { + 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? { + if (readDedupeKey() == report.dedupeKey) return null + if (!persistDedupeKey(report.dedupeKey)) return null + return report.toMap() + } +} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt index e67018f2..198dcd1f 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/MainActivity.kt @@ -35,6 +35,9 @@ import io.flutter.embedding.android.TransparencyMode import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterShellArgs import io.flutter.plugin.common.MethodChannel +import java.util.concurrent.Executors +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.atomic.AtomicBoolean import kotlin.math.roundToInt class MainActivity : FlutterActivity() { @@ -42,6 +45,21 @@ class MainActivity : FlutterActivity() { companion object { private const val TAG = "MainActivity" private const val TEXT_INPUT_DIAGNOSTICS_ENABLED = false + private const val EXIT_DIAGNOSTICS_PREFS = "plezy_exit_diagnostics" + private const val LAST_EXIT_DEDUPE_KEY = "last_reported_exit" + private const val LAST_STARTUP_PHASE_KEY = "last_startup_phase" + private val startupPhaseLock = Any() + + @Volatile private var startupPhaseInitializationAttempted = false + + @Volatile private var startupPhaseStore: StartupPhaseStore? = null + + @Volatile private var previousRuntimeDiagnostics = RuntimeDiagnosticSnapshot() + private val exitDiagnosticsExecutor by lazy { + Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "plezy-exit-diagnostics").apply { isDaemon = true } + } + } // Mirrors DevicePerformance._lowMemThresholdBytes (2252 MiB): nominal // "2GB" devices report totalMem slightly above 2 GiB after carve-outs. @@ -63,6 +81,7 @@ class MainActivity : FlutterActivity() { private var flutterSurfaceReconnectPending = false private var activityStarted = false private val externalPlayerChannel = ExternalPlayerChannel(this) + private val exitDiagnosticsRequested = AtomicBoolean(false) private inline fun logTextInputDiag(message: () -> String) { if (TEXT_INPUT_DIAGNOSTICS_ENABLED) { @@ -184,6 +203,135 @@ class MainActivity : FlutterActivity() { ) } + private fun initializeStartupPhaseStore() { + var shouldMarkNativeOnCreate = false + synchronized(startupPhaseLock) { + if (startupPhaseInitializationAttempted) return + startupPhaseInitializationAttempted = true + try { + previousRuntimeDiagnostics = AndroidRuntimeDiagnostics.read(this) + val preferences = getSharedPreferences(EXIT_DIAGNOSTICS_PREFS, Context.MODE_PRIVATE) + startupPhaseStore = StartupPhaseStore( + readPhase = { preferences.getString(LAST_STARTUP_PHASE_KEY, null) }, + persistPhase = { phase -> + preferences.edit().putString(LAST_STARTUP_PHASE_KEY, phase).commit() + } + ) + shouldMarkNativeOnCreate = true + } catch (_: Throwable) { + Log.w(TAG, "Startup phase persistence unavailable") + } + } + if (shouldMarkNativeOnCreate) { + queueStartupPhase(AndroidStartupPhases.NATIVE_ON_CREATE) + } + } + + private fun queueStartupPhase(raw: String?, result: MethodChannel.Result? = null) { + val phase = AndroidStartupPhases.sanitize(raw) + if (phase == null) { + result?.let { completeStartupPhase(it, false) } + return + } + AndroidRuntimeDiagnostics.update(this, uiState = uiStateForStartupPhase(phase)) + try { + exitDiagnosticsExecutor.execute { + val persisted = try { + startupPhaseStore?.mark(phase) == true + } catch (_: Throwable) { + Log.w(TAG, "Startup phase update failed") + false + } + result?.let { reply -> + runOnUiThread { completeStartupPhase(reply, persisted) } + } + } + } catch (_: Throwable) { + Log.w(TAG, "Startup phase update could not start") + result?.let { completeStartupPhase(it, false) } + } + } + + private fun uiStateForStartupPhase(phase: String): String = when (phase) { + "credentials_loaded", "binding_started", "binding_settled" -> AndroidRuntimeDiagnostics.UI_AUTHENTICATION + "main_screen" -> AndroidRuntimeDiagnostics.UI_MAIN_SCREEN + else -> AndroidRuntimeDiagnostics.UI_STARTUP + } + + private fun completeStartupPhase(result: MethodChannel.Result, persisted: Boolean) { + try { + result.success(persisted) + } catch (_: Throwable) { + Log.w(TAG, "Startup phase reply failed") + } + } + + private fun handlePreviousExit(result: MethodChannel.Result) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + completePreviousExit(result, null) + return + } + if (!exitDiagnosticsRequested.compareAndSet(false, true)) { + completePreviousExit(result, null) + return + } + + try { + exitDiagnosticsExecutor.execute { + val report = try { + readPreviousExit() + } catch (_: Throwable) { + Log.w(TAG, "Previous exit diagnostics failed") + null + } + runOnUiThread { completePreviousExit(result, report) } + } + } catch (_: RejectedExecutionException) { + completePreviousExit(result, null) + } catch (_: Throwable) { + Log.w(TAG, "Previous exit diagnostics could not start") + completePreviousExit(result, null) + } + } + + private fun completePreviousExit(result: MethodChannel.Result, report: Map?) { + try { + result.success(report) + } catch (_: Throwable) { + Log.w(TAG, "Previous exit diagnostics reply failed") + } + } + + @RequiresApi(Build.VERSION_CODES.R) + private fun readPreviousExit(): Map? { + val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val exitInfo = activityManager + .getHistoricalProcessExitReasons(packageName, 0, 1) + .firstOrNull() + ?: return null + val report = AndroidExitReportMapper.map( + record = HistoricalExitRecord( + reason = exitInfo.reason, + status = exitInfo.status, + importance = exitInfo.importance, + timestamp = exitInfo.timestamp + ), + deviceModel = Build.MODEL, + apiLevel = Build.VERSION.SDK_INT, + abi = Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown", + lowRam = activityManager.isLowRamDevice, + startupPhase = startupPhaseStore?.previousPhase, + runtime = previousRuntimeDiagnostics + ) + val preferences = getSharedPreferences(EXIT_DIAGNOSTICS_PREFS, Context.MODE_PRIVATE) + return PreviousExitReportStore( + readDedupeKey = { preferences.getString(LAST_EXIT_DEDUPE_KEY, null) }, + persistDedupeKey = { key -> + preferences.edit().putString(LAST_EXIT_DEDUPE_KEY, key).commit() + } + ).takeIfNew(report) + } + /** * Same triple DevicePerformance uses for the reduced tier on the Dart * side — keep the two in sync. Evaluated here too because engine shell @@ -208,6 +356,8 @@ class MainActivity : FlutterActivity() { } override fun onCreate(savedInstanceState: Bundle?) { + // Snapshot the previous process phase before this launch can overwrite it. + initializeStartupPhaseStore() // Apply persisted theme color to the window background before anything // else renders. This prevents a white flash between the native splash // screen and Flutter's first frame for non-default themes (e.g. OLED). @@ -428,6 +578,17 @@ class MainActivity : FlutterActivity() { "getTvDetection" -> result.success(getAndroidTvDetection()) "getDeviceName" -> result.success(getDeviceName()) "getPerformanceSignals" -> result.success(getPerformanceSignals()) + "getPreviousExit" -> handlePreviousExit(result) + "setStartupPhase" -> queueStartupPhase(call.arguments as? String, result) + "setRuntimeUiState" -> { + val uiState = AndroidRuntimeDiagnostics.sanitizeUiState(call.arguments as? String) + if (uiState == null) { + result.success(false) + } else { + AndroidRuntimeDiagnostics.update(this, uiState = uiState) + result.success(true) + } + } else -> result.notImplemented() } } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index 0e82691e..669480e5 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -63,6 +63,7 @@ import androidx.media3.extractor.ts.TsExtractor import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.CaptionStyleCompat import androidx.media3.ui.SubtitleView +import com.edde746.plezy.AndroidRuntimeDiagnostics import com.edde746.plezy.libass.media.AssHandler import com.edde746.plezy.libass.media.parser.AssSubtitleParserFactory import com.edde746.plezy.libass.media.widget.AssSubtitleSurfaceView @@ -2327,11 +2328,23 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { parts.add("channels=${format.channelCount}") parts.add("sampleRate=${format.sampleRate}") format.id?.let { parts.add("id=$it") } - format.label?.let { parts.add("label=$it") } - format.language?.let { parts.add("lang=$it") } return parts.joinToString(", ") } + private fun persistRuntimePlaybackDiagnostics(format: Format? = selectedAudioFormat(), decoderName: String? = null) { + AndroidRuntimeDiagnostics.update( + context = activity, + codecContext = AndroidRuntimeDiagnostics.codecContextForMime(format?.sampleMimeType), + channelCount = format?.channelCount, + sampleRate = format?.sampleRate, + selectedDecoder = decoderName, + passthroughEnabled = audioPassthroughEnabled, + downmixEnabled = audioDownmixEnabled, + normalizationEnabled = audioNormalizationEnabled, + uiState = AndroidRuntimeDiagnostics.UI_PLAYER + ) + } + private fun describeAudioTrackConfig(config: AudioSink.AudioTrackConfig?): String { if (config == null) return "none" return "encoding=${config.encoding}, channels=${Integer.bitCount(config.channelConfig)}, " + @@ -2351,6 +2364,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { initializationDurationMs: Long ) { decoderInitName = decoderName + persistRuntimePlaybackDiagnostics(currentVideoFormat ?: exoPlayer?.videoFormat, decoderName) firstFrameRendered = false emitLog("debug", "decoder-hang", "Decoder initialized: $decoderName (${initializationDurationMs}ms)") logDolbyVisionPlaybackPathIfNeeded(decoderName) @@ -2363,6 +2377,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { initializationDurationMs: Long ) { audioDecoderInitName = decoderName + persistRuntimePlaybackDiagnostics(decoderName = decoderName) emitLog("info", "audio", "Decoder initialized: $decoderName (${initializationDurationMs}ms)") } @@ -2371,6 +2386,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { format: Format, decoderReuseEvaluation: DecoderReuseEvaluation? ) { + persistRuntimePlaybackDiagnostics(format, audioDecoderInitName) emitLog("info", "audio", "Input format: ${formatAudioSummary(format)}") if (format.sampleMimeType == MimeTypes.AUDIO_TRUEHD) { updateAudioDecoderPolicy("input format", format) @@ -2412,6 +2428,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { ) { lastAudioTrackConfig = audioTrackConfig val audioFormat = selectedAudioFormat() + persistRuntimePlaybackDiagnostics(audioFormat, audioDecoderInitName ?: "direct") emitLog( "info", "audio", @@ -2802,6 +2819,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { // audioNormalizationEnabled persists across opens (user-level state, like // tunnelingUserEnabled); only the in-flight bounce is abandoned. pendingAudioRendererBounce = false + AndroidRuntimeDiagnostics.clearPlayback(activity) + persistRuntimePlaybackDiagnostics() handler.removeCallbacks(audioBounceTimeout) pendingStartPositionMs = startPositionMs pendingPlayWhenReady = autoPlay @@ -2834,6 +2853,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { fun setAudioNormalization(enabled: Boolean) { if (audioNormalizationEnabled == enabled) return audioNormalizationEnabled = enabled + persistRuntimePlaybackDiagnostics(decoderName = audioDecoderInitName) emitLog("info", "audio-normalization", "Loudness normalization ${if (enabled) "enabled" else "disabled"}") if (enabled) attachNormalizationEffect() else audioNormalization.release() @@ -2862,6 +2882,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { fun setAudioPassthrough(enabled: Boolean) { if (audioPassthroughEnabled == enabled) return audioPassthroughEnabled = enabled + persistRuntimePlaybackDiagnostics(decoderName = audioDecoderInitName) emitLog("info", "audio", "Audio passthrough ${if (enabled) "enabled" else "disabled"}") if (exoPlayer == null) return @@ -2887,6 +2908,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { audioDownmixEnabled = enabled audioDownmixCenterBoostDb = boost audioDownmixNormalize = normalize + persistRuntimePlaybackDiagnostics(decoderName = audioDecoderInitName) emitLog( "info", "audio-downmix", @@ -2916,7 +2938,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { if (coefficients != null) { ChannelMixingMatrix(count, 2, coefficients) } else { - ChannelMixingMatrix.create(count, count) + renderersFactory?.identityChannelMixingMatrix(count) ?: return } ) } @@ -3652,6 +3674,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { } } + AndroidRuntimeDiagnostics.clearPlayback(activity) Log.d(TAG, "Disposed") } } diff --git a/android/app/src/test/kotlin/com/edde746/plezy/AndroidExitDiagnosticsTest.kt b/android/app/src/test/kotlin/com/edde746/plezy/AndroidExitDiagnosticsTest.kt new file mode 100644 index 00000000..6f0f825f --- /dev/null +++ b/android/app/src/test/kotlin/com/edde746/plezy/AndroidExitDiagnosticsTest.kt @@ -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() + 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 + ) +} diff --git a/lib/main.dart b/lib/main.dart index 4d810829..160d7d07 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; // ignore: depend_on_referenced_packages import 'package:shared_preferences_foundation/shared_preferences_foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' as material show ThemeMode; import 'package:material_symbols_icons/symbols.dart'; import 'package:flutter/gestures.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_registry.dart'; import 'mixins/mounted_set_state_mixin.dart'; +import 'theme/mono_theme.dart'; import 'profiles/plex_home_service.dart'; import 'screens/auth_screen.dart'; import 'screens/profile/pin_entry_dialog.dart'; @@ -72,11 +74,13 @@ import 'utils/watch_state_notifier.dart'; import 'i18n/strings.g.dart'; import 'widgets/app_icon.dart'; import 'focus/input_mode_tracker.dart'; +import 'focus/focusable_button.dart'; import 'focus/key_event_utils.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'utils/navigation_transitions.dart'; import 'utils/log_redaction_manager.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 String _sentryDsn = 'https://6a1a6ef8c72140099b2798973c1bfb2f@bugs.plezy.app/1'; @@ -112,8 +116,9 @@ void _registerTvosPlatformPlugins() { SharedPreferencesFoundation.registerWith(); } -Future main() async { +void main() { final binding = WidgetsFlutterBinding.ensureInitialized(); + AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.dartMain); // Keep the accessibility tree available to Maestro and other UI automation // without adding release-build overhead. if (kDebugMode) binding.ensureSemantics(); @@ -123,10 +128,47 @@ Future main() async { // target in Flutter's tool), so register platform stores manually for // the plugins we use. _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 initializeStartup() async { + AndroidExitDiagnostics.markTelemetryReady(); + dependencies = await _initializeStartup(settings); + } if (_enableSentry) { final packageInfo = await PackageInfo.fromPlatform(); - await SentryFlutter.init((options) { options.dsn = _sentryDsn; options.release = gitCommit.isNotEmpty @@ -143,14 +185,170 @@ Future main() async { options.appHangTimeoutInterval = const Duration(seconds: 3); options.beforeSend = _beforeSend; options.beforeBreadcrumb = _beforeBreadcrumb; - }, appRunner: _bootstrapApp); - return; + }, appRunner: initializeStartup); + } else { + await initializeStartup(); } - - await _bootstrapApp(); + return dependencies!; } -Future _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 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 Function() initialize; + final Widget Function(BuildContext context, T value) buildApp; + final FutureOr Function(T value)? discard; + final FutureOr Function(T value)? onCommitted; + final ThemeData? lightTheme; + final ThemeData? darkTheme; + final material.ThemeMode themeMode; + + @override + State> createState() => _StartupBootstrapState(); +} + +class _StartupBootstrapState extends State> { + 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 _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 _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(); var lastStartupMarkMs = 0; void markStartupPhase(String phase) { @@ -160,140 +358,123 @@ Future _bootstrapApp() async { lastStartupMarkMs = elapsedMs; } - final settings = await SettingsService.getInstance(); - markStartupPhase('settings'); - final savedLocale = settings.read(SettingsService.appLocale); + AppDatabase? openedDatabase; + try { + final savedLocale = settings.read(SettingsService.appLocale); + await LocaleSettings.setLocale(savedLocale); + await initializeDateFormatting(savedLocale.languageCode, null); + markStartupPhase('locale'); - unawaited(LocaleSettings.setLocale(savedLocale)); + final futures = >[]; + if (PlatformDetector.isDesktopOS()) { + if (Platform.isMacOS) { + futures.add(windowManager.ensureInitialized().then((_) => MacOSWindowService.setupCustomTitlebar())); + } else { + futures.add(windowManager.ensureInitialized()); + } + } - await initializeDateFormatting(savedLocale.languageCode, null); - markStartupPhase('locale'); + // MainApp reads both synchronous facades during its first build. + 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 - // (replaced by cached_network_image_ce in a prior refactor). - if (!settings.read(SettingsService.cleanedOldImageCache)) { + final storageFuture = StorageService.getInstance(); + futures.add(storageFuture); + 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 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 { final tempDir = await getTemporaryDirectory(); final oldCacheDir = Directory('${tempDir.path}/plexImageCache'); - if (await oldCacheDir.exists()) { - await oldCacheDir.delete(recursive: true); - } - } catch (_) { - // Best-effort; the directory may be locked or already partial. + if (await oldCacheDir.exists()) await oldCacheDir.delete(recursive: true); + } finally { + await settings.write(SettingsService.cleanedOldImageCache, true); } - await settings.write(SettingsService.cleanedOldImageCache, true); - } + }); - final futures = >[]; + 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 (Platform.isMacOS) { - futures.add(windowManager.ensureInitialized().then((_) => MacOSWindowService.setupCustomTitlebar())); - } else { - futures.add(windowManager.ensureInitialized()); - } + bestEffort('Discord RPC', DiscordRPCService.instance.initialize); } - // Initialize TV detection on every platform: auto-detect covers Android - // leanback and Apple TV; the force-TV setting applies anywhere, including - // 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(); + if (settings.read(SettingsService.crashReporting)) { + unawaited(AndroidExitDiagnostics.logPreviousExit()); } - // Hook Windows native fullscreen callback (no-op elsewhere). - NativeWindowService.initialize(); - - 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); + bestEffort('Trakt scrobble', TraktScrobbleService.instance.initialize); + bestEffort('Shader licenses', _registerShaderLicenses); + bestEffort('Environment diagnostics', _logEnvironmentDiagnostics); +} +Future _logEnvironmentDiagnostics() async { final packageInfo = await PackageInfo.fromPlatform(); final commitSuffix = gitCommit.isNotEmpty ? ' (${gitCommit.substring(0, 7)})' : ''; String renderer = ''; if (Platform.isAndroid) { final rendererName = await const MethodChannel('com.plezy/theme').invokeMethod('getRenderer'); renderer = ' [$rendererName]'; - // Tag crash reports with the active renderer while Impeller rolls back - // out to Android TV, so device-specific regressions are attributable. - // configureScope returns FutureOr; Future.sync flattens it for unawaited. - unawaited(Future.sync(() => Sentry.configureScope((scope) => scope.setTag('renderer', rendererName ?? 'unknown')))); + await Future.sync(() => Sentry.configureScope((scope) => scope.setTag('renderer', rendererName ?? 'unknown'))); } appLogger.i( 'Plezy v${packageInfo.version}+${packageInfo.buildNumber}$commitSuffix$renderer' ' [effects: ${DevicePerformance.describeSync()}]', ); 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'); } - 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 _) { @@ -1160,6 +1341,7 @@ class _SetupScreenState extends State with MountedSetStateMixin { _setStatus(t.common.startingOfflineMode); await context.read().ensureInitialized(); if (!mounted) return; + AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.mainScreen); unawaited(Navigator.pushReplacement(context, fadeRoute(const ProfileSessionScreen(isOfflineMode: true)))); } @@ -1260,6 +1442,7 @@ class _SetupScreenState extends State with MountedSetStateMixin { final List allConnections; try { allConnections = await connectionRegistry.list(); + AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.credentialsLoaded); } catch (e, st) { // Defence-in-depth: a DB-open failure here used to propagate // uncaught and strand the splash forever (#1022). Route to auth so @@ -1341,6 +1524,7 @@ class _SetupScreenState extends State with MountedSetStateMixin { // Start only after network/offline startup has been decided and the // active profile snapshot is hydrated. This prevents an eager binder // microtask from racing the no-network/manual-offline fast path. + AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.bindingStarted); binder.start(); // If "prompt for profile on launch" is on (or no profile is selected @@ -1372,6 +1556,7 @@ class _SetupScreenState extends State with MountedSetStateMixin { bindingSucceeded = await activeProfile.awaitBindingSettle(); if (!mounted) return; } + AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.bindingSettled); if (shouldEnterOfflineModeAfterStartupBind( bindingSucceeded: bindingSucceeded, @@ -1389,6 +1574,7 @@ class _SetupScreenState extends State with MountedSetStateMixin { await downloadProvider.refreshMetadataFromCache(); if (!mounted) return; + AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.mainScreen); unawaited(Navigator.pushReplacement(context, fadeRoute(ProfileSessionScreen(initialPromptHandled: shouldPrompt)))); } diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 45e9e84c..f23ebcda 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -82,6 +82,7 @@ import '../utils/provider_extensions.dart'; import '../utils/snackbar_helper.dart'; import '../utils/stream_buffer_sizing.dart'; import '../utils/video_player_navigation.dart'; +import '../utils/android_exit_diagnostics.dart'; import 'video_player/completion_latch.dart'; import 'video_player/frame_rate_matcher.dart'; import 'video_player/live_stream_retry.dart'; @@ -684,6 +685,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin @override void initState() { super.initState(); + unawaited(AndroidExitDiagnostics.markUiState(AndroidUiState.player)); _playerNavigationCoordinator = PlayerNavigationCoordinator( chromeController: _chromeController, @@ -1428,6 +1430,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin @override void dispose() { + unawaited(AndroidExitDiagnostics.markUiState(AndroidUiState.mainScreen)); _playerInitializationGeneration++; _frameRate.dispose(); WidgetsBinding.instance.removeObserver(this); diff --git a/lib/services/base_shared_preferences_service.dart b/lib/services/base_shared_preferences_service.dart index 9d972c42..82219a76 100644 --- a/lib/services/base_shared_preferences_service.dart +++ b/lib/services/base_shared_preferences_service.dart @@ -17,6 +17,7 @@ abstract class BaseSharedPreferencesService { // Single shared cache across all subclasses so writes from one service are // visible to reads from another without per-instance cache divergence. static Future? _cacheFuture; + static Future Function() _cacheLoader = _loadSharedCache; late SharedPreferencesWithCache _cache; @@ -61,15 +62,38 @@ abstract class BaseSharedPreferencesService { /// migration on first call; subsequent calls return the same future. /// Use this from services that don't extend [BaseSharedPreferencesService]. static Future sharedCache() { - return _cacheFuture ??= () 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()); - }(); + final cached = _cacheFuture; + if (cached != null) return cached; + + late final Future loading; + loading = _cacheLoader().then( + (cache) => cache, + onError: (Object error, StackTrace stackTrace) { + // 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 _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 Function() loader) { + _cacheFuture = null; + _cacheLoader = loader; } /// Drop all cached singleton instances and the shared cache future so the @@ -81,6 +105,7 @@ abstract class BaseSharedPreferencesService { _initializations.clear(); _instances.clear(); _cacheFuture = null; + _cacheLoader = _loadSharedCache; } /// Typed read helpers — return the stored value or [defaultValue] when missing. diff --git a/lib/utils/android_exit_diagnostics.dart b/lib/utils/android_exit_diagnostics.dart new file mode 100644 index 00000000..bff08dd8 --- /dev/null +++ b/lib/utils/android_exit_diagnostics.dart @@ -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 _persistStartupPhase(String phase) async { + try { + await _channel.invokeMethod('setStartupPhase', phase); + } catch (_) { + // Native phase persistence is best-effort. + } + } + + static Future markUiState(AndroidUiState state) async { + if (!Platform.isAndroid) return; + try { + await _channel.invokeMethod('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 logPreviousExit() async { + if (!Platform.isAndroid) return; + try { + final raw = await _channel.invokeMapMethod('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? _validate(Map? 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 { + '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; +} diff --git a/test/services/base_shared_preferences_service_test.dart b/test/services/base_shared_preferences_service_test.dart new file mode 100644 index 00000000..12cfb835 --- /dev/null +++ b/test/services/base_shared_preferences_service_test.dart @@ -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(); + 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.error(StateError('injected failure')), + ); + + BaseSharedPreferencesService.resetForTesting(); + + final cache = await BaseSharedPreferencesService.sharedCache(); + await cache.setString('loader', 'production'); + expect(cache.getString('loader'), 'production'); + }); +} diff --git a/test/startup_bootstrap_test.dart b/test/startup_bootstrap_test.dart new file mode 100644 index 00000000..ff2233a2 --- /dev/null +++ b/test/startup_bootstrap_test.dart @@ -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(); + var bootstrapWasMounted = false; + + await tester.pumpWidget( + StartupBootstrap( + 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(); + + await tester.pumpWidget( + StartupBootstrap( + 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( + 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(); + var attempts = 0; + + await tester.pumpWidget( + StartupBootstrap( + initialize: () { + attempts++; + if (attempts == 1) return Future.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(); + final discarded = []; + + await tester.pumpWidget( + StartupBootstrap( + 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); + }); +} diff --git a/test/utils/android_exit_diagnostics_test.dart b/test/utils/android_exit_diagnostics_test.dart new file mode 100644 index 00000000..b0591c8a --- /dev/null +++ b/test/utils/android_exit_diagnostics_test.dart @@ -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.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])); + }); +}