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: