| 1 | import 'dart:async'; |
| 2 | import 'dart:io'; |
| 3 | |
| 4 | import 'package:cake_wallet/bitcoin/bitcoin.dart'; |
| 5 | import 'package:cake_wallet/core/auth_service.dart'; |
| 6 | import 'package:cake_wallet/core/node_switching_service.dart'; |
| 7 | import 'package:cake_wallet/core/totp_request_details.dart'; |
| 8 | import 'package:cake_wallet/core/trade_monitor.dart'; |
| 9 | import 'package:cake_wallet/entities/qr_scanner.dart'; |
| 10 | import 'package:cake_wallet/reactions/wallet_utils.dart'; |
| 11 | import 'package:cake_wallet/routes.dart'; |
| 12 | import 'package:cake_wallet/src/screens/auth/auth_page.dart'; |
| 13 | import 'package:cake_wallet/src/screens/setup_2fa/setup_2fa_enter_code_page.dart'; |
| 14 | import 'package:cake_wallet/store/app_store.dart'; |
| 15 | import 'package:cake_wallet/store/authentication_store.dart'; |
| 16 | import 'package:cake_wallet/utils/device_info.dart'; |
| 17 | import 'package:cake_wallet/view_model/link_view_model.dart'; |
| 18 | import 'package:cw_core/utils/print_verbose.dart'; |
| 19 | import 'package:cw_core/utils/socket_health_logger.dart'; |
| 20 | import 'package:cw_core/wallet_base.dart'; |
| 21 | import 'package:cw_core/wallet_type.dart'; |
| 22 | import 'package:flutter/material.dart'; |
| 23 | import 'package:mobx/mobx.dart'; |
| 24 | import 'package:trezor_connect/trezor_connect.dart'; |
| 25 | import 'package:uni_links/uni_links.dart'; |
| 26 | |
| 27 | class Root extends StatefulWidget { |
| 28 | Root({ |
| 29 | required Key key, |
| 30 | required this.authenticationStore, |
| 31 | required this.appStore, |
| 32 | required this.child, |
| 33 | required this.navigatorKey, |
| 34 | required this.authService, |
| 35 | required this.linkViewModel, |
| 36 | required this.tradeMonitor, |
| 37 | required this.nodeSwitchingService, |
| 38 | required this.trezorConnect, |
| 39 | this.initialQuickAction, |
| 40 | required this.quickActionsStream, |
| 41 | }) : super(key: key); |
| 42 | |
| 43 | final AuthenticationStore authenticationStore; |
| 44 | final AppStore appStore; |
| 45 | final GlobalKey<NavigatorState> navigatorKey; |
| 46 | final AuthService authService; |
| 47 | final Widget child; |
| 48 | final LinkViewModel linkViewModel; |
| 49 | final TradeMonitor tradeMonitor; |
| 50 | final NodeSwitchingService nodeSwitchingService; |
| 51 | final TrezorConnect trezorConnect; |
| 52 | final String? initialQuickAction; |
| 53 | final Stream<Uri?> quickActionsStream; |
| 54 | |
| 55 | @override |
| 56 | RootState createState() => RootState(); |
| 57 | } |
| 58 | |
| 59 | class RootState extends State<Root> with WidgetsBindingObserver { |
| 60 | RootState() |
| 61 | : _isInactiveController = StreamController<bool>.broadcast(), |
| 62 | _isInactive = false, |
| 63 | _requestAuth = true, |
| 64 | _postFrameCallback = false; |
| 65 | |
| 66 | Stream<bool> get isInactive => _isInactiveController.stream; |
| 67 | StreamController<bool> _isInactiveController; |
| 68 | bool _isInactive; |
| 69 | bool _postFrameCallback; |
| 70 | bool _requestAuth; |
| 71 | |
| 72 | StreamSubscription<Uri?>? stream; |
| 73 | ReactionDisposer? _walletReactionDisposer; |
| 74 | ReactionDisposer? _deepLinksReactionDisposer; |
| 75 | |
| 76 | @override |
| 77 | void initState() { |
| 78 | WidgetsBinding.instance.addObserver(this); |
| 79 | |
| 80 | widget.authService.requireAuth().then((value) { |
| 81 | WidgetsBinding.instance.addPostFrameCallback((_) { |
| 82 | setState(() => _requestAuth = value); |
| 83 | }); |
| 84 | }); |
| 85 | _isInactiveController = StreamController<bool>.broadcast(); |
| 86 | _isInactive = false; |
| 87 | _postFrameCallback = false; |
| 88 | super.initState(); |
| 89 | if (DeviceInfo.instance.isMobile) { |
| 90 | initUniLinks(); |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | @override |
| 95 | void dispose() { |
| 96 | stream?.cancel(); |
| 97 | _walletReactionDisposer?.call(); |
| 98 | _deepLinksReactionDisposer?.call(); |
| 99 | super.dispose(); |
| 100 | } |
| 101 | |
| 102 | /// handle app links while the app is already started |
| 103 | /// whether its in the foreground or in the background. |
| 104 | Future<void> initUniLinks() async { |
| 105 | try { |
| 106 | stream = uriLinkStream.listen((Uri? uri) { |
| 107 | handleDeepLinking(uri); |
| 108 | }); |
| 109 | |
| 110 | // listen for quick actions |
| 111 | widget.quickActionsStream.listen((Uri? uri) { |
| 112 | handleDeepLinking(uri); |
| 113 | }); |
| 114 | |
| 115 | handleDeepLinking(await getInitialUri()); |
| 116 | |
| 117 | if (widget.initialQuickAction != null) { |
| 118 | final uri = Uri.parse('cakewallet://quickaction/${widget.initialQuickAction}'); |
| 119 | handleDeepLinking(uri); |
| 120 | } |
| 121 | } catch (e) { |
| 122 | printV(e); |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | void handleDeepLinking(Uri? uri) async { |
| 127 | if (uri == null || !mounted) return; |
| 128 | |
| 129 | widget.linkViewModel.currentLink = uri; |
| 130 | |
| 131 | if (uri.toString().startsWith(widget.trezorConnect.callbackBackUri)) { |
| 132 | widget.trezorConnect.handleCallback(uri); |
| 133 | return; |
| 134 | } |
| 135 | |
| 136 | bool requireAuth = await widget.authService.requireAuth(); |
| 137 | |
| 138 | if (widget.authenticationStore.state == AuthenticationState.allowedCreate) { |
| 139 | requireAuth = false; |
| 140 | } |
| 141 | |
| 142 | if (!requireAuth && |
| 143 | (widget.authenticationStore.state == AuthenticationState.allowed || |
| 144 | widget.authenticationStore.state == AuthenticationState.allowedCreate)) { |
| 145 | _navigateToDeepLinkScreen(); |
| 146 | return; |
| 147 | } |
| 148 | |
| 149 | _deepLinksReactionDisposer = reaction( |
| 150 | (_) => widget.authenticationStore.state, |
| 151 | (AuthenticationState state) { |
| 152 | if (state == AuthenticationState.allowed || state == AuthenticationState.allowedCreate) { |
| 153 | if (widget.appStore.wallet == null) { |
| 154 | waitForWalletInstance(context); |
| 155 | } else { |
| 156 | _navigateToDeepLinkScreen(); |
| 157 | } |
| 158 | _deepLinksReactionDisposer?.call(); |
| 159 | _deepLinksReactionDisposer = null; |
| 160 | } |
| 161 | }, |
| 162 | ); |
| 163 | } |
| 164 | |
| 165 | @override |
| 166 | void didChangeAppLifecycleState(AppLifecycleState state) { |
| 167 | switch (state) { |
| 168 | case AppLifecycleState.paused: |
| 169 | if (isQrScannerShown) { |
| 170 | return; |
| 171 | } |
| 172 | |
| 173 | if (!_isInactive && widget.authenticationStore.state == AuthenticationState.allowed) { |
| 174 | setState(() => _setInactive(true)); |
| 175 | } |
| 176 | |
| 177 | if (widget.appStore.wallet?.type == WalletType.bitcoin) { |
| 178 | bitcoin!.stopPayjoinSessions(widget.appStore.wallet!); |
| 179 | } |
| 180 | |
| 181 | widget.tradeMonitor.stopTradeMonitoring(); |
| 182 | |
| 183 | break; |
| 184 | case AppLifecycleState.resumed: |
| 185 | |
| 186 | // Reset inactive state when app resumes |
| 187 | if (_isInactive) setState(() => _setInactive(false)); |
| 188 | |
| 189 | widget.authService.requireAuth().then((value) { |
| 190 | if (mounted) { |
| 191 | setState(() { |
| 192 | _requestAuth = value; |
| 193 | }); |
| 194 | } |
| 195 | }); |
| 196 | if (widget.appStore.wallet?.type == WalletType.bitcoin && |
| 197 | widget.appStore.settingsStore.usePayjoin) { |
| 198 | bitcoin!.resumePayjoinSessions(widget.appStore.wallet!); |
| 199 | } |
| 200 | |
| 201 | widget.tradeMonitor.resumeTradeMonitoring(); |
| 202 | |
| 203 | // Trigger node health check when app resumes |
| 204 | widget.nodeSwitchingService.performHealthCheck(); |
| 205 | |
| 206 | // Electrum Wallet socket health check and reconnection flow |
| 207 | final wallet = widget.appStore.wallet; |
| 208 | if (wallet != null && isElectrumWallet(wallet.type)) { |
| 209 | SocketHealthLogger().logHealthCheck( |
| 210 | walletType: wallet.type, |
| 211 | walletName: wallet.name, |
| 212 | syncStatus: wallet.syncStatus.toString(), |
| 213 | wasReconnected: false, |
| 214 | trigger: 'app_resume', |
| 215 | ); |
| 216 | |
| 217 | wallet.checkSocketHealth().then((isHealthy) { |
| 218 | SocketHealthLogger().logHealthCheck( |
| 219 | walletType: wallet.type, |
| 220 | walletName: wallet.name, |
| 221 | isHealthy: isHealthy, |
| 222 | syncStatus: wallet.syncStatus.toString(), |
| 223 | wasReconnected: true, |
| 224 | trigger: 'app_resume_socket_health_check', |
| 225 | ); |
| 226 | }); |
| 227 | } |
| 228 | |
| 229 | break; |
| 230 | default: |
| 231 | break; |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | @override |
| 236 | void didChangePlatformBrightness() { |
| 237 | // Only handle theme changes when the app is active (not in background) |
| 238 | if (_isInactive) return; |
| 239 | |
| 240 | if (widget.appStore.themeStore.themeMode == ThemeMode.system) { |
| 241 | Future.delayed(Duration(milliseconds: Platform.isIOS ? 500 : 0), () { |
| 242 | // Double-check that app is still active before applying theme change |
| 243 | if (_isInactive) return; |
| 244 | |
| 245 | final systemTheme = widget.appStore.themeStore.getThemeFromSystem(); |
| 246 | if (widget.appStore.themeStore.currentTheme != systemTheme) { |
| 247 | widget.appStore.themeStore.setTheme(systemTheme); |
| 248 | } |
| 249 | }); |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | @override |
| 254 | Widget build(BuildContext context) { |
| 255 | // this only happens when the app has been in the background for some time |
| 256 | // this does NOT trigger when the app is started from the "closed" state! |
| 257 | if (_isInactive && !_postFrameCallback && _requestAuth) { |
| 258 | _postFrameCallback = true; |
| 259 | WidgetsBinding.instance.addPostFrameCallback((_) { |
| 260 | widget.navigatorKey.currentState?.pushNamed( |
| 261 | Routes.unlock, |
| 262 | arguments: (bool isAuthenticatedSuccessfully, AuthPageState auth) { |
| 263 | if (!isAuthenticatedSuccessfully) { |
| 264 | return; |
| 265 | } |
| 266 | final useTotp = widget.appStore.settingsStore.useTOTP2FA; |
| 267 | final shouldUseTotp2FAToAccessWallets = |
| 268 | widget.appStore.settingsStore.shouldRequireTOTP2FAForAccessingWallet; |
| 269 | if (useTotp && shouldUseTotp2FAToAccessWallets) { |
| 270 | _reset(); |
| 271 | auth.close( |
| 272 | route: Routes.totpAuthCodePage, |
| 273 | arguments: TotpAuthArgumentsModel( |
| 274 | onTotpAuthenticationFinished: |
| 275 | (bool isAuthenticatedSuccessfully, TotpAuthCodePageState totpAuth) { |
| 276 | if (!isAuthenticatedSuccessfully) { |
| 277 | return; |
| 278 | } |
| 279 | _reset(); |
| 280 | totpAuth.close( |
| 281 | route: widget.linkViewModel.getRouteToGo(), |
| 282 | arguments: widget.linkViewModel.getRouteArgs(), |
| 283 | ); |
| 284 | widget.linkViewModel.currentLink = null; |
| 285 | }, |
| 286 | isForSetup: false, |
| 287 | isClosable: false, |
| 288 | ), |
| 289 | ); |
| 290 | } else { |
| 291 | _reset(); |
| 292 | auth.close( |
| 293 | route: widget.linkViewModel.getRouteToGo(), |
| 294 | arguments: widget.linkViewModel.getRouteArgs(), |
| 295 | ); |
| 296 | widget.linkViewModel.currentLink = null; |
| 297 | } |
| 298 | }, |
| 299 | ); |
| 300 | }); |
| 301 | } |
| 302 | |
| 303 | return WillPopScope( |
| 304 | onWillPop: () async => false, |
| 305 | child: widget.child, |
| 306 | ); |
| 307 | } |
| 308 | |
| 309 | void _reset() { |
| 310 | setState(() { |
| 311 | _postFrameCallback = false; |
| 312 | _setInactive(false); |
| 313 | }); |
| 314 | |
| 315 | // Apply any missed theme changes after successful authentication |
| 316 | if (widget.appStore.themeStore.themeMode == ThemeMode.system) { |
| 317 | Future.delayed(Duration(milliseconds: 100), () { |
| 318 | final systemTheme = widget.appStore.themeStore.getThemeFromSystem(); |
| 319 | if (widget.appStore.themeStore.currentTheme != systemTheme) { |
| 320 | widget.appStore.themeStore.setTheme(systemTheme); |
| 321 | } |
| 322 | }); |
| 323 | } |
| 324 | } |
| 325 | |
| 326 | void _setInactive(bool value) { |
| 327 | _isInactive = value; |
| 328 | _isInactiveController.add(value); |
| 329 | } |
| 330 | |
| 331 | void waitForWalletInstance(BuildContext context) { |
| 332 | WidgetsBinding.instance.addPostFrameCallback((_) { |
| 333 | if (mounted) { |
| 334 | _walletReactionDisposer = reaction( |
| 335 | (_) => widget.appStore.wallet, |
| 336 | (WalletBase? wallet) { |
| 337 | if (wallet != null) { |
| 338 | _navigateToDeepLinkScreen(); |
| 339 | _walletReactionDisposer?.call(); |
| 340 | _walletReactionDisposer = null; |
| 341 | } |
| 342 | }, |
| 343 | ); |
| 344 | } |
| 345 | }); |
| 346 | } |
| 347 | |
| 348 | void _navigateToDeepLinkScreen() { |
| 349 | widget.linkViewModel.handleLink(); |
| 350 | } |
| 351 | } |