import 'dart:async'; import 'dart:io'; import 'package:app_links/app_links.dart'; import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/tasks/app_widget.dart'; import 'package:appflowy/user/application/auth/auth_error.dart'; import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/application/auth/device_id.dart'; import 'package:appflowy/user/application/user_auth_listener.dart'; import 'package:appflowy/workspace/application/subscription_success_listenable/subscription_success_listenable.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter/material.dart'; import 'package:url_protocol/url_protocol.dart'; const appflowyDeepLinkSchema = 'appflowy-flutter'; class AppFlowyCloudDeepLink { AppFlowyCloudDeepLink() { _deepLinkSubscription = _AppLinkWrapper.instance.listen( (Uri? uri) async { Log.info('onDeepLink: ${uri.toString()}'); await _handleUri(uri); }, onError: (Object err, StackTrace stackTrace) { Log.error('on DeepLink stream error: ${err.toString()}', stackTrace); _deepLinkSubscription.cancel(); }, ); if (Platform.isWindows) { // register deep link for Windows registerProtocolHandler(appflowyDeepLinkSchema); } } ValueNotifier? _stateNotifier = ValueNotifier(null); Completer>? _completer; set completer(Completer>? value) { Log.debug('AppFlowyCloudDeepLink: $hashCode completer'); _completer = value; } late final StreamSubscription _deepLinkSubscription; Future dispose() async { Log.debug('AppFlowyCloudDeepLink: $hashCode dispose'); await _deepLinkSubscription.cancel(); _stateNotifier?.dispose(); _stateNotifier = null; completer = null; } void registerCompleter( Completer> completer, ) { this.completer = completer; } VoidCallback subscribeDeepLinkLoadingState( ValueChanged listener, ) { void listenerFn() { if (_stateNotifier?.value != null) { listener(_stateNotifier!.value!); } } _stateNotifier?.addListener(listenerFn); return listenerFn; } void unsubscribeDeepLinkLoadingState(VoidCallback listener) => _stateNotifier?.removeListener(listener); Future passGotrueTokenResponse( GotrueTokenResponsePB gotrueTokenResponse, ) async { final uri = _buildDeepLinkUri(gotrueTokenResponse); await _handleUri(uri); } Future _handleUri( Uri? uri, ) async { _stateNotifier?.value = DeepLinkResult(state: DeepLinkState.none); if (uri == null) { Log.error('onDeepLinkError: Unexpected empty deep link callback'); _completer?.complete(FlowyResult.failure(AuthError.emptyDeepLink)); completer = null; return; } if (_isPaymentSuccessUri(uri)) { Log.debug("Payment success deep link: ${uri.toString()}"); final plan = uri.queryParameters['plan']; return getIt().onPaymentSuccess(plan); } return _isAuthCallbackDeepLink(uri).fold( (_) async { final deviceId = await getDeviceId(); final payload = OauthSignInPB( authenticator: AuthenticatorPB.AppFlowyCloud, map: { AuthServiceMapKeys.signInURL: uri.toString(), AuthServiceMapKeys.deviceId: deviceId, }, ); _stateNotifier?.value = DeepLinkResult(state: DeepLinkState.loading); final result = await UserEventOauthSignIn(payload).send(); _stateNotifier?.value = DeepLinkResult( state: DeepLinkState.finish, result: result, ); // If there is no completer, runAppFlowy() will be called. if (_completer == null) { await result.fold( (_) async { await runAppFlowy(); }, (err) { Log.error(err); final context = AppGlobals.rootNavKey.currentState?.context; if (context != null) { showToastNotification( message: err.msg, ); } }, ); } else { _completer?.complete(result); completer = null; } }, (err) { Log.error('onDeepLinkError: Unexpected deep link: $err'); if (_completer == null) { final context = AppGlobals.rootNavKey.currentState?.context; if (context != null) { showSnackBarMessage( context, err.msg, ); } } else { _completer?.complete(FlowyResult.failure(err)); completer = null; } }, ); } FlowyResult _isAuthCallbackDeepLink(Uri uri) { if (uri.fragment.contains('access_token')) { return FlowyResult.success(null); } return FlowyResult.failure( FlowyError.create() ..code = ErrorCode.MissingAuthField ..msg = uri.path, ); } bool _isPaymentSuccessUri(Uri uri) { return uri.host == 'payment-success'; } Uri? _buildDeepLinkUri(GotrueTokenResponsePB gotrueTokenResponse) { final params = {}; if (gotrueTokenResponse.hasAccessToken() && gotrueTokenResponse.accessToken.isNotEmpty) { params['access_token'] = gotrueTokenResponse.accessToken; } if (gotrueTokenResponse.hasExpiresAt()) { params['expires_at'] = gotrueTokenResponse.expiresAt.toString(); } if (gotrueTokenResponse.hasExpiresIn()) { params['expires_in'] = gotrueTokenResponse.expiresIn.toString(); } if (gotrueTokenResponse.hasProviderRefreshToken() && gotrueTokenResponse.providerRefreshToken.isNotEmpty) { params['provider_refresh_token'] = gotrueTokenResponse.providerRefreshToken; } if (gotrueTokenResponse.hasProviderAccessToken() && gotrueTokenResponse.providerAccessToken.isNotEmpty) { params['provider_token'] = gotrueTokenResponse.providerAccessToken; } if (gotrueTokenResponse.hasRefreshToken() && gotrueTokenResponse.refreshToken.isNotEmpty) { params['refresh_token'] = gotrueTokenResponse.refreshToken; } if (gotrueTokenResponse.hasTokenType() && gotrueTokenResponse.tokenType.isNotEmpty) { params['token_type'] = gotrueTokenResponse.tokenType; } if (params.isEmpty) { return null; } final fragment = params.entries .map( (e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}', ) .join('&'); return Uri.parse('appflowy-flutter://login-callback#$fragment'); } } class InitAppFlowyCloudTask extends LaunchTask { UserAuthStateListener? _authStateListener; bool isLoggingOut = false; @override Future initialize(LaunchContext context) async { if (!isAppFlowyCloudEnabled) { return; } _authStateListener = UserAuthStateListener(); _authStateListener?.start( didSignIn: () { isLoggingOut = false; }, onInvalidAuth: (message) async { Log.error(message); if (!isLoggingOut) { await runAppFlowy(); } }, ); } @override Future dispose() async { await _authStateListener?.stop(); _authStateListener = null; } } class DeepLinkResult { DeepLinkResult({ required this.state, this.result, }); final DeepLinkState state; final FlowyResult? result; } enum DeepLinkState { none, loading, finish, } // wrapper for AppLinks to support multiple listeners class _AppLinkWrapper { _AppLinkWrapper._() { _appLinkSubscription = _appLinks.uriLinkStream.listen((event) { _streamSubscription.sink.add(event); }); } static final _AppLinkWrapper instance = _AppLinkWrapper._(); final AppLinks _appLinks = AppLinks(); final _streamSubscription = StreamController.broadcast(); late final StreamSubscription _appLinkSubscription; StreamSubscription listen( void Function(Uri?) listener, { Function? onError, bool? cancelOnError, }) { return _streamSubscription.stream.listen( listener, onError: onError, cancelOnError: cancelOnError, ); } void dispose() { _streamSubscription.close(); _appLinkSubscription.cancel(); } }