import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:advanced_in_app_review/advanced_in_app_review.dart'; import 'package:app_links/app_links.dart'; import 'package:dio/dio.dart'; import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:firebase_performance/firebase_performance.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:frosty/apis/base_api_client.dart' show ApiException; import 'package:frosty/apis/bttv_api.dart'; import 'package:frosty/apis/dio_client.dart'; import 'package:frosty/apis/ffz_api.dart'; import 'package:frosty/apis/seventv_api.dart'; import 'package:frosty/apis/twitch_api.dart'; import 'package:frosty/apis/twitch_auth_interceptor.dart'; import 'package:frosty/apis/twitch_gql_api.dart'; import 'package:frosty/apis/unauthorized_interceptor.dart'; import 'package:frosty/cache_manager.dart'; import 'package:frosty/constants.dart'; import 'package:frosty/firebase_options.dart'; import 'package:frosty/screens/channel/channel.dart'; import 'package:frosty/screens/home/home.dart'; import 'package:frosty/screens/onboarding/onboarding_intro.dart'; import 'package:frosty/screens/settings/stores/auth_store.dart'; import 'package:frosty/screens/settings/stores/settings_store.dart'; import 'package:frosty/stores/global_assets_store.dart'; import 'package:frosty/theme.dart'; import 'package:frosty/utils.dart'; import 'package:frosty/widgets/alert_message.dart'; import 'package:mobx/mobx.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; /// Errors that are expected on a mobile client — transient network/I/O, plugin /// teardown races, and image fetch failures — and therefore shouldn't be /// reported to Crashlytics as fatal crashes (which would tank the crash-free /// metric and bucket unrelated errors together under the global handlers). /// /// This is a last-resort net. The layers that own these errors already record /// them non-fatally with precise reasons (e.g. base_api_client, the IRC /// websocket handlers); this only catches what escapes that local handling. bool _isNonFatalError(Object error) { return error is SocketException || error is HttpException || error is WebSocketChannelException || error is ApiException || error is MissingPluginException || error is NetworkImageLoadException || (error is DioException && error.type != DioExceptionType.badResponse); } void main() async { WidgetsFlutterBinding.ensureInitialized(); CustomCacheManager.removeOrphanedCacheFiles(); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); // Route uncaught framework errors to Crashlytics. Non-crashing UI errors // (layout overflows, image-load failures) and other expected transient // failures are recorded as non-fatal so they don't inflate the crash count. FlutterError.onError = (details) { // Run the cheap type checks first; only fall back to the diagnostics string // (RenderFlex overflow has no typed signal) when those miss. final isNonFatal = _isNonFatalError(details.exception) || details.exceptionAsString().contains('overflowed'); if (isNonFatal) { FirebaseCrashlytics.instance.recordFlutterError(details); } else { FirebaseCrashlytics.instance.recordFlutterFatalError(details); } }; // Pass uncaught asynchronous errors to Crashlytics. Expected transient errors // are recorded non-fatally; everything else stays fatal. The reason gives the // grouping engine a real signal so distinct errors don't collapse into one // catch-all issue under this handler frame. PlatformDispatcher.instance.onError = (error, stack) { FirebaseCrashlytics.instance.recordError( error, stack, reason: error.runtimeType.toString(), fatal: !_isNonFatalError(error), ); return true; }; final prefs = await SharedPreferences.getInstance(); // One-time migration: the quality preference used to be stored under a // single global key, which meant a saved 1080p60 from one channel could // pin another channel to a variant it doesn't serve. Remove the legacy // key; new writes go under per-channel keys. await prefs.remove(kLegacyLastStreamQualityKey); final firstRun = prefs.getBool('first_run') ?? true; // Workaround for clearing stored tokens on uninstall. // If first time running app, will clear all tokens in the secure storage. // Run non-blocking to avoid ANR on slow Android devices (especially Android 14). if (firstRun) { debugPrint('Clearing secure storage...'); const storage = FlutterSecureStorage(); // Don't block - run in background after app starts. // This is safe because first run means no active session depends on this data. unawaited( storage.deleteAll().catchError((e) { debugPrint('Error clearing secure storage: $e'); }), ); } await initUtils(); // With the shared preferences instance, obtain the existing user settings if it exists. // If default settings don't exist, use an empty JSON string to use the default values. final userSettings = prefs.getString('settings') ?? '{}'; // Initialize a settings store from the settings JSON string. final settingsStore = SettingsStore.fromJson(jsonDecode(userSettings)); // Disable Firebase collection in debug builds to avoid polluting production data. // In release builds, respect the user's preference. if (kDebugMode) { FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(false); FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(false); FirebasePerformance.instance.setPerformanceCollectionEnabled(false); } else { final shareEnabled = settingsStore.shareCrashLogsAndAnalytics; FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(shareEnabled); FirebaseAnalytics.instance.setAnalyticsCollectionEnabled(shareEnabled); FirebasePerformance.instance.setPerformanceCollectionEnabled(shareEnabled); } // Create a MobX reaction that will save the settings on disk every time they are changed. autorun((_) => prefs.setString('settings', jsonEncode(settingsStore))); /// Initialize API services with a common Dio client. /// This will prevent every request from creating a new client instance. final dioClient = DioClient.createClient(); // Create API services final twitchApiService = TwitchApi(dioClient); final bttvApiService = BTTVApi(dioClient); final ffzApiService = FFZApi(dioClient); final sevenTVApiService = SevenTVApi(dioClient); final twitchGqlApiService = TwitchGqlApi(dioClient); // Create global assets store (shared cache for global emotes/badges) final globalAssetsStore = GlobalAssetsStore( twitchApi: twitchApiService, bttvApi: bttvApiService, ffzApi: ffzApiService, sevenTVApi: sevenTVApiService, ); // Create and initialize the authentication store final authStore = AuthStore( twitchApi: twitchApiService, twitchGqlApi: twitchGqlApiService, ); // Add the auth interceptor to the Dio client after AuthStore creation dioClient.interceptors.add(TwitchAuthInterceptor(authStore)); // Add the unauthorized interceptor to catch 401 errors dioClient.interceptors.add(UnauthorizedInterceptor(authStore)); await authStore.init(); FirebaseCrashlytics.instance.setCustomKey('is_logged_in', authStore.isLoggedIn); if (authStore.isLoggedIn && authStore.user.details != null) { FirebaseCrashlytics.instance.setUserIdentifier(authStore.user.details!.id); } runApp( MultiProvider( providers: [ Provider.value(value: authStore), Provider.value(value: settingsStore), Provider.value(value: twitchApiService), Provider.value(value: bttvApiService), Provider.value(value: ffzApiService), Provider.value(value: sevenTVApiService), Provider.value(value: twitchGqlApiService), Provider.value(value: globalAssetsStore), ], child: MyApp(firstRun: firstRun), ), ); } // Navigator key for sleep timer. Allows navigation popping without context. final navigatorKey = GlobalKey(); class MyApp extends StatefulWidget { final bool firstRun; const MyApp({super.key, this.firstRun = false}); @override State createState() => _MyAppState(); } class _MyAppState extends State { final AppLinks _appLinks = AppLinks(); StreamSubscription? _linkSubscription; @override void initState() { super.initState(); AdvancedInAppReview() .setMinDaysBeforeRemind(7) .setMinDaysAfterInstall(1) .setMinLaunchTimes(5) .setMinSecondsBeforeShowDialog(3) .monitor(); _initDeepLinks(); } @override Widget build(BuildContext context) { return Observer( builder: (context) { final settingsStore = context.read(); final themes = FrostyThemes( colorSchemeSeed: Color(settingsStore.accentColor), ); return MaterialApp( title: 'Frosty', theme: themes.light, darkTheme: themes.dark, themeMode: settingsStore.themeType == ThemeType.system ? ThemeMode.system : settingsStore.themeType == ThemeType.light ? ThemeMode.light : ThemeMode.dark, navigatorObservers: [ FirebaseAnalyticsObserver( analytics: FirebaseAnalytics.instance, ), ], home: widget.firstRun ? const OnboardingIntro() : const Home(), navigatorKey: navigatorKey, ); }, ); } Future _initDeepLinks() async { try { // Handle links when app is already open _linkSubscription = _appLinks.uriLinkStream.listen((uri) { handleDeepLink(uri); }); // Handle the initial link if app was opened from a link. // Add timeout to prevent indefinite blocking on certain Android lifecycle states. final initialLink = await _appLinks.getInitialLink().timeout( const Duration(seconds: 3), onTimeout: () => null, ); if (initialLink != null) { handleDeepLink(initialLink); } } catch (e) { debugPrint('Failed to initialize deep links: $e'); } } Future handleDeepLink(Uri uri) async { final failureSnackbar = SnackBar( content: AlertMessage( message: 'Unable to navigate to \'$uri\'', centered: false, trailingIcon: Icons.open_in_browser_rounded, // Fallback, allow user to open URL outside app onTrailingIconPressed: () async { await launchUrl( uri, mode: LaunchMode.inAppWebView, // Force browser ); }, ), ); // Handle channel links if (uri.pathSegments.isNotEmpty) { final channelName = uri.pathSegments.first; try { final twitchApi = context.read(); final user = await twitchApi.getUser(userLogin: channelName); final route = MaterialPageRoute( settings: const RouteSettings(name: VideoChat.routeName), builder: (context) => VideoChat( userId: user.id, userName: user.displayName, userLogin: user.login, ), ); if (navigatorKey.currentState == null) return; WidgetsBinding.instance.addPostFrameCallback( (_) => navigatorKey.currentState?.push(route), ); } catch (e) { // If we get here, there was most likely an error with the Twitch API call and/or this isn't really a channel link debugPrint('Failed to open link $uri due to error: $e'); if (navigatorKey.currentContext == null) return; ScaffoldMessenger.of( navigatorKey.currentContext!, ).showSnackBar(failureSnackbar); } } // TODO: Here we can implement handlers for other types of links else { // If we get here, it's a link format that we're unable to handle if (navigatorKey.currentContext == null) return; ScaffoldMessenger.of( navigatorKey.currentContext!, ).showSnackBar(failureSnackbar); } } @override void dispose() { _linkSubscription?.cancel(); super.dispose(); } }