diff --git a/.github/workflows/integrate.yaml b/.github/workflows/integrate.yaml index cacb3b53f..3138775f9 100644 --- a/.github/workflows/integrate.yaml +++ b/.github/workflows/integrate.yaml @@ -14,6 +14,7 @@ jobs: with: flutter-version: ${{ env.FLUTTER_VERSION }} cache: true + - run: ./scripts/prepare-web.sh - run: flutter pub get - name: Check formatting run: dart format lib/ test/ --set-exit-if-changed @@ -41,7 +42,7 @@ jobs: - run: flutter pub get - run: flutter build apk --debug - build_debug_web: + build_web: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -49,11 +50,50 @@ jobs: - uses: subosito/flutter-action@v2 with: flutter-version: ${{ env.FLUTTER_VERSION }} - cache: true + cache: false + - name: Install dependencies + run: sudo apt-get update && sudo apt-get install nodejs -y + - name: Remove Emoji Font + run: | + rm -rf fonts/NotoEmoji + yq -i 'del( .flutter.fonts[] | select(.family == "NotoEmoji") )' pubspec.yaml - run: flutter pub get - name: Prepare web run: ./scripts/prepare-web.sh - - run: flutter build web + - name: Build Release Web + run: flutter build web --dart-define=FLUTTER_WEB_CANVASKIT_URL=canvaskit/ --release --source-maps + # - name: Create archive + # run: tar -czf fluffychat-web.tar.gz build/web/ + - name: Upload Web Build + uses: actions/upload-artifact@v4 + with: + name: web + path: build/web/ + + + deploy_github_pages: + needs: build_web + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + name: web + path: . + - run: ls -la + - uses: actions/configure-pages@v4 + - uses: actions/upload-pages-artifact@v2 + with: + path: . + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v2 + build_debug_linux: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 0ce56e79e..c6e290231 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,12 @@ ios/Runner.ipa /macos/out .vs olm + +web/*.dart.js* +web/Imaging.js +web/Imaging.wasm +web/olm.js +web/olm.wasm +web/olm_legacy.js +libolm.3.dylib +libcrypto.1.1.dylib diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index d0e0fbc9b..0aeeedced 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -1 +1,5 @@ --keep class net.sqlcipher.** { *; } \ No newline at end of file +-keep class net.sqlcipher.** { *; } + +-keep class com.cloudwebrtc.webrtc.** { *; } +-keep class org.webrtc.** { *; } +-keep class com.hiennv.flutter_callkit_incoming.** { *; } \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c0595780a..6af50e425 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -13,23 +13,24 @@ + + + + - + + + + + + - - - - - - - - - + > + - - - - - - diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_logo.png b/android/app/src/main/res/drawable-xxxhdpi/ic_logo.png new file mode 100644 index 000000000..fcd0f6e4a Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/ic_logo.png differ diff --git a/assets/js/package/.gitkeep b/assets/js/package/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index a03fb5c02..40dd1aa1d 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -1960,11 +1960,6 @@ "type": "text", "placeholders": {} }, - "videoCall": "Video call", - "@videoCall": { - "type": "text", - "placeholders": {} - }, "visibilityOfTheChatHistory": "Visibility of the chat history", "@visibilityOfTheChatHistory": { "type": "text", @@ -2463,5 +2458,44 @@ "sender": {} } }, + "videoCall": "Video call", + "audioCall": "Audio call", + "groupCall": "Group call", + "heldTheCall": "held the call", + "startScreenShare": "Start screen sharing", + "stopScreenShare": "Stop screen sharing", + "holdCall": "Hold call", + "unholdCall": "Unhold call", + "flipCamera": "Flip camera", + "audioOutput": "Audio output", + "audioInput": "Audio input", + "hangup": "Hangup", + "joinGroupCall": "Join group call", + "cameraTurnedOff": "Camera turned off", + "youHeldTheCall": "You held the call", + "userHeldTheCall": "{user} held the call", + "@userHeldTheCall": { + "placeholders": { + "user": {} + } + }, + "unknownUser": "Unknown user", + "connecting": "Connecting...", + "youAreCalling": "You are calling...", + "ringing": "Ringing...", + "incomingCall": "Incoming call...", + "answering": "Answering...", + "ended": "Ended", + "calling": "Calling", + "startGroupCall": "Start group call", + "startVideoCall": "Start video call", + "startVoiceCall": "Start voice call", + "showInformation": "Show information", + "showMember": "Show member", + "showImages": "Show images", + "activeCall": "Active call", + "call": "Call", + "missedCall": "Missed call", + "callBack": "Call back", "transparent": "Transparent" } \ No newline at end of file diff --git a/ios/Podfile b/ios/Podfile index a069cc794..3bc280d30 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -platform :ios, '12.0' +platform :ios, '12.1' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index 92cfb312d..4c988a7b5 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -50,6 +50,10 @@ abstract class AppConfig { static bool sendPublicReadReceipts = true; static bool? sendOnEnter; static bool experimentalVoip = false; + static bool livekitEnabledCalls = true; + // static const livekitServiceUrl = 'https://famedly-livekit-server.teedee.dev'; + static const livekitServiceUrl = 'https://livekit-jwt.call.element.dev'; + static const enableLivekitE2EE = false; static const bool hideTypingUsernames = false; static const bool hideAllStateEvents = false; static const String inviteLinkPrefix = 'https://matrix.to/#/'; diff --git a/lib/config/routes.dart b/lib/config/routes.dart index d525b94e6..4bfa9c860 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -1,8 +1,10 @@ import 'dart:async'; import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pages/archive/archive.dart'; @@ -29,6 +31,9 @@ import 'package:fluffychat/pages/settings_notifications/settings_notifications.d import 'package:fluffychat/pages/settings_password/settings_password.dart'; import 'package:fluffychat/pages/settings_security/settings_security.dart'; import 'package:fluffychat/pages/settings_style/settings_style.dart'; +import 'package:fluffychat/pages/voip/calling_page.dart'; +import 'package:fluffychat/pages/voip/group_call_onboarding/group_call_onboarding_view.dart'; +import 'package:fluffychat/utils/voip/voip_plugin.dart'; import 'package:fluffychat/widgets/layouts/empty_page.dart'; import 'package:fluffychat/widgets/layouts/two_column_layout.dart'; import 'package:fluffychat/widgets/log_view.dart'; @@ -80,6 +85,38 @@ abstract class AppRoutes { const LogViewer(), ), ), + GoRoute( + redirect: (context, state) { + if (VoipPlugin.currentCallProxy == null) { + final parts = state.uri.path.split('/'); + final redirectPath = '/${parts[1]}/${parts[2]}'; + Logs().w( + '[GoRouter] voip currentCallProxy was null, redirecting to $redirectPath', + ); + return redirectPath; + } + return null; + }, + path: '/rooms/:roomid/call', + pageBuilder: (context, state) => defaultPageBuilder( + context, + VoipPlugin.currentCallProxy == null + ? const Center(child: CircularProgressIndicator()) + : Calling( + voipPlugin: Matrix.of(context).voipPlugin, + proxy: VoipPlugin.currentCallProxy!, + ), + ), + ), + GoRoute( + path: '/rooms/:roomid/group_call_onboarding', + pageBuilder: (context, state) => defaultPageBuilder( + context, + GroupCallOnboardingView( + roomId: state.pathParameters['roomid']!, + ), + ), + ), ShellRoute( pageBuilder: (context, state, child) => defaultPageBuilder( context, diff --git a/lib/main.dart b/lib/main.dart index 877f80da3..e9ac41748 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/utils/client_manager.dart'; import 'package:fluffychat/utils/platform_infos.dart'; +import 'package:fluffychat/utils/voip/voip_plugin.dart'; import 'package:fluffychat/widgets/error_widget.dart'; import 'config/setting_keys.dart'; import 'utils/background_push.dart'; @@ -24,6 +25,7 @@ void main() async { Logs().nativeColors = !PlatformInfos.isIOS; final store = await SharedPreferences.getInstance(); final clients = await ClientManager.getClients(store: store); + final voipPlugins = clients.map((e) => VoipPlugin.clientOnly(e)).toList(); // If the app starts in detached mode, we assume that it is in // background fetch mode for processing push notifications. This is @@ -34,12 +36,12 @@ void main() async { for (final client in clients) { client.syncPresence = PresenceType.offline; } - // In the background fetch mode we do not want to waste ressources with // starting the Flutter engine but process incoming push notifications. BackgroundPush.clientOnly(clients.first); // To start the flutter engine afterwards we add an custom observer. - WidgetsBinding.instance.addObserver(AppStarter(clients, store)); + WidgetsBinding.instance + .addObserver(AppStarter(clients, voipPlugins, store)); Logs().i( '${AppConfig.applicationName} started in background-fetch mode. No GUI will be created unless the app is no longer detached.', ); @@ -50,11 +52,15 @@ void main() async { Logs().i( '${AppConfig.applicationName} started in foreground mode. Rendering GUI...', ); - await startGui(clients, store); + await startGui(clients, voipPlugins, store); } /// Fetch the pincode for the applock and start the flutter engine. -Future startGui(List clients, SharedPreferences store) async { +Future startGui( + List clients, + List voipPlugins, + SharedPreferences store, +) async { // Fetch the pin for the applock if existing for mobile applications. String? pin; if (PlatformInfos.isMobile) { @@ -72,17 +78,25 @@ Future startGui(List clients, SharedPreferences store) async { await firstClient?.accountDataLoading; ErrorWidget.builder = (details) => FluffyChatErrorWidget(details); - runApp(FluffyChatApp(clients: clients, pincode: pin, store: store)); + runApp( + FluffyChatApp( + clients: clients, + voipPlugins: voipPlugins, + pincode: pin, + store: store, + ), + ); } /// Watches the lifecycle changes to start the application when it /// is no longer detached. class AppStarter with WidgetsBindingObserver { final List clients; + List voipPlugins; final SharedPreferences store; bool guiStarted = false; - AppStarter(this.clients, this.store); + AppStarter(this.clients, this.voipPlugins, this.store); @override void didChangeAppLifecycleState(AppLifecycleState state) { @@ -96,7 +110,7 @@ class AppStarter with WidgetsBindingObserver { for (final client in clients) { client.syncPresence = PresenceType.online; } - startGui(clients, store); + startGui(clients, voipPlugins, store); // We must make sure that the GUI is only started once. guiStarted = true; } diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 1f7eb4242..60a227ad6 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -30,6 +30,7 @@ import 'package:fluffychat/utils/error_reporter.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/event_extension.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/utils/platform_infos.dart'; +import 'package:fluffychat/utils/voip/voip_plugin.dart'; import 'package:fluffychat/widgets/app_lock.dart'; import 'package:fluffychat/widgets/matrix.dart'; import '../../utils/account_bundles.dart'; @@ -1251,7 +1252,7 @@ class ChatController extends State // VoIP required Android SDK 21 if (PlatformInfos.isAndroid) { DeviceInfoPlugin().androidInfo.then((value) { - if (value.version.sdkInt < 21) { + if (value.version.sdkInt < 23) { Navigator.pop(context); showOkAlertDialog( context: context, @@ -1262,45 +1263,41 @@ class ChatController extends State } }); } - final callType = await showModalActionSheet( + final callType = await showModalActionSheet( context: context, title: L10n.of(context)!.warning, message: L10n.of(context)!.videoCallsBetaWarning, cancelLabel: L10n.of(context)!.cancel, actions: [ - SheetAction( - label: L10n.of(context)!.voiceCall, - icon: Icons.phone_outlined, - key: CallType.kVoice, - ), - SheetAction( - label: L10n.of(context)!.videoCall, - icon: Icons.video_call_outlined, - key: CallType.kVideo, - ), + if (room.isDirectChat) + SheetAction( + label: L10n.of(context)!.voiceCall, + icon: Icons.phone_outlined, + key: VoipType.kVoice, + ), + if (room.isDirectChat) + SheetAction( + label: L10n.of(context)!.videoCall, + icon: Icons.video_call_outlined, + key: VoipType.kVideo, + ), + if (!room.isDirectChat) + SheetAction( + label: L10n.of(context)!.groupCall, + icon: Icons.people, + key: VoipType.kGroup, + ), ], ); if (callType == null) return; - final success = await showFutureLoadingDialog( - context: context, - future: () => - Matrix.of(context).voipPlugin!.voip.requestTurnServerCredentials(), - ); - if (success.result != null) { - final voipPlugin = Matrix.of(context).voipPlugin; - try { - await voipPlugin!.voip.inviteToCall(room.id, callType); - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.toLocalizedString(context))), - ); - } - } else { - await showOkAlertDialog( - context: context, - title: L10n.of(context)!.unavailable, - okLabel: L10n.of(context)!.next, + final voipPlugin = Matrix.of(context).voipPlugin; + try { + voipPlugin.onPhoneButtonTap(context, room, callType); + } catch (e, s) { + Logs().e('[VOIP] onPhoneButtonTap fialed', e, s); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toLocalizedString(context))), ); } } diff --git a/lib/pages/chat/chat_view.dart b/lib/pages/chat/chat_view.dart index eb9bdda5c..29ee19c09 100644 --- a/lib/pages/chat/chat_view.dart +++ b/lib/pages/chat/chat_view.dart @@ -113,13 +113,11 @@ class ChatView extends StatelessWidget { ]; } else if (!controller.room.isArchived) { return [ - if (Matrix.of(context).voipPlugin != null && - controller.room.isDirectChat) - IconButton( - onPressed: controller.onPhoneButtonTap, - icon: const Icon(Icons.call_outlined), - tooltip: L10n.of(context)!.placeCall, - ), + IconButton( + onPressed: controller.onPhoneButtonTap, + icon: const Icon(Icons.call_outlined), + tooltip: L10n.of(context)!.placeCall, + ), EncryptionButton(controller.room), ChatSettingsPopupMenu(controller.room, true), ]; diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart index db0708ffb..b3869df7c 100644 --- a/lib/pages/chat_list/chat_list.dart +++ b/lib/pages/chat_list/chat_list.dart @@ -23,7 +23,6 @@ import 'package:fluffychat/utils/platform_infos.dart'; import '../../../utils/account_bundles.dart'; import '../../utils/matrix_sdk_extensions/matrix_file_extension.dart'; import '../../utils/url_launcher.dart'; -import '../../utils/voip/callkeep_manager.dart'; import '../../widgets/fluffy_chat_app.dart'; import '../../widgets/matrix.dart'; import '../bootstrap/bootstrap_dialog.dart'; @@ -406,8 +405,6 @@ class ChatListController extends State scrollController.addListener(_onScroll); _waitForFirstSync(); - _hackyWebRTCFixForWeb(); - CallKeepManager().initialize(); WidgetsBinding.instance.addPostFrameCallback((_) async { if (mounted) { searchServer = @@ -740,10 +737,6 @@ class ChatListController extends State @override Widget build(BuildContext context) => ChatListView(this); - void _hackyWebRTCFixForWeb() { - ChatList.contextForVoip = context; - } - Future _checkTorBrowser() async { if (!kIsWeb) return; final isTor = await TorBrowserDetector.isTorBrowser; diff --git a/lib/pages/dialer/dialer.dart b/lib/pages/dialer/dialer.dart deleted file mode 100644 index a8e5a9202..000000000 --- a/lib/pages/dialer/dialer.dart +++ /dev/null @@ -1,651 +0,0 @@ -/* - * Famedly - * Copyright (C) 2019, 2020, 2021 Famedly GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import 'dart:async'; -import 'dart:math'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -import 'package:flutter_foreground_task/flutter_foreground_task.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:flutter_webrtc/flutter_webrtc.dart'; -import 'package:just_audio/just_audio.dart'; -import 'package:matrix/matrix.dart'; -import 'package:vibration/vibration.dart'; -import 'package:wakelock_plus/wakelock_plus.dart'; - -import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; -import 'package:fluffychat/utils/platform_infos.dart'; -import 'package:fluffychat/widgets/avatar.dart'; -import 'pip/pip_view.dart'; - -class _StreamView extends StatelessWidget { - const _StreamView( - this.wrappedStream, { - this.mainView = false, - required this.matrixClient, - }); - - final WrappedMediaStream wrappedStream; - final Client matrixClient; - - final bool mainView; - - Uri? get avatarUrl => wrappedStream.getUser().avatarUrl; - - String? get displayName => wrappedStream.displayName; - - String get avatarName => wrappedStream.avatarName; - - bool get isLocal => wrappedStream.isLocal(); - - bool get mirrored => - wrappedStream.isLocal() && - wrappedStream.purpose == SDPStreamMetadataPurpose.Usermedia; - - bool get audioMuted => wrappedStream.audioMuted; - - bool get videoMuted => wrappedStream.videoMuted; - - bool get isScreenSharing => - wrappedStream.purpose == SDPStreamMetadataPurpose.Screenshare; - - @override - Widget build(BuildContext context) { - return Container( - decoration: const BoxDecoration( - color: Colors.black54, - ), - child: Stack( - alignment: Alignment.center, - children: [ - if (videoMuted) - Container( - color: Colors.transparent, - ), - if (!videoMuted) - RTCVideoView( - // yes, it must explicitly be casted even though I do not feel - // comfortable with it... - wrappedStream.renderer as RTCVideoRenderer, - mirror: mirrored, - objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain, - ), - if (videoMuted) - Positioned( - child: Avatar( - mxContent: avatarUrl, - name: displayName, - size: mainView ? 96 : 48, - client: matrixClient, - // textSize: mainView ? 36 : 24, - // matrixClient: matrixClient, - ), - ), - if (!isScreenSharing) - Positioned( - left: 4.0, - bottom: 4.0, - child: Icon( - audioMuted ? Icons.mic_off : Icons.mic, - color: Colors.white, - size: 18.0, - ), - ), - ], - ), - ); - } -} - -class Calling extends StatefulWidget { - final VoidCallback? onClear; - final BuildContext context; - final String callId; - final CallSession call; - final Client client; - - const Calling({ - required this.context, - required this.call, - required this.client, - required this.callId, - this.onClear, - super.key, - }); - - @override - MyCallingPage createState() => MyCallingPage(); -} - -class MyCallingPage extends State { - Room? get room => call.room; - - String get displayName => call.room.getLocalizedDisplayname( - MatrixLocals(L10n.of(widget.context)!), - ); - - String get callId => widget.callId; - - CallSession get call => widget.call; - - MediaStream? get localStream { - if (call.localUserMediaStream != null) { - return call.localUserMediaStream!.stream!; - } - return null; - } - - MediaStream? get remoteStream { - if (call.getRemoteStreams.isNotEmpty) { - return call.getRemoteStreams[0].stream!; - } - return null; - } - - bool get speakerOn => call.speakerOn; - - bool get isMicrophoneMuted => call.isMicrophoneMuted; - - bool get isLocalVideoMuted => call.isLocalVideoMuted; - - bool get isScreensharingEnabled => call.screensharingEnabled; - - bool get isRemoteOnHold => call.remoteOnHold; - - bool get voiceonly => call.type == CallType.kVoice; - - bool get connecting => call.state == CallState.kConnecting; - - bool get connected => call.state == CallState.kConnected; - - bool get mirrored => call.facingMode == 'user'; - - List get streams => call.streams; - double? _localVideoHeight; - double? _localVideoWidth; - EdgeInsetsGeometry? _localVideoMargin; - CallState? _state; - - void _playCallSound() async { - const path = 'assets/sounds/call.ogg'; - if (kIsWeb || PlatformInfos.isMobile || PlatformInfos.isMacOS) { - final player = AudioPlayer(); - await player.setAsset(path); - player.play(); - } else { - Logs().w('Playing sound not implemented for this platform!'); - } - } - - @override - void initState() { - super.initState(); - initialize(); - _playCallSound(); - } - - void initialize() async { - final call = this.call; - call.onCallStateChanged.stream.listen(_handleCallState); - call.onCallEventChanged.stream.listen((event) { - if (event == CallEvent.kFeedsChanged) { - setState(() { - call.tryRemoveStopedStreams(); - }); - } else if (event == CallEvent.kLocalHoldUnhold || - event == CallEvent.kRemoteHoldUnhold) { - setState(() {}); - Logs().i( - 'Call hold event: local ${call.localHold}, remote ${call.remoteOnHold}', - ); - } - }); - _state = call.state; - - if (call.type == CallType.kVideo) { - try { - // Enable wakelock (keep screen on) - unawaited(WakelockPlus.enable()); - } catch (_) {} - } - } - - void cleanUp() { - Timer( - const Duration(seconds: 2), - () => widget.onClear?.call(), - ); - if (call.type == CallType.kVideo) { - try { - unawaited(WakelockPlus.disable()); - } catch (_) {} - } - } - - @override - void dispose() { - super.dispose(); - call.cleanUp.call(); - } - - void _resizeLocalVideo(Orientation orientation) { - final shortSide = min( - MediaQuery.of(widget.context).size.width, - MediaQuery.of(widget.context).size.height, - ); - _localVideoMargin = remoteStream != null - ? const EdgeInsets.only(top: 20.0, right: 20.0) - : EdgeInsets.zero; - _localVideoWidth = remoteStream != null - ? shortSide / 3 - : MediaQuery.of(widget.context).size.width; - _localVideoHeight = remoteStream != null - ? shortSide / 4 - : MediaQuery.of(widget.context).size.height; - } - - void _handleCallState(CallState state) { - Logs().v('CallingPage::handleCallState: ${state.toString()}'); - if ({CallState.kConnected, CallState.kEnded}.contains(state)) { - try { - Vibration.vibrate(duration: 200); - } catch (e) { - Logs().e('[Dialer] could not vibrate for call updates'); - } - } - - if (mounted) { - setState(() { - _state = state; - if (_state == CallState.kEnded) cleanUp(); - }); - } - } - - void _answerCall() { - setState(() { - call.answer(); - }); - } - - void _hangUp() { - setState(() { - if (call.isRinging) { - call.reject(); - } else { - call.hangup(); - } - }); - } - - void _muteMic() { - setState(() { - call.setMicrophoneMuted(!call.isMicrophoneMuted); - }); - } - - void _screenSharing() async { - if (PlatformInfos.isAndroid) { - if (!call.screensharingEnabled) { - FlutterForegroundTask.init( - androidNotificationOptions: AndroidNotificationOptions( - channelId: 'notification_channel_id', - channelName: 'Foreground Notification', - channelDescription: - L10n.of(widget.context)!.foregroundServiceRunning, - ), - iosNotificationOptions: const IOSNotificationOptions(), - foregroundTaskOptions: const ForegroundTaskOptions(), - ); - FlutterForegroundTask.startService( - notificationTitle: L10n.of(widget.context)!.screenSharingTitle, - notificationText: L10n.of(widget.context)!.screenSharingDetail, - ); - } else { - FlutterForegroundTask.stopService(); - } - } - - setState(() { - call.setScreensharingEnabled(!call.screensharingEnabled); - }); - } - - void _remoteOnHold() { - setState(() { - call.setRemoteOnHold(!call.remoteOnHold); - }); - } - - void _muteCamera() { - setState(() { - call.setLocalVideoMuted(!call.isLocalVideoMuted); - }); - } - - void _switchCamera() async { - if (call.localUserMediaStream != null) { - await Helper.switchCamera( - call.localUserMediaStream!.stream!.getVideoTracks()[0], - ); - if (PlatformInfos.isMobile) { - call.facingMode == 'user' - ? call.facingMode = 'environment' - : call.facingMode = 'user'; - } - } - setState(() {}); - } - - /* - void _switchSpeaker() { - setState(() { - session.setSpeakerOn(); - }); - } - */ - - List _buildActionButtons(bool isFloating) { - if (isFloating) { - return []; - } - - final switchCameraButton = FloatingActionButton( - heroTag: 'switchCamera', - onPressed: _switchCamera, - backgroundColor: Colors.black45, - child: const Icon(Icons.switch_camera), - ); - /* - var switchSpeakerButton = FloatingActionButton( - heroTag: 'switchSpeaker', - child: Icon(_speakerOn ? Icons.volume_up : Icons.volume_off), - onPressed: _switchSpeaker, - foregroundColor: Colors.black54, - backgroundColor: Theme.of(widget.context).backgroundColor, - ); - */ - final hangupButton = FloatingActionButton( - heroTag: 'hangup', - onPressed: _hangUp, - tooltip: 'Hangup', - backgroundColor: _state == CallState.kEnded ? Colors.black45 : Colors.red, - child: const Icon(Icons.call_end), - ); - - final answerButton = FloatingActionButton( - heroTag: 'answer', - onPressed: _answerCall, - tooltip: 'Answer', - backgroundColor: Colors.green, - child: const Icon(Icons.phone), - ); - - final muteMicButton = FloatingActionButton( - heroTag: 'muteMic', - onPressed: _muteMic, - foregroundColor: isMicrophoneMuted ? Colors.black26 : Colors.white, - backgroundColor: isMicrophoneMuted ? Colors.white : Colors.black45, - child: Icon(isMicrophoneMuted ? Icons.mic_off : Icons.mic), - ); - - final screenSharingButton = FloatingActionButton( - heroTag: 'screenSharing', - onPressed: _screenSharing, - foregroundColor: isScreensharingEnabled ? Colors.black26 : Colors.white, - backgroundColor: isScreensharingEnabled ? Colors.white : Colors.black45, - child: const Icon(Icons.desktop_mac), - ); - - final holdButton = FloatingActionButton( - heroTag: 'hold', - onPressed: _remoteOnHold, - foregroundColor: isRemoteOnHold ? Colors.black26 : Colors.white, - backgroundColor: isRemoteOnHold ? Colors.white : Colors.black45, - child: const Icon(Icons.pause), - ); - - final muteCameraButton = FloatingActionButton( - heroTag: 'muteCam', - onPressed: _muteCamera, - foregroundColor: isLocalVideoMuted ? Colors.black26 : Colors.white, - backgroundColor: isLocalVideoMuted ? Colors.white : Colors.black45, - child: Icon(isLocalVideoMuted ? Icons.videocam_off : Icons.videocam), - ); - - switch (_state) { - case CallState.kRinging: - case CallState.kInviteSent: - case CallState.kCreateAnswer: - case CallState.kConnecting: - return call.isOutgoing - ? [hangupButton] - : [answerButton, hangupButton]; - case CallState.kConnected: - return [ - muteMicButton, - //switchSpeakerButton, - if (!voiceonly && !kIsWeb) switchCameraButton, - if (!voiceonly) muteCameraButton, - if (PlatformInfos.isMobile || PlatformInfos.isWeb) - screenSharingButton, - holdButton, - hangupButton, - ]; - case CallState.kEnded: - return [ - hangupButton, - ]; - case CallState.kFledgling: - // TODO: Handle this case. - break; - case CallState.kWaitLocalMedia: - // TODO: Handle this case. - break; - case CallState.kCreateOffer: - // TODO: Handle this case. - break; - case null: - // TODO: Handle this case. - break; - } - return []; - } - - List _buildContent(Orientation orientation, bool isFloating) { - final stackWidgets = []; - - final call = this.call; - if (call.callHasEnded) { - return stackWidgets; - } - - if (call.localHold || call.remoteOnHold) { - var title = ''; - if (call.localHold) { - title = '${call.room.getLocalizedDisplayname( - MatrixLocals(L10n.of(widget.context)!), - )} held the call.'; - } else if (call.remoteOnHold) { - title = 'You held the call.'; - } - stackWidgets.add( - Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.pause, - size: 48.0, - color: Colors.white, - ), - Text( - title, - style: const TextStyle( - color: Colors.white, - fontSize: 24.0, - ), - ), - ], - ), - ), - ); - return stackWidgets; - } - - var primaryStream = call.remoteScreenSharingStream ?? - call.localScreenSharingStream ?? - call.remoteUserMediaStream ?? - call.localUserMediaStream; - - if (!connected) { - primaryStream = call.localUserMediaStream; - } - - if (primaryStream != null) { - stackWidgets.add( - Center( - child: _StreamView( - primaryStream, - mainView: true, - matrixClient: widget.client, - ), - ), - ); - } - - if (isFloating || !connected) { - return stackWidgets; - } - - _resizeLocalVideo(orientation); - - if (call.getRemoteStreams.isEmpty) { - return stackWidgets; - } - - final secondaryStreamViews = []; - - if (call.remoteScreenSharingStream != null) { - final remoteUserMediaStream = call.remoteUserMediaStream; - secondaryStreamViews.add( - SizedBox( - width: _localVideoWidth, - height: _localVideoHeight, - child: - _StreamView(remoteUserMediaStream!, matrixClient: widget.client), - ), - ); - secondaryStreamViews.add(const SizedBox(height: 10)); - } - - final localStream = - call.localUserMediaStream ?? call.localScreenSharingStream; - if (localStream != null && !isFloating) { - secondaryStreamViews.add( - SizedBox( - width: _localVideoWidth, - height: _localVideoHeight, - child: _StreamView(localStream, matrixClient: widget.client), - ), - ); - secondaryStreamViews.add(const SizedBox(height: 10)); - } - - if (call.localScreenSharingStream != null && !isFloating) { - secondaryStreamViews.add( - SizedBox( - width: _localVideoWidth, - height: _localVideoHeight, - child: _StreamView( - call.remoteUserMediaStream!, - matrixClient: widget.client, - ), - ), - ); - secondaryStreamViews.add(const SizedBox(height: 10)); - } - - if (secondaryStreamViews.isNotEmpty) { - stackWidgets.add( - Container( - padding: const EdgeInsets.fromLTRB(0, 20, 0, 120), - alignment: Alignment.bottomRight, - child: Container( - width: _localVideoWidth, - margin: _localVideoMargin, - child: Column( - children: secondaryStreamViews, - ), - ), - ), - ); - } - - return stackWidgets; - } - - @override - Widget build(BuildContext context) { - return PIPView( - builder: (context, isFloating) { - return Scaffold( - resizeToAvoidBottomInset: !isFloating, - floatingActionButtonLocation: - FloatingActionButtonLocation.centerFloat, - floatingActionButton: SizedBox( - width: 320.0, - height: 150.0, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: _buildActionButtons(isFloating), - ), - ), - body: OrientationBuilder( - builder: (BuildContext context, Orientation orientation) { - return Container( - decoration: const BoxDecoration( - color: Colors.black87, - ), - child: Stack( - children: [ - ..._buildContent(orientation, isFloating), - if (!isFloating) - Positioned( - top: 24.0, - left: 24.0, - child: IconButton( - color: Colors.black45, - icon: const Icon(Icons.arrow_back), - onPressed: () { - PIPView.of(context)?.setFloating(true); - }, - ), - ), - ], - ), - ); - }, - ), - ); - }, - ); - } -} diff --git a/lib/pages/dialer/pip/dismiss_keyboard.dart b/lib/pages/dialer/pip/dismiss_keyboard.dart deleted file mode 100644 index c9ca3180f..000000000 --- a/lib/pages/dialer/pip/dismiss_keyboard.dart +++ /dev/null @@ -1,5 +0,0 @@ -import 'package:flutter/material.dart'; - -void dismissKeyboard(BuildContext context) { - FocusScope.of(context).requestFocus(FocusNode()); -} diff --git a/lib/pages/dialer/pip/pip_view.dart b/lib/pages/dialer/pip/pip_view.dart deleted file mode 100644 index 5396c9136..000000000 --- a/lib/pages/dialer/pip/pip_view.dart +++ /dev/null @@ -1,345 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:fluffychat/config/themes.dart'; -import 'dismiss_keyboard.dart'; - -class PIPView extends StatefulWidget { - final PIPViewCorner initialCorner; - final double? floatingWidth; - final double? floatingHeight; - final bool avoidKeyboard; - - final Widget Function( - BuildContext context, - bool isFloating, - ) builder; - - const PIPView({ - super.key, - required this.builder, - this.initialCorner = PIPViewCorner.topRight, - this.floatingWidth, - this.floatingHeight, - this.avoidKeyboard = true, - }); - - @override - PIPViewState createState() => PIPViewState(); - - static PIPViewState? of(BuildContext context) { - return context.findAncestorStateOfType(); - } -} - -class PIPViewState extends State with TickerProviderStateMixin { - late AnimationController _toggleFloatingAnimationController; - late AnimationController _dragAnimationController; - late PIPViewCorner _corner; - Offset _dragOffset = Offset.zero; - bool _isDragging = false; - bool _floating = false; - Map _offsets = {}; - - @override - void initState() { - super.initState(); - _corner = widget.initialCorner; - _toggleFloatingAnimationController = AnimationController( - duration: FluffyThemes.animationDuration, - vsync: this, - ); - _dragAnimationController = AnimationController( - duration: FluffyThemes.animationDuration, - vsync: this, - ); - } - - void _updateCornersOffsets({ - required Size spaceSize, - required Size widgetSize, - required EdgeInsets windowPadding, - }) { - _offsets = _calculateOffsets( - spaceSize: spaceSize, - widgetSize: widgetSize, - windowPadding: windowPadding, - ); - } - - bool _isAnimating() { - return _toggleFloatingAnimationController.isAnimating || - _dragAnimationController.isAnimating; - } - - void setFloating(bool floating) { - if (_isAnimating()) return; - dismissKeyboard(context); - setState(() { - _floating = floating; - }); - _toggleFloatingAnimationController.forward(); - } - - void stopFloating() { - if (_isAnimating()) return; - dismissKeyboard(context); - _toggleFloatingAnimationController.reverse().whenCompleteOrCancel(() { - if (mounted) { - setState(() { - _floating = false; - }); - } - }); - } - - void _onPanUpdate(DragUpdateDetails details) { - if (!_isDragging) return; - setState(() { - _dragOffset = _dragOffset.translate( - details.delta.dx, - details.delta.dy, - ); - }); - } - - void _onPanCancel() { - if (!_isDragging) return; - setState(() { - _dragAnimationController.value = 0; - _dragOffset = Offset.zero; - _isDragging = false; - }); - } - - void _onPanEnd(_) { - if (!_isDragging) return; - - final nearestCorner = _calculateNearestCorner( - offset: _dragOffset, - offsets: _offsets, - ); - setState(() { - _corner = nearestCorner; - _isDragging = false; - }); - _dragAnimationController.forward().whenCompleteOrCancel(() { - _dragAnimationController.value = 0; - _dragOffset = Offset.zero; - }); - } - - void _onPanStart(_) { - if (_isAnimating()) return; - setState(() { - _dragOffset = _offsets[_corner]!; - _isDragging = true; - }); - } - - @override - Widget build(BuildContext context) { - final mediaQuery = MediaQuery.of(context); - var windowPadding = mediaQuery.padding; - if (widget.avoidKeyboard) { - windowPadding += mediaQuery.viewInsets; - } - final isFloating = _floating; - - return LayoutBuilder( - builder: (context, constraints) { - final width = constraints.maxWidth; - final height = constraints.maxHeight; - var floatingWidth = widget.floatingWidth; - var floatingHeight = widget.floatingHeight; - if (floatingWidth == null && floatingHeight != null) { - floatingWidth = width / height * floatingHeight; - } - floatingWidth ??= 100.0; - floatingHeight ??= height / width * floatingWidth; - - final floatingWidgetSize = Size(floatingWidth, floatingHeight); - final fullWidgetSize = Size(width, height); - - _updateCornersOffsets( - spaceSize: fullWidgetSize, - widgetSize: floatingWidgetSize, - windowPadding: windowPadding, - ); - - final calculatedOffset = _offsets[_corner]; - - // BoxFit.cover - final widthRatio = floatingWidth / width; - final heightRatio = floatingHeight / height; - final scaledDownScale = widthRatio > heightRatio - ? floatingWidgetSize.width / fullWidgetSize.width - : floatingWidgetSize.height / fullWidgetSize.height; - - return Stack( - children: [ - AnimatedBuilder( - animation: Listenable.merge([ - _toggleFloatingAnimationController, - _dragAnimationController, - ]), - builder: (context, child) { - final animationCurve = CurveTween( - curve: Curves.easeInOutQuad, - ); - final dragAnimationValue = animationCurve.transform( - _dragAnimationController.value, - ); - final toggleFloatingAnimationValue = animationCurve.transform( - _toggleFloatingAnimationController.value, - ); - - final floatingOffset = _isDragging - ? _dragOffset - : Tween( - begin: _dragOffset, - end: calculatedOffset, - ).transform( - _dragAnimationController.isAnimating - ? dragAnimationValue - : toggleFloatingAnimationValue, - ); - final borderRadius = Tween( - begin: 0, - end: 10, - ).transform(toggleFloatingAnimationValue); - final width = Tween( - begin: fullWidgetSize.width, - end: floatingWidgetSize.width, - ).transform(toggleFloatingAnimationValue); - final height = Tween( - begin: fullWidgetSize.height, - end: floatingWidgetSize.height, - ).transform(toggleFloatingAnimationValue); - final scale = Tween( - begin: 1, - end: scaledDownScale, - ).transform(toggleFloatingAnimationValue); - return Positioned( - left: floatingOffset.dx, - top: floatingOffset.dy, - child: GestureDetector( - onPanStart: isFloating ? _onPanStart : null, - onPanUpdate: isFloating ? _onPanUpdate : null, - onPanCancel: isFloating ? _onPanCancel : null, - onPanEnd: isFloating ? _onPanEnd : null, - onTap: isFloating ? stopFloating : null, - child: Material( - elevation: 10, - borderRadius: BorderRadius.circular(borderRadius), - child: Container( - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - color: Colors.transparent, - borderRadius: BorderRadius.circular(borderRadius), - ), - width: width, - height: height, - child: Transform.scale( - scale: scale, - child: OverflowBox( - maxHeight: fullWidgetSize.height, - maxWidth: fullWidgetSize.width, - child: IgnorePointer( - ignoring: isFloating, - child: child, - ), - ), - ), - ), - ), - ), - ); - }, - child: Builder( - builder: (context) => widget.builder(context, isFloating), - ), - ), - ], - ); - }, - ); - } -} - -enum PIPViewCorner { - topLeft, - topRight, - bottomLeft, - bottomRight, -} - -class _CornerDistance { - final PIPViewCorner corner; - final double distance; - - _CornerDistance({ - required this.corner, - required this.distance, - }); -} - -PIPViewCorner _calculateNearestCorner({ - required Offset offset, - required Map offsets, -}) { - _CornerDistance calculateDistance(PIPViewCorner corner) { - final distance = offsets[corner]! - .translate( - -offset.dx, - -offset.dy, - ) - .distanceSquared; - return _CornerDistance( - corner: corner, - distance: distance, - ); - } - - final distances = PIPViewCorner.values.map(calculateDistance).toList(); - - distances.sort((cd0, cd1) => cd0.distance.compareTo(cd1.distance)); - - return distances.first.corner; -} - -Map _calculateOffsets({ - required Size spaceSize, - required Size widgetSize, - required EdgeInsets windowPadding, -}) { - Offset getOffsetForCorner(PIPViewCorner corner) { - const spacing = 16; - final left = spacing + windowPadding.left; - final top = spacing + windowPadding.top; - final right = - spaceSize.width - widgetSize.width - windowPadding.right - spacing; - final bottom = - spaceSize.height - widgetSize.height - windowPadding.bottom - spacing; - - switch (corner) { - case PIPViewCorner.topLeft: - return Offset(left, top); - case PIPViewCorner.topRight: - return Offset(right, top); - case PIPViewCorner.bottomLeft: - return Offset(left, bottom); - case PIPViewCorner.bottomRight: - return Offset(right, bottom); - default: - throw Exception('Not implemented.'); - } - } - - const corners = PIPViewCorner.values; - final offsets = {}; - for (final corner in corners) { - offsets[corner] = getOffsetForCorner(corner); - } - - return offsets; -} diff --git a/lib/pages/global_banner_scaffold.dart b/lib/pages/global_banner_scaffold.dart new file mode 100644 index 000000000..675316790 --- /dev/null +++ b/lib/pages/global_banner_scaffold.dart @@ -0,0 +1,74 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/utils/app_state.dart'; +import 'package:fluffychat/widgets/fluffy_chat_app.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class GlobalBannerScaffold extends StatefulWidget { + // the actual page below the banner + final Widget child; + + const GlobalBannerScaffold({super.key, required this.child}); + + // add strings or whole routes you don't want banner to be in + static const ignoreBannerRoutes = ['call']; + + @override + State createState() => _GlobalBannerScaffoldState(); +} + +class _GlobalBannerScaffoldState extends State { + StreamSubscription? _onSyncStatusSub; + + @override + void dispose() { + _onSyncStatusSub?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (FluffyThemes.isColumnMode(context)) return widget.child; + return Selector( + selector: (_, state) => state.globalBanner, + child: widget.child, + builder: (context, banner, child) { + final bool showBanner = banner != null && + !GlobalBannerScaffold.ignoreBannerRoutes.any( + (route) => FluffyChatApp + .router.routerDelegate.currentConfiguration.uri + .toString() + .contains(route), + ) && + Matrix.of(context).client.isLogged(); + + return showBanner + ? Scaffold( + appBar: AppBar( + titleSpacing: 0, + toolbarHeight: showBanner ? 70 : 0, + automaticallyImplyLeading: false, + title: Column( + children: [ + if (showBanner) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + ), + child: banner, + ), + ], + ), + ), + body: child, + ) + : child ?? widget.child; // only for null safety, never occurs + }, + ); + } +} diff --git a/lib/pages/settings_chat/settings_chat_view.dart b/lib/pages/settings_chat/settings_chat_view.dart index b2d4a40ad..f32c427c1 100644 --- a/lib/pages/settings_chat/settings_chat_view.dart +++ b/lib/pages/settings_chat/settings_chat_view.dart @@ -6,9 +6,7 @@ import 'package:go_router/go_router.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/setting_keys.dart'; import 'package:fluffychat/utils/platform_infos.dart'; -import 'package:fluffychat/utils/voip/callkeep_manager.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; -import 'package:fluffychat/widgets/matrix.dart'; import 'package:fluffychat/widgets/settings_switch_list_tile.dart'; import 'settings_chat.dart'; @@ -75,22 +73,12 @@ class SettingsChatView extends StatelessWidget { title: L10n.of(context)!.experimentalVideoCalls, onChanged: (b) { AppConfig.experimentalVoip = b; - Matrix.of(context).createVoipPlugin(); + // Matrix.of(context).createVoipPlugin(); return; }, storeKey: SettingKeys.experimentalVoip, defaultValue: AppConfig.experimentalVoip, ), - if (PlatformInfos.isMobile) - ListTile( - title: Text(L10n.of(context)!.callingPermissions), - onTap: () => - CallKeepManager().checkoutPhoneAccountSetting(context), - trailing: const Padding( - padding: EdgeInsets.all(16.0), - child: Icon(Icons.call), - ), - ), SettingsSwitchListTile.adaptive( title: L10n.of(context)!.separateChatTypes, onChanged: (b) => AppConfig.separateChatTypes = b, diff --git a/lib/pages/voip/calling_page.dart b/lib/pages/voip/calling_page.dart new file mode 100644 index 000000000..60a2d936b --- /dev/null +++ b/lib/pages/voip/calling_page.dart @@ -0,0 +1,810 @@ +/* + * Famedly + * Copyright (C) 2019, 2020, 2021 Famedly GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:flutter_foreground_task/flutter_foreground_task.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:matrix/matrix.dart'; +import 'package:provider/provider.dart'; + +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/utils/app_state.dart'; +import 'package:fluffychat/utils/platform_infos.dart'; +import 'package:fluffychat/utils/voip/voip_plugin.dart'; +import '../../utils/voip/call_session_state.dart'; +import '../../utils/voip/call_state_proxy.dart'; +import '../../utils/voip/group_call_session_state.dart'; +import '../../utils/voip/livekit_group_call_session_state.dart'; +import 'group_call_view/group_call_view.dart'; +import 'p2p_call_view/p2p_view.dart'; +import 'widgets/call_buttons.dart'; +import 'widgets/call_timer.dart'; +import 'widgets/more_options_listtile.dart'; + +enum ChangeAudioMode { input, output } + +class Calling extends StatefulWidget { + final VoipPlugin voipPlugin; + final CallStateProxy proxy; + + /// when a call is not connected we don't have the remote user, so pass this + /// to show the remote user + // final User? remoteUserInCall; + const Calling({ + super.key, + required this.voipPlugin, + required this.proxy, + // this.remoteUserInCall, + }); + + @override + MyCallingPage createState() => MyCallingPage(); +} + +class MyCallingPage extends State { + CallStateProxy get proxy => widget.proxy; + Room get room => proxy.room; + + String get displayName => proxy.displayName ?? ''; + + MediaStream? get localStream { + if (proxy.localUserMediaStream != null) { + return proxy.localUserMediaStream!.stream!; + } + return null; + } + + bool get isMicrophoneMuted => proxy.isMicrophoneMuted; + bool get isLocalVideoMuted => proxy.isLocalVideoMuted; + bool get isScreensharingEnabled => proxy.isScreensharingEnabled; + bool get isRemoteOnHold => proxy.remoteOnHold; + bool get isLocalOnHold => proxy.localHold; + bool get voiceonly => proxy.voiceonly; + bool get answering => proxy.answering; + bool get connecting => proxy.connecting; + bool get connected => proxy.connected; + bool get ended => proxy.ended; + bool get callOnHold => proxy.callOnHold; + bool get isGroupCall => + (proxy is GroupCallSessionState || proxy is LiveKitGroupCallSessionState); + bool get showMicMuteButton => connected; + bool get showScreenSharingButton => connected; + bool get showHoldButton => connected && !isGroupCall; + bool get showEstablishingConnection => connecting; + WrappedMediaStream get screenSharing => screenSharingStreams.elementAt(0); + WrappedMediaStream? get primaryStream => proxy.primaryStream; + + // TODO(td): remove this when ios gets callkeepv3 + bool get showAnswerButton => + (!connected && !connecting && !ended) && + !proxy.isOutgoing && + !isGroupCall && + (kIsWeb || !Platform.isAndroid); + + bool get showVideoMuteButton => connected; + bool get showFlipCameraButton => + !kIsWeb && + PlatformInfos.isMobile && + proxy.localUserMediaStream?.videoMuted == false; + + List get screenSharingStreams => + (proxy.screenSharingStreams); + + List get userMediaStreams { + if (isGroupCall) { + return (proxy.userMediaStreams); + } + final streams = [ + ...proxy.screenSharingStreams, + ...proxy.userMediaStreams, + ]; + streams.removeWhere((s) => s.stream?.id == proxy.primaryStream?.stream?.id); + return streams; + } + + String get title { + if (isGroupCall) { + return 'Group call'; + } + return '${voiceonly ? L10n.of(context)!.audioCall : L10n.of(context)!.videoCall} (${proxy.callState})'; + } + + String get heldTitle { + var heldTitle = ''; + if (proxy.localHold) { + heldTitle = '${proxy.displayName ?? ''} ${L10n.of(context)!.heldTheCall}'; + } else if (proxy.remoteOnHold) { + heldTitle = '${L10n.of(context)!.you} ${L10n.of(context)!.heldTheCall}'; + } + return heldTitle; + } + + VoipPlugin get voipPlugin => widget.voipPlugin; + + @override + void initState() { + super.initState(); + // Do not rely on this initState to be called only once, user can close this + // page and reopen from call banner anytime. + ServicesBinding.instance.addPostFrameCallback((timeStamp) { + Provider.of(context, listen: false) + .removeGlobalBanner(); // ideally was a call banner + }); + SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); + proxy.onUpdateViewCallback(_handleCallState); + } + + StreamSubscription? proximitySubscription; + + @override + void dispose() { + SystemChrome.setPreferredOrientations([]); + super.dispose(); + } + + void _handleCallState() { + if (mounted) { + setState(() {}); + } + } + + Future handleAnswerButtonClick() async { + try { + await proxy.answer(); + } catch (e) { + Logs().e('answer failed?', e); + } + } + + Future handleHangupButtonClick() async { + try { + await proxy.hangup(); + } catch (e) { + Logs().e('hangup failed?', e); + } + } + + Future handleMicMuteButtonClick() async { + await proxy.setMicrophoneMuted(!isMicrophoneMuted); + } + + Future handleScreenSharingButtonClick() async { + if (!kIsWeb && Platform.isAndroid) { + if (!proxy.isScreensharingEnabled) { + FlutterForegroundTask.init( + androidNotificationOptions: AndroidNotificationOptions( + channelId: 'notification_channel_id', + channelName: 'Foreground Notification', + channelDescription: + 'This notification appears when the foreground service is running.', + ), + iosNotificationOptions: const IOSNotificationOptions(), + foregroundTaskOptions: const ForegroundTaskOptions(), + ); + await FlutterForegroundTask.startService( + notificationTitle: 'Screen sharing', + notificationText: 'You are sharing your screen in famedly', + ); + } else { + await FlutterForegroundTask.stopService(); + } + } + + await proxy.setScreensharingEnabled(!isScreensharingEnabled); + } + + Future handleHoldButtonClick() async { + await proxy.setRemoteOnHold(!isRemoteOnHold); + } + + Future handleVideoMuteButtonClick() async { + await proxy.setLocalVideoMuted(!isLocalVideoMuted); + } + + Future handleFlipCameraButtonClick() async { + if (proxy.localUserMediaStream != null) { + await Helper.switchCamera( + proxy.localUserMediaStream!.stream!.getVideoTracks()[0], + ); + } + setState(() {}); + } + + bool speakerPhoneTurnedOn = false; + void handleturnOnSpeakerPhoneClicked() async { + if (proxy.localUserMediaStream != null) { + await Helper.setSpeakerphoneOn(!speakerPhoneTurnedOn); + setState(() { + speakerPhoneTurnedOn = !speakerPhoneTurnedOn; + }); + } + } + + void changeAudioStuff(ChangeAudioMode mode) async { + if (proxy.localUserMediaStream != null) { + final devices = mode == ChangeAudioMode.input + ? await Helper.enumerateDevices('audioinput') + : await Helper.audiooutputs; + await showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(8.0), + topRight: Radius.circular(8.0), + ), + ), + builder: (context) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: devices + .map( + (device) => ListTile( + title: Text( + device.label, + style: Theme.of(context).textTheme.bodyLarge!, + ), + onTap: () async { + Logs().d('setting audio $mode to ${device.label}'); + if (mode == ChangeAudioMode.input) { + await Helper.selectAudioInput(device.deviceId); + } else { + await Helper.selectAudioOutput(device.deviceId); + } + + GoRouter.of(context).pop(); + }, + ), + ) + .toList(), + ), + ); + }, + ); + } + setState(() {}); + } + + void handleMoreButtonClicked() async { + await showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(8.0), + topRight: Radius.circular(8.0), + ), + ), + builder: (context) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showScreenSharingButton && + !FluffyThemes.isColumnMode(context)) + MoreOptionsListTile( + icon: Icons.screen_share, + title: isScreensharingEnabled + ? L10n.of(context)!.stopScreenShare + : L10n.of(context)!.startScreenShare, + onPressed: handleScreenSharingButtonClick, + shouldPopOnPress: true, + ), + if (proxy is CallSessionState) + MoreOptionsListTile( + icon: Icons.pause, + title: isRemoteOnHold + ? L10n.of(context)!.unholdCall + : L10n.of(context)!.holdCall, + onPressed: handleHoldButtonClick, + shouldPopOnPress: true, + ), + if (showFlipCameraButton && + MediaQuery.of(context).size.width < 400) + MoreOptionsListTile( + icon: Icons.flip_camera_android, + title: L10n.of(context)!.flipCamera, + onPressed: handleFlipCameraButtonClick, + ), + if (!kIsWeb) + MoreOptionsListTile( + icon: Icons.speaker, + title: L10n.of(context)!.audioOutput, + onPressed: () async => + changeAudioStuff(ChangeAudioMode.output), + ), + if (!kIsWeb) + MoreOptionsListTile( + icon: Icons.mic, + title: L10n.of(context)!.audioInput, + onPressed: () async => + changeAudioStuff(ChangeAudioMode.input), + ), + ], + ), + ); + }, + ); + } + + bool miniHangupButtonLoading = false; + + /// PIP buttons + /// New buttons will also need to go to more list manually for now if no space available. + List _buildActionButtons() { + return [ + if (connected && + voiceonly && + !kIsWeb && + PlatformInfos.isMobile && + !FluffyThemes.isColumnMode(context)) + CallButton( + onPressed: handleturnOnSpeakerPhoneClicked, + selected: speakerPhoneTurnedOn, + selectedIcon: Icons.speaker, + unSelectedIcon: Icons.speaker, + extendedView: expandedMainView, + ), + if (showMicMuteButton) + CallButton( + onPressed: handleMicMuteButtonClick, + selected: isMicrophoneMuted, + selectedIcon: Icons.mic_off, + unSelectedIcon: Icons.mic, + extendedView: expandedMainView, + ), + if (showFlipCameraButton && MediaQuery.of(context).size.width > 400) + CallButton( + onPressed: handleFlipCameraButtonClick, + selected: false, + selectedIcon: Icons.flip_camera_android, + unSelectedIcon: Icons.flip_camera_android, + extendedView: expandedMainView, + ), + if (showVideoMuteButton) + CallButton( + onPressed: handleVideoMuteButtonClick, + selected: isLocalVideoMuted, + selectedIcon: Icons.videocam_off, + unSelectedIcon: Icons.videocam, + extendedView: expandedMainView, + ), + if (showScreenSharingButton && FluffyThemes.isColumnMode(context)) + CallButton( + onPressed: handleScreenSharingButtonClick, + selected: isScreensharingEnabled, + selectedIcon: Icons.stop_screen_share, + unSelectedIcon: Icons.screen_share, + extendedView: expandedMainView, + ), + if (connected && + (!FluffyThemes.isColumnMode(context) || + !isGroupCall)) // show for p2p calls because hold, audio change buttons + CallButton( + onPressed: handleMoreButtonClicked, + selected: false, + selectedIcon: Icons.more_vert, + unSelectedIcon: Icons.more_vert, + extendedView: expandedMainView, + doLoadingAnimation: false, + ), + if (expandedMainView) + InkWell( + onTap: () async { + if (miniHangupButtonLoading) return; + setState(() { + miniHangupButtonLoading = true; + }); + await handleHangupButtonClick(); + if (mounted) { + setState(() { + miniHangupButtonLoading = false; + }); + } + }, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + 8.0, + ), + color: Colors.red, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 12, + ), + child: miniHangupButtonLoading + ? const Center( + child: SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + color: Colors.white, + ), + ), + ) + : isGroupCall + ? Center( + child: Text( + L10n.of(context)!.leave, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.bold, + ), + ), + ) + : const Center( + child: Icon( + Icons.call_end, + color: Colors.white, + ), + ), + ), + ), + ), + ]; + } + + bool get expandedMainView => + FluffyThemes.isColumnMode(context) || + isGroupCall || + primaryStream != null && !voiceonly && connected; + bool membersEnabled = true; + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + appBar: !FluffyThemes.isColumnMode(context) + ? AppBar( + automaticallyImplyLeading: false, + title: expandedMainView + ? CallerId( + proxy: proxy, + voipPlugin: voipPlugin, + ) + : Container(), + toolbarHeight: 70, + backgroundColor: Colors.black.withOpacity(0.9), + actions: [ + CallActionButtons( + proxy: proxy, + voipPlugin: voipPlugin, + toggleMembers: () { + setState(() { + membersEnabled = !membersEnabled; + }); + }, + ), + const SizedBox(width: 12), + ], + ) + : const PreferredSize( + preferredSize: Size.fromHeight(24.0), + child: SizedBox(height: 24.0), + ), + body: AnnotatedRegion( + value: const SystemUiOverlayStyle( + systemNavigationBarColor: Colors.black, + statusBarColor: Colors.black, + ), + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + if (isGroupCall) + Expanded( + child: Center( + child: GroupCallView( + call: proxy, + client: voipPlugin.client, + ), + ), + ) + else + Expanded( + child: Center( + child: P2PCallView( + call: proxy as CallSessionState, + voipPlugin: voipPlugin, + ), + ), + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: expandedMainView ? 12.0 : 40, + ), + if (connected) + SizedBox( + height: 56.0, + width: MediaQuery.of(context).size.width, + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (FluffyThemes.isColumnMode(context)) + Flexible( + flex: 1, + child: Align( + alignment: Alignment.centerLeft, + child: CallerId( + proxy: proxy, + voipPlugin: voipPlugin, + ), + ), + ), + Flexible( + flex: 3, + child: Align( + alignment: Alignment.center, + child: ListView.separated( + shrinkWrap: true, + itemBuilder: (context, index) => + _buildActionButtons().toList()[index], + separatorBuilder: (context, index) => + const SizedBox(width: 12), + itemCount: _buildActionButtons().length, + scrollDirection: Axis.horizontal, + ), + ), + ), + if (FluffyThemes.isColumnMode(context)) + Flexible( + flex: 1, + child: Align( + alignment: Alignment.centerRight, + child: CallActionButtons( + proxy: proxy, + voipPlugin: voipPlugin, + toggleMembers: () { + setState(() { + membersEnabled = !membersEnabled; + }); + }, + ), + ), + ), + ], + ), + ), + ), + ), + SizedBox( + height: expandedMainView ? 0 : 16.0, + ), + if (!expandedMainView || (!connected && !isGroupCall)) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CallBigButton( + onPressed: handleHangupButtonClick, + iconData: Icons.call_end, + proxy: proxy, + text: proxy.isOutgoing || proxy.connected + ? L10n.of(context)!.hangup + : L10n.of(context)!.reject, + backgroundColor: Colors.red, + ), + const SizedBox(width: 12), + if (showAnswerButton) + CallBigButton( + onPressed: handleAnswerButtonClick, + iconData: Icons.call, + proxy: proxy, + text: L10n.of(context)!.accept, + backgroundColor: Colors.green, + ), + ], + ), + SizedBox( + height: expandedMainView + ? 12.0 + : connected + ? MediaQuery.of(context).size.height * 0.05 + : MediaQuery.of(context).size.height * 0.1, + ), + ], + ), + ], + ), + ), + ); + } +} + +class CallBigButton extends StatefulWidget { + final Function onPressed; + final CallStateProxy proxy; + final Color backgroundColor; + final IconData iconData; + final String text; + const CallBigButton({ + super.key, + required this.onPressed, + required this.proxy, + required this.backgroundColor, + required this.iconData, + required this.text, + }); + + @override + State createState() => _CallBigButtonState(); +} + +class _CallBigButtonState extends State { + bool loading = false; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () async { + if (loading) return; + setState(() { + loading = true; + }); + await widget.onPressed(); + if (mounted) { + setState(() { + loading = false; + }); + } + }, + child: Container( + decoration: BoxDecoration( + color: widget.backgroundColor, + borderRadius: BorderRadius.circular(8.0), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 16.0, + horizontal: 16.0 + 4, + ), + child: loading + ? const Center( + child: SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator( + color: Colors.white, + ), + ), + ) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(widget.iconData, color: Colors.white), + const SizedBox(width: 12), + Text( + widget.text, + style: const TextStyle(color: Colors.white), + ), + ], + ), + ), + ), + ); + } +} + +class CallerId extends StatelessWidget { + final CallStateProxy proxy; + final VoipPlugin voipPlugin; + const CallerId({super.key, required this.proxy, required this.voipPlugin}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: + EdgeInsets.only(left: FluffyThemes.isColumnMode(context) ? 0 : 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + proxy.room.getLocalizedDisplayname(), + style: Theme.of(context) + .textTheme + .titleMedium! + .copyWith(color: Colors.white), + maxLines: 1, + ), + CallTimer( + voipPlugin: voipPlugin, + appBar: true, + proxy: proxy, + ), + ], + ), + ); + } +} + +class CallActionButtons extends StatelessWidget { + final CallStateProxy proxy; + final VoipPlugin voipPlugin; + final Function toggleMembers; + const CallActionButtons({ + super.key, + required this.proxy, + required this.voipPlugin, + required this.toggleMembers, + }); + + @override + Widget build(BuildContext context) { + final toRoomPath = '/rooms/${proxy.room.id}'; + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + onPressed: () { + GoRouter.of(context).go(toRoomPath); + voipPlugin.createMinimizer(proxy); + }, + icon: const Icon( + Icons.chat, + color: Colors.white, + ), + ), + if ((proxy.screenSharingStreams.isNotEmpty && + proxy is GroupCallSessionState) || + proxy.userMediaStreams.length > 4) + IconButton( + onPressed: () => toggleMembers(), + icon: const Icon( + Icons.people, + color: Colors.white, + ), + ), + IconButton( + onPressed: () { + GoRouter.of(context).go( + Provider.of(context, listen: false) + .bannerClickedOnPath ?? + toRoomPath, + ); + voipPlugin.createMinimizer(proxy); + }, + icon: const Icon( + Icons.minimize, + color: Colors.white, + ), + ), + ], + ); + } +} diff --git a/lib/pages/voip/group_call_onboarding/group_call_onboarding_view.dart b/lib/pages/voip/group_call_onboarding/group_call_onboarding_view.dart new file mode 100644 index 000000000..c47a9af55 --- /dev/null +++ b/lib/pages/voip/group_call_onboarding/group_call_onboarding_view.dart @@ -0,0 +1,367 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:go_router/go_router.dart'; +import 'package:matrix/matrix.dart'; +import 'package:webrtc_interface/webrtc_interface.dart' hide Navigator; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/utils/voip/voip_plugin.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import '../../../utils/voip/group_call_session_state.dart'; +import '../../../utils/voip/livekit_group_call_session_state.dart'; +import '../widgets/stream_view.dart'; + +class GroupCallOnboardingView extends StatefulWidget { + const GroupCallOnboardingView({super.key, required this.roomId}); + final String roomId; + + @override + State createState() => + _GroupCallOnboardingViewState(); +} + +class _GroupCallOnboardingViewState extends State { + MediaStream? _localMediaStream; + WrappedMediaStream? _localStream; + Client get client => Matrix.of(context).client; + + late Room room; + + Future setup() async { + room = client.getRoomById(widget.roomId)!; + + final voipPlugin = Matrix.of(context).voipPlugin; + + _localMediaStream = await voipPlugin.mediaDevices.getUserMedia( + { + 'audio': true, + 'video': { + 'mandatory': { + 'minWidth': '640', + 'minHeight': '480', + 'minFrameRate': '30', + }, + 'facingMode': 'user', + 'optional': [], + }, + }, + ); + + _localStream = WrappedMediaStream( + renderer: voipPlugin.createRenderer(), + stream: _localMediaStream, + participant: + Participant(deviceId: client.deviceID!, userId: client.userID!), + room: room, + client: voipPlugin.client, + purpose: SDPStreamMetadataPurpose.Usermedia, + audioMuted: _localMediaStream!.getAudioTracks().isEmpty, + videoMuted: _localMediaStream!.getVideoTracks().isEmpty, + isWeb: voipPlugin.isWeb, + isGroupCall: true, + ); + + await _localStream!.initialize(); + + setState(() {}); + Logs().i( + 'setting up group calls onboarding page with localStream = $_localStream', + ); + } + + @override + void initState() { + super.initState(); + setup(); + SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); + } + + @override + void dispose() { + SystemChrome.setPreferredOrientations([]); + super.dispose(); + } + + bool cancelButtonLoading = false; + bool joinGroupCallButtonLoading = false; + GroupCallSession? groupCallSession; + + @override + Widget build(BuildContext context) { + if (_localStream == null) { + return const Scaffold( + body: Center( + child: CircularProgressIndicator(), + ), + ); + } + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + leadingWidth: 120, + leading: InkWell( + onTap: _localStream == null + ? null + : () async { + if (cancelButtonLoading) return; + setState(() { + cancelButtonLoading = true; + }); + + await _localStream?.dispose(); + await _localMediaStream?.dispose(); + await groupCallSession?.leave(); + + if (mounted) { + setState(() { + cancelButtonLoading = false; + }); + } + final path = '/rooms/${room.id}'; + GoRouter.of(context).go(path); + }, + child: Row( + children: [ + const SizedBox( + width: 16.0, + ), + if (cancelButtonLoading) + const SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator( + color: Colors.white, + ), + ), + if (!cancelButtonLoading) ...[ + const Icon(Icons.cancel, color: Colors.white), + const SizedBox( + width: 12.0, + ), + Text( + L10n.of(context)!.cancel, + style: const TextStyle(color: Colors.white), + ), + ], + ], + ), + ), + ), + body: _localStream == null + ? const Center( + child: CircularProgressIndicator(), + ) + : Center( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + L10n.of(context)!.joinGroupCall, + style: Theme.of(context) + .textTheme + .bodyLarge! + .copyWith(color: Colors.white70), + ), + const SizedBox( + height: 4.0, + ), + Text( + room.getLocalizedDisplayname(), + style: const TextStyle(fontSize: 28, color: Colors.white), + ), + const SizedBox( + height: 32.0, + ), + Center( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + 8.0, + ), + color: Colors.white.withOpacity(0.08), + ), + height: 200, + width: + min(343, MediaQuery.of(context).size.width * 0.9), + child: _localStream!.videoMuted + ? Center( + child: Text( + L10n.of(context)!.cameraTurnedOff, + style: Theme.of(context) + .textTheme + .bodyLarge! + .copyWith( + color: Colors.white.withOpacity(0.8), + ), + ), + ) + : StreamView(wrappedStream: _localStream!), + ), + ), + const SizedBox(height: 60), + Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + InkWell( + onTap: () { + setState(() { + _localStream! + .setAudioMuted(!_localStream!.audioMuted); + }); + }, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + 8.0, + ), + color: _localStream!.audioMuted + ? Colors.white + : Colors.white10, + ), + child: Padding( + padding: const EdgeInsets.all( + 16.0, + ), + child: Icon( + _localStream!.audioMuted + ? Icons.mic_off + : Icons.mic, + color: !_localStream!.audioMuted + ? Colors.white + : Colors.black, + ), + ), + ), + ), + const SizedBox( + width: 16.0, + ), + InkWell( + onTap: () { + setState(() { + _localStream! + .setVideoMuted(!_localStream!.videoMuted); + }); + }, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + 8.0, + ), + color: _localStream!.videoMuted + ? Colors.white + : Colors.white10, + ), + child: Padding( + padding: const EdgeInsets.all( + 16.0, + ), + child: Icon( + _localStream!.videoMuted + ? Icons.videocam_off + : Icons.videocam, + color: !_localStream!.videoMuted + ? Colors.white + : Colors.black, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 24), + InkWell( + onTap: () async { + if (joinGroupCallButtonLoading) return; + setState(() { + joinGroupCallButtonLoading = true; + }); + groupCallSession = await Matrix.of(context) + .voipPlugin + .voip + .fetchOrCreateGroupCall( + '', //call for whole room + room, + [ + AppConfig.livekitEnabledCalls + ? LiveKitBackend( + livekitServiceUrl: + AppConfig.livekitServiceUrl, + livekitAlias: widget.roomId, + ) + : MeshBackend(), + ], + 'm.call', + 'm.room', + e2ee: AppConfig.enableLivekitE2EE, + ); + final voipPlugin = Matrix.of(context).voipPlugin; + final groupCallProxy = groupCallSession!.isLivekitCall + ? LiveKitGroupCallSessionState( + groupCallSession!, + voipPlugin, + ) + : GroupCallSessionState( + groupCallSession!, + voipPlugin, + ); + VoipPlugin.currentCallProxy = groupCallProxy; + + voipPlugin.connectedTsSinceEpoch = 0; + voipPlugin.onHoldMs = 0; + + await groupCallProxy.enter(_localStream!); + + voipPlugin.setupCallAndOpenCallPage(groupCallProxy); + + if (mounted) { + setState(() { + joinGroupCallButtonLoading = false; + }); + } + }, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + 8.0, + ), + color: Theme.of(context).colorScheme.primary, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 16.0, + horizontal: 32.0, + ), + child: joinGroupCallButtonLoading + ? const SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator( + color: Colors.white, + ), + ) + : Text( + L10n.of(context)!.joinGroupCall, + style: Theme.of(context) + .textTheme + .bodyLarge! + .copyWith(color: Colors.white), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/voip/group_call_view/group_call_view.dart b/lib/pages/voip/group_call_view/group_call_view.dart new file mode 100644 index 000000000..e7cb0f704 --- /dev/null +++ b/lib/pages/voip/group_call_view/group_call_view.dart @@ -0,0 +1,375 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; +import 'package:matrix/matrix.dart'; +import 'package:uuid/uuid.dart'; + +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/widgets/fluffy_chat_app.dart'; +import '../../../utils/voip/call_state_proxy.dart'; +import '../widgets/stream_view.dart'; +import 'widgets/grid_definitions.dart'; +import 'widgets/no_tracks_published_tile.dart'; + +enum TileType { userMedia, screenShare } + +class GroupCallView extends StatefulWidget { + final CallStateProxy call; + final Client client; + + const GroupCallView({ + super.key, + required this.call, + required this.client, + }); + + @override + State createState() => GroupCallViewState(); +} + +class GroupCallViewState extends State { + GroupCallSession get groupCall => widget.call.groupCall!; + + List get participants => groupCall.participants; + + List? userMediaStreams; + List? screenSharingStreams; + + List getStreamOfTileType(TileType type) { + return (type == TileType.userMedia + ? userMediaStreams + : screenSharingStreams) ?? + []; + } + + List selectedLayouts = []; + int currentPage = 0; + final pageViewController = PageController(); + + /// called every build + void createLayoutList(int pcount) { + final layout = selectGridLayout( + GRID_LAYOUTS, + pcount, + MediaQuery.of(context).size.width, + MediaQuery.of(context).size.height, + ); + int pSet; + final pLeft = pcount - layout.maxTiles; + pSet = pLeft > 0 ? layout.maxTiles : pcount; + selectedLayouts.add(SelectedLayout(layout: layout, tilesOnLayout: pSet)); + if (pLeft > 0) { + createLayoutList(pLeft); + } + } + + /// keep in sync with getTiles + int getTilesCount(TileType tileType) { + int tiles = 0; + for (final p in participants) { + final tracksLength = getStreamOfTileType(tileType) + .where((element) => element.participant == p) + .length; + if (tileType == TileType.userMedia) { + tiles += tracksLength > 0 ? tracksLength : 1; + } + } + return tiles; + } + + /// keep in sync with getTilesCount + List getTiles({ + required TileType tileType, + required double height, + double? width, + }) { + final List tiles = []; + for (final p in participants) { + final user = widget.call.room.unsafeGetUserFromMemoryOrFallback(p.userId); + + final List ptiles = []; + for (final stream in getStreamOfTileType(tileType) + .where((element) => element.participant == p)) { + final streamUuid = const Uuid().v4().toString(); + final tile = InkWell( + key: ValueKey(stream.id + streamUuid), + onTap: () => togglePinned(stream), + child: IgnorePointer( + child: SizedBox( + height: height, + width: width, + child: StreamView( + highLight: false, + wrappedStream: stream, + showName: true, + avatarSize: 80, + avatarTextSize: 48, + avatarBorderRadius: 16, + showExtendedName: true, + ), + ), + ), + ); + ptiles.add(tile); + } + + tiles.addAll(ptiles); + Logs().d( + 'Found ${ptiles.length} tiles for ${user.displayName} of $tileType', + ); + // add users who haven't published any tracks and hide missing screen share blanks + if (tileType == TileType.userMedia && ptiles.isEmpty) { + tiles.add( + NoTracksPublishedTile( + client: widget.client, + user: user, + height: height, + ), + ); + Logs().d( + 'Added blank tile for for ${user.displayName} of $tileType', + ); + } + } + Logs().d( + 'Rendering ${tiles.length} tiles of $tileType', + ); + return tiles; + } + + WrappedMediaStream? pinnedStream; + + void togglePinned(WrappedMediaStream stream) { + // ignore pinning when only one tile + + final totalTiles = + getTilesCount(TileType.userMedia) + getTilesCount(TileType.screenShare); + + if (totalTiles <= 1) return; + + setState(() { + pinnedStream = pinnedStream == stream ? null : stream; + }); + Logs() + .e('pinnsed stream set to: ${(pinnedStream?.displayName).toString()}'); + } + + @override + Widget build(BuildContext context) { + final focusedStreamUuid = const Uuid().v4().toString(); + Logs().d('[GroupCallView] rebuilding callgrid children'); + + // groupCall.encryptionKeysMap.forEach((key, value) { + // Logs().i(key.userId.toString()); + // Logs().i(value.values.map((e) => base64Encode(e).toString()).toString()); + // }); + + userMediaStreams = List.from(widget.call.userMediaStreams); + screenSharingStreams = List.from(widget.call.screenSharingStreams); + + // reset the pinned stream + if (!userMediaStreams!.contains(pinnedStream) && + !screenSharingStreams!.contains(pinnedStream)) pinnedStream = null; + + selectedLayouts.clear(); + createLayoutList(getTilesCount(TileType.userMedia)); + + if (currentPage > selectedLayouts.length - 1) { + currentPage = selectedLayouts.length - 1; + Logs().d('[GroupCallView] currentPage set to $currentPage'); + } + + // use global context so no other widget in the tree can affect getting this + final mediaQuery = + MediaQuery.of(FluffyChatApp.appGlobalKey.currentContext!); + final columnMode = + FluffyThemes.isColumnMode(FluffyChatApp.appGlobalKey.currentContext!); + + final availableHeight = mediaQuery.size.height - + (70 + + mediaQuery.padding.top + + mediaQuery.padding.bottom + + 56.0 + + 24 + // bottom and top padding of action buttons bar + 8.0 + // seperator padding + // pagination buttons + (columnMode ? 0 : 40)); + final availableWidth = mediaQuery.size.width - + 24; // acts as left right padding because centered + + if ((screenSharingStreams ?? []).isEmpty && pinnedStream == null) { + return Column( + children: [ + SizedBox( + height: availableHeight, + child: PageView.builder( + physics: const NeverScrollableScrollPhysics(), + itemCount: selectedLayouts.length, + controller: pageViewController, + itemBuilder: (BuildContext context, int pageIndex) { + // Logs().d('pageIndex: $pageIndex'); + // Logs().d('users: ${selectedLayouts[pageIndex].tilesOnLayout}'); + // Logs() + // .d('grid name: ${selectedLayouts[pageIndex].layout.name}'); + // Logs() + // .d('columns: ${selectedLayouts[pageIndex].layout.columns}'); + // Logs().d('rows: ${selectedLayouts[pageIndex].layout.rows}'); + // Logs().d( + // 'maxTiles: ${selectedLayouts[pageIndex].layout.maxTiles}', + // ); + + int startIndex = 0; + for (final page in selectedLayouts.sublist(0, pageIndex)) { + startIndex += page.tilesOnLayout; + } + Logs().d('startIndex: $startIndex'); + + final tiles = getTiles( + tileType: TileType.userMedia, + height: + availableHeight / selectedLayouts[pageIndex].layout.rows, + ); + + final tilesForPage = tiles.sublist( + startIndex, + startIndex + selectedLayouts[pageIndex].tilesOnLayout, + ); + + Logs().d('tilesForPage length: ${tilesForPage.toString()}'); + + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: AlignedGridView.count( + mainAxisSpacing: 3.0, + crossAxisSpacing: 4.0, + crossAxisCount: selectedLayouts[pageIndex].layout.columns, + itemCount: selectedLayouts[pageIndex].tilesOnLayout, + itemBuilder: (context, index) { + return tilesForPage[index]; + }, + ), + ), + ); + }, + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: currentPage > 0 + ? () { + setState(() { + currentPage--; + pageViewController.jumpToPage(currentPage); + }); + } + : null, + ), + Text('${currentPage + 1}/${selectedLayouts.length}'), + IconButton( + icon: const Icon(Icons.arrow_forward), + onPressed: currentPage < selectedLayouts.length - 1 + ? () { + setState(() { + currentPage++; + pageViewController.jumpToPage(currentPage); + }); + } + : null, + ), + ], + ), + ], + ); + } else { + final focusedStream = pinnedStream ?? + screenSharingStreams!.firstWhere((element) => element.stream != null); + + final List miniTiles = [ + ...getTiles( + tileType: TileType.screenShare, + height: 120.0, + width: columnMode ? 220.0 - 8.0 : 120.0, + ), + ...getTiles( + tileType: TileType.userMedia, + height: 120.0, + width: columnMode ? 220.0 - 8.0 : 120.0, + ), + ] + .where( + (element) => + element.key != ValueKey(focusedStream.id + focusedStreamUuid), + ) + .toList(); + + final List tilesWhileScreenSharing = [ + InkWell( + key: ValueKey(focusedStream.id + focusedStreamUuid), + onTap: () => togglePinned(focusedStream), + child: IgnorePointer( + child: SizedBox( + height: columnMode ? availableHeight : availableHeight - 120.0, + width: columnMode ? availableWidth - 220.0 : availableWidth, + child: StreamView( + highLight: false, + showName: true, + avatarSize: 80, + avatarTextSize: 48, + avatarBorderRadius: 16, + showExtendedName: true, + wrappedStream: focusedStream, + ), + ), + ), + ), + const SizedBox( + height: 2.0, + width: 2.0, + ), + SizedBox( + height: columnMode + ? null + : 120.0, // allow complete height to scroll in columnMode + width: columnMode + ? 220.0 - 8.0 + : null, // allow complete width to scroll in mobileMode + child: ListView.separated( + scrollDirection: columnMode ? Axis.vertical : Axis.horizontal, + itemCount: miniTiles.length, + separatorBuilder: (context, index) => const SizedBox( + height: 2.0, + width: 2.0, + ), + itemBuilder: (context, index) => miniTiles[index], + ), + ), + ]; + return SizedBox( + // height: availableHeight, + width: availableWidth, + child: columnMode + ? Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: tilesWhileScreenSharing, + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: tilesWhileScreenSharing.reversed.toList(), + ), + ); + } + } +} + +class SelectedLayout { + GridLayoutDefinition layout; + int tilesOnLayout; + + SelectedLayout({required this.layout, required this.tilesOnLayout}); +} diff --git a/lib/pages/voip/group_call_view/widgets/grid_definitions.dart b/lib/pages/voip/group_call_view/widgets/grid_definitions.dart new file mode 100644 index 000000000..d4e01cf7f --- /dev/null +++ b/lib/pages/voip/group_call_view/widgets/grid_definitions.dart @@ -0,0 +1,131 @@ +import 'package:matrix/matrix.dart'; + +class GridLayoutDefinition { + late String name; + late int columns; + late int rows; + late int minTiles; + late int maxTiles; + late int minWidth; + late int minHeight; + + GridLayoutDefinition({ + required this.name, + required this.columns, + required this.rows, + required this.minTiles, + required this.maxTiles, + required this.minWidth, + required this.minHeight, + }); +} + +// ignore: non_constant_identifier_names +List GRID_LAYOUTS = [ + GridLayoutDefinition( + name: '1x1', + columns: 1, + rows: 1, + minTiles: 1, + maxTiles: 1, + minWidth: 0, + minHeight: 0, + ), + GridLayoutDefinition( + name: '1x2', + columns: 1, + rows: 2, + minTiles: 2, + maxTiles: 2, + minWidth: 0, + minHeight: 0, + ), + GridLayoutDefinition( + name: '2x1', + columns: 2, + rows: 1, + minTiles: 2, + maxTiles: 2, + minWidth: 560, + minHeight: 0, + ), + GridLayoutDefinition( + name: '2x2', + columns: 2, + rows: 2, + minTiles: 3, + maxTiles: 4, + minWidth: 560, + minHeight: 0, + ), + GridLayoutDefinition( + name: '3x3', + columns: 3, + rows: 3, + minTiles: 5, + maxTiles: 9, + minWidth: 700, + minHeight: 0, + ), + GridLayoutDefinition( + name: '4x4', + columns: 4, + rows: 4, + minTiles: 10, + maxTiles: 16, + minWidth: 960, + minHeight: 0, + ), + GridLayoutDefinition( + name: '5x5', + columns: 5, + rows: 5, + minTiles: 17, + maxTiles: 25, + minWidth: 1100, + minHeight: 0, + ), +]; + +GridLayoutDefinition selectGridLayout( + List layouts, + int participantCount, + double width, + double height, +) { + int currentLayoutIndex = 0; + var layout = layouts.firstWhere( + (layout_) { + currentLayoutIndex = layouts.indexOf(layout_); + final isBiggerLayoutAvailable = layouts.indexWhere( + (l) => + layouts.indexOf(l) > currentLayoutIndex && + l.maxTiles == layout_.maxTiles, + ) != + -1; + return layout_.maxTiles >= participantCount && !isBiggerLayoutAvailable; + }, + orElse: () { + final lastLayout = layouts.last; + // ignore: avoid_print + Logs().d( + 'No layout found for: participantCount: $participantCount, width/height: $width/$height fallback to biggest available layout (${lastLayout.name}).', + ); + return lastLayout; + }, + ); + + if (width < layout.minWidth || height < layout.minHeight) { + if (currentLayoutIndex > 0) { + final smallerLayout = layouts[currentLayoutIndex - 1]; + layout = selectGridLayout( + layouts.sublist(0, currentLayoutIndex), + smallerLayout.maxTiles, + width, + height, + ); + } + } + + return layout; +} diff --git a/lib/pages/voip/group_call_view/widgets/lk_participant_stats.dart b/lib/pages/voip/group_call_view/widgets/lk_participant_stats.dart new file mode 100644 index 000000000..d43389cfd --- /dev/null +++ b/lib/pages/voip/group_call_view/widgets/lk_participant_stats.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; + +import 'package:livekit_client/livekit_client.dart'; + +enum StatsType { + kUnknown, + kLocalAudioSender, + kLocalVideoSender, + kRemoteAudioReceiver, + kRemoteVideoReceiver, +} + +class ParticipantStatsWidget extends StatefulWidget { + const ParticipantStatsWidget({super.key, required this.participant}); + final Participant participant; + @override + State createState() => _ParticipantStatsWidgetState(); +} + +class _ParticipantStatsWidgetState extends State { + List> listeners = []; + StatsType statsType = StatsType.kUnknown; + Map stats = {}; + + void _setUpListener(Track track) { + final listener = track.createListener(); + listeners.add(listener); + if (track is LocalVideoTrack) { + statsType = StatsType.kLocalVideoSender; + listener.on((event) { + setState(() { + stats['video tx'] = 'total sent ${event.currentBitrate.toInt()} kpbs'; + event.stats.forEach((key, value) { + stats['layer-$key'] = + '${value.frameWidth ?? 0}x${value.frameHeight ?? 0} ${value.framesPerSecond?.toDouble() ?? 0} fps, ${event.bitrateForLayers[key] ?? 0} kbps'; + }); + final firstStats = + event.stats['f'] ?? event.stats['h'] ?? event.stats['q']; + if (firstStats != null) { + stats['encoder'] = firstStats.encoderImplementation ?? ''; + stats['video codec'] = + '${firstStats.mimeType}, ${firstStats.clockRate}hz, pt: ${firstStats.payloadType}'; + stats['qualityLimitationReason'] = + firstStats.qualityLimitationReason ?? ''; + } + }); + }); + } else if (track is RemoteVideoTrack) { + statsType = StatsType.kRemoteVideoReceiver; + listener.on((event) { + setState(() { + stats['video rx'] = '${event.currentBitrate.toInt()} kpbs'; + stats['video codec'] = + '${event.stats.mimeType}, ${event.stats.clockRate}hz, pt: ${event.stats.payloadType}'; + stats['video size'] = + '${event.stats.frameWidth}x${event.stats.frameHeight} ${event.stats.framesPerSecond?.toDouble()}fps'; + stats['video jitter'] = '${event.stats.jitter} s'; + stats['video decoder'] = '${event.stats.decoderImplementation}'; + //stats['video packets lost'] = '${event.stats.packetsLost}'; + //stats['video packets received'] = '${event.stats.packetsReceived}'; + stats['video frames received'] = '${event.stats.framesReceived}'; + stats['video frames decoded'] = '${event.stats.framesDecoded}'; + stats['video frames dropped'] = '${event.stats.framesDropped}'; + }); + }); + } else if (track is LocalAudioTrack) { + statsType = StatsType.kLocalAudioSender; + listener.on((event) { + setState(() { + stats['audio tx'] = '${event.currentBitrate.toInt()} kpbs'; + stats['audio codec'] = + '${event.stats.mimeType}, ${event.stats.clockRate}hz, ${event.stats.channels}ch, pt: ${event.stats.payloadType}'; + }); + }); + } else if (track is RemoteAudioTrack) { + statsType = StatsType.kRemoteAudioReceiver; + listener.on((event) { + setState(() { + stats['audio rx'] = '${event.currentBitrate.toInt()} kpbs'; + stats['audio codec'] = + '${event.stats.mimeType}, ${event.stats.clockRate}hz, ${event.stats.channels}ch, pt: ${event.stats.payloadType}'; + stats['audio jitter'] = '${event.stats.jitter} s'; + //stats['audio concealed samples'] = + // '${event.stats.concealedSamples} / ${event.stats.concealmentEvents}'; + stats['audio packets lost'] = '${event.stats.packetsLost}'; + stats['audio packets received'] = '${event.stats.packetsReceived}'; + }); + }); + } + } + + _onParticipantChanged() { + for (final element in listeners) { + element.dispose(); + } + listeners.clear(); + for (final track in widget.participant.trackPublications.values) { + if (track.track != null) { + _setUpListener(track.track!); + } + } + } + + @override + void initState() { + super.initState(); + widget.participant.addListener(_onParticipantChanged); + // trigger initial change + _onParticipantChanged(); + } + + @override + void deactivate() { + for (final element in listeners) { + element.dispose(); + } + widget.participant.removeListener(_onParticipantChanged); + super.deactivate(); + } + + num sendBitrate = 0; + + @override + Widget build(BuildContext context) { + return Container( + color: Colors.black.withOpacity(0.3), + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 8, + ), + child: Column( + children: + stats.entries.map((e) => Text('${e.key}: ${e.value}')).toList(), + ), + ); + } +} diff --git a/lib/pages/voip/group_call_view/widgets/no_tracks_published_tile.dart b/lib/pages/voip/group_call_view/widgets/no_tracks_published_tile.dart new file mode 100644 index 000000000..8866c5197 --- /dev/null +++ b/lib/pages/voip/group_call_view/widgets/no_tracks_published_tile.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +import 'package:matrix/matrix.dart'; + +import '../../../../widgets/avatar.dart'; + +class NoTracksPublishedTile extends StatelessWidget { + final double? height; + final User user; + final Client client; + + const NoTracksPublishedTile({ + super.key, + this.height, + required this.user, + required this.client, + }); + + @override + Widget build(BuildContext context) { + return Container( + height: height, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.18), + borderRadius: BorderRadius.circular( + 4.0, + ), + ), + child: Stack( + alignment: Alignment.center, + children: [ + Avatar( + mxContent: user.avatarUrl, + name: user.displayName ?? '', + size: 80, + fontSize: 48, + client: client, + ), + Positioned( + left: 4.0, + bottom: 4.0, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + const Icon( + Icons.mic_off, + size: 15, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + user.displayName.toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/voip/p2p_call_view/p2p_view.dart b/lib/pages/voip/p2p_call_view/p2p_view.dart new file mode 100644 index 000000000..b072695ab --- /dev/null +++ b/lib/pages/voip/p2p_call_view/p2p_view.dart @@ -0,0 +1,241 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/utils/voip/voip_plugin.dart'; +import 'package:fluffychat/widgets/fluffy_chat_app.dart'; +import '../../../utils/voip/call_session_state.dart'; +import '../../../widgets/avatar.dart'; +import '../widgets/call_timer.dart'; +import '../widgets/stream_view.dart'; + +/// stack of remote view and user view +class P2PCallView extends StatefulWidget { + final CallSessionState call; + final VoipPlugin voipPlugin; + const P2PCallView({ + super.key, + required this.call, + required this.voipPlugin, + }); + + @override + State createState() => _P2PCallViewState(); +} + +class _P2PCallViewState extends State { + CallSessionState get call => widget.call; + VoipPlugin get voipPlugin => widget.voipPlugin; + + WrappedMediaStream? get primaryStream => call.primaryStream; + List get remoteStreams => + call.userMediaStreams.where((element) => !element.isLocal()).toList(); + List get screenSharingStreams => + call.screenSharingStreams; + + List secondaryViews() { + final views = []; + + final List userMediaStreamsCopy = + List.from(call.userMediaStreams); + + if (call.connected && call.voiceonly) { + userMediaStreamsCopy.removeWhere( + (element) => element.participant == voipPlugin.voip.localParticipant, + ); + } + userMediaStreamsCopy.removeWhere( + (element) => element.stream!.id == primaryStream!.stream!.id, + ); + + for (final stream in userMediaStreamsCopy) { + views.add( + Material( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.0), + ), + elevation: 20, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + 8.0, + ), + color: Colors.black, + ), + child: SizedBox( + width: 96, + height: 128, + child: stream.participant == voipPlugin.voip.localParticipant && + stream.videoMuted + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + Icons.videocam_off, + color: Colors.white.withOpacity(0.3), + ), + const SizedBox(height: 11), + Text( + 'Camera turned off', + style: + Theme.of(context).textTheme.bodySmall!.copyWith( + color: Colors.white.withOpacity(0.8), + ), + textAlign: TextAlign.center, + ), + ], + ), + ) + : StreamView( + wrappedStream: stream, + isVoiceOnly: call.voiceonly, + ), + ), + ), + ), + ); + views.add(const SizedBox(height: 8)); + } + + return views; + } + + bool get expandedMainView => + primaryStream != null && !call.voiceonly && call.connected; + + @override + Widget build(BuildContext context) { + // use global context so no other widget in the tree can affect getting this + final mediaQuery = + MediaQuery.of(FluffyChatApp.appGlobalKey.currentContext!); + final availableHeight = mediaQuery.size.height - + (70 + + mediaQuery.padding.top + + mediaQuery.padding.bottom + + 56.0 + + 24 // bottom and top padding of action buttons bar + ); + + return call.callOnHold + ? Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.pause, + size: 48.0, + color: Colors.white, + ), + Text( + call.localHold + ? L10n.of(context)!.userHeldTheCall( + call.displayName ?? L10n.of(context)!.unknownUser, + ) + : L10n.of(context)!.youHeldTheCall, + style: const TextStyle( + color: Colors.white, + fontSize: 24.0, + ), + ), + ], + ) + : Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (!call.connected) + Text( + !call.isOutgoing && !call.connecting + ? call.type == VoipType.kVoice + ? L10n.of(context)!.audioCall + : L10n.of(context)!.videoCall + : call.isOutgoing && !call.connecting + ? L10n.of(context)!.youAreCalling + : L10n.of(context)!.connecting, + style: Theme.of(context) + .textTheme + .bodyLarge! + .copyWith(color: Colors.white.withOpacity(0.8)), + ), + if (call.connected && call.voiceonly) + CallTimer( + voipPlugin: voipPlugin, + appBar: false, + proxy: call, + ), + SizedBox(height: !expandedMainView ? 32 : 0), + if (!call.connected) + // outgoing calls initial page + Avatar( + mxContent: call.call.remoteUser!.avatarUrl, + name: call.call.remoteUser!.displayName.toString(), + size: min(MediaQuery.of(context).size.height * 0.2, 160), + fontSize: 64, + client: call.room.client, + ) + else + Stack( + children: [ + Center( + child: SizedBox( + height: expandedMainView ? availableHeight : null, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Center( + child: StreamView( + wrappedStream: primaryStream!, + avatarSize: min( + MediaQuery.of(context).size.height * 0.2, + 160, + ), + avatarTextSize: 64, + avatarBorderRadius: 24, + isVoiceOnly: call.voiceonly, + ), + ), + ), + ), + ), + call.userMediaStreams.isEmpty || + (call.voiceonly && primaryStream!.videoMuted) + ? Container() + : Positioned( + right: 8, + bottom: 0, + child: Column(children: secondaryViews()), + ), + ], + ), + SizedBox(height: expandedMainView ? 0 : 32), + if (screenSharingStreams.isEmpty && + (call.voiceonly || !call.connected)) ...[ + // show name and org only if voice only and no screen sharing + Text( + !call.connected + ? call.call.remoteUser!.displayName.toString() + : primaryStream!.displayName.toString(), + style: Theme.of(context) + .textTheme + .headlineSmall! + .copyWith(fontSize: 24, color: Colors.white), + ), + const SizedBox(height: 6), + Text( + !call.connected + ? call.call.remoteUser!.id.domain.toString() + : primaryStream!.participant.userId.domain.toString(), + style: Theme.of(context) + .textTheme + .bodyLarge! + .copyWith(color: Colors.white), + ), + ], + ], + ); + } +} diff --git a/lib/pages/voip/widgets/call_banner.dart b/lib/pages/voip/widgets/call_banner.dart new file mode 100644 index 000000000..70380f472 --- /dev/null +++ b/lib/pages/voip/widgets/call_banner.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:provider/provider.dart'; + +import 'package:fluffychat/utils/app_state.dart'; +import 'package:fluffychat/utils/voip/voip_plugin.dart'; +import 'package:fluffychat/widgets/fluffy_chat_app.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import '../../../utils/voip/call_state_proxy.dart'; +import '../../../utils/voip/group_call_session_state.dart'; +import '../../../widgets/avatar.dart'; +import 'call_timer.dart'; + +class CallBanner extends StatelessWidget { + final CallStateProxy proxy; + final VoipPlugin? voipPlugin; + const CallBanner({super.key, required this.proxy, this.voipPlugin}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isBright = theme.brightness == Brightness.light; + return ListTile( + onTap: () { + Provider.of(context, listen: false).bannerClickedOnPath = + FluffyChatApp.router.routerDelegate.currentConfiguration.uri + .toString(); + + FluffyChatApp.router.go('/rooms/${proxy.room.id}/call'); + }, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(12.0), + ), + ), + tileColor: Colors.green, + contentPadding: const EdgeInsets.all(0), + minVerticalPadding: 0, + horizontalTitleGap: 0, + leading: Padding( + padding: const EdgeInsets.only( + left: 8.0, + right: 12.0, + ), + child: Avatar( + mxContent: proxy.room.avatar, + size: 52, + name: proxy.room.getLocalizedDisplayname(), + client: proxy.room.client, + ), + ), + title: Text( + proxy.room.getLocalizedDisplayname(), + style: Theme.of(context).textTheme.labelLarge!.copyWith(fontSize: 16), + ), + subtitle: CallTimer( + appendText: proxy is GroupCallSessionState + ? L10n.of(context)!.groupCall + : proxy.type == VoipType.kVoice + ? L10n.of(context)!.audioCall + : L10n.of(context)!.videoCall, + proxy: proxy, + appBar: true, + voipPlugin: voipPlugin ?? Matrix.of(context).voipPlugin, + overrideTextStyle: Theme.of(context).textTheme.bodySmall!.copyWith( + color: isBright + ? Colors.white.withOpacity(0.8) + : Colors.black.withOpacity(0.72), + ), + ), + trailing: const Padding( + padding: EdgeInsets.only(right: 16.0), + child: Icon(Icons.arrow_forward_ios, size: 16), + ), + ); + } +} diff --git a/lib/pages/voip/widgets/call_buttons.dart b/lib/pages/voip/widgets/call_buttons.dart new file mode 100644 index 000000000..68369722e --- /dev/null +++ b/lib/pages/voip/widgets/call_buttons.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +class CallButton extends StatefulWidget { + final IconData selectedIcon; + final IconData unSelectedIcon; + final bool selected; + final Function onPressed; + final bool extendedView; + final bool doLoadingAnimation; + const CallButton({ + super.key, + required this.onPressed, + required this.selectedIcon, + required this.selected, + required this.unSelectedIcon, + this.extendedView = false, + this.doLoadingAnimation = true, + }); + + @override + State createState() => _CallButtonState(); +} + +class _CallButtonState extends State { + bool loading = false; + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () async { + if (loading) return; + setState(() { + loading = true; + }); + await widget.onPressed(); + setState(() { + loading = false; + }); + }, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8.0), + color: widget.selected + ? Colors.white + : widget.extendedView + ? null + : Colors.white10, + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Center( + child: loading && widget.doLoadingAnimation + ? SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator( + color: widget.selected ? Colors.black : Colors.white, + ), + ) + : Icon( + widget.selected + ? widget.selectedIcon + : widget.unSelectedIcon, + color: widget.selected ? Colors.black : Colors.white, + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/voip/widgets/call_overlay.dart b/lib/pages/voip/widgets/call_overlay.dart new file mode 100644 index 000000000..00df2ddc7 --- /dev/null +++ b/lib/pages/voip/widgets/call_overlay.dart @@ -0,0 +1,380 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/matrix.dart'; +import 'package:provider/provider.dart'; + +import 'package:fluffychat/utils/app_state.dart'; +import 'package:fluffychat/utils/voip/voip_plugin.dart'; +import 'package:fluffychat/widgets/fluffy_chat_app.dart'; +import '../../../utils/voip/call_state_proxy.dart'; +import '../../../utils/voip/group_call_session_state.dart'; +import '../../../utils/voip/livekit_group_call_session_state.dart'; +import '../../../widgets/avatar.dart'; +import 'call_timer.dart'; +import 'stream_view.dart'; + +class CallOverlay extends StatefulWidget { + final CallStateProxy callStateProxy; + final VoipPlugin voipPlugin; + const CallOverlay({ + super.key, + required this.callStateProxy, + required this.voipPlugin, + }); + + @override + State createState() => _CallOverlayState(); +} + +class _CallOverlayState extends State { + List screenSharingStreams = []; + List userMediaStreams = []; + WrappedMediaStream? get primaryScreenShare => screenSharingStreams.first; + + late bool isGroupCall; + + BuildContext get globalContext => FluffyChatApp.appGlobalKey.currentContext!; + void setupCall() { + isGroupCall = widget.callStateProxy is GroupCallSessionState || + widget.callStateProxy is LiveKitGroupCallSessionState; + + p2pCallConnecting = !widget.callStateProxy.connected && + !widget.callStateProxy.ended && + !isGroupCall; + + userMediaStreams = List.from(widget.callStateProxy.userMediaStreams); + + screenSharingStreams = + List.from(widget.callStateProxy.screenSharingStreams); + + widget.callStateProxy.onUpdateViewCallback(() { + if (mounted) setState(() {}); + }); + } + + bool? p2pCallConnecting; + bool hovering = false; + + void toCallAndRemovePopup() { + Provider.of(globalContext, listen: false).bannerClickedOnPath = + FluffyChatApp.router.routerDelegate.currentConfiguration.uri.toString(); + + FluffyChatApp.router.go('/rooms/${widget.callStateProxy.room.id}/call'); + + widget.voipPlugin.removeCallPopupOverlay(); + } + + Future handleAnswerButtonClick() async { + try { + await widget.callStateProxy.answer(); + } catch (e) { + Logs().e('answer failed?', e); + } + toCallAndRemovePopup(); + } + + Future handleHangupButtonClick() async { + try { + await widget.callStateProxy.hangup(); + } catch (e) { + Logs().e('hangup failed?', e); + } + } + + @override + Widget build(BuildContext context) { + /// why call this per build and not initState? widget can close/open multiple + /// times so initState is called multiple times, having it in the build to avoid confusion + /// state is stored in a global variable + setupCall(); + + return SafeArea( + child: p2pCallConnecting ?? true + ? Align( + heightFactor: 1.0, + alignment: Alignment.center, + child: SizedBox( + height: 360, + width: 360, + child: Material( + color: Colors.black, + borderRadius: const BorderRadius.all(Radius.circular(20)), + child: Container( + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.16), + border: Border.all(), + borderRadius: const BorderRadius.all(Radius.circular(20)), + ), + child: widget.callStateProxy.call!.remoteUser == null + ? const Center(child: CircularProgressIndicator()) + : Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox( + height: 16.0, + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + ), + child: Row( + children: [ + if (widget.callStateProxy.type == + VoipType.kVoice) + Icon( + Icons.call, + color: Colors.white.withOpacity(0.6), + size: 21, + ) + else + Icon( + Icons.videocam, + color: Colors.white.withOpacity(0.6), + size: 21, + ), + const SizedBox( + width: 12.0, + ), + Text( + widget.voipPlugin.getCallStateSuffix( + widget.callStateProxy, + context, + ), + style: TextStyle( + color: Colors.white.withOpacity(0.6), + ), + ), + const Spacer(), + IconButton( + onPressed: toCallAndRemovePopup, + icon: const Icon( + Icons.maximize, + color: Colors.white, + ), + ), + const SizedBox( + width: 12.0, + ), + ], + ), + ), + const SizedBox( + height: 32.0, + ), + Avatar( + mxContent: widget + .callStateProxy.call!.remoteUser!.avatarUrl, + name: widget.callStateProxy.call!.remoteUser! + .displayName + .toString(), + size: 96, + fontSize: 16, + client: widget.callStateProxy.client, + ), + const SizedBox( + height: 24.0, + ), + Text( + widget.callStateProxy.call!.remoteUser! + .displayName + .toString(), + style: Theme.of(context) + .textTheme + .bodyLarge! + .copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox( + height: 4.0, + ), + Text( + widget + .callStateProxy.call!.remoteUser!.id.domain + .toString(), + ), + const SizedBox( + height: 24.0, + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + InkWell( + onTap: handleHangupButtonClick, + child: Container( + height: 56, + width: 64, + decoration: BoxDecoration( + color: + Theme.of(context).colorScheme.error, + border: Border.all(), + borderRadius: const BorderRadius.all( + Radius.circular(8), + ), + ), + child: const Icon( + Icons.call_end, + color: Colors.red, + ), + ), + ), + const SizedBox( + width: 16.0, + ), + InkWell( + onTap: handleAnswerButtonClick, + child: Container( + height: 56, + width: 64, + decoration: BoxDecoration( + color: Colors.green, + border: Border.all(), + borderRadius: const BorderRadius.all( + Radius.circular(8), + ), + ), + child: Icon( + widget.callStateProxy.type == + VoipType.kVoice + ? Icons.call + : Icons.videocam, + color: Colors.green, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ) + : Align( + heightFactor: 1.0, + alignment: Alignment.bottomRight, + child: Container( + margin: const EdgeInsets.only( + bottom: 74.0, + right: 8.0, + ), + height: 300, + width: 300, + child: Material( + color: Colors.black, + borderRadius: const BorderRadius.all(Radius.circular(12)), + child: Container( + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.16), + border: Border.all(), + borderRadius: const BorderRadius.all(Radius.circular(12)), + ), + child: InkWell( + enableFeedback: false, + onTap: toCallAndRemovePopup, + onHover: (value) { + setState(() { + hovering = value; + }); + }, + child: screenSharingStreams.isEmpty && + userMediaStreams.isEmpty + ? const Center(child: CircularProgressIndicator()) + : Stack( + children: [ + StreamView( + highLight: true, + wrappedStream: screenSharingStreams.isNotEmpty + ? screenSharingStreams.first + : userMediaStreams.first, + avatarSize: 80, + avatarTextSize: 48, + avatarBorderRadius: 16, + ), + if (hovering) + Align( + alignment: Alignment.bottomCenter, + child: Container( + height: 64, + width: 300, + margin: const EdgeInsets.only( + left: 8.0, + right: 8.0, + bottom: 8.0, + ), + decoration: BoxDecoration( + color: Colors.black, + border: Border.all(), + borderRadius: const BorderRadius.all( + Radius.circular(12), + ), + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.start, + crossAxisAlignment: + CrossAxisAlignment.center, + children: [ + const SizedBox( + width: 12.0, + ), + Column( + mainAxisAlignment: + MainAxisAlignment.center, + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + widget.callStateProxy + .displayName ?? + L10n.of(context)!.call, + style: Theme.of(context) + .textTheme + .titleMedium! + .copyWith( + fontWeight: + FontWeight.bold, + ), + ), + const SizedBox( + height: 4.0, + ), + CallTimer( + appBar: false, + proxy: widget.callStateProxy, + voipPlugin: widget.voipPlugin, + overrideTextStyle: + Theme.of(context) + .textTheme + .bodySmall!, + ), + ], + ), + const Spacer(), + IconButton( + onPressed: toCallAndRemovePopup, + icon: const Icon( + Icons.maximize, + color: Colors.white, + ), + ), + const SizedBox( + width: 12.0, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/voip/widgets/call_timer.dart b/lib/pages/voip/widgets/call_timer.dart new file mode 100644 index 000000000..e42f422cf --- /dev/null +++ b/lib/pages/voip/widgets/call_timer.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/utils/format_time_helper.dart'; +import 'package:fluffychat/utils/voip/voip_plugin.dart'; +import '../../../utils/voip/call_state_proxy.dart'; + +class CallTimer extends StatelessWidget { + final CallStateProxy proxy; + final VoipPlugin voipPlugin; + final bool appBar; + final TextStyle? overrideTextStyle; + final String? appendText; + const CallTimer({ + super.key, + required this.voipPlugin, + required this.appBar, + this.overrideTextStyle, + this.appendText, + required this.proxy, + }); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: Stream.periodic(const Duration(seconds: 1)), + builder: (context, snapshot) { + final ts = FormatTimeHelper.formatHHMMSS( + ((DateTime.now().millisecondsSinceEpoch - + voipPlugin.connectedTsSinceEpoch - + voipPlugin.onHoldMs) / + 1000) + .floor(), + ); + return Text( + '${appendText?.isEmpty ?? true ? '' : '$appendText '}${proxy.connected ? ts : voipPlugin.getCallStateSuffix(proxy, context)}', + style: overrideTextStyle ?? + Theme.of(context).textTheme.bodyLarge!.copyWith( + color: Colors.white.withOpacity(0.8), + fontSize: appBar ? 12 : 16, + ), + ); + }, + ); + } +} diff --git a/lib/pages/voip/widgets/more_options_listtile.dart b/lib/pages/voip/widgets/more_options_listtile.dart new file mode 100644 index 000000000..24b5767d3 --- /dev/null +++ b/lib/pages/voip/widgets/more_options_listtile.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import 'package:go_router/go_router.dart'; + +class MoreOptionsListTile extends StatelessWidget { + final IconData icon; + final Function onPressed; + final String title; + final bool shouldPopOnPress; + const MoreOptionsListTile({ + super.key, + required this.icon, + required this.onPressed, + required this.title, + this.shouldPopOnPress = false, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + onTap: () async { + await onPressed(); + if (shouldPopOnPress) GoRouter.of(context).pop(); + }, + minLeadingWidth: 1, + leading: Icon( + icon, + color: Colors.black, + ), + title: Text( + title, + style: Theme.of(context) + .textTheme + .bodyLarge! + .copyWith(color: Colors.black), + ), + trailing: const Icon( + Icons.arrow_forward_ios, + color: Colors.black, + size: 16, + ), + ); + } +} diff --git a/lib/pages/voip/widgets/stream_view.dart b/lib/pages/voip/widgets/stream_view.dart new file mode 100644 index 000000000..959610f80 --- /dev/null +++ b/lib/pages/voip/widgets/stream_view.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_webrtc/flutter_webrtc.dart'; +import 'package:livekit_client/livekit_client.dart'; +import 'package:matrix/matrix.dart'; + +import '../../../utils/voip/livekit_stream.dart'; +import '../../../widgets/avatar.dart'; + +class StreamView extends StatefulWidget { + final WrappedMediaStream wrappedStream; + final double avatarSize; + final double avatarTextSize; + final double avatarBorderRadius; + final bool showExtendedName; + final bool showName; + final bool isVoiceOnly; + final bool highLight; + const StreamView({ + super.key, + required this.wrappedStream, + this.isVoiceOnly = false, + this.showName = false, + this.highLight = false, + this.avatarSize = 44, + this.avatarTextSize = 18, + this.avatarBorderRadius = 8.0, + this.showExtendedName = false, + }); + + @override + State createState() => _StreamViewState(); +} + +class _StreamViewState extends State { + Uri? get avatarUrl => widget.wrappedStream.getUser().avatarUrl; + + String? get displayName => widget.wrappedStream.displayName; + + String get avatarName => widget.wrappedStream.avatarName; + + bool get isLocal => widget.wrappedStream.isLocal(); + + bool get isLivekit => widget.wrappedStream is LivekitParticipantStream; + + bool get isEncrypted => + isLivekit && + (widget.wrappedStream as LivekitParticipantStream).isEncrypted; + + VideoTrack? get videoTrack => isLivekit + ? (widget.wrappedStream as LivekitParticipantStream).lkVideoTrack() + : null; + + bool get mirrored => + widget.wrappedStream.isLocal() && + widget.wrappedStream.purpose == SDPStreamMetadataPurpose.Usermedia; + + bool get isScreenSharing => + widget.wrappedStream.purpose == SDPStreamMetadataPurpose.Screenshare; + + @override + void initState() { + widget.wrappedStream.onMuteStateChanged.stream.listen((stream) { + if (mounted) { + setState(() {}); + } + }); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + border: Border.all( + width: widget.highLight && !widget.wrappedStream.audioMuted ? 2 : 0, + color: Theme.of(context).colorScheme.primary, + strokeAlign: BorderSide.strokeAlignInside, + style: widget.highLight && !widget.wrappedStream.audioMuted + ? BorderStyle.solid + : BorderStyle.none, + ), + borderRadius: BorderRadius.circular(8.0), + color: widget.isVoiceOnly + ? Colors.black.withOpacity(0.9) + : Colors.white.withOpacity(0.18), + ), + child: Stack( + alignment: Alignment.center, + children: [ + if (widget.wrappedStream.videoMuted) + Container( + color: Colors.transparent, + ), + if (!widget.wrappedStream.videoMuted) + isLivekit && videoTrack != null + ? VideoTrackRenderer(videoTrack!) + : RTCVideoView( + widget.wrappedStream.renderer as RTCVideoRenderer, + mirror: mirrored, + ), + if (widget.wrappedStream.videoMuted) + Builder( + builder: (context) { + return Avatar( + mxContent: avatarUrl, + name: displayName ?? '', + size: widget.avatarSize, + fontSize: widget.avatarTextSize, + client: widget.wrappedStream.client, + ); + }, + ), + if (widget.showName) + Positioned( + left: 4.0, + bottom: 4.0, + child: !widget.showExtendedName + ? Text(displayName.toString()) + : Container( + decoration: BoxDecoration( + color: + widget.highLight && !widget.wrappedStream.audioMuted + ? Theme.of(context).colorScheme.primary + : Colors.black.withOpacity(0.4), + borderRadius: BorderRadius.circular( + 4.0, + ), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Icon( + widget.wrappedStream.audioMuted + ? Icons.mic_off + : Icons.mic, + size: 15, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + displayName.toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 12, + ), + ), + const SizedBox(width: 4), + Icon( + isEncrypted ? Icons.lock : Icons.lock_open, + size: 15, + color: isEncrypted ? Colors.green : Colors.red, + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/utils/app_state.dart b/lib/utils/app_state.dart new file mode 100644 index 000000000..4223fe5b0 --- /dev/null +++ b/lib/utils/app_state.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +import 'package:matrix/matrix.dart'; + +class AppState extends ChangeNotifier { + /// used by the call screen minimize button to navigate back to the last + /// screen where banner was tapped + String? bannerClickedOnPath; + + /// a banner widget which can be set using the `setGlobalBanner` and removed + /// using the `removeGlobalBanner` methods. Is shown on all screens + Widget? globalBanner; + + /// stores a list of the room ids where user clicked to hide the join group call + /// banner + List hideGroupCallBanner = []; + void addRoomToHideBanner(Room room) { + hideGroupCallBanner.add(room.id); + notifyListeners(); + } + + /// Use these to set banner anytime in the app + void setGlobalBanner(Widget banner, {bool isSecondary = false}) { + globalBanner = banner; + + notifyListeners(); + } + + /// Use these to remove the global banner + void removeGlobalBanner({bool isSecondary = false}) { + globalBanner = null; + + notifyListeners(); + } +} diff --git a/lib/utils/format_time_helper.dart b/lib/utils/format_time_helper.dart new file mode 100644 index 000000000..5a8cc25d5 --- /dev/null +++ b/lib/utils/format_time_helper.dart @@ -0,0 +1,35 @@ +import 'package:duration/duration.dart'; +import 'package:duration/locale.dart'; +import 'package:intl/intl.dart'; + +class FormatTimeHelper { + static String formatHHMMSS(int seconds) { + if (seconds != 0) { + final int hours = (seconds / 3600).truncate(); + seconds = (seconds % 3600).truncate(); + final int minutes = (seconds / 60).truncate(); + + final String hoursStr = (hours).toString().padLeft(2, '0'); + final String minutesStr = (minutes).toString().padLeft(2, '0'); + final String secondsStr = (seconds % 60).toString().padLeft(2, '0'); + if (hours == 0) { + return "$minutesStr:$secondsStr"; + } + return "$hoursStr:$minutesStr:$secondsStr"; + } else { + return " "; + } + } + + /// returns seconds in a prettier format supporting localizations + /// Use l10n.localeName to pass localeName + static String prettierTime(int seconds, String localeName) { + return prettyDuration( + Duration(seconds: seconds), + locale: DurationLocale.fromLanguageCode( + Intl.getCurrentLocale(), + ) ?? + DurationLocale.fromLanguageCode(localeName)!, + ); + } +} diff --git a/lib/utils/localization_for_locale_extension.dart b/lib/utils/localization_for_locale_extension.dart new file mode 100644 index 000000000..fd4a9de31 --- /dev/null +++ b/lib/utils/localization_for_locale_extension.dart @@ -0,0 +1,32 @@ +/* + * Famedly + * Copyright (C) 2019, 2020, 2021 Famedly GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +extension LocalizationForLocaleExtension on PlatformDispatcher { + /// Loads the right L10n delegate for the current locale or falls back to a default otherwise. This reuses the Flutter locale selection algorithm. Usually you should not call this. Only do that if you have no access to a build context. + Future loadL10n() { + final locale = basicLocaleListResolution(locales, L10n.supportedLocales); + + return L10n.delegate.load(locale); + } +} diff --git a/lib/utils/push_helper.dart b/lib/utils/push_helper.dart index 2b4b561bf..6ed1311fb 100644 --- a/lib/utils/push_helper.dart +++ b/lib/utils/push_helper.dart @@ -15,7 +15,6 @@ import 'package:fluffychat/config/setting_keys.dart'; import 'package:fluffychat/utils/client_manager.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/utils/platform_infos.dart'; -import 'package:fluffychat/utils/voip/callkeep_manager.dart'; Future pushHelper( PushNotification notification, { @@ -132,12 +131,6 @@ Future _tryPushHelper( client.backgroundSync = true; } - if (event.type == EventTypes.CallInvite) { - CallKeepManager().initialize(); - } else if (event.type == EventTypes.CallHangup) { - client.backgroundSync = false; - } - if (event.type.startsWith('m.call') && event.type != EventTypes.CallInvite) { Logs().v('Push message is a m.call but not invite. Do not display.'); return; diff --git a/lib/utils/voip/call_session_state.dart b/lib/utils/voip/call_session_state.dart new file mode 100644 index 000000000..ea8c3a7ab --- /dev/null +++ b/lib/utils/voip/call_session_state.dart @@ -0,0 +1,309 @@ +import 'dart:async'; + +import 'package:all_sensors/all_sensors.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:matrix/matrix.dart'; +import 'package:vibration/vibration.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; + +import 'package:fluffychat/utils/platform_infos.dart'; +import 'package:fluffychat/utils/voip/voip_plugin.dart'; +import 'call_state_proxy.dart'; + +// maybe make it a singleton? + +class CallSessionState implements CallStateProxy { + final CallSession _call; + Function()? callback; + final VoipPlugin voipPlugin; + AudioPlayer? _outgoingCallAudioPlayer; + CallSessionState(this._call, this.voipPlugin) { + StreamSubscription? proximitySubscription; + int onHold = 0; + int onUnhold = 0; + _call.onCallEventChanged.stream.listen((CallEvent event) async { + Logs().d('[CallSessionState] onCallEventChanged ${event.toString()}'); + // if (event == CallEvent.kError) { + // await ErrorReporter( + // call.lastError, + // StackTrace.current, + // level: SentryLevel.error, + // ).onErrorCallback(error); + // } + + if (event == CallEvent.kState || + event == CallEvent.kFeedsChanged || + event == CallEvent.kLocalHoldUnhold || + event == CallEvent.kRemoteHoldUnhold) { + if (event == CallEvent.kFeedsChanged) { + await _call.tryRemoveStopedStreams(); + } else if ({CallEvent.kLocalHoldUnhold, CallEvent.kRemoteHoldUnhold} + .contains(event)) { + if (callOnHold) { + onHold = DateTime.now().millisecondsSinceEpoch; + } else { + onUnhold = DateTime.now().millisecondsSinceEpoch; + voipPlugin.onHoldMs = voipPlugin.onHoldMs + (onUnhold - onHold); + } + Logs().w(voipPlugin.onHoldMs.toString()); + } + callback?.call(); + } + }); + + _call.onCallStateChanged.stream.listen((state) async { + Logs().d('[CallSessionState] onCallStateChanged ${state.toString()}'); + + if (_call.isOutgoing) { + if (state == CallState.kInviteSent) { + final player = _outgoingCallAudioPlayer = AudioPlayer(); + await player.setLoopMode(LoopMode.all); + await player.setAudioSource( + AudioSource.asset('assets/sounds/call.ogg'), + initialIndex: 0, + initialPosition: Duration.zero, + ); + await player.play(); + } else if ({ + CallState.kConnecting, + CallState.kConnected, + CallState.kEnded, + }.contains(state)) { + await _outgoingCallAudioPlayer?.stop(); + _outgoingCallAudioPlayer = null; + } + } + + if (state == CallState.kConnected) { + voipPlugin.connectedTsSinceEpoch = + DateTime.now().millisecondsSinceEpoch; + if (PlatformInfos.isMobile) { + if (voiceonly) { + // once you start listening to proximity stream remember to cancel it or + // proximity sensor will keep turning off screen + proximitySubscription = + proximityEvents!.listen((ProximityEvent event) {}); + } else { + await WakelockPlus.enable(); + } + await vibrate(); + } + } else if (state == CallState.kEnded) { + voipPlugin.connectedTsSinceEpoch = 0; + if (PlatformInfos.isMobile) { + if (voiceonly) { + await proximitySubscription?.cancel(); + } else { + await WakelockPlus.disable(); + } + await vibrate(); + } + } + callback?.call(); + }); + } + + @override + GroupCallSession? get groupCall => null; + + @override + CallSession get call => _call; + + Future vibrate() async { + try { + await Vibration.vibrate(duration: 100); + } catch (e) { + Logs().e('[Dialer] could not vibrate for call updates'); + } + } + + @override + Stream get callEventStream => _call.onCallEventChanged.stream; + @override + Stream get callStateStream => _call.onCallStateChanged.stream; + @override + bool get voiceonly => + userMediaStreams.every((stream) => stream.videoMuted) && + screenSharingStreams.isEmpty; + + @override + bool get connecting => _call.state == CallState.kConnecting; + + @override + bool get answering => _call.state == CallState.kCreateAnswer; + + @override + bool get connected => _call.state == CallState.kConnected; + + @override + bool get ended => _call.state == CallState.kEnded; + + @override + bool get isOutgoing => _call.isOutgoing; + + @override + bool get ringingPlay => _call.state == CallState.kInviteSent; + + @override + Future answer() async { + await _call.answer(); + callback?.call(); + } + + @override + Future enter(WrappedMediaStream stream) async { + // TODO: implement enter + } + + @override + Future hangup() async { + if ({CallState.kRinging, CallState.kFledgling}.contains(_call.state)) { + await _call.reject(); + } else { + await _call.hangup(); + } + callback?.call(); + } + + @override + bool get isLocalVideoMuted => _call.isLocalVideoMuted; + + @override + bool get isMicrophoneMuted => _call.isMicrophoneMuted; + + @override + bool get localHold => _call.localHold; + + @override + bool get remoteOnHold => _call.remoteOnHold; + + @override + bool get isScreensharingEnabled => _call.screensharingEnabled; + + @override + bool get callOnHold => _call.localHold || _call.remoteOnHold; + + @override + Future setLocalVideoMuted(bool muted) async { + await _call.setLocalVideoMuted(muted); + callback?.call(); + } + + @override + Future setMicrophoneMuted(bool muted) async { + await _call.setMicrophoneMuted(muted); + // TODO(Nico): Refactor this to be more granular + callback?.call(); + } + + @override + Future setRemoteOnHold(bool onHold) async { + await _call.setRemoteOnHold(onHold); + callback?.call(); + } + + @override + Future setScreensharingEnabled(bool enabled) async { + await _call.setScreensharingEnabled(enabled); + callback?.call(); + } + + @override + String get callState { + switch (_call.state) { + case CallState.kCreateAnswer: + case CallState.kFledgling: + case CallState.kWaitLocalMedia: + case CallState.kCreateOffer: + break; + case CallState.kRinging: + state = 'Ringing'; + break; + case CallState.kInviteSent: + state = 'Invite Sent'; + break; + case CallState.kConnecting: + state = 'Connecting'; + break; + case CallState.kConnected: + state = 'Connected'; + break; + case CallState.kEnded: + state = 'Ended'; + break; + } + return state; + } + + String state = 'New Call'; + @override + WrappedMediaStream? get localUserMediaStream => _call.localUserMediaStream; + @override + WrappedMediaStream? get localScreenSharingStream => + _call.localScreenSharingStream; + + @override + List get screenSharingStreams { + final streams = []; + if (connected) { + if (_call.remoteScreenSharingStream != null) { + streams.add(_call.remoteScreenSharingStream!); + } + if (_call.localScreenSharingStream != null) { + streams.add(_call.localScreenSharingStream!); + } + } + return streams; + } + + @override + List get userMediaStreams { + final streams = []; + if (connected) { + if (_call.remoteUserMediaStream != null) { + streams.add(_call.remoteUserMediaStream!); + } + if (_call.localUserMediaStream != null) { + streams.add(_call.localUserMediaStream!); + } + } + return streams; + } + + @override + WrappedMediaStream? get primaryStream { + if (screenSharingStreams.isNotEmpty) { + return screenSharingStreams.first; + } + + if (userMediaStreams.isNotEmpty) { + return userMediaStreams.first; + } + + if (!connected) { + return _call.type == CallType.kVoice && !_call.isOutgoing + ? _call.remoteUserMediaStream // show remote avatar on incoming call + : _call.localUserMediaStream; + } + + return _call.localScreenSharingStream ?? _call.localUserMediaStream; + } + + @override + String? get displayName => _call.room.getLocalizedDisplayname(); + + @override + void onUpdateViewCallback(Function() handler) { + callback = handler; + } + + @override + Room get room => _call.room; + + @override + Client get client => _call.client; + + @override + VoipType get type => + _call.type == CallType.kVideo ? VoipType.kVideo : VoipType.kVoice; +} diff --git a/lib/utils/voip/call_state_proxy.dart b/lib/utils/voip/call_state_proxy.dart new file mode 100644 index 000000000..6a521a374 --- /dev/null +++ b/lib/utils/voip/call_state_proxy.dart @@ -0,0 +1,54 @@ +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/utils/voip/voip_plugin.dart'; + +abstract class CallStateProxy { + String? get displayName; + bool get isMicrophoneMuted; + bool get isLocalVideoMuted; + bool get isScreensharingEnabled; + bool get localHold; + bool get remoteOnHold; + bool get answering; + bool get voiceonly; + bool get connecting; + bool get connected; + bool get ended; + bool get callOnHold; + bool get isOutgoing; + bool get ringingPlay; + String get callState; + Room get room; + Stream get callEventStream; + Stream get callStateStream; + Client get client; + VoipType get type; + GroupCallSession? get groupCall; + CallSession? get call; + + Future answer(); + + Future hangup(); + + Future enter(WrappedMediaStream stream); + + Future setMicrophoneMuted(bool muted); + + Future setLocalVideoMuted(bool muted); + + Future setScreensharingEnabled(bool enabled); + + Future setRemoteOnHold(bool onHold); + + WrappedMediaStream? get localUserMediaStream; + + WrappedMediaStream? get localScreenSharingStream; + + WrappedMediaStream? get primaryStream; + + List get screenSharingStreams; + + List get userMediaStreams; + + void onUpdateViewCallback(Function() callback); +} diff --git a/lib/utils/voip/callkeep_manager.dart b/lib/utils/voip/callkeep_manager.dart deleted file mode 100644 index 6de7cb6e3..000000000 --- a/lib/utils/voip/callkeep_manager.dart +++ /dev/null @@ -1,367 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; - -import 'package:callkeep/callkeep.dart'; -import 'package:flutter_foreground_task/flutter_foreground_task.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:matrix/matrix.dart'; -import 'package:permission_handler/permission_handler.dart'; - -import 'package:fluffychat/utils/voip_plugin.dart'; - -class CallKeeper { - CallKeeper(this.callKeepManager, this.call) { - call.onCallStateChanged.stream.listen(_handleCallState); - } - - CallKeepManager callKeepManager; - bool? held = false; - bool? muted = false; - bool connected = false; - CallSession call; - - // update native caller to show what remote user has done. - void _handleCallState(CallState state) { - Logs().i('CallKeepManager::handleCallState: ${state.toString()}'); - switch (state) { - case CallState.kConnecting: - Logs().v('callkeep connecting'); - break; - case CallState.kConnected: - Logs().v('callkeep connected'); - if (!connected) { - callKeepManager.answer(call.callId); - } else { - callKeepManager.setMutedCall(call.callId, false); - callKeepManager.setOnHold(call.callId, false); - } - break; - case CallState.kEnded: - callKeepManager.hangup(call.callId); - break; - /* TODO: - case CallState.kMuted: - callKeepManager.setMutedCall(uuid, true); - break; - case CallState.kHeld: - callKeepManager.setOnHold(uuid, true); - break; - */ - case CallState.kFledgling: - // TODO: Handle this case. - break; - case CallState.kInviteSent: - // TODO: Handle this case. - break; - case CallState.kWaitLocalMedia: - // TODO: Handle this case. - break; - case CallState.kCreateOffer: - // TODO: Handle this case. - break; - case CallState.kCreateAnswer: - // TODO: Handle this case. - break; - case CallState.kRinging: - // TODO: Handle this case. - break; - } - } -} - -Map calls = {}; - -class CallKeepManager { - factory CallKeepManager() { - return _instance; - } - - CallKeepManager._internal() { - _callKeep = FlutterCallkeep(); - } - - static final CallKeepManager _instance = CallKeepManager._internal(); - - late FlutterCallkeep _callKeep; - VoipPlugin? _voipPlugin; - - String get appName => 'FluffyChat'; - - Future get hasPhoneAccountEnabled async => - await _callKeep.hasPhoneAccount(); - - Map get alertOptions => { - 'alertTitle': 'Permissions required', - 'alertDescription': - 'Allow $appName to register as a calling account? This will allow calls to be handled by the native android dialer.', - 'cancelButton': 'Cancel', - 'okButton': 'ok', - // Required to get audio in background when using Android 11 - 'foregroundService': { - 'channelId': 'com.fluffy.fluffychat', - 'channelName': 'Foreground service for my app', - 'notificationTitle': '$appName is running on background', - 'notificationIcon': 'mipmap/ic_notification_launcher', - }, - 'additionalPermissions': [''], - }; - bool setupDone = false; - - Future showCallkitIncoming(CallSession call) async { - if (!setupDone) { - await _callKeep.setup( - null, - { - 'ios': { - 'appName': appName, - }, - 'android': alertOptions, - }, - backgroundMode: true, - ); - } - setupDone = true; - await displayIncomingCall(call); - call.onCallStateChanged.stream.listen((state) { - if (state == CallState.kEnded) { - _callKeep.endAllCalls(); - } - }); - call.onCallEventChanged.stream.listen( - (event) { - if (event == CallEvent.kLocalHoldUnhold) { - Logs().i( - 'Call hold event: local ${call.localHold}, remote ${call.remoteOnHold}', - ); - } - }, - ); - } - - void removeCall(String? callUUID) { - calls.remove(callUUID); - } - - void addCall(String? callUUID, CallKeeper callKeeper) { - if (calls.containsKey(callUUID)) return; - calls[callUUID] = callKeeper; - } - - void setCallHeld(String? callUUID, bool? held) { - calls[callUUID]!.held = held; - } - - void setCallMuted(String? callUUID, bool? muted) { - calls[callUUID]!.muted = muted; - } - - void didDisplayIncomingCall(CallKeepDidDisplayIncomingCall event) { - final callUUID = event.callUUID; - final number = event.handle; - Logs().v('[displayIncomingCall] $callUUID number: $number'); - // addCall(callUUID, CallKeeper(this null)); - } - - void onPushKitToken(CallKeepPushKitToken event) { - Logs().v('[onPushKitToken] token => ${event.token}'); - } - - Future initialize() async { - _callKeep.on(CallKeepPerformAnswerCallAction(), answerCall); - _callKeep.on(CallKeepDidPerformDTMFAction(), didPerformDTMFAction); - _callKeep.on( - CallKeepDidReceiveStartCallAction(), - didReceiveStartCallAction, - ); - _callKeep.on(CallKeepDidToggleHoldAction(), didToggleHoldCallAction); - _callKeep.on( - CallKeepDidPerformSetMutedCallAction(), - didPerformSetMutedCallAction, - ); - _callKeep.on(CallKeepPerformEndCallAction(), endCall); - _callKeep.on(CallKeepPushKitToken(), onPushKitToken); - _callKeep.on(CallKeepDidDisplayIncomingCall(), didDisplayIncomingCall); - Logs().i('[VOIP] Initialized'); - } - - Future hangup(String callUUID) async { - await _callKeep.endCall(callUUID); - removeCall(callUUID); - } - - Future reject(String callUUID) async { - await _callKeep.rejectCall(callUUID); - } - - Future answer(String? callUUID) async { - final keeper = calls[callUUID]!; - if (!keeper.connected) { - await _callKeep.answerIncomingCall(callUUID!); - keeper.connected = true; - } - } - - Future setOnHold(String callUUID, bool held) async { - await _callKeep.setOnHold(callUUID, held); - setCallHeld(callUUID, held); - } - - Future setMutedCall(String callUUID, bool muted) async { - await _callKeep.setMutedCall(callUUID, muted); - setCallMuted(callUUID, muted); - } - - Future updateDisplay(String callUUID) async { - // Workaround because Android doesn't display well displayName, se we have to switch ... - if (isIOS) { - await _callKeep.updateDisplay( - callUUID, - displayName: 'New Name', - handle: callUUID, - ); - } else { - await _callKeep.updateDisplay( - callUUID, - displayName: callUUID, - handle: 'New Name', - ); - } - } - - Future displayIncomingCall(CallSession call) async { - final callKeeper = CallKeeper(this, call); - addCall(call.callId, callKeeper); - await _callKeep.displayIncomingCall( - call.callId, - '${call.room.getLocalizedDisplayname()} (FluffyChat)', - localizedCallerName: - '${call.room.getLocalizedDisplayname()} (FluffyChat)', - handleType: 'number', - hasVideo: call.type == CallType.kVideo, - ); - return callKeeper; - } - - Future checkoutPhoneAccountSetting(BuildContext context) async { - showDialog( - context: context, - barrierDismissible: true, - useRootNavigator: false, - builder: (_) => AlertDialog( - title: Text(L10n.of(context)!.callingPermissions), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - onTap: () => openCallingAccountsPage(context), - title: Text(L10n.of(context)!.callingAccount), - subtitle: Text(L10n.of(context)!.callingAccountDetails), - trailing: const Icon(Icons.phone), - ), - const Divider(), - ListTile( - onTap: () => FlutterForegroundTask.openSystemAlertWindowSettings( - forceOpen: true, - ), - title: Text(L10n.of(context)!.appearOnTop), - subtitle: Text(L10n.of(context)!.appearOnTopDetails), - trailing: const Icon(Icons.file_upload_rounded), - ), - const Divider(), - ListTile( - onTap: () => openAppSettings(), - title: Text(L10n.of(context)!.otherCallingPermissions), - trailing: const Icon(Icons.mic), - ), - ], - ), - ), - ); - } - - void openCallingAccountsPage(BuildContext context) async { - await _callKeep.setup(context, { - 'ios': { - 'appName': appName, - }, - 'android': alertOptions, - }); - final hasPhoneAccount = await _callKeep.hasPhoneAccount(); - Logs().e(hasPhoneAccount.toString()); - if (!hasPhoneAccount) { - await _callKeep.hasDefaultPhoneAccount(context, alertOptions); - } else { - await _callKeep.openPhoneAccounts(); - } - } - - /// CallActions. - Future answerCall(CallKeepPerformAnswerCallAction event) async { - final callUUID = event.callUUID; - final keeper = calls[event.callUUID]!; - if (!keeper.connected) { - Logs().e('answered'); - // Answer Call - keeper.call.answer(); - keeper.connected = true; - } - Timer(const Duration(seconds: 1), () { - _callKeep.setCurrentCallActive(callUUID!); - }); - } - - Future endCall(CallKeepPerformEndCallAction event) async { - final keeper = calls[event.callUUID]; - keeper?.call.hangup(); - removeCall(event.callUUID); - } - - Future didPerformDTMFAction(CallKeepDidPerformDTMFAction event) async { - final keeper = calls[event.callUUID]!; - keeper.call.sendDTMF(event.digits!); - } - - Future didReceiveStartCallAction( - CallKeepDidReceiveStartCallAction event, - ) async { - if (event.handle == null) { - // @TODO: sometime we receive `didReceiveStartCallAction` with handle` undefined` - return; - } - final callUUID = event.callUUID!; - if (event.callUUID == null) { - final call = - await _voipPlugin!.voip.inviteToCall(event.handle!, CallType.kVideo); - addCall(callUUID, CallKeeper(this, call)); - } - await _callKeep.startCall(callUUID, event.handle!, event.handle!); - Timer(const Duration(seconds: 1), () { - _callKeep.setCurrentCallActive(callUUID); - }); - } - - Future didPerformSetMutedCallAction( - CallKeepDidPerformSetMutedCallAction event, - ) async { - final keeper = calls[event.callUUID]; - if (event.muted!) { - keeper!.call.setMicrophoneMuted(true); - } else { - keeper!.call.setMicrophoneMuted(false); - } - setCallMuted(event.callUUID, event.muted); - } - - Future didToggleHoldCallAction( - CallKeepDidToggleHoldAction event, - ) async { - final keeper = calls[event.callUUID]; - if (event.hold!) { - keeper!.call.setRemoteOnHold(true); - } else { - keeper!.call.setRemoteOnHold(false); - } - setCallHeld(event.callUUID, event.hold); - } -} diff --git a/lib/utils/voip/famedly_key_provider_impl.dart b/lib/utils/voip/famedly_key_provider_impl.dart new file mode 100644 index 000000000..750ba74d0 --- /dev/null +++ b/lib/utils/voip/famedly_key_provider_impl.dart @@ -0,0 +1,65 @@ +import 'dart:typed_data'; + +import 'package:livekit_client/livekit_client.dart' as lk; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/utils/voip/voip_plugin.dart'; + +class FamedlyAppEncryptionKeyProviderImpl implements EncryptionKeyProvider { + final Client client; + final VoipPlugin voip; + + FamedlyAppEncryptionKeyProviderImpl(this.client, this.voip) { + _initFuture = init(); + } + late Future _initFuture; + + late lk.BaseKeyProvider _keyProvider; + + lk.BaseKeyProvider get keyProvider => _keyProvider; + + Future init() async { + _keyProvider = await lk.BaseKeyProvider.create( + sharedKey: false, + ratchetWindowSize: 16, + failureTolerance: -1, + ); + } + + @override + Future onSetEncryptionKey( + Participant participant, + String key, + int index, + ) async { + await _initFuture; + await _keyProvider.setKey( + key, + keyIndex: index, + participantId: participant.id, + ); + Logs().i( + 'onSetEncryptionKey Set key for ${participant.id}, key = $key, index = $index,', + ); + } + + @override + Future onExportKey(Participant participant, int index) async { + await _initFuture; + final key = await _keyProvider.exportKey(participant.id, index); + Logs().i( + 'onExportKey Got key for ${participant.id}, key = $key, index = $index,', + ); + return key; + } + + @override + Future onRatchetKey(Participant participant, int index) async { + await _initFuture; + final key = await _keyProvider.ratchetKey(participant.id, index); + Logs().i( + 'onRatchetKey Ratched key for ${participant.id}, new key = $key, index = $index,', + ); + return key; + } +} diff --git a/lib/utils/voip/group_call_session_state.dart b/lib/utils/voip/group_call_session_state.dart new file mode 100644 index 000000000..1d8390e63 --- /dev/null +++ b/lib/utils/voip/group_call_session_state.dart @@ -0,0 +1,194 @@ +import 'dart:async'; + +import 'package:matrix/matrix.dart'; +import 'package:vibration/vibration.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; + +import 'package:fluffychat/utils/platform_infos.dart'; +import 'package:fluffychat/utils/voip/voip_plugin.dart'; +import 'call_state_proxy.dart'; + +class GroupCallSessionState implements CallStateProxy { + Function()? callback; + final GroupCallSession _groupCall; + final VoipPlugin _voipPlugin; + GroupCallSessionState(this._groupCall, this._voipPlugin) { + _groupCall.onGroupCallEvent.stream.listen((event) { + Logs().d('[GroupCallSessionState] onGroupCallEvent ${event.toString()}'); + callback?.call(); + }); + + _groupCall.onGroupCallState.stream.listen((state) async { + Logs().d('[GroupCallSessionState] onGroupCallState ${state.toString()}'); + if (state == GroupCallState.Entered) { + _voipPlugin.connectedTsSinceEpoch = + DateTime.now().millisecondsSinceEpoch; + if (PlatformInfos.isMobile) { + await WakelockPlus.enable(); + await vibrate(); + } + } else if ({ + GroupCallState.LocalCallFeedUninitialized, + GroupCallState.Ended, + }.contains(state)) { + // uninititalized when call not terminated, still has participant + _voipPlugin.connectedTsSinceEpoch = 0; + if (PlatformInfos.isMobile) { + await WakelockPlus.disable(); + await vibrate(); + } + } + callback?.call(); + }); + + _groupCall.onStreamAdd.stream.listen((event) { + Logs().d('[GroupCallSessionState] onStreamAdd ${event.toString()}'); + callback?.call(); + }); + _groupCall.onStreamRemoved.stream.listen((event) { + Logs().d('[GroupCallSessionState] onStreamRemoved ${event.toString()}'); + callback?.call(); + }); + } + + Future vibrate() async { + try { + await Vibration.vibrate(duration: 100); + } catch (e) { + Logs().e('[Dialer] could not vibrate for call updates'); + } + } + + @override + Future answer() async { + // TODO: implement answer + } + + @override + Stream get callEventStream => _groupCall.onGroupCallEvent.stream; + + @override + Stream get callStateStream => _groupCall.onGroupCallState.stream; + + @override + bool get callOnHold => false; + + @override + String get callState => _groupCall.state; + + @override + bool get connected => _groupCall.state == GroupCallState.Entered; + + @override + bool get connecting => _groupCall.state == GroupCallState.Entering; + + @override + bool get answering => _groupCall.state == GroupCallState.Entering; + + @override + GroupCallSession get groupCall => _groupCall; + + @override + CallSession? get call => null; + + @override + String get displayName => _groupCall.room.getLocalizedDisplayname(); + + @override + bool get ended => + _groupCall.state == GroupCallState.Ended || + _groupCall.state == GroupCallState.LocalCallFeedUninitialized; + + @override + Future enter(WrappedMediaStream stream) async { + await _groupCall.enter( + stream: stream, + ); + callback?.call(); + } + + @override + Future hangup() async { + await _groupCall.leave(); + callback?.call(); + } + + @override + bool get isLocalVideoMuted => _groupCall.isLocalVideoMuted; + + @override + bool get isMicrophoneMuted => _groupCall.isMicrophoneMuted; + + @override + bool get isOutgoing => false; + + @override + bool get isScreensharingEnabled => _groupCall.isScreensharing(); + + @override + bool get localHold => false; + + @override + WrappedMediaStream? get localScreenSharingStream => + _groupCall.localScreenshareStream; + + @override + WrappedMediaStream? get localUserMediaStream => + _groupCall.localUserMediaStream; + + @override + void onUpdateViewCallback(Function() handler) { + callback = handler; + } + + @override + WrappedMediaStream? get primaryStream => _groupCall.localUserMediaStream; + + @override + bool get remoteOnHold => false; + + @override + bool get ringingPlay => false; + + @override + List get screenSharingStreams => + _groupCall.screenshareStreams; + + @override + List get userMediaStreams => _groupCall.userMediaStreams; + + @override + Future setLocalVideoMuted(bool muted) async { + await _groupCall.setLocalVideoMuted(muted); + callback?.call(); + } + + @override + Future setMicrophoneMuted(bool muted) async { + await _groupCall.setMicrophoneMuted(muted); + callback?.call(); + } + + @override + Future setRemoteOnHold(bool onHold) async { + // TODO: implement setRemoteOnHold + } + + @override + Future setScreensharingEnabled(bool enabled) async { + await _groupCall.setScreensharingEnabled(enabled, ''); + callback?.call(); + } + + @override + bool get voiceonly => false; + + @override + Room get room => _groupCall.room; + + @override + Client get client => _groupCall.client; + + @override + VoipType get type => VoipType.kGroup; +} diff --git a/lib/utils/voip/incoming_call.dart b/lib/utils/voip/incoming_call.dart new file mode 100644 index 000000000..23fd7affe --- /dev/null +++ b/lib/utils/voip/incoming_call.dart @@ -0,0 +1,202 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:flutter_callkit_incoming/entities/entities.dart'; +import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/matrix.dart' hide CallEvent, Event; +import 'package:provider/provider.dart'; +import 'package:uuid/uuid.dart'; + +import 'package:fluffychat/utils/app_state.dart'; +import 'package:fluffychat/utils/localization_for_locale_extension.dart'; +import 'package:fluffychat/utils/voip/voip_plugin.dart'; +import 'package:fluffychat/widgets/fluffy_chat_app.dart'; +import '../../pages/voip/widgets/call_banner.dart'; + +Map calls = {}; + +/// Add uuid extension field for CallSession +extension CallSessionUuidExt on CallSession { + static Map uuids_ = {}; + String get callUUID { + if (uuids_.containsKey(callId)) { + return uuids_[callId]!; + } + final uuid = const Uuid().v4().toString(); + uuids_[callId] = uuid; + return uuid; + } + + void removeUUID() { + uuids_.remove(callId); + } +} + +class CallKeeper { + CallKeeper(this.incomingCallManager, this.call) { + call.onCallStateChanged.stream.listen(_handleCallState); + } + + IncomingCallManager incomingCallManager; + bool? held = false; + bool? muted = false; + bool connected = false; + CallSession call; + + // update native caller to show what remote user has done. + Future _handleCallState(CallState state) async { + Logs().d('CallKeepManager::handleCallState: ${state.toString()}'); + if (state case CallState.kConnected) { + await incomingCallManager.endIncomingCall(call.callUUID); + } else if (state case CallState.kEnded) { + await incomingCallManager.endIncomingCall(call.callUUID); + } + } +} + +class IncomingCallManager { + late VoipPlugin voipPlugin; + factory IncomingCallManager(VoipPlugin voipPlugin) { + _instance.voipPlugin = voipPlugin; + return _instance; + } + + static final IncomingCallManager _instance = IncomingCallManager._internal(); + IncomingCallManager._internal(); + + void removeCall(String? callUUID) { + final callkeep = calls.remove(callUUID); + callkeep?.call.removeUUID(); + } + + void addCall(String? callUUID, CallKeeper callKeeper) { + if (calls.containsKey(callUUID)) return; + calls[callUUID] = callKeeper; + } + + Future initialize() async { + FlutterCallkitIncoming.onEvent.listen((event) async { + Logs().w(event!.event.name.toString()); + if (event.event case Event.actionCallAccept) { + await answerCall(event.body['id']); + } else if (event.event case Event.actionCallDecline) { + await reject(event.body['id']); + } + }); + Logs().d('callkeepv3 init done'); + } + + /// CallActions. + Future answerCall(String callUUID) async { + final keeper = calls[callUUID]!; + + final provider = Provider.of( + FluffyChatApp.appGlobalKey.currentContext!, + listen: false, + ); + + provider.setGlobalBanner( + CallBanner(proxy: VoipPlugin.currentCallProxy!), + ); + FluffyChatApp.router + .go('/rooms/${VoipPlugin.currentCallProxy!.room.id}/call'); + if (!keeper.connected) { + Logs().d('[VOIP] answering call'); + // Answer Call, don't await because call page is not up yet no loading + // inndicator to show + unawaited(keeper.call.answer()); + keeper.connected = true; + } + } + + /// recieved decline from other end, passed on by callkeepmanager + Future hangup(String callUUID) async { + await endIncomingCall(callUUID); + removeCall(callUUID); + } + + /// user clicked the decline button on incoming call UI + Future reject(String callUUID) async { + await endIncomingCall(callUUID); + final keeper = calls[callUUID]; + // unawaited because we don't have a screen to show any progress indicator + unawaited(keeper!.call.reject()); + removeCall(callUUID); + } + + Future showIncomingCall(CallSession call) async { + final callKeeper = CallKeeper(this, call); + final l10n = await WidgetsBinding.instance.platformDispatcher.loadL10n(); + addCall(call.callUUID, callKeeper); + final remoteUser = await call.room.requestUser(call.remoteUserId!); + final avatarMxc = await call.client.getAvatarUrl(remoteUser?.id ?? ''); + final avatarUrl = avatarMxc?.getThumbnail( + call.client, + width: 500, + height: 500, + method: ThumbnailMethod.scale, + ); + + final params = getParams( + call, + avatarUrl.toString(), + l10n, + ); + + Logs().e('[VOIP] showing incoming call with params: ${params.toJson()}'); + + await FlutterCallkitIncoming.showCallkitIncoming(params); + } + + /// only hides incoming call notification + Future endIncomingCall(String callUUID) async { + try { + await FlutterCallkitIncoming.endCall(callUUID); + } catch (e) { + Logs().e('[VOIP] removing callkit incoming failed on endIncomingCall'); + } + } + + Future showMissedCallNotification(CallSession call) async { + final l10n = await WidgetsBinding.instance.platformDispatcher.loadL10n(); + final remoteUser = await call.room.requestUser(call.remoteUserId!); + final avatarMxc = await call.client.getAvatarUrl(remoteUser?.id ?? ''); + final avatarUrl = avatarMxc?.getThumbnail( + call.client, + width: 500, + height: 500, + method: ThumbnailMethod.scale, + ); + await FlutterCallkitIncoming.showMissCallNotification( + getParams( + call, + avatarUrl.toString(), + l10n, + ), + ); + } + + CallKitParams getParams(CallSession call, String avatarUrl, L10n l10n) { + return CallKitParams( + id: call.callUUID, + appName: call.client.clientName, + nameCaller: call.room.getLocalizedDisplayname(), + handle: call.type == CallType.kVideo ? l10n.videoCall : l10n.audioCall, + avatar: avatarUrl, + textAccept: l10n.accept, + textDecline: l10n.reject, + type: call.type == CallType.kVideo ? 1 : 0, + missedCallNotification: NotificationParams( + isShowCallback: false, + subtitle: 'Missed call from ${call.room.getLocalizedDisplayname()}', + ), + android: const AndroidParams( + isShowLogo: true, + isShowCallID: true, + ), + // TODO: add ios once you can run decryption stuff in background + ); + } +} diff --git a/lib/utils/voip/livekit_group_call_session_state.dart b/lib/utils/voip/livekit_group_call_session_state.dart new file mode 100644 index 000000000..a3d089fc7 --- /dev/null +++ b/lib/utils/voip/livekit_group_call_session_state.dart @@ -0,0 +1,616 @@ +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart'; +import 'package:http/http.dart' as http; +import 'package:livekit_client/livekit_client.dart' as livekit; +import 'package:matrix/matrix.dart'; +import 'package:vibration/vibration.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; +import 'package:webrtc_interface/webrtc_interface.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/utils/platform_infos.dart'; +import 'package:fluffychat/utils/voip/voip_plugin.dart'; +import 'call_state_proxy.dart'; +import 'livekit_stream.dart'; + +class LiveKitGroupCallSessionState implements CallStateProxy { + Function()? callback; + final GroupCallSession _groupCall; + final VoipPlugin voipPlugin; + livekit.Room? lkRoom; + + /// livekitstreams + final List _userMediaStreams = []; + + /// livekitstreams + final List _screenSharingStreams = []; + + LiveKitGroupCallSessionState(this._groupCall, this.voipPlugin) { + _groupCall.onGroupCallEvent.stream.listen((event) { + Logs().d( + '[LiveKitGroupCallSessionState] onGroupCallEvent ${event.toString()}', + ); + callback?.call(); + }); + + _groupCall.onGroupCallState.stream.listen((state) async { + Logs().d( + '[LiveKitGroupCallSessionState] onGroupCallState ${state.toString()}', + ); + if (state == GroupCallState.Entered) { + voipPlugin.connectedTsSinceEpoch = + DateTime.now().millisecondsSinceEpoch; + if (PlatformInfos.isMobile) { + await WakelockPlus.enable(); + await vibrate(); + } + } else if ({ + GroupCallState.LocalCallFeedUninitialized, + GroupCallState.Ended, + }.contains(state)) { + // uninititalized when call not terminated, still has participant + voipPlugin.connectedTsSinceEpoch = 0; + if (PlatformInfos.isMobile) { + await WakelockPlus.disable(); + await vibrate(); + } + } + callback?.call(); + }); + } + + Future vibrate() async { + try { + await Vibration.vibrate(duration: 100); + } catch (e) { + Logs().e('[Dialer] could not vibrate for call updates'); + } + } + + @override + Future answer() async {} + + @override + Stream get callEventStream => _groupCall.onGroupCallEvent.stream; + + @override + Stream get callStateStream => _groupCall.onGroupCallState.stream; + + @override + bool get callOnHold => false; + + @override + String get callState => _groupCall.state; + + @override + bool get connected => _groupCall.state == GroupCallState.Entered; + + @override + bool get connecting => _groupCall.state == GroupCallState.Entering; + + @override + bool get answering => _groupCall.state == GroupCallState.Entering; + + @override + GroupCallSession get groupCall => _groupCall; + + @override + CallSession? get call => null; + + @override + String get displayName => _groupCall.room.getLocalizedDisplayname(); + + @override + bool get ended => + _groupCall.state == GroupCallState.Ended || + _groupCall.state == GroupCallState.LocalCallFeedUninitialized; + + @override + Future enter(WrappedMediaStream stream) async { + await _groupCall.enter(); + + if (_groupCall.isLivekitCall && _groupCall.state != GroupCallState.Ended) { + try { + final sfuConfig = await getSFUConfigWithOpenID( + client: client, + roomName: _groupCall.room.id, + groupCall: _groupCall, + ); + if (sfuConfig == null) { + Logs().w('Failed to get SFU config for group call'); + return; + } + await join( + groupCall: _groupCall, + enableE2EE: AppConfig.enableLivekitE2EE, + sfuConfig: sfuConfig, + stream: stream, + ); + } catch (e) { + Logs().e('Failed to join SFU for group call', e); + return; + } + } + callback?.call(); + } + + @override + Future hangup() async { + await _groupCall.leave(); + if (_groupCall.isLivekitCall) { + if (lkRoom != null) { + await lkRoom?.disconnect(); + await _groupCall.localUserMediaStream?.dispose(); + _groupCall.localUserMediaStream = null; + } + } + callback?.call(); + } + + @override + bool get isLocalVideoMuted => + !(lkRoom?.localParticipant?.isCameraEnabled() ?? false); + + @override + bool get isMicrophoneMuted => + !(lkRoom?.localParticipant?.isMicrophoneEnabled() ?? false); + + @override + bool get isScreensharingEnabled => + lkRoom?.localParticipant?.isScreenShareEnabled() ?? false; + + @override + bool get isOutgoing => false; + + @override + bool get localHold => false; + + @override + WrappedMediaStream? get localScreenSharingStream => + screenSharingStreams.firstWhereOrNull((element) => element.isLocal()); + + @override + WrappedMediaStream? get localUserMediaStream => + userMediaStreams.firstWhereOrNull((element) => element.isLocal()); + + @override + List get userMediaStreams => _userMediaStreams.toList(); + + @override + WrappedMediaStream? get primaryStream => localUserMediaStream; + + @override + List get screenSharingStreams => + _screenSharingStreams.toList(); + + @override + void onUpdateViewCallback(Function() handler) { + callback = handler; + } + + @override + bool get remoteOnHold => false; + + @override + bool get ringingPlay => false; + + @override + Room get room => _groupCall.room; + + @override + Future setLocalVideoMuted(bool muted) async { + await lkRoom?.localParticipant?.setCameraEnabled(!muted); + localUserMediaStream?.setVideoMuted(muted); + callback?.call(); + } + + @override + Future setMicrophoneMuted(bool muted) async { + await lkRoom?.localParticipant?.setMicrophoneEnabled(!muted); + localUserMediaStream?.setAudioMuted(muted); + callback?.call(); + } + + @override + Future setRemoteOnHold(bool onHold) async {} + + @override + Future setScreensharingEnabled(bool enabled) async { + enabled ? await _enableScreenShare() : await _disableScreenShare(); + } + + Future _enableScreenShare() async { + if (livekit.lkPlatformIs(livekit.PlatformType.iOS)) { + final track = await livekit.LocalVideoTrack.createScreenShareTrack( + const livekit.ScreenShareCaptureOptions( + useiOSBroadcastExtension: true, + maxFrameRate: 15.0, + ), + ); + await lkRoom?.localParticipant?.publishVideoTrack(track); + return; + } + await lkRoom?.localParticipant?.setScreenShareEnabled(true); + } + + Future _disableScreenShare() async { + await lkRoom?.localParticipant?.setScreenShareEnabled(false); + } + + @override + VoipType get type => VoipType.kGroup; + + @override + bool get voiceonly => false; + + @override + Client get client => _groupCall.client; + + Future join({ + required GroupCallSession groupCall, + required SFUConfig sfuConfig, + bool? enableE2EE, + required WrappedMediaStream stream, + }) async { + livekit.E2EEOptions? e2eeOptions; + if (enableE2EE == true) { + e2eeOptions = livekit.E2EEOptions( + keyProvider: voipPlugin.encryptionKeyProvider.keyProvider, + ); + } + + livekit.FastConnectOptions? fastConnectOptions; + + fastConnectOptions = livekit.FastConnectOptions( + microphone: livekit.TrackOption(enabled: !stream.audioMuted), + camera: livekit.TrackOption(enabled: !stream.videoMuted), + ); + + // create new room + lkRoom = livekit.Room( + roomOptions: livekit.RoomOptions( + adaptiveStream: true, + dynacast: true, + defaultAudioPublishOptions: const livekit.AudioPublishOptions(), + defaultVideoPublishOptions: const livekit.VideoPublishOptions(), + defaultScreenShareCaptureOptions: livekit.ScreenShareCaptureOptions( + useiOSBroadcastExtension: true, + params: livekit.VideoParametersPresets.screenShareH1080FPS30, + ), + e2eeOptions: e2eeOptions, + defaultCameraCaptureOptions: livekit.CameraCaptureOptions( + maxFrameRate: 30, + params: livekit.VideoParametersPresets.h720_169, + ), + ), + ); + + // Create a Listener before connecting + final livekit.EventsListener listener = + lkRoom!.createListener(); + + await setUpListeners(lkRoom, listener, groupCall); + + await lkRoom!.connect( + sfuConfig.url, + sfuConfig.jwt, + fastConnectOptions: fastConnectOptions, + ); + + Logs().i( + 'Connected to room ${lkRoom?.name}, local participant => ${lkRoom?.localParticipant!.identity}', + ); + + // we don't need the preview stream anymore? I think + await stream.disposeRenderer(); + await stopMediaStream(stream.stream!); + } + + Future _sortParticipants(GroupCallSession groupCall) async { + _userMediaStreams.clear(); + _screenSharingStreams.clear(); + + final lkps = List.from( + lkRoom?.participants.values.toList() ?? [], + ); + for (final lkp in lkps) { + // skip livekit participant updates that don't have a valid matrix event set + if (!groupCall.participants.contains(Participant.fromId(lkp.identity))) { + continue; + } + + final remoteTrackPublications = List.from( + lkp.trackPublications.values.toList(), + ); + for (final t in remoteTrackPublications) { + if (t.kind == livekit.TrackType.AUDIO) continue; + + final lkpStream = LivekitParticipantStream( + lkParticipant: lkp, + client: client, + room: groupCall.room, + participant: Participant.fromId(lkp.identity), + audioMuted: !lkp.isMicrophoneEnabled(), + videoMuted: t.isScreenShare + ? !lkp.isScreenShareEnabled() + : !lkp.isCameraEnabled(), + stream: t.track?.mediaStream, + renderer: voipPlugin.createRenderer(), + isWeb: false, + isGroupCall: true, + purpose: t.isScreenShare + ? SDPStreamMetadataPurpose.Screenshare + : SDPStreamMetadataPurpose.Usermedia, + publication: [t], + ); + await lkpStream.initialize(); + if (t.isScreenShare) { + if (_screenSharingStreams.contains(lkpStream)) continue; + _screenSharingStreams.add(lkpStream); + } else { + if (_userMediaStreams.contains(lkpStream)) continue; + _userMediaStreams.add(lkpStream); + } + } + } + // sort speakers for the grid + _userMediaStreams.sort((a, b) { + // loudest speaker first + if (a.lkParticipant.isSpeaking && b.lkParticipant.isSpeaking) { + if (a.lkParticipant.audioLevel > b.lkParticipant.audioLevel) { + return -1; + } else { + return 1; + } + } + + // last spoken at + final aSpokeAt = a.lkParticipant.lastSpokeAt?.millisecondsSinceEpoch ?? 0; + final bSpokeAt = b.lkParticipant.lastSpokeAt?.millisecondsSinceEpoch ?? 0; + + if (aSpokeAt != bSpokeAt) { + return aSpokeAt > bSpokeAt ? -1 : 1; + } + + // video on + if (a.lkParticipant.hasVideo != b.lkParticipant.hasVideo) { + return a.lkParticipant.hasVideo ? -1 : 1; + } + + // joinedAt + return a.lkParticipant.joinedAt.millisecondsSinceEpoch - + b.lkParticipant.joinedAt.millisecondsSinceEpoch; + }); + + final localParticipantTracks = List.from( + lkRoom?.localParticipant?.trackPublications.values.toList() ?? [], + ); + + for (final t in localParticipantTracks) { + if (t.kind == livekit.TrackType.AUDIO) continue; + final lkp = lkRoom!.localParticipant!; + + final lkpStream = LivekitParticipantStream( + lkParticipant: lkp, + client: client, + room: groupCall.room, + participant: Participant.fromId(lkp.identity), + audioMuted: !lkp.isMicrophoneEnabled(), + videoMuted: t.isScreenShare + ? !lkp.isScreenShareEnabled() + : !lkp.isCameraEnabled(), + stream: t.track?.mediaStream, + renderer: voipPlugin.createRenderer(), + isWeb: false, + isGroupCall: true, + purpose: t.isScreenShare + ? SDPStreamMetadataPurpose.Screenshare + : SDPStreamMetadataPurpose.Usermedia, + publication: [t], + ); + await lkpStream.initialize(); + if (t.isScreenShare) { + if (_screenSharingStreams.contains(lkpStream)) continue; + _screenSharingStreams.add(lkpStream); + } else { + if (_userMediaStreams.contains(lkpStream)) continue; + _userMediaStreams.add(lkpStream); + } + } + + callback?.call(); + } + + Future setUpListeners( + room, + livekit.EventsListener listener, + GroupCallSession groupCall, + ) async { + listener.on((livekit.RoomDisconnectedEvent event) async { + Logs().i('RoomDisconnectedEvent'); + if (event.reason != null) { + Logs().i('Room disconnected: reason => ${event.reason}'); + } + await groupCall.leave(); + }); + listener.on((livekit.LocalTrackPublishedEvent event) async { + Logs().i( + 'LocalTrackPublishedEvent, p: ${event.participant.identity}, sid: ${event.publication.sid}, kind: ${event.publication.track?.kind}, screenShare: ${event.publication.isScreenShare}', + ); + await _sortParticipants(groupCall); + }); + + listener.on((livekit.LocalTrackUnpublishedEvent event) async { + Logs().i( + 'LocalTrackUnpublishedEvent, p: ${event.participant.identity}, sid: ${event.publication.sid}, kind: ${event.publication.track?.kind}, screenShare: ${event.publication.isScreenShare}', + ); + await _sortParticipants(groupCall); + }); + + listener.on((livekit.TrackSubscribedEvent event) async { + Logs().i( + 'TrackSubscribedEvent, p: ${event.participant.identity}, sid: ${event.publication.sid}, kind: ${event.publication.track?.kind}, screenShare: ${event.publication.isScreenShare}', + ); + await _sortParticipants(groupCall); + }); + + listener.on((livekit.TrackUnsubscribedEvent event) async { + Logs().i( + 'TrackUnsubscribedEvent, p: ${event.participant.identity}, sid: ${event.publication.sid}, kind: ${event.publication.track?.kind}, screenShare: ${event.publication.isScreenShare}', + ); + await _sortParticipants(groupCall); + }); + + listener.on((livekit.ParticipantNameUpdatedEvent event) async { + Logs().i('ParticipantNameUpdatedEvent'); + callback?.call(); + }); + + listener.on((livekit.DataReceivedEvent event) async { + String decoded = 'Failed to decode'; + try { + decoded = utf8.decode(event.data); + } catch (_) { + Logs().e('Failed to decode: $_'); + } + Logs() + .i('Data received: ${event.participant?.identity}, data => $decoded'); + }); + + listener.on((livekit.TrackMutedEvent event) async { + Logs().i( + 'TrackMutedEvent, p: ${event.participant.identity}, sid: ${event.publication.sid}, kind: ${event.publication.track?.kind}, screenShare: ${event.publication.isScreenShare}', + ); + await _sortParticipants(groupCall); + }); + + listener.on((livekit.TrackUnmutedEvent event) async { + Logs().i( + 'TrackUnmutedEvent, p: ${event.participant.identity}, sid: ${event.publication.sid}, kind: ${event.publication.track?.kind}, screenShare: ${event.publication.isScreenShare}', + ); + await _sortParticipants(groupCall); + }); + + listener.on((livekit.TrackE2EEStateEvent event) async { + Logs().i( + 'TrackE2EEStateEvent: ${event.participant.identity}, ${event.publication.sid} state: ${event.state}', + ); + final participant = Participant.fromId(event.participant.identity); + if (event.state == livekit.E2EEState.kMissingKey && + participant != voipPlugin.voip.localParticipant) { + Logs().i('TrackE2EEStateEvent: requesting keys from ${participant.id}'); + await groupCall.requestEncrytionKey( + [participant], + ); + } + await _sortParticipants(groupCall); + }); + } + + Future stopMediaStream(MediaStream? stream) async { + if (stream != null) { + for (final track in stream.getTracks()) { + try { + await track.stop(); + } catch (e, s) { + Logs().e('[VOIP] stopping track ${track.id} failed', e, s); + } + } + try { + await stream.dispose(); + } catch (e, s) { + Logs().e('[VOIP] disposing stream ${stream.id} failed', e, s); + } + } + } +} + +/// lk token processing +/// The livekitServiceURL is usually set by the participant who created the +/// meeting, and everyone who joins later needs to use the same livekitServiceURL +/// 1, request a openID token from our matrix server +/// 2, use the { openIDToken, roomId,deviceId } to request a SFUConfig from the lk jwt service, +/// `lk-jwt-service` will use openIdToken to go to our matrix server to verify the validity of the client. +/// and then we will get a jwt and a url from the livekit server +/// 3, put jwt/url to the livekit-flutter-sdk to connect to the livekit server +Future getSFUConfigWithOpenID({ + required Client client, + required String roomName, + required GroupCallSession groupCall, +}) async { + final openIdToken = await client.requestOpenIdToken(client.userID!, {}); + Logs().d('Got openID token of type ${openIdToken.tokenType}'); + if (groupCall.isLivekitCall) { + final backend = (groupCall.backends.first as LiveKitBackend); + try { + Logs().i( + 'Trying to get JWT from call\'s configured URL of ${backend.livekitServiceUrl}...', + ); + final sfuConfig = await getLiveKitJWT( + client, + backend.livekitServiceUrl, + roomName, + openIdToken, + ); + Logs().i('Got JWT from call state event URL.'); + + return sfuConfig; + } catch (e) { + Logs().w( + 'Failed to get JWT from group call\'s configured URL of ${backend.livekitServiceUrl}. $e', + ); + } + } + const urlFromConf = AppConfig.livekitServiceUrl; + Logs().i('Trying livekit service URL from our config: $urlFromConf...'); + try { + final sfuConfig = + await getLiveKitJWT(client, urlFromConf, roomName, openIdToken); + + Logs() + .i('Got JWT, updating call livekit service URL with: $urlFromConf...'); + + return sfuConfig; + } catch (e) { + Logs().e('Failed to get JWT from URL defined in Config.', e); + rethrow; + } +} + +/// identity for the user is set in livekit-jwt-service (currently userId:deviceId) +Future getLiveKitJWT( + Client client, + String livekitServiceURL, + String roomName, + OpenIdCredentials openIdCredentials, +) async { + try { + final res = await http.post( + Uri.parse('$livekitServiceURL/sfu/get'), // element compantibilty + headers: { + 'Content-Type': 'application/json', + }, + body: jsonEncode({ + 'room': roomName, + 'openid_token': { + 'access_token': openIdCredentials.accessToken, + 'token_type': openIdCredentials.tokenType, + 'matrix_server_name': openIdCredentials.matrixServerName, + }, + 'device_id': client.deviceID!, + }), + ); + if (res.statusCode != 200) { + throw Exception( + 'SFU Config fetch failed with status code #${res.statusCode}', + ); + } + return SFUConfig.fromJson(jsonDecode(res.body)); + } catch (e) { + throw Exception( + 'SFU Config fetch failed with exception $e', + ); + } +} diff --git a/lib/utils/voip/livekit_stream.dart b/lib/utils/voip/livekit_stream.dart new file mode 100644 index 000000000..e0bcd7291 --- /dev/null +++ b/lib/utils/voip/livekit_stream.dart @@ -0,0 +1,58 @@ +import 'package:livekit_client/livekit_client.dart' as livekit; +import 'package:matrix/matrix.dart'; + +class SFUConfig { + final String url; + final String jwt; + SFUConfig({required this.url, required this.jwt}); + factory SFUConfig.fromJson(Map json) { + return SFUConfig(url: json['url'], jwt: json['jwt']); + } +} + +class LivekitParticipantStream extends WrappedMediaStream { + final livekit.Participant lkParticipant; + List publication = []; + LivekitParticipantStream({ + required super.participant, + required super.renderer, + required super.room, + required super.purpose, + required super.client, + required super.audioMuted, + required super.videoMuted, + required super.isWeb, + required super.isGroupCall, + required super.stream, + required this.lkParticipant, + this.publication = const [], + }); + + bool publicationExists(livekit.TrackPublication pub) { + return publication.contains(pub); + } + + void addPublication(livekit.TrackPublication pub) { + publication.add(pub); + super.onMuteStateChanged.add(this); + } + + void removePublication(livekit.TrackPublication pub) { + publication.remove(pub); + super.onMuteStateChanged.add(this); + } + + livekit.VideoTrack? lkVideoTrack() { + if (lkParticipant is livekit.LocalParticipant) { + return publication.firstOrNull?.track as livekit.VideoTrack?; + } + + if (lkParticipant is livekit.RemoteParticipant) { + return publication.firstOrNull?.track as livekit.VideoTrack?; + } + + return null; + } + + bool get isEncrypted => lkParticipant.isEncrypted; +} diff --git a/lib/utils/voip/user_media_manager.dart b/lib/utils/voip/user_media_manager.dart deleted file mode 100644 index 03ecd6030..000000000 --- a/lib/utils/voip/user_media_manager.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:flutter/foundation.dart'; - -import 'package:flutter_ringtone_player/flutter_ringtone_player.dart'; -import 'package:just_audio/just_audio.dart'; - -import 'package:fluffychat/utils/platform_infos.dart'; - -class UserMediaManager { - factory UserMediaManager() { - return _instance; - } - - UserMediaManager._internal(); - - static final UserMediaManager _instance = UserMediaManager._internal(); - - AudioPlayer? _assetsAudioPlayer; - - final FlutterRingtonePlayer _flutterRingtonePlayer = FlutterRingtonePlayer(); - - Future startRingingTone() async { - if (PlatformInfos.isMobile) { - await _flutterRingtonePlayer.playRingtone(volume: 80); - } else if ((kIsWeb || PlatformInfos.isMacOS) && - _assetsAudioPlayer != null) { - const path = 'assets/sounds/phone.ogg'; - final player = _assetsAudioPlayer = AudioPlayer(); - player.setAsset(path); - player.play(); - } - return; - } - - Future stopRingingTone() async { - if (PlatformInfos.isMobile) { - await _flutterRingtonePlayer.stop(); - } - await _assetsAudioPlayer?.stop(); - _assetsAudioPlayer = null; - return; - } -} diff --git a/lib/utils/voip/voip_plugin.dart b/lib/utils/voip/voip_plugin.dart new file mode 100644 index 000000000..0e6dbfcdb --- /dev/null +++ b/lib/utils/voip/voip_plugin.dart @@ -0,0 +1,447 @@ +import 'dart:async'; +import 'dart:core'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter_foreground_task/flutter_foreground_task.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart' as webrtc_impl; +import 'package:just_audio/just_audio.dart'; +import 'package:matrix/matrix.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:provider/provider.dart'; +import 'package:webrtc_interface/webrtc_interface.dart' hide Navigator; + +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/utils/app_state.dart'; +import 'package:fluffychat/utils/platform_infos.dart'; +import 'package:fluffychat/utils/voip/livekit_group_call_session_state.dart'; +import 'package:fluffychat/widgets/fluffy_chat_app.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import '../../pages/voip/widgets/call_banner.dart'; +import '../../pages/voip/widgets/call_overlay.dart'; +import '../../utils/voip/call_session_state.dart'; +import '../../utils/voip/call_state_proxy.dart'; +import '../../utils/voip/famedly_key_provider_impl.dart'; +import '../../utils/voip/incoming_call.dart'; + +enum VoipType { kVoice, kVideo, kGroup } + +class MediaDevicesWrapper extends MediaDevices { + MediaDevicesWrapper() { + AppLifecycleListener( + onResume: () { + _appLifecycleStateResumeStream.sink.add(true); + }, + ); + } + final StreamController _appLifecycleStateResumeStream = + StreamController.broadcast(); + + // We only request mic/cam permissions during the first call (this is handled in + // getUserMedia from flutter_webrtc). But if the first call happens when the + // app is in background (in android), getUserMedia throws an exception and is + // unable to request permissions. + // + // So, awaiting for _waitBeforeRequestingMediaPermission method will make sure + // you wait till the app comes back to foreground (or timeout, whichever comes first). + // If the respective media permissions for the call (mic for audio call, + // mic+cam for video call) have already been granted, the method just returns. + // + // The expected behaviour is, only the push notification that the call has started + // will be shown to users and no callkit ringing, if the respective media + // permissions for the call haven't been granted. The user will click on the push + // notification and the app will come to foreground, then getUserMedia will run. + Future _waitBeforeRequestingMediaPermission({ + required bool isVideoCall, + }) async { + // if android and not on web, + if (kIsWeb || !Platform.isAndroid) return; + // if mic permission not granted and if video call, mic+cam permission not granted, + if (await Permission.microphone.isGranted && + (!isVideoCall || await Permission.camera.isGranted)) return; + // if app currently isn't in foreground, + if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed) { + return; + } + // then, wait till it comes to foreground. + await Future.any([ + _appLifecycleStateResumeStream.stream.first, + Future.delayed(CallTimeouts.callInviteLifetime), + ]); + } + + @override + Future> enumerateDevices() => + webrtc_impl.navigator.mediaDevices.enumerateDevices(); + + @override + Future getDisplayMedia( + Map mediaConstraints, + ) async { + final mediaConstraintsCopy = Map.from(mediaConstraints); + if (!kIsWeb && Platform.isIOS) { + mediaConstraintsCopy['video'] = {'deviceId': 'broadcast'}; + } + return webrtc_impl.navigator.mediaDevices + .getDisplayMedia(mediaConstraintsCopy); + } + + @override + Future getSources() => + webrtc_impl.navigator.mediaDevices.enumerateDevices(); + + @override + Future getUserMedia( + Map mediaConstraints, + ) async { + await _waitBeforeRequestingMediaPermission( + isVideoCall: mediaConstraints.containsKey('video') && + mediaConstraints['video'] != false, + ); + return webrtc_impl.navigator.mediaDevices.getUserMedia(mediaConstraints); + } +} + +class VoipPlugin implements WebRTCDelegate { + static VoipPlugin? _instance; + static CallStateProxy? currentCallProxy; + + final Client client; + + factory VoipPlugin.clientOnly(Client client) { + _instance ??= VoipPlugin._(client); + return _instance!; + } + + /// dw it'll be there + /// do not use this randomly everywhere, rare cases where you are either stuck + /// with a context hack or this + + VoipPlugin._(this.client) { + voip = VoIP(client, this); + encryptionKeyProvider = FamedlyAppEncryptionKeyProviderImpl(client, this); + if (PlatformInfos.isMobile) { + IncomingCallManager(this).initialize(); + } + Connectivity() + .onConnectivityChanged + .listen(_handleNetworkChanged) + .onError((e) => _currentConnectivity = ConnectivityResult.none); + Connectivity() + .checkConnectivity() + .then((result) => _currentConnectivity = result) + .catchError((e) => _currentConnectivity = ConnectivityResult.none); + } + + final MediaDevicesWrapper _mediaDevices = MediaDevicesWrapper(); + + late FamedlyAppEncryptionKeyProviderImpl encryptionKeyProvider; + + late VoIP voip; + ConnectivityResult? _currentConnectivity; + + /// the only time this is null is when `FamedlyApp` is not in the tree (eg: call when the app is not open) + BuildContext get globalContext => FluffyChatApp.appGlobalKey.currentContext!; + + void setupCallAndOpenCallPage(CallStateProxy proxy) { + final provider = Provider.of(globalContext, listen: false); + + if (!FluffyThemes.isColumnMode(globalContext)) { + provider.setGlobalBanner(CallBanner(proxy: proxy, voipPlugin: this)); + } + + if (FluffyThemes.isColumnMode(globalContext) && + currentCall != null && + currentGroupCall == null && + !currentCall!.isOutgoing) { + createOverlay(proxy); + } else { + FluffyChatApp.router.go('/rooms/${proxy.room.id}/call'); + } + } + + void onPhoneButtonTap( + BuildContext context, + Room room, + VoipType callType, + ) async { + await startCall(context, room, callType); + } + + void _handleNetworkChanged(ConnectivityResult result) async { + /// Got a new connectivity status! + if (_currentConnectivity != result) { + voip.calls.forEach((_, sess) { + sess.restartIce(); + }); + } + _currentConnectivity = result; + } + + CallSession? get currentCall => + voip.currentCID == null ? null : voip.calls[voip.currentCID]; + + GroupCallSession? get currentGroupCall => voip.currentGroupCID == null + ? null + : voip.groupCalls[voip.currentGroupCID]; + + bool getInMeetingState() { + return currentCall != null || currentGroupCall != null; + } + + @override + MediaDevices get mediaDevices => _mediaDevices; + + @override + bool get isWeb => kIsWeb; + + @override + Future createPeerConnection( + Map configuration, [ + Map constraints = const {}, + ]) => + webrtc_impl.createPeerConnection(configuration, constraints); + + @override + VideoRenderer createRenderer() { + return webrtc_impl.RTCVideoRenderer(); + } + + int connectedTsSinceEpoch = 0; + int onHoldMs = 0; + + @override + Future handleNewCall(CallSession call) async { + Logs().d('[VoipPlugin] Handle new call'); + connectedTsSinceEpoch = 0; + onHoldMs = 0; + final callProxy = CallSessionState(call, this); + currentCallProxy = callProxy; + if (!call.isOutgoing && !kIsWeb && Platform.isAndroid) { + await IncomingCallManager(this).showIncomingCall(call); + } else { + setupCallAndOpenCallPage(callProxy); + } + client.backgroundSync = true; + } + + @override + Future handleCallEnded(CallSession call) async { + try { + Logs().d('[VoipPlugin] handleCallEnded'); + if (currentCallProxy != null && + currentCallProxy is CallSessionState && + currentCallProxy!.call?.callId == call.callId) { + if (!kIsWeb && Platform.isAndroid) { + await IncomingCallManager(this).endIncomingCall(call.callId); + await FlutterForegroundTask.stopService(); + } + Logs().d('[VoipPlugin] Handle 1:1 call ended'); + + connectedTsSinceEpoch = 0; + onHoldMs = 0; + final provider = Provider.of(globalContext, listen: false); + provider.removeGlobalBanner(); + removeCallPopupOverlay(); + currentCallProxy = null; + + final path = '/rooms/${call.room.id}'; + + if (FluffyChatApp.router.routerDelegate.currentConfiguration.uri + .toString() == + '/rooms/${call.room.id}/call') { + FluffyChatApp.router.go(path); + } + } + } catch (e) { + Logs().e('[VoipPlugin] handleCallEnded failed', e); + } + } + + @override + Future handleNewGroupCall(GroupCallSession groupCall) async { + Logs().d('[VoipPlugin] new group call found'); + } + + @override + Future handleGroupCallEnded(GroupCallSession groupCall) async { + try { + if ((currentCallProxy is GroupCallSession || + currentCallProxy is LiveKitGroupCallSessionState) && + currentCallProxy != null && + currentCallProxy?.groupCall?.groupCallId == groupCall.groupCallId) { + if (!kIsWeb && Platform.isAndroid) { + await FlutterForegroundTask.stopService(); + } + final provider = Provider.of(globalContext, listen: false); + provider.removeGlobalBanner(); + removeCallPopupOverlay(); + currentCallProxy = null; + + final roomPath = '/rooms/${groupCall.room.id}'; + if (FluffyChatApp.router.routerDelegate.currentConfiguration.uri + .toString() == + '/rooms/${groupCall.room.id}/call') { + FluffyChatApp.router.go(roomPath); + } + } + } catch (e, s) { + Logs().e('[VoipPlugin] handleGroupCallEnded failed', e, s); + } + } + + @override + bool get canHandleNewCall => + voip.currentCID == null && voip.currentGroupCID == null; + + @override + Future handleMissedCall(CallSession call) async { + try { + if (currentGroupCall == null) { + await IncomingCallManager(this).showMissedCallNotification(call); + } + } catch (e) { + Logs().w('[VoipPlugin] unable to show missed call notification'); + } + } + + final player = AudioPlayer(); + + @override + Future playRingtone() async { + try { + if (kIsWeb || Platform.isIOS) { + await player.setLoopMode(LoopMode.all); + await player.setAudioSource( + // https://pixabay.com/sound-effects/ringtone-126505 + AudioSource.asset('assets/sounds/ringtone.mp3'), + initialIndex: 0, + initialPosition: Duration.zero, + ); + // don't want to block the UI + unawaited(player.play()); + } + Logs().d('[VoipPlugin] ringtone playing'); + } catch (e) { + Logs().e('[VoipPlugin] unable to start ringtone', e); + } + } + + @override + Future stopRingtone() async { + try { + // TODO: remove ios once callkeep is implemented + if (kIsWeb || Platform.isIOS) { + if (player.playerState.playing) { + // don't want to block the UI + unawaited(player.stop()); + } + } + } catch (e) { + Logs().e('[VoipPlugin] unable to stop ringtone', e); + } + } + + OverlayEntry? callPopupOverlayEntry; + void createOverlay(CallStateProxy proxy) { + callPopupOverlayEntry = OverlayEntry( + maintainState: true, + builder: (context) { + return CallOverlay( + callStateProxy: proxy, + voipPlugin: this, + ); + }, + ); + + Overlay.of(globalContext).insert(callPopupOverlayEntry!); + } + + void createMinimizer(CallStateProxy proxy) async { + if (FluffyThemes.isColumnMode(globalContext)) { + Provider.of(globalContext, listen: false).removeGlobalBanner(); + createOverlay(proxy); + } else { + removeCallPopupOverlay(); + Provider.of(globalContext, listen: false) + .setGlobalBanner(CallBanner(proxy: proxy, voipPlugin: this)); + } + } + + // Remove the OverlayEntry. + void removeCallPopupOverlay() { + callPopupOverlayEntry?.remove(); + callPopupOverlayEntry = null; + } + + Future startCall( + BuildContext context, + Room room, + VoipType callType, + ) async { + FocusManager.instance.primaryFocus?.unfocus(); + + final voipPlugin = Matrix.of(context).voipPlugin; + if ({VoipType.kVideo, VoipType.kVoice}.contains(callType) && + room.isDirectChat) { + if (currentCallProxy != null) { + setupCallAndOpenCallPage(currentCallProxy!); + return; + } + + try { + await voipPlugin.voip.inviteToCall( + room.id, + callType == VoipType.kVoice ? CallType.kVoice : CallType.kVideo, + room.directChatMatrixID!, + ); + + // force null check here because handleNewCall is triggered in the above line anyway + setupCallAndOpenCallPage(currentCallProxy!); + } catch (e, s) { + Logs().e('startCall', e, s); + } + } else if (callType == VoipType.kGroup) { + if (voipPlugin.currentGroupCall != null && + (currentCallProxy is GroupCallSession || + currentCallProxy is LiveKitGroupCallSessionState) && + currentCallProxy != null) { + setupCallAndOpenCallPage(currentCallProxy!); + } else if (currentCallProxy == null) { + FluffyChatApp.router.go('/rooms/${room.id}/group_call_onboarding'); + } + } + } + + String getCallStateSuffix(CallStateProxy proxy, BuildContext context) { + if (proxy.connecting) { + return L10n.of(context)!.connecting; + } + if (proxy.answering) { + return L10n.of(context)!.answering; + } + if (proxy.ended) { + return L10n.of(context)!.ended; + } + if (proxy is CallSessionState) { + if (proxy.isOutgoing) { + return L10n.of(context)!.calling; + } + if (!proxy.isOutgoing) { + return L10n.of(context)!.incomingCall; + } + if (proxy.ringingPlay) { + return L10n.of(context)!.ringing; + } + } + + return 'Unknown state'; + } + + @override + EncryptionKeyProvider? get keyProvider => encryptionKeyProvider; +} diff --git a/lib/utils/voip_plugin.dart b/lib/utils/voip_plugin.dart deleted file mode 100644 index bf36385a5..000000000 --- a/lib/utils/voip_plugin.dart +++ /dev/null @@ -1,200 +0,0 @@ -import 'dart:core'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -import 'package:flutter_foreground_task/flutter_foreground_task.dart'; -import 'package:flutter_webrtc/flutter_webrtc.dart' as webrtc_impl; -import 'package:matrix/matrix.dart'; -import 'package:webrtc_interface/webrtc_interface.dart' hide Navigator; - -import 'package:fluffychat/pages/chat_list/chat_list.dart'; -import 'package:fluffychat/pages/dialer/dialer.dart'; -import 'package:fluffychat/utils/platform_infos.dart'; -import '../../utils/voip/callkeep_manager.dart'; -import '../../utils/voip/user_media_manager.dart'; -import '../widgets/matrix.dart'; - -class VoipPlugin with WidgetsBindingObserver implements WebRTCDelegate { - final MatrixState matrix; - Client get client => matrix.client; - VoipPlugin(this.matrix) { - voip = VoIP(client, this); - if (!kIsWeb) { - final wb = WidgetsBinding.instance; - wb.addObserver(this); - didChangeAppLifecycleState(wb.lifecycleState); - } - } - bool background = false; - bool speakerOn = false; - late VoIP voip; - OverlayEntry? overlayEntry; - BuildContext get context => matrix.context; - - @override - void didChangeAppLifecycleState(AppLifecycleState? state) { - background = (state == AppLifecycleState.detached || - state == AppLifecycleState.paused); - Logs().w('Set background mode in VOIP plugin', background); - } - - void addCallingOverlay(String callId, CallSession call) { - final context = - kIsWeb ? ChatList.contextForVoip! : this.context; // web is weird - - if (overlayEntry != null) { - Logs().e('[VOIP] addCallingOverlay: The call session already exists?'); - overlayEntry!.remove(); - } - // Overlay.of(context) is broken on web - // falling back on a dialog - if (kIsWeb) { - showDialog( - context: context, - builder: (context) => Calling( - context: context, - client: client, - callId: callId, - call: call, - onClear: () => Navigator.of(context).pop(), - ), - ); - } else { - overlayEntry = OverlayEntry( - builder: (_) => Calling( - context: context, - client: client, - callId: callId, - call: call, - onClear: () { - overlayEntry?.remove(); - overlayEntry = null; - }, - ), - ); - Overlay.of(context).insert(overlayEntry!); - } - } - - @override - MediaDevices get mediaDevices => webrtc_impl.navigator.mediaDevices; - - @override - bool get isWeb => kIsWeb; - - @override - Future createPeerConnection( - Map configuration, [ - Map constraints = const {}, - ]) => - webrtc_impl.createPeerConnection(configuration, constraints); - - @override - VideoRenderer createRenderer() { - return webrtc_impl.RTCVideoRenderer(); - } - - Future get hasCallingAccount async => - kIsWeb ? false : await CallKeepManager().hasPhoneAccountEnabled; - - @override - Future playRingtone() async { - if (!background && !await hasCallingAccount) { - try { - await UserMediaManager().startRingingTone(); - } catch (_) {} - } - } - - @override - Future stopRingtone() async { - if (!background && !await hasCallingAccount) { - try { - await UserMediaManager().stopRingingTone(); - } catch (_) {} - } - } - - @override - Future handleNewCall(CallSession call) async { - if (PlatformInfos.isAndroid) { - // probably works on ios too - final hasCallingAccount = await CallKeepManager().hasPhoneAccountEnabled; - if (call.direction == CallDirection.kIncoming && - hasCallingAccount && - call.type == CallType.kVoice) { - ///Popup native telecom manager call UI for incoming call. - final callKeeper = CallKeeper(CallKeepManager(), call); - CallKeepManager().addCall(call.callId, callKeeper); - await CallKeepManager().showCallkitIncoming(call); - return; - } else { - try { - final wasForeground = await FlutterForegroundTask.isAppOnForeground; - - await matrix.store.setString( - 'wasForeground', - wasForeground == true ? 'true' : 'false', - ); - FlutterForegroundTask.setOnLockScreenVisibility(true); - FlutterForegroundTask.wakeUpScreen(); - FlutterForegroundTask.launchApp(); - } catch (e) { - Logs().e('VOIP foreground failed $e'); - } - // use fallback flutter call pages for outgoing and video calls. - addCallingOverlay(call.callId, call); - try { - if (!hasCallingAccount) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'No calling accounts found (used for native calls UI)', - ), - ), - ); - } - } catch (e) { - Logs().e('failed to show snackbar'); - } - } - } else { - addCallingOverlay(call.callId, call); - } - } - - @override - Future handleCallEnded(CallSession session) async { - if (overlayEntry != null) { - overlayEntry!.remove(); - overlayEntry = null; - if (PlatformInfos.isAndroid) { - FlutterForegroundTask.setOnLockScreenVisibility(false); - FlutterForegroundTask.stopService(); - final wasForeground = matrix.store.getString('wasForeground'); - wasForeground == 'false' ? FlutterForegroundTask.minimizeApp() : null; - } - } - } - - @override - Future handleGroupCallEnded(GroupCall groupCall) async { - // TODO: implement handleGroupCallEnded - } - - @override - Future handleNewGroupCall(GroupCall groupCall) async { - // TODO: implement handleNewGroupCall - } - - @override - // TODO: implement canHandleNewCall - bool get canHandleNewCall => - voip.currentCID == null && voip.currentGroupCID == null; - - @override - Future handleMissedCall(CallSession session) async { - // TODO: implement handleMissedCall - } -} diff --git a/lib/widgets/fluffy_chat_app.dart b/lib/widgets/fluffy_chat_app.dart index d9d2f042a..22130a586 100644 --- a/lib/widgets/fluffy_chat_app.dart +++ b/lib/widgets/fluffy_chat_app.dart @@ -3,10 +3,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:go_router/go_router.dart'; import 'package:matrix/matrix.dart'; +import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:fluffychat/config/routes.dart'; import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/utils/app_state.dart'; +import 'package:fluffychat/utils/voip/voip_plugin.dart'; import 'package:fluffychat/widgets/app_lock.dart'; import 'package:fluffychat/widgets/theme_builder.dart'; import '../config/app_config.dart'; @@ -16,6 +19,7 @@ import 'matrix.dart'; class FluffyChatApp extends StatelessWidget { final Widget? testWidget; final List clients; + final List voipPlugins; final String? pincode; final SharedPreferences store; @@ -23,6 +27,7 @@ class FluffyChatApp extends StatelessWidget { super.key, this.testWidget, required this.clients, + required this.voipPlugins, required this.store, this.pincode, }); @@ -36,6 +41,10 @@ class FluffyChatApp extends StatelessWidget { // the current path. static final GoRouter router = GoRouter(routes: AppRoutes.routes); + static final appGlobalKey = GlobalKey(); + + static final appState = AppState(); + @override Widget build(BuildContext context) { return ThemeBuilder( @@ -49,21 +58,29 @@ class FluffyChatApp extends StatelessWidget { localizationsDelegates: L10n.localizationsDelegates, supportedLocales: L10n.supportedLocales, routerConfig: router, - builder: (context, child) => AppLockWidget( - pincode: pincode, - clients: clients, - // Need a navigator above the Matrix widget for - // displaying dialogs - child: Navigator( - onGenerateRoute: (_) => MaterialPageRoute( - builder: (_) => Matrix( - clients: clients, - store: store, - child: testWidget ?? child, + builder: (context, child) { + return ChangeNotifierProvider.value( + key: ValueKey(themeMode), + value: appState, + child: AppLockWidget( + pincode: pincode, + clients: clients, + // Need a navigator above the Matrix widget for + // displaying dialogs + child: Navigator( + onGenerateRoute: (_) => MaterialPageRoute( + builder: (_) => Matrix( + key: appGlobalKey, + clients: clients, + voipPlugins: voipPlugins, + store: store, + child: testWidget ?? child, + ), + ), ), ), - ), - ), + ); + }, ), ); } diff --git a/lib/widgets/layouts/two_column_layout.dart b/lib/widgets/layouts/two_column_layout.dart index a6f4c8bdf..13355e545 100644 --- a/lib/widgets/layouts/two_column_layout.dart +++ b/lib/widgets/layouts/two_column_layout.dart @@ -1,5 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +import 'package:fluffychat/pages/global_banner_scaffold.dart'; +import 'package:fluffychat/utils/app_state.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + class TwoColumnLayout extends StatelessWidget { final Widget mainView; final Widget sideView; @@ -15,21 +22,56 @@ class TwoColumnLayout extends StatelessWidget { Widget build(BuildContext context) { return ScaffoldMessenger( child: Scaffold( - body: Row( + body: Column( children: [ - Container( - clipBehavior: Clip.antiAlias, - decoration: const BoxDecoration(), - width: 360.0 + (displayNavigationRail ? 64 : 0), - child: mainView, - ), - Container( - width: 1.0, - color: Theme.of(context).dividerColor, + Selector( + selector: (_, state) => state.globalBanner, + builder: (context, banner, _) { + final showBanner = !GlobalBannerScaffold.ignoreBannerRoutes.any( + (route) => GoRouter.of(context) + .routerDelegate + .currentConfiguration + .uri + .toString() + .contains(route), + ) && + Matrix.of(context).client.isLogged(); + return AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + child: Column( + children: [ + if (showBanner && banner != null) + Padding( + padding: const EdgeInsets.only( + top: 8.0, + ), + child: banner, + ), + ], + ), + ); + }, ), Expanded( - child: ClipRRect( - child: sideView, + child: Row( + children: [ + Container( + clipBehavior: Clip.antiAlias, + decoration: const BoxDecoration(), + width: 360.0 + (displayNavigationRail ? 64 : 0), + child: mainView, + ), + Container( + width: 1.0, + color: Theme.of(context).dividerColor, + ), + Expanded( + child: ClipRRect( + child: sideView, + ), + ), + ], ), ), ], diff --git a/lib/widgets/matrix.dart b/lib/widgets/matrix.dart index 9f98931b3..546b70140 100644 --- a/lib/widgets/matrix.dart +++ b/lib/widgets/matrix.dart @@ -18,12 +18,13 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:universal_html/html.dart' as html; import 'package:url_launcher/url_launcher_string.dart'; +import 'package:fluffychat/pages/global_banner_scaffold.dart'; import 'package:fluffychat/utils/client_manager.dart'; import 'package:fluffychat/utils/init_with_restore.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/utils/uia_request_manager.dart'; -import 'package:fluffychat/utils/voip_plugin.dart'; +import 'package:fluffychat/utils/voip/voip_plugin.dart'; import 'package:fluffychat/widgets/fluffy_chat_app.dart'; import '../config/app_config.dart'; import '../config/setting_keys.dart'; @@ -32,12 +33,11 @@ import '../utils/account_bundles.dart'; import '../utils/background_push.dart'; import 'local_notifications_extension.dart'; -// import 'package:flutter_secure_storage/flutter_secure_storage.dart'; - class Matrix extends StatefulWidget { final Widget? child; final List clients; + final List voipPlugins; final Map? queryParameters; @@ -46,6 +46,7 @@ class Matrix extends StatefulWidget { const Matrix({ this.child, required this.clients, + required this.voipPlugins, required this.store, this.queryParameters, super.key, @@ -71,6 +72,9 @@ class MatrixState extends State with WidgetsBindingObserver { BackgroundPush? backgroundPush; + VoipPlugin get voipPlugin => widget.voipPlugins + .singleWhere((element) => element.client.userID == client.userID); + Client get client { if (widget.clients.isEmpty) { widget.clients.add(getLoginClient()); @@ -81,8 +85,6 @@ class MatrixState extends State with WidgetsBindingObserver { return widget.clients[_activeClient]; } - VoipPlugin? voipPlugin; - bool get isMultiAccount => widget.clients.length > 1; int getClientIndexByMatrixId(String matrixId) => @@ -95,8 +97,6 @@ class MatrixState extends State with WidgetsBindingObserver { final i = widget.clients.indexWhere((c) => c == cl); if (i != -1) { _activeClient = i; - // TODO: Multi-client VoiP support - createVoipPlugin(); } else { Logs().w('Tried to set an unknown client ${cl!.userID} as active'); } @@ -403,16 +403,6 @@ class MatrixState extends State with WidgetsBindingObserver { }, ); } - - createVoipPlugin(); - } - - void createVoipPlugin() async { - if (store.getBool(SettingKeys.experimentalVoip) == false) { - voipPlugin = null; - return; - } - voipPlugin = VoipPlugin(this); } @override @@ -489,7 +479,7 @@ class MatrixState extends State with WidgetsBindingObserver { Widget build(BuildContext context) { return Provider( create: (_) => this, - child: widget.child, + child: GlobalBannerScaffold(child: widget.child ?? Container()), ); } } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index f5ab4a3f8..c1f5f43bf 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,6 +7,7 @@ import Foundation import appkit_ui_element_colors import audio_session +import connectivity_plus import desktop_drop import device_info_plus import dynamic_color @@ -19,6 +20,7 @@ import flutter_web_auth_2 import flutter_webrtc import geolocator_apple import just_audio +import livekit_client import macos_ui import macos_window_utils import package_info_plus @@ -38,6 +40,7 @@ import window_to_front func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppkitUiElementColorsPlugin.register(with: registry.registrar(forPlugin: "AppkitUiElementColorsPlugin")) AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) + ConnectivityPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlugin")) DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin")) @@ -50,6 +53,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) + LiveKitPlugin.register(with: registry.registrar(forPlugin: "LiveKitPlugin")) MacOSUiPlugin.register(with: registry.registrar(forPlugin: "MacOSUiPlugin")) MacOSWindowUtilsPlugin.register(with: registry.registrar(forPlugin: "MacOSWindowUtilsPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 221a21755..2cebe7c7d 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -3,6 +3,9 @@ PODS: - FlutterMacOS - audio_session (0.0.1): - FlutterMacOS + - connectivity_plus (0.0.1): + - FlutterMacOS + - ReachabilitySwift - desktop_drop (0.0.1): - FlutterMacOS - device_info_plus (0.0.1): @@ -34,6 +37,9 @@ PODS: - FlutterMacOS - just_audio (0.0.1): - FlutterMacOS + - livekit_client (1.5.6): + - FlutterMacOS + - WebRTC-SDK (= 114.5735.08) - macos_ui (0.1.0): - FlutterMacOS - macos_window_utils (1.0.0): @@ -45,6 +51,7 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - ReachabilitySwift (5.0.0) - record_macos (0.2.0): - FlutterMacOS - share_plus (0.0.1): @@ -80,6 +87,7 @@ PODS: DEPENDENCIES: - appkit_ui_element_colors (from `Flutter/ephemeral/.symlinks/plugins/appkit_ui_element_colors/macos`) - audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`) + - connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`) - desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos`) - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - dynamic_color (from `Flutter/ephemeral/.symlinks/plugins/dynamic_color/macos`) @@ -93,6 +101,7 @@ DEPENDENCIES: - FlutterMacOS (from `Flutter/ephemeral`) - geolocator_apple (from `Flutter/ephemeral/.symlinks/plugins/geolocator_apple/macos`) - just_audio (from `Flutter/ephemeral/.symlinks/plugins/just_audio/macos`) + - livekit_client (from `Flutter/ephemeral/.symlinks/plugins/livekit_client/macos`) - macos_ui (from `Flutter/ephemeral/.symlinks/plugins/macos_ui/macos`) - macos_window_utils (from `Flutter/ephemeral/.symlinks/plugins/macos_window_utils/macos`) - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) @@ -112,6 +121,7 @@ DEPENDENCIES: SPEC REPOS: trunk: - FMDB + - ReachabilitySwift - SQLCipher - WebRTC-SDK @@ -120,6 +130,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/appkit_ui_element_colors/macos audio_session: :path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos + connectivity_plus: + :path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos desktop_drop: :path: Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos device_info_plus: @@ -146,6 +158,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/geolocator_apple/macos just_audio: :path: Flutter/ephemeral/.symlinks/plugins/just_audio/macos + livekit_client: + :path: Flutter/ephemeral/.symlinks/plugins/livekit_client/macos macos_ui: :path: Flutter/ephemeral/.symlinks/plugins/macos_ui/macos macos_window_utils: @@ -180,6 +194,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: appkit_ui_element_colors: 39bb2d80be3f19b152ccf4c70d5bbe6cba43d74a audio_session: dea1f41890dbf1718f04a56f1d6150fd50039b72 + connectivity_plus: 18d3c32514c886e046de60e9c13895109866c747 desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898 device_info_plus: 5401765fde0b8d062a2f8eb65510fb17e77cf07f dynamic_color: 2eaa27267de1ca20d879fbd6e01259773fb1670f @@ -194,11 +209,13 @@ SPEC CHECKSUMS: FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a geolocator_apple: 821be05bbdb1b49500e029ebcbf2d6acf2dfb966 just_audio: 9b67ca7b97c61cfc9784ea23cd8cc55eb226d489 + livekit_client: 741fe5ed70d6de06aed4e5d8423903e422b0ec18 macos_ui: 6229a8922cd97bafb7d9636c8eb8dfb0744183ca macos_window_utils: 933f91f64805e2eb91a5bd057cf97cd097276663 package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce pasteboard: 9b69dba6fedbb04866be632205d532fe2f6b1d99 path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 + ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 record_macos: 937889e0f2a7a12b6fc14e97a3678e5a18943de6 share_plus: 76dd39142738f7a68dd57b05093b5e8193f220f7 shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 @@ -214,4 +231,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: d0975b16fbdecb73b109d8fbc88aa77ffe4c7a8d -COCOAPODS: 1.14.3 +COCOAPODS: 1.15.0 diff --git a/pubspec.lock b/pubspec.lock index 81cfa74f1..943b9c4f3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + all_sensors: + dependency: "direct main" + description: + name: all_sensors + sha256: "5de1d20c3fee63d18be29ed27c368143d980e7fc5a805c3903946028816ac375" + url: "https://pub.dev" + source: hosted + version: "0.4.2" analyzer: dependency: transitive description: @@ -129,14 +137,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - callkeep: - dependency: "direct main" - description: - name: callkeep - sha256: "9e86e9632a603a61f7045c179ea5ca0ee4da0a49fc5f80c2fe09fb422b96d3c6" - url: "https://pub.dev" - source: hosted - version: "0.3.3" canonical_json: dependency: transitive description: @@ -201,6 +201,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: "224a77051d52a11fbad53dd57827594d3bd24f945af28bd70bab376d68d437f0" + url: "https://pub.dev" + source: hosted + version: "5.0.2" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: cf1d1c28f4416f8c654d7dc3cd638ec586076255d407cef3ddbdaf178272a71a + url: "https://pub.dev" + source: hosted + version: "1.2.4" console: dependency: transitive description: @@ -233,6 +249,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + cryptography: + dependency: transitive + description: + name: cryptography + sha256: d146b76d33d94548cf035233fbc2f4338c1242fa119013bead807d033fc4ae05 + url: "https://pub.dev" + source: hosted + version: "2.7.0" csslib: dependency: transitive description: @@ -329,6 +353,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.0" + duration: + dependency: "direct main" + description: + name: duration + sha256: "0548a12d235dab185c677ef660995f23fdc06a02a2b984aa23805f6a03d82815" + url: "https://pub.dev" + source: hosted + version: "3.0.13" dynamic_color: dependency: "direct main" description: @@ -441,6 +473,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.3+1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + url: "https://pub.dev" + source: hosted + version: "1.1.0" flutter: dependency: "direct main" description: flutter @@ -470,6 +510,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.3.1" + flutter_callkit_incoming: + dependency: "direct main" + description: + name: flutter_callkit_incoming + sha256: ccfc2cb0a1ad22e4e94e235803a85f3403007fda9efac9922b69d64fb51efbe4 + url: "https://pub.dev" + source: hosted + version: "2.0.1+2" flutter_driver: dependency: transitive description: flutter @@ -712,6 +760,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + flutter_staggered_grid_view: + dependency: "direct main" + description: + name: flutter_staggered_grid_view + sha256: "19e7abb550c96fbfeb546b23f3ff356ee7c59a019a651f8f102a4ba9b7349395" + url: "https://pub.dev" + source: hosted + version: "0.7.0" flutter_svg: dependency: transitive description: @@ -758,10 +814,10 @@ packages: dependency: "direct main" description: name: flutter_webrtc - sha256: "8522e9f347aed9f03ec591d05fc286a698c1b11a1a6d3e994e92727d24c6f352" + sha256: "577216727181cb13776a65d3e7cb33e783e740c5496335011aed4a038b28c3fe" url: "https://pub.dev" source: hosted - version: "0.9.46" + version: "0.9.47" fuchsia_remote_debug_protocol: dependency: transitive description: flutter @@ -1101,6 +1157,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + livekit_client: + dependency: "direct main" + description: + name: livekit_client + sha256: e56294ac4b837f00dfeb16a814ee6d5290cb2ad6230714ce8722cbeed85bff3e + url: "https://pub.dev" + source: hosted + version: "1.5.6" logging: dependency: transitive description: @@ -1152,19 +1216,20 @@ packages: matrix: dependency: "direct main" description: - name: matrix - sha256: "84e5745dd41468a2870d119e597529e6471f3ce2f400e4b35d5bd6a036a98692" - url: "https://pub.dev" - source: hosted - version: "0.25.7" + path: "." + ref: "td/fosdemDemoFork" + resolved-ref: bee59c1508caac8189fa47f38fd15991e286010b + url: "https://github.com/famedly/matrix-dart-sdk.git" + source: git + version: "0.25.6" matrix_api_lite: dependency: transitive description: name: matrix_api_lite - sha256: "62bdd1dffb956e956863ba21e52109157502342b749e4728f4105f0c6d73a254" + sha256: "0e92d3402b4cbb8ab9283fd2fbe44147facf6f73de88f5adf0b3123bc5114bc1" url: "https://pub.dev" source: hosted - version: "1.7.2" + version: "1.7.3" meta: dependency: transitive description: @@ -1213,6 +1278,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" olm: dependency: transitive description: @@ -1437,6 +1510,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + protobuf: + dependency: transitive + description: + name: protobuf + sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d" + url: "https://pub.dev" + source: hosted + version: "3.1.0" provider: dependency: "direct main" description: @@ -1706,6 +1787,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.0" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" sqflite: dependency: "direct main" description: @@ -2003,13 +2092,13 @@ packages: source: hosted version: "3.1.0" uuid: - dependency: transitive + dependency: "direct main" description: name: uuid - sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313" + sha256: cd210a09f7c18cbe5a02511718e0334de6559871052c90a90c0cca46a4aa81c8 url: "https://pub.dev" source: hosted - version: "3.0.7" + version: "4.3.3" vector_graphics: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 26b9f62bf..1605dbbba 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,29 +9,31 @@ environment: dependencies: adaptive_dialog: ^2.0.0 + all_sensors: ^0.4.2 animations: ^2.0.8 archive: ^3.4.9 async: ^2.11.0 badges: ^3.1.2 blurhash_dart: ^1.2.1 - callkeep: ^0.3.2 chewie: ^1.7.1 collection: ^1.17.2 + connectivity_plus: ^5.0.2 cupertino_icons: any desktop_drop: ^0.4.4 desktop_notifications: ^0.6.3 device_info_plus: ^9.1.0 + duration: ^3.0.13 dynamic_color: ^1.6.8 emoji_picker_flutter: ^1.6.3 emoji_proposal: ^0.0.1 emojis: ^0.9.9 - #fcm_shared_isolate: ^0.1.0 file_picker: ^6.1.1 flutter: sdk: flutter flutter_app_badger: ^1.5.0 flutter_blurhash: ^0.8.2 flutter_cache_manager: ^3.3.0 + flutter_callkit_incoming: ^2.0.1+2 flutter_foreground_task: ^6.0.0+1 flutter_highlighter: ^0.1.1 flutter_html: ^3.0.0-beta.2 @@ -42,10 +44,11 @@ dependencies: sdk: flutter flutter_map: ^4.0.0 flutter_math_fork: ^0.7.2 - flutter_olm: 1.3.2 # Keep in sync with scripts/prepare-web.sh ! 1.4.0 does currently not build on Android + flutter_olm: 1.3.2 flutter_openssl_crypto: ^0.3.0 flutter_ringtone_player: ^4.0.0+2 flutter_secure_storage: ^9.0.0 + flutter_staggered_grid_view: ^0.7.0 flutter_typeahead: ^4.8.0 flutter_web_auth_2: ^3.0.4 flutter_webrtc: ^0.9.46 @@ -62,7 +65,8 @@ dependencies: keyboard_shortcuts: ^0.1.4 latlong2: ^0.8.1 linkify: ^5.0.0 - matrix: ^0.25.7 + livekit_client: ^1.5.6 + matrix: ^0.25.8 native_imaging: ^0.1.0 package_info_plus: ^4.0.0 pasteboard: ^0.2.0 @@ -73,10 +77,10 @@ dependencies: qr_code_scanner: ^1.0.0 qr_flutter: ^4.0.0 receive_sharing_intent: ^1.4.5 - record: 4.4.4 # Upgrade to 5 currently breaks playing on iOS + record: 4.4.4 scroll_to_index: ^3.0.1 share_plus: ^7.2.1 - shared_preferences: ^2.2.0 # Pinned because https://github.com/flutter/flutter/issues/118401 + shared_preferences: ^2.2.0 slugify: ^2.0.0 sqflite: ^2.3.0 sqflite_common_ffi: ^2.3.0+4 @@ -87,11 +91,12 @@ dependencies: unifiedpush: ^5.0.1 universal_html: ^2.2.4 url_launcher: ^6.2.1 + uuid: ^4.3.3 vibration: ^1.8.3 video_compress: ^3.1.1 video_player: ^2.8.1 wakelock_plus: ^1.1.3 - webrtc_interface: ^1.0.13 + webrtc_interface: ^1.1.2 dev_dependencies: dart_code_metrics: ^5.7.5 @@ -116,8 +121,6 @@ flutter: assets: - assets/ - assets/sounds/ - - assets/js/ - - assets/js/package/ fonts: - family: Roboto @@ -160,6 +163,12 @@ dependency_overrides: git: url: https://github.com/TheOneWithTheBraid/keyboard_shortcuts.git ref: null-safety + matrix: + # path: ../matrix-dart-sdk + git: + url: https://github.com/famedly/matrix-dart-sdk.git + ref: td/fosdemDemoFork + uuid: ^4.3.3 # blocked upgrade of package_info_plus for null safety # https://github.com/creativecreatorormaybenot/wakelock/pull/203 wakelock_windows: diff --git a/scripts/prepare-web.sh b/scripts/prepare-web.sh index 70b15a246..d8adceb77 100755 --- a/scripts/prepare-web.sh +++ b/scripts/prepare-web.sh @@ -1,10 +1,37 @@ #!/bin/sh -ve -rm -r assets/js/package +rm -r assets/js || true +mkdir assets/js +cd web/ +# curl -L $(curl -s 'https://github.com/repos/famedly/olm/releases' | jq -r '.[0] | .assets | .[0] | .browser_download_url') > olm.zip +curl -L 'https://github.com/famedly/olm/releases/download/v1.3.2/olm.zip' > olm.zip # make sure to sync version with pubspec.yaml +unzip olm.zip +rm olm.zip -OLM_VERSION=$(cat pubspec.yaml | yq .dependencies.flutter_olm) -DOWNLOAD_PATH="https://github.com/famedly/olm/releases/download/v$OLM_VERSION/olm.zip" +# extract olm version and encode it in the files to cache bust +# The first line is a link to source code including tag. +# We extract the version number from the tag by only printing the line matching the sed expression (-n). +# We only print the first capture group, which is 3 digit groups separated by dots. +# macOS sed does not have the + quantifier, so for the first group we emulate it by matching the first digit explicitly. +olm_version=$(sed -n 's,// @source: .*\([[:digit:]][[:digit:]]*\.[[:digit:]]*\.[[:digit:]]*\),\1,p' javascript/olm.js) +sed -i'.bak' "s/olm.js.*\"/olm.js?v=${olm_version}\"/" index.html +sed -i'.bak' "s/olm.wasm/olm.wasm?v=${olm_version}/" javascript/olm.js +rm index.html.bak +rm javascript/olm.js.bak -cd assets/js/ && curl -L $DOWNLOAD_PATH > olm.zip && cd ../../ -cd assets/js/ && unzip olm.zip && cd ../../ -cd assets/js/ && rm olm.zip && cd ../../ -cd assets/js/ && mv javascript package && cd ../../ +mv javascript/* . +rmdir javascript + +# curl -L $(curl -s 'https://github.com/repos/famedly/dart_native_imaging/releases' | jq -r '.[0] | .assets | .[0] | .browser_download_url') > native_imaging.zip +curl -L 'https://github.com/famedly/dart_native_imaging/releases/download/v0.1.1/native_imaging.zip' > native_imaging.zip # make sure to sync version with pubspec.yaml +unzip native_imaging.zip +mv js/* . +rmdir js +rm native_imaging.zip + + +git clone https://github.com/flutter-webrtc/dart-webrtc.git -b e2ee/improvements +cd dart-webrtc +dart pub get +dart compile js lib/src/e2ee.worker/e2ee.worker.dart -o ../e2ee.worker.dart.js -m +cd .. && rm -rf dart-webrtc && cd .. +flutter pub get \ No newline at end of file diff --git a/web/index.html b/web/index.html index 3a3fdc773..ef27ed866 100644 --- a/web/index.html +++ b/web/index.html @@ -15,7 +15,7 @@ This is a placeholder for base href that will be replaced by the value of the `--base-href` argument provided to `flutter build`. --> - + @@ -40,7 +40,8 @@ - + + diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index f3aa99546..332ebf514 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,12 +6,14 @@ #include "generated_plugin_registrant.h" +#include #include #include #include #include #include #include +#include #include #include #include @@ -20,6 +22,8 @@ #include void RegisterPlugins(flutter::PluginRegistry* registry) { + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); DesktopDropPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("DesktopDropPlugin")); DynamicColorPluginCApiRegisterWithRegistrar( @@ -32,6 +36,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); FlutterWebRTCPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterWebRTCPlugin")); + LiveKitPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("LiveKitPlugin")); PasteboardPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PasteboardPlugin")); PermissionHandlerWindowsPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 69d3cc36e..898ec04a9 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,12 +3,14 @@ # list(APPEND FLUTTER_PLUGIN_LIST + connectivity_plus desktop_drop dynamic_color emoji_picker_flutter file_selector_windows flutter_secure_storage_windows flutter_webrtc + livekit_client pasteboard permission_handler_windows record_windows