fix(android): persist startup and runtime exit diagnostics

This commit is contained in:
edde746
2026-07-24 03:56:40 +02:00
parent 9f2e050797
commit fb45ff44f3
11 changed files with 1539 additions and 124 deletions
+298 -112
View File
@@ -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<void> 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<void> 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<void> 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<void> 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<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();
var lastStartupMarkMs = 0;
void markStartupPhase(String phase) {
@@ -160,140 +358,123 @@ Future<void> _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 = <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);
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<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 {
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 = <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 (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<void> _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<String>('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<void>; 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<SetupScreen> with MountedSetStateMixin {
_setStatus(t.common.startingOfflineMode);
await context.read<DownloadProvider>().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<SetupScreen> with MountedSetStateMixin {
final List<Connection> 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<SetupScreen> 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<SetupScreen> 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<SetupScreen> with MountedSetStateMixin {
await downloadProvider.refreshMetadataFromCache();
if (!mounted) return;
AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.mainScreen);
unawaited(Navigator.pushReplacement(context, fadeRoute(ProfileSessionScreen(initialPromptHandled: shouldPrompt))));
}
+3
View File
@@ -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<VideoPlayerScreen> with WidgetsBindin
@override
void initState() {
super.initState();
unawaited(AndroidExitDiagnostics.markUiState(AndroidUiState.player));
_playerNavigationCoordinator = PlayerNavigationCoordinator(
chromeController: _chromeController,
@@ -1428,6 +1430,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
@override
void dispose() {
unawaited(AndroidExitDiagnostics.markUiState(AndroidUiState.mainScreen));
_playerInitializationGeneration++;
_frameRate.dispose();
WidgetsBinding.instance.removeObserver(this);
@@ -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<SharedPreferencesWithCache>? _cacheFuture;
static Future<SharedPreferencesWithCache> 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<SharedPreferencesWithCache> 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<SharedPreferencesWithCache> 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<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
@@ -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.
+253
View File
@@ -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;
}