fix(android): persist startup and runtime exit diagnostics
This commit is contained in:
+298
-112
@@ -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))));
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user