diff --git a/dogfooding/android/app/src/main/AndroidManifest.xml b/dogfooding/android/app/src/main/AndroidManifest.xml index 0a0ccad2e..d2564950d 100644 --- a/dogfooding/android/app/src/main/AndroidManifest.xml +++ b/dogfooding/android/app/src/main/AndroidManifest.xml @@ -44,6 +44,10 @@ + + diff --git a/dogfooding/ios/Runner/Info.plist b/dogfooding/ios/Runner/Info.plist index 5385b3df7..b1e7715ce 100644 --- a/dogfooding/ios/Runner/Info.plist +++ b/dogfooding/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + FlutterDeepLinkingEnabled + RTCScreenSharingExtension io.getstream.video.flutter.dogfooding.ScreenSharing RTCAppGroupIdentifier diff --git a/dogfooding/lib/app/app_content.dart b/dogfooding/lib/app/app_content.dart index 3904fd011..b6f09375c 100644 --- a/dogfooding/lib/app/app_content.dart +++ b/dogfooding/lib/app/app_content.dart @@ -1,8 +1,4 @@ -import 'dart:async'; - -import 'package:app_links/app_links.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:rxdart/rxdart.dart'; @@ -10,7 +6,6 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart' as chat; import 'package:stream_video_flutter/stream_video_flutter.dart'; import 'package:stream_video_flutter/stream_video_flutter_l10n.dart'; -import '../core/model/environment.dart'; import '../di/injector.dart'; import '../router/router.dart'; import '../router/routes.dart'; @@ -69,8 +64,6 @@ class _StreamDogFoodingAppContentState // Observe call kit events. _observeRingingEvents(); - // Observes deep links. - _observeDeepLinks(); // Observe FCM messages. _observeFcmMessages(); @@ -171,74 +164,6 @@ class _StreamDogFoodingAppContentState ); } - Future _observeDeepLinks() async { - if (kIsWeb) return; - - // The app was in the background. - final deepLinkSubscription = AppLinks().uriLinkStream.listen((uri) { - if (mounted) _handleDeepLink(uri); - }); - - _compositeSubscription.add(deepLinkSubscription); - - // The app was terminated. - try { - final initialUri = await AppLinks().getInitialLink(); - if (initialUri != null) { - await _handleDeepLink(initialUri); - } - } catch (e) { - debugPrint(e.toString()); - } - } - - Future _handleDeepLink(Uri uri) async { - final user = _userAuthController.currentUser; - - if (user == null) { - return; - } - - final environment = Environment.fromHost(uri.host); - - await AppInjector.reset(); - await AppInjector.init(forceEnvironment: environment); - - final authController = locator.get(); - await authController.login(User(info: user), environment); - - String? callId; - for (final segment in uri.pathSegments.indexed) { - if (segment.$2 == 'join') { - // Next segment is the callId - callId = uri.pathSegments[segment.$1 + 1]; - break; - } - } - - callId ??= uri.queryParameters['id']; - if (callId == null) return; - - // return if the video user is not yet logged in. - final currentUser = _userAuthController.currentUser; - if (currentUser == null) return; - - try { - final streamVideo = locator.get(); - final call = streamVideo.makeCall(callType: kCallType, id: callId); - - await call.getOrCreate(); - - await _router.push(LobbyRoute($extra: call).location, extra: call); - } catch (e, stk) { - debugPrint('Error joining or creating call: $e'); - debugPrint(stk.toString()); - return; - } - - // Navigate to the lobby screen. - } - @override void dispose() { _compositeSubscription.dispose(); diff --git a/dogfooding/lib/router/router.dart b/dogfooding/lib/router/router.dart index 9c5db6065..63f14d57d 100644 --- a/dogfooding/lib/router/router.dart +++ b/dogfooding/lib/router/router.dart @@ -5,6 +5,13 @@ import '../app/user_auth_controller.dart'; import '../di/injector.dart'; import 'routes.dart'; +/// Where a link wanted to go while nobody was logged in. +/// +/// A link can arrive before there is a user — the app is opened by it from a +/// terminated state — and the login screen would otherwise drop it. It is +/// consumed the moment login completes. +Uri? _pendingLink; + GoRouter initRouter(UserAuthController authNotifier) { return GoRouter( routes: [ @@ -34,11 +41,50 @@ GoRouter initRouter(UserAuthController authNotifier) { // if the user is not logged in, they need to login final loggedIn = currentUser != null; final loggingIn = state.matchedLocation == LoginRoute().location; - if (!loggedIn && !loggingIn) return LoginRoute().location; - if (loggedIn && loggingIn) return HomeRoute().location; + + if (!loggedIn) { + // Hold on to the link so it survives the trip through login. + if (state.matchedLocation.startsWith(_joinPrefix)) { + _pendingLink = state.uri; + } + return loggingIn ? null : LoginRoute().location; + } + + if (loggingIn) { + // Just logged in: honour a link that was waiting, otherwise go home. + final pending = _pendingLink; + _pendingLink = null; + return pending?.toString() ?? HomeRoute().location; + } // no need to redirect at all return null; }, + onException: (context, state, router) { + final callId = _callIdFromLink(state.uri); + if (callId == null) return router.go(HomeRoute().location); + + // Keep the original scheme and host: the host selects the environment. + router.go(state.uri.replace(path: '$_joinPrefix/$callId').toString()); + }, ); } + +const _joinPrefix = '/join'; + +/// Pulls the call id out of a join link, or returns null when the link does not +/// carry one. Accepts a `join` segment anywhere in the path so the web app's +/// nested paths keep working, and falls back to an `id` query parameter. +String? _callIdFromLink(Uri uri) { + final segments = uri.pathSegments; + for (final (index, segment) in segments.indexed) { + if (segment != 'join') continue; + final next = index + 1; + if (next < segments.length && segments[next].isNotEmpty) { + return segments[next]; + } + } + + final id = uri.queryParameters['id']; + return (id != null && id.isNotEmpty) ? id : null; +} diff --git a/dogfooding/lib/router/routes.dart b/dogfooding/lib/router/routes.dart index 8655df502..15c5f583f 100644 --- a/dogfooding/lib/router/routes.dart +++ b/dogfooding/lib/router/routes.dart @@ -7,6 +7,7 @@ import '../screens/call_participants_list.dart'; import '../screens/call_screen.dart'; import '../screens/call_stats_screen.dart'; import '../screens/home_screen.dart'; +import '../screens/join_call_screen.dart'; import '../screens/livestream_demo_screen.dart'; import '../screens/lobby_screen.dart'; import '../screens/login_screen.dart'; @@ -14,7 +15,11 @@ import '../screens/login_screen.dart'; part 'routes.g.dart'; @immutable -@TypedGoRoute(path: '/', name: 'home') +@TypedGoRoute( + path: '/', + name: 'home', + routes: [TypedGoRoute(path: 'join/:callId', name: 'join')], +) class HomeRoute extends GoRouteData with $HomeRoute { @override Widget build(BuildContext context, GoRouterState state) { @@ -31,6 +36,18 @@ class LoginRoute extends GoRouteData with $LoginRoute { } } +@immutable +class JoinRoute extends GoRouteData with $JoinRoute { + const JoinRoute({required this.callId}); + + final String callId; + + @override + Widget build(BuildContext context, GoRouterState state) { + return JoinCallScreen(callId: callId, linkHost: state.uri.host); + } +} + @immutable @TypedGoRoute(path: '/lobby', name: 'lobby') class LobbyRoute extends GoRouteData with $LobbyRoute { diff --git a/dogfooding/lib/router/routes.g.dart b/dogfooding/lib/router/routes.g.dart index 6f5fb593f..be95a76b8 100644 --- a/dogfooding/lib/router/routes.g.dart +++ b/dogfooding/lib/router/routes.g.dart @@ -16,8 +16,18 @@ List get $appRoutes => [ $callStatsRoute, ]; -RouteBase get $homeRoute => - GoRouteData.$route(path: '/', name: 'home', factory: $HomeRoute._fromState); +RouteBase get $homeRoute => GoRouteData.$route( + path: '/', + name: 'home', + factory: $HomeRoute._fromState, + routes: [ + GoRouteData.$route( + path: 'join/:callId', + name: 'join', + factory: $JoinRoute._fromState, + ), + ], +); mixin $HomeRoute on GoRouteData { static HomeRoute _fromState(GoRouterState state) => HomeRoute(); @@ -39,6 +49,30 @@ mixin $HomeRoute on GoRouteData { void replace(BuildContext context) => context.replace(location); } +mixin $JoinRoute on GoRouteData { + static JoinRoute _fromState(GoRouterState state) => + JoinRoute(callId: state.pathParameters['callId']!); + + JoinRoute get _self => this as JoinRoute; + + @override + String get location => + GoRouteData.$location('/join/${Uri.encodeComponent(_self.callId)}'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + RouteBase get $loginRoute => GoRouteData.$route( path: '/login', name: 'login', diff --git a/dogfooding/lib/screens/call_screen.dart b/dogfooding/lib/screens/call_screen.dart index 48f2366dc..113f045dd 100644 --- a/dogfooding/lib/screens/call_screen.dart +++ b/dogfooding/lib/screens/call_screen.dart @@ -6,7 +6,8 @@ import 'dart:convert'; import 'package:crypto/crypto.dart'; // �🐦 Flutter imports: import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart' + hide CurrentPlatform; import 'package:stream_video_filters/video_effects_manager.dart'; import 'package:stream_video_flutter/stream_video_flutter.dart' hide User; @@ -59,6 +60,7 @@ class _CallScreenState extends State { static const _snackbarCooldown = Duration(seconds: 5); Channel? _channel; + StreamSubscription? _chatConnectionRecoverySubscription; ParticipantLayoutMode _currentLayoutMode = ParticipantLayoutMode.grid; bool _moreMenuVisible = false; @@ -104,6 +106,7 @@ class _CallScreenState extends State { _speakingWhileMutedDebounce?.cancel(); _speakingWhileMutedSubscription.cancel(); _speakingWhileMuted.dispose(); + _chatConnectionRecoverySubscription?.cancel(); widget.call.leave(); _userChatRepo.disconnectUser(); _videoEffectsManager.dispose(); @@ -138,8 +141,25 @@ class _CallScreenState extends State { appPreferences.environment, ); + if (!mounted) return; + + // A channel watch is bound to the chat websocket connection id, and + // StreamChatCore disables the client-level state recovery. After a + // reconnect (e.g. the network blip that also triggers a video fast + // reconnect) nothing re-watches the channel, so new messages silently stop + // reaching the device. Re-watch it ourselves. + _chatConnectionRecoverySubscription = _userChatRepo.chatClient + .on(EventType.connectionRecovered) + .listen((_) async { + try { + await _channel?.watch(); + } catch (e) { + debugPrint('Failed to re-watch chat channel after reconnect: $e'); + } + }); + // Rebuild the widget to enable the chat button. - if (mounted) setState(() {}); + setState(() {}); } void showParticipants(BuildContext context) { @@ -317,7 +337,10 @@ class _CallScreenState extends State { AppColorPalette.appRed, // Keep the track alive on mute so speaking-while- // muted detection also works on iOS/macOS. - stopTrackOnMute: false, + stopTrackOnMute: + CurrentPlatform.isIos || CurrentPlatform.isMacOS + ? false + : null, ), ToggleCameraOption( call: call, diff --git a/dogfooding/lib/screens/join_call_screen.dart b/dogfooding/lib/screens/join_call_screen.dart new file mode 100644 index 000000000..500512d8e --- /dev/null +++ b/dogfooding/lib/screens/join_call_screen.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:stream_video_flutter/stream_video_flutter.dart'; + +import '../app/user_auth_controller.dart'; +import '../core/model/environment.dart'; +import '../core/repos/app_preferences.dart'; +import '../di/injector.dart'; +import '../router/routes.dart'; +import '../theme/app_palette.dart'; +import '../utils/consts.dart'; + +/// Turns a `/join/` link into the lobby for that call. +/// +/// Fetching the call is asynchronous and the environment may have to change +/// first, so the route lands here rather than on the lobby directly. This +/// screen replaces itself with the lobby as soon as the call is ready. +class JoinCallScreen extends StatefulWidget { + const JoinCallScreen({super.key, required this.callId, this.linkHost}); + + /// Id of the call to join. + final String callId; + + /// Host of the link that opened the app, empty when the navigation did not + /// come from a link. It is the only place the app learns which environment + /// the call lives in. + final String? linkHost; + + @override + State createState() => _JoinCallScreenState(); +} + +class _JoinCallScreenState extends State { + bool _failed = false; + + @override + void initState() { + super.initState(); + _openCall(); + } + + Future _openCall() async { + try { + await _switchEnvironmentIfNeeded(); + + final call = locator.get().makeCall( + callType: kCallType, + id: widget.callId, + ); + await call.getOrCreate(); + + if (!mounted) return; + LobbyRoute($extra: call).replace(context); + } catch (e, stk) { + debugPrint('Could not open the call from the link: $e'); + debugPrintStack(stackTrace: stk); + if (mounted) setState(() => _failed = true); + } + } + + /// A link carries its environment in its host, so a link to staging must not + /// be joined against pronto. + /// + /// Re-initialising the injector tears down the video and chat clients and + /// reconnects them, so it only happens when the link actually points + /// somewhere other than where the app is already pointing. + Future _switchEnvironmentIfNeeded() async { + final host = widget.linkHost; + if (host == null || host.isEmpty) return; + + final target = Environment.fromHost(host); + if (locator.get().environment == target) return; + + final user = locator.get().currentUser; + if (user == null) return; + + await AppInjector.reset(); + await AppInjector.init(forceEnvironment: target); + await locator.get().login(User(info: user), target); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColorPalette.backgroundColor, + body: Center( + child: _failed + ? _JoinFailed(callId: widget.callId) + : const CircularProgressIndicator.adaptive(), + ), + ); + } +} + +class _JoinFailed extends StatelessWidget { + const _JoinFailed({required this.callId}); + + final String callId; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Could not open call $callId', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Check that the link is still valid, then try again.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () => HomeRoute().go(context), + child: const Text('Go to home'), + ), + ], + ), + ); + } +} diff --git a/dogfooding/pubspec.yaml b/dogfooding/pubspec.yaml index 0e14d5eb5..725fb8665 100644 --- a/dogfooding/pubspec.yaml +++ b/dogfooding/pubspec.yaml @@ -10,7 +10,6 @@ environment: resolution: workspace dependencies: - app_links: ^7.2.1 collection: ^1.19.1 crypto: ^3.0.6 cupertino_icons: ^1.0.8 diff --git a/pubspec.lock b/pubspec.lock index 89688238e..b77508080 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,38 +41,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.2+1" - app_links: - dependency: transitive - description: - name: app_links - sha256: f8db46d2ea9ff6f3a37191a7fd5b7813da1253ace8c32c1b9eadace41d1188ea - url: "https://pub.dev" - source: hosted - version: "7.2.1" - app_links_linux: - dependency: transitive - description: - name: app_links_linux - sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 - url: "https://pub.dev" - source: hosted - version: "1.0.3" - app_links_platform_interface: - dependency: transitive - description: - name: app_links_platform_interface - sha256: "7546f09a6e93f4a2df2fe2bd40a5c6c64310ac461b036d82b43033be7a59f809" - url: "https://pub.dev" - source: hosted - version: "2.0.4" - app_links_web: - dependency: transitive - description: - name: app_links_web - sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 - url: "https://pub.dev" - source: hosted - version: "1.0.4" archive: dependency: transitive description: @@ -909,14 +877,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" - gtk: - dependency: transitive - description: - name: gtk - sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5" - url: "https://pub.dev" - source: hosted - version: "2.2.0" hashcodes: dependency: transitive description: