diff --git a/android/app/build.gradle b/android/app/build.gradle index fba903e3..2ea90145 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -32,7 +32,7 @@ if (keystorePropertiesFile.exists()) { } android { - compileSdkVersion 32 + compileSdkVersion 33 sourceSets { main.java.srcDirs += 'src/main/kotlin' diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ff394d9d..a24f8046 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -14,8 +14,6 @@ - - @@ -23,10 +21,11 @@ + + - + tools:overrideLibrary="io.wazo.callkeep, net.touchcapture.qr.flutterqr, com.cloudwebrtc.webrtc, org.webrtc, com.it_nomads.fluttersecurestorage, com.pichillilorenzo.flutter_inappwebview, com.example.video_compress, com.otaliastudios.transcoder, com.otaliastudios.opengl, com.kineapps.flutter_file_dialog, com.llfbandit.record, com.pravera.flutter_foreground_task"/> @@ -109,6 +110,19 @@ + + + + + + + + + diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index a57e3e9e..728fccd5 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -2891,6 +2891,15 @@ }, "user": "User", "custom": "Custom", + "foregroundServiceRunning": "This notification appears when the foreground service is running.", + "screenSharingTitle": "screen sharing", + "screenSharingDetail": "You are sharing your screen in famedly", + "callingPermissions": "Calling permissions", + "callingAccount": "Calling account", + "callingAccountDetails": "Allows FluffyChat to use the native android dialer app.", + "appearOnTop": "Appear on top", + "appearOnTopDetails": "Allows the app to appear on top (not needed if you already have Fluffychat setup as a calling account)", + "otherCallingPermissions":"Microphone, camera and other FluffyChat permissions", "whyIsThisMessageEncrypted": "Why is this message unreadable?", "noKeyForThisMessage": "This can happen if the message was sent before you have signed in to your account at this device.\n\nIt is also possible that the sender has blocked your device or something went wrong with the internet connection.\n\nAre you able to read the message on another session? Then you can transfer the message from it! Go to Settings > Devices and make sure that your devices have verified each other. When you open the room the next time and both sessions are in the foreground, the keys will be transmitted automatically.\n\nDo you not want to loose the keys when logging out or switching devices? Make sure that you have enabled the chat backup in the settings.", "newGroup": "New group", diff --git a/ios/Podfile b/ios/Podfile index fcd934c7..5c139667 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -39,12 +39,17 @@ post_install do |installer| flutter_additional_ios_build_settings(target) target.build_configurations.each do |config| config.build_settings['ENABLE_BITCODE'] = 'NO' - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '9.0' - # https://github.com/flutter-webrtc/flutter-webrtc/issues/713 - if target.name == "flutter_webrtc" || target.name == "WebRTC-SDK" - config.build_settings['ONLY_ACTIVE_ARCH'] = 'YES' - end + # see https://github.com/flutter-webrtc/flutter-webrtc/issues/1054 + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '11.0' + config.build_settings["EXCLUDED_ARCHS[sdk=iphonesimulator*]"] = 'arm64 i386' + + config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ + '$(inherited)', + # dart: PermissionGroup.microphone + 'PERMISSION_MICROPHONE=1', + ] end end + flutter_post_install(installer) if defined?(flutter_post_install) end diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 80031751..577d2aea 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -25,7 +25,6 @@ import 'package:fluffychat/utils/matrix_sdk_extensions.dart/event_extension.dart import 'package:fluffychat/utils/matrix_sdk_extensions.dart/ios_badge_client_extension.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions.dart/matrix_locals.dart'; import 'package:fluffychat/utils/platform_infos.dart'; -import 'package:fluffychat/utils/voip/callkeep_manager.dart'; import 'package:fluffychat/widgets/matrix.dart'; import '../../utils/account_bundles.dart'; import '../../utils/localized_exception_extension.dart'; @@ -180,14 +179,6 @@ class ChatController extends State { void initState() { scrollController.addListener(_updateScrollController); inputFocus.addListener(_inputFocusListener); - final voipPlugin = Matrix.of(context).voipPlugin; - - if (voipPlugin != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - CallKeepManager().setVoipPlugin(voipPlugin); - CallKeepManager().initialize().catchError((_) => true); - }); - } super.initState(); } diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart index 32707ff0..42682e24 100644 --- a/lib/pages/chat_list/chat_list.dart +++ b/lib/pages/chat_list/chat_list.dart @@ -22,6 +22,7 @@ import 'package:fluffychat/utils/platform_infos.dart'; import '../../../utils/account_bundles.dart'; import '../../utils/matrix_sdk_extensions.dart/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'; @@ -53,6 +54,7 @@ enum ActiveFilter { } class ChatList extends StatefulWidget { + static BuildContext? contextForVoip; const ChatList({Key? key}) : super(key: key); @override @@ -361,7 +363,7 @@ class ChatListController extends State scrollController.addListener(_onScroll); _waitForFirstSync(); _hackyWebRTCFixForWeb(); - + CallKeepManager().initialize(); WidgetsBinding.instance.addPostFrameCallback((_) async { searchServer = await Store().getItem(_serverStoreNamespace); }); @@ -670,7 +672,7 @@ class ChatListController extends State } void _hackyWebRTCFixForWeb() { - Matrix.of(context).voipPlugin?.context = context; + ChatList.contextForVoip = context; } Future _checkTorBrowser() async { diff --git a/lib/pages/dialer/dialer.dart b/lib/pages/dialer/dialer.dart index a7b6b067..20e35fd8 100644 --- a/lib/pages/dialer/dialer.dart +++ b/lib/pages/dialer/dialer.dart @@ -22,9 +22,12 @@ 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/wakelock.dart'; import 'package:fluffychat/utils/platform_infos.dart'; @@ -124,47 +127,47 @@ class Calling extends StatefulWidget { } class MyCallingPage extends State { - Room? get room => call?.room; + Room? get room => call.room; - String get displayName => call?.displayName ?? ''; + String get displayName => call.displayName ?? ''; String get callId => widget.callId; - CallSession? get call => widget.call; + CallSession get call => widget.call; MediaStream? get localStream { - if (call != null && call!.localUserMediaStream != null) { - return call!.localUserMediaStream!.stream!; + if (call.localUserMediaStream != null) { + return call.localUserMediaStream!.stream!; } return null; } MediaStream? get remoteStream { - if (call != null && call!.getRemoteStreams.isNotEmpty) { - return call!.getRemoteStreams[0].stream!; + if (call.getRemoteStreams.isNotEmpty) { + return call.getRemoteStreams[0].stream!; } return null; } - bool get speakerOn => call?.speakerOn ?? false; + bool get speakerOn => call.speakerOn; - bool get isMicrophoneMuted => call?.isMicrophoneMuted ?? false; + bool get isMicrophoneMuted => call.isMicrophoneMuted; - bool get isLocalVideoMuted => call?.isLocalVideoMuted ?? false; + bool get isLocalVideoMuted => call.isLocalVideoMuted; - bool get isScreensharingEnabled => call?.screensharingEnabled ?? false; + bool get isScreensharingEnabled => call.screensharingEnabled; - bool get isRemoteOnHold => call?.remoteOnHold ?? false; + bool get isRemoteOnHold => call.remoteOnHold; - bool get voiceonly => call == null || call?.type == CallType.kVoice; + bool get voiceonly => call.type == CallType.kVoice; - bool get connecting => call?.state == CallState.kConnecting; + bool get connecting => call.state == CallState.kConnecting; - bool get connected => call?.state == CallState.kConnected; + bool get connected => call.state == CallState.kConnected; - bool get mirrored => call?.facingMode == 'user'; + bool get mirrored => call.facingMode == 'user'; - List get streams => call?.streams ?? []; + List get streams => call.streams; double? _localVideoHeight; double? _localVideoWidth; EdgeInsetsGeometry? _localVideoMargin; @@ -190,8 +193,6 @@ class MyCallingPage extends State { void initialize() async { final call = this.call; - if (call == null) return; - call.onCallStateChanged.stream.listen(_handleCallState); call.onCallEventChanged.stream.listen((event) { if (event == CallEvent.kFeedsChanged) { @@ -220,7 +221,7 @@ class MyCallingPage extends State { const Duration(seconds: 2), () => widget.onClear?.call(), ); - if (call?.type == CallType.kVideo) { + if (call.type == CallType.kVideo) { try { unawaited(Wakelock.disable()); } catch (_) {} @@ -230,7 +231,7 @@ class MyCallingPage extends State { @override void dispose() { super.dispose(); - call?.cleanUp.call(); + call.cleanUp.call(); } void _resizeLocalVideo(Orientation orientation) { @@ -249,6 +250,14 @@ class MyCallingPage extends State { 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; @@ -259,52 +268,69 @@ class MyCallingPage extends State { void _answerCall() { setState(() { - call?.answer(); + call.answer(); }); } void _hangUp() { setState(() { - if (call != null && (call?.isRinging ?? false)) { - call?.reject(); + if (call.isRinging) { + call.reject(); } else { - call?.hangup(); + call.hangup(); } }); } void _muteMic() { setState(() { - call?.setMicrophoneMuted(!call!.isMicrophoneMuted); + call.setMicrophoneMuted(!call.isMicrophoneMuted); }); } - void _screenSharing() { + void _screenSharing() async { + if (PlatformInfos.isAndroid) { + if (!call.screensharingEnabled) { + await FlutterForegroundTask.init( + androidNotificationOptions: AndroidNotificationOptions( + channelId: 'notification_channel_id', + channelName: 'Foreground Notification', + channelDescription: L10n.of(context)!.foregroundServiceRunning, + ), + ); + FlutterForegroundTask.startService( + notificationTitle: L10n.of(context)!.screenSharingTitle, + notificationText: L10n.of(context)!.screenSharingDetail); + } else { + FlutterForegroundTask.stopService(); + } + } + setState(() { - call?.setScreensharingEnabled(!call!.screensharingEnabled); + call.setScreensharingEnabled(!call.screensharingEnabled); }); } void _remoteOnHold() { setState(() { - call?.setRemoteOnHold(!call!.remoteOnHold); + call.setRemoteOnHold(!call.remoteOnHold); }); } void _muteCamera() { setState(() { - call?.setLocalVideoMuted(!call!.isLocalVideoMuted); + call.setLocalVideoMuted(!call.isLocalVideoMuted); }); } void _switchCamera() async { - if (call!.localUserMediaStream != null) { + if (call.localUserMediaStream != null) { await Helper.switchCamera( - call!.localUserMediaStream!.stream!.getVideoTracks()[0]); + call.localUserMediaStream!.stream!.getVideoTracks()[0]); if (PlatformInfos.isMobile) { - call!.facingMode == 'user' - ? call!.facingMode = 'environment' - : call!.facingMode = 'user'; + call.facingMode == 'user' + ? call.facingMode = 'environment' + : call.facingMode = 'user'; } } setState(() {}); @@ -319,7 +345,7 @@ class MyCallingPage extends State { */ List _buildActionButtons(bool isFloating) { - if (isFloating || call == null) { + if (isFloating) { return []; } @@ -391,7 +417,7 @@ class MyCallingPage extends State { case CallState.kInviteSent: case CallState.kCreateAnswer: case CallState.kConnecting: - return call!.isOutgoing + return call.isOutgoing ? [hangupButton] : [answerButton, hangupButton]; case CallState.kConnected: @@ -429,7 +455,7 @@ class MyCallingPage extends State { final stackWidgets = []; final call = this.call; - if (call == null || call.callHasEnded) { + if (call.callHasEnded) { return stackWidgets; } diff --git a/lib/pages/settings_chat/settings_chat_view.dart b/lib/pages/settings_chat/settings_chat_view.dart index 2b381c64..e62a9411 100644 --- a/lib/pages/settings_chat/settings_chat_view.dart +++ b/lib/pages/settings_chat/settings_chat_view.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; @@ -6,6 +7,7 @@ import 'package:vrouter/vrouter.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'; @@ -68,6 +70,17 @@ class SettingsChatView extends StatelessWidget { defaultValue: AppConfig.experimentalVoip, ), const Divider(height: 1), + if (Matrix.of(context).webrtcIsSupported && !kIsWeb) + ListTile( + title: Text(L10n.of(context)!.callingPermissions), + onTap: () => + CallKeepManager().checkoutPhoneAccountSetting(context), + trailing: const Padding( + padding: EdgeInsets.all(16.0), + child: Icon(Icons.call), + ), + ), + const Divider(height: 1), ListTile( title: Text(L10n.of(context)!.emoteSettings), onTap: () => VRouter.of(context).to('emotes'), diff --git a/lib/utils/push_helper.dart b/lib/utils/push_helper.dart index 985eaa7d..d3db5473 100644 --- a/lib/utils/push_helper.dart +++ b/lib/utils/push_helper.dart @@ -13,6 +13,7 @@ import 'package:fluffychat/config/setting_keys.dart'; import 'package:fluffychat/utils/client_manager.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions.dart/matrix_locals.dart'; import 'package:fluffychat/utils/platform_infos.dart'; +import 'package:fluffychat/utils/voip/callkeep_manager.dart'; Future pushHelper( PushNotification notification, { @@ -110,11 +111,29 @@ Future _tryPushHelper( } return; } - Logs().v('Push helper got notification event.'); + Logs().v('Push helper got notification event of type ${event.type}.'); - if (!event.isEventTypeKnown) { - Logs() - .v('Push message event is from an unknown event type. Do not display.'); + if (event.type.startsWith('m.call')) { + // make sure bg sync is on (needed to update hold, unhold events) + // prevent over write from app life cycle change + 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; + } + + if ((event.type.startsWith('m.call') && + event.type != EventTypes.CallInvite) || + event.type == 'org.matrix.call.sdp_stream_metadata_changed') { + Logs().v('Push message was for a call, but not call invite.'); return; } diff --git a/lib/utils/voip/callkeep_manager.dart b/lib/utils/voip/callkeep_manager.dart index d2b56f23..eaf25630 100644 --- a/lib/utils/voip/callkeep_manager.dart +++ b/lib/utils/voip/callkeep_manager.dart @@ -1,42 +1,44 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; 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:uuid/uuid.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:fluffychat/utils/voip_plugin.dart'; class CallKeeper { - CallKeeper(this.callKeepManager, this.uuid, this.number, this.call) { - call?.onCallStateChanged.stream.listen(_handleCallState); + CallKeeper(this.callKeepManager, this.call) { + call.onCallStateChanged.stream.listen(_handleCallState); } CallKeepManager callKeepManager; - String number; - String uuid; - bool held = false; - bool muted = false; + bool? held = false; + bool? muted = false; bool connected = false; - CallSession? call; + CallSession call; + // update native caller to show what remote user has done. void _handleCallState(CallState state) { - Logs().v('CallKeepManager::handleCallState: ${state.toString()}'); + 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(uuid); + callKeepManager.answer(call.callId); } else { - callKeepManager.setMutedCall(uuid, false); - callKeepManager.setOnHold(uuid, false); + callKeepManager.setMutedCall(call.callId, false); + callKeepManager.setOnHold(call.callId, false); } break; case CallState.kEnded: - callKeepManager.hangup(uuid); + callKeepManager.hangup(call.callId); break; /* TODO: case CallState.kMuted: @@ -68,6 +70,8 @@ class CallKeeper { } } +Map calls = {}; + class CallKeepManager { factory CallKeepManager() { return _instance; @@ -81,32 +85,29 @@ class CallKeepManager { late FlutterCallkeep _callKeep; VoipPlugin? _voipPlugin; - Map calls = {}; - String newUUID() => const Uuid().v4(); - - String get appName => 'Famedly'; + String get appName => 'FluffyChat'; + Future get hasPhoneAccountEnabled async => + await _callKeep.hasPhoneAccount(); Map get alertOptions => { 'alertTitle': 'Permissions required', - 'alertDescription': '$appName needs to access your phone accounts!', + '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.famedly.talk', + 'channelId': 'com.fluffy.fluffychat', 'channelName': 'Foreground service for my app', 'notificationTitle': '$appName is running on background', 'notificationIcon': 'mipmap/ic_notification_launcher', }, + 'additionalPermissions': [''], }; - - void setVoipPlugin(VoipPlugin plugin) { - if (kIsWeb) { - throw 'Not support callkeep for flutter web'; - } - _voipPlugin = plugin; - _voipPlugin!.onIncomingCall = (CallSession call) async { + bool setupDone = false; + Future showCallkitIncoming(CallSession call) async { + if (!setupDone) { await _callKeep.setup( null, { @@ -116,47 +117,38 @@ class CallKeepManager { 'android': alertOptions, }, backgroundMode: true); - - await displayIncomingCall(call); - - call.onCallStateChanged.stream.listen((state) { - if (state == CallState.kEnded) { - _callKeep.endAllCalls(); - } - }); - call.onCallEventChanged.stream.listen((event) { + } + 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) { + void removeCall(String? callUUID) { calls.remove(callUUID); } - void addCall(String callUUID, CallKeeper callKeeper) { + void addCall(String? callUUID, CallKeeper callKeeper) { + if (calls.containsKey(callUUID)) return; calls[callUUID] = callKeeper; } - String findCallUUID(String number) { - var uuid = ''; - calls.forEach((String key, CallKeeper item) { - if (item.number == number) { - uuid = key; - return; - } - }); - return uuid; - } - - void setCallHeld(String callUUID, bool held) { + void setCallHeld(String? callUUID, bool? held) { calls[callUUID]!.held = held; } - void setCallMuted(String callUUID, bool muted) { + void setCallMuted(String? callUUID, bool? muted) { calls[callUUID]!.muted = muted; } @@ -164,7 +156,7 @@ class CallKeepManager { final callUUID = event.callUUID; final number = event.handle; Logs().v('[displayIncomingCall] $callUUID number: $number'); - addCall(callUUID!, CallKeeper(this, callUUID, number!, null)); + // addCall(callUUID, CallKeeper(this null)); } void onPushKitToken(CallKeepPushKitToken event) { @@ -182,6 +174,7 @@ class CallKeepManager { _callKeep.on(CallKeepPerformEndCallAction(), endCall); _callKeep.on(CallKeepPushKitToken(), onPushKitToken); _callKeep.on(CallKeepDidDisplayIncomingCall(), didDisplayIncomingCall); + Logs().i('[VOIP] Initialized'); } Future hangup(String callUUID) async { @@ -193,10 +186,10 @@ class CallKeepManager { await _callKeep.rejectCall(callUUID); } - Future answer(String callUUID) async { - final keeper = calls[callUUID]; - if (!keeper!.connected) { - await _callKeep.answerIncomingCall(callUUID); + Future answer(String? callUUID) async { + final keeper = calls[callUUID]!; + if (!keeper.connected) { + await _callKeep.answerIncomingCall(callUUID!); keeper.connected = true; } } @@ -212,27 +205,66 @@ class CallKeepManager { } Future updateDisplay(String callUUID) async { - final number = calls[callUUID]!.number; // Workaround because Android doesn't display well displayName, se we have to switch ... if (isIOS) { await _callKeep.updateDisplay(callUUID, - displayName: 'New Name', handle: number); + displayName: 'New Name', handle: callUUID); } else { await _callKeep.updateDisplay(callUUID, - displayName: number, handle: 'New Name'); + displayName: callUUID, handle: 'New Name'); } } Future displayIncomingCall(CallSession call) async { - final callUUID = newUUID(); - final callKeeper = CallKeeper(this, callUUID, call.displayName!, call); - addCall(callUUID, callKeeper); - await _callKeep.displayIncomingCall(callUUID, call.displayName!, - handleType: 'number', hasVideo: call.type == CallType.kVideo); + final callKeeper = CallKeeper(this, call); + addCall(call.callId, callKeeper); + await _callKeep.displayIncomingCall( + call.callId, + '${call.displayName!} (FluffyChat)', + localizedCallerName: '${call.displayName!} (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(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, @@ -240,8 +272,11 @@ class CallKeepManager { 'android': alertOptions, }); final hasPhoneAccount = await _callKeep.hasPhoneAccount(); + Logs().e(hasPhoneAccount.toString()); if (!hasPhoneAccount) { await _callKeep.hasDefaultPhoneAccount(context, alertOptions); + } else { + await _callKeep.openPhoneAccounts(); } } @@ -250,8 +285,9 @@ class CallKeepManager { final callUUID = event.callUUID; final keeper = calls[event.callUUID]!; if (!keeper.connected) { + Logs().e('answered'); // Answer Call - keeper.call!.answer(); + keeper.call.answer(); keeper.connected = true; } Timer(const Duration(seconds: 1), () { @@ -261,13 +297,13 @@ class CallKeepManager { Future endCall(CallKeepPerformEndCallAction event) async { final keeper = calls[event.callUUID]; - keeper?.call?.hangup(); - removeCall(event.callUUID!); + keeper?.call.hangup(); + removeCall(event.callUUID); } Future didPerformDTMFAction(CallKeepDidPerformDTMFAction event) async { final keeper = calls[event.callUUID]!; - keeper.call?.sendDTMF(event.digits!); + keeper.call.sendDTMF(event.digits!); } Future didReceiveStartCallAction( @@ -276,11 +312,11 @@ class CallKeepManager { // @TODO: sometime we receive `didReceiveStartCallAction` with handle` undefined` return; } - final callUUID = event.callUUID ?? newUUID(); + final callUUID = event.callUUID!; if (event.callUUID == null) { final call = await _voipPlugin!.voip.inviteToCall(event.handle!, CallType.kVideo); - addCall(callUUID, CallKeeper(this, callUUID, call.displayName!, call)); + addCall(callUUID, CallKeeper(this, call)); } await _callKeep.startCall(callUUID, event.handle!, event.handle!); Timer(const Duration(seconds: 1), () { @@ -290,23 +326,23 @@ class CallKeepManager { Future didPerformSetMutedCallAction( CallKeepDidPerformSetMutedCallAction event) async { - final keeper = calls[event.callUUID]!; - if (event.muted ?? false) { - keeper.call?.setMicrophoneMuted(true); + final keeper = calls[event.callUUID]; + if (event.muted!) { + keeper!.call.setMicrophoneMuted(true); } else { - keeper.call?.setMicrophoneMuted(false); + keeper!.call.setMicrophoneMuted(false); } - setCallMuted(event.callUUID!, event.muted!); + setCallMuted(event.callUUID, event.muted); } Future didToggleHoldCallAction( CallKeepDidToggleHoldAction event) async { - final keeper = calls[event.callUUID]!; - if (event.hold ?? false) { - keeper.call?.setRemoteOnHold(true); + final keeper = calls[event.callUUID]; + if (event.hold!) { + keeper!.call.setRemoteOnHold(true); } else { - keeper.call?.setRemoteOnHold(false); + keeper!.call.setRemoteOnHold(false); } - setCallHeld(event.callUUID!, event.hold!); + setCallHeld(event.callUUID, event.hold); } } diff --git a/lib/utils/voip_plugin.dart b/lib/utils/voip_plugin.dart index c1420721..c5d71bb9 100644 --- a/lib/utils/voip_plugin.dart +++ b/lib/utils/voip_plugin.dart @@ -4,24 +4,27 @@ 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_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 'package:fluffychat/widgets/fluffy_chat_app.dart'; +import '../../utils/famedlysdk_store.dart'; +import '../../utils/voip/callkeep_manager.dart'; import '../../utils/voip/user_media_manager.dart'; -class VoipPlugin extends WidgetsBindingObserver implements WebRTCDelegate { - VoipPlugin({required this.client, required this.context}) { +class VoipPlugin with WidgetsBindingObserver implements WebRTCDelegate { + final Client client; + VoipPlugin(this.client) { voip = VoIP(client, this); - try { - Connectivity() - .onConnectivityChanged - .listen(_handleNetworkChanged) - .onError((e) => _currentConnectivity = ConnectivityResult.none); - } catch (e, s) { - Logs().w('Could not subscribe network updates', e, s); - } + Connectivity() + .onConnectivityChanged + .listen(_handleNetworkChanged) + .onError((e) => _currentConnectivity = ConnectivityResult.none); Connectivity() .checkConnectivity() .then((result) => _currentConnectivity = result) @@ -29,24 +32,15 @@ class VoipPlugin extends WidgetsBindingObserver implements WebRTCDelegate { if (!kIsWeb) { final wb = WidgetsBinding.instance; wb.addObserver(this); - didChangeAppLifecycleState(wb.lifecycleState!); + didChangeAppLifecycleState(wb.lifecycleState); } } - - final Client client; bool background = false; bool speakerOn = false; late VoIP voip; ConnectivityResult? _currentConnectivity; - ValueChanged? onIncomingCall; OverlayEntry? overlayEntry; - // hacky workaround: in order to have [Overlay.of] working on web, the context - // mus explicitly be re-assigned - // - // hours wasted: 5 - BuildContext context; - void _handleNetworkChanged(ConnectivityResult result) async { /// Got a new connectivity status! if (_currentConnectivity != result) { @@ -58,17 +52,19 @@ class VoipPlugin extends WidgetsBindingObserver implements WebRTCDelegate { } @override - void didChangeAppLifecycleState(AppLifecycleState state) { + void didChangeAppLifecycleState(AppLifecycleState? state) { Logs().v('AppLifecycleState = $state'); - background = !(state != AppLifecycleState.detached && - state != AppLifecycleState.paused); + background = (state == AppLifecycleState.detached || + state == AppLifecycleState.paused); } - void addCallingOverlay( - BuildContext context, String callId, CallSession call) { + void addCallingOverlay(String callId, CallSession call) { + final context = kIsWeb + ? ChatList.contextForVoip! + : FluffyChatApp.routerKey.currentContext!; // web is weird if (overlayEntry != null) { - Logs().w('[VOIP] addCallingOverlay: The call session already exists?'); - overlayEntry?.remove(); + Logs().e('[VOIP] addCallingOverlay: The call session already exists?'); + overlayEntry!.remove(); } // Overlay.of(context) is broken on web // falling back on a dialog @@ -103,7 +99,8 @@ class VoipPlugin extends WidgetsBindingObserver implements WebRTCDelegate { MediaDevices get mediaDevices => webrtc_impl.navigator.mediaDevices; @override - bool get isBackgroud => background; + // remove this from sdk once callkeep is stable + bool get isBackgroud => false; @override bool get isWeb => kIsWeb; @@ -119,9 +116,12 @@ class VoipPlugin extends WidgetsBindingObserver implements WebRTCDelegate { return webrtc_impl.RTCVideoRenderer(); } + Future get hasCallingAccount async => + kIsWeb ? false : await CallKeepManager().hasPhoneAccountEnabled; + @override void playRingtone() async { - if (!background) { + if (!background && !await hasCallingAccount) { try { await UserMediaManager().startRingingTone(); } catch (_) {} @@ -130,7 +130,7 @@ class VoipPlugin extends WidgetsBindingObserver implements WebRTCDelegate { @override void stopRingtone() async { - if (!background) { + if (!background && !await hasCallingAccount) { try { await UserMediaManager().stopRingingTone(); } catch (_) {} @@ -139,19 +139,58 @@ class VoipPlugin extends WidgetsBindingObserver implements WebRTCDelegate { @override void handleNewCall(CallSession call) async { - /// Popup CallingPage for incoming call. - if (!background) { - addCallingOverlay(context, call.callId, call); + 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 Store().setItem( + '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(FluffyChatApp.routerKey.currentContext!) + .showSnackBar(const SnackBar( + content: Text( + 'No calling accounts found (used for native calls UI)', + ))); + } + } catch (e) { + Logs().e('failed to show snackbar'); + } + } } else { - onIncomingCall?.call(call); + addCallingOverlay(call.callId, call); } } @override void handleCallEnded(CallSession session) async { if (overlayEntry != null) { - overlayEntry?.remove(); + overlayEntry!.remove(); overlayEntry = null; + if (PlatformInfos.isAndroid) { + FlutterForegroundTask.setOnLockScreenVisibility(false); + FlutterForegroundTask.stopService(); + final wasForeground = await Store().getItem('wasForeground'); + wasForeground == 'false' ? FlutterForegroundTask.minimizeApp() : null; + } } } diff --git a/lib/widgets/fluffy_chat_app.dart b/lib/widgets/fluffy_chat_app.dart index 6bcce179..1dadf74d 100644 --- a/lib/widgets/fluffy_chat_app.dart +++ b/lib/widgets/fluffy_chat_app.dart @@ -17,7 +17,7 @@ class FluffyChatApp extends StatefulWidget { final Widget? testWidget; final List clients; final Map? queryParameters; - + static final GlobalKey routerKey = GlobalKey(); const FluffyChatApp({ Key? key, this.testWidget, @@ -35,7 +35,6 @@ class FluffyChatApp extends StatefulWidget { } class FluffyChatAppState extends State { - GlobalKey? _router; bool? columnMode; String? _initialUrl; @@ -67,14 +66,13 @@ class FluffyChatAppState extends State { Logs().v('Set Column Mode = $isColumnMode'); WidgetsBinding.instance.addPostFrameCallback((_) { setState(() { - _initialUrl = _router?.currentState?.url; + _initialUrl = FluffyChatApp.routerKey.currentState?.url; columnMode = isColumnMode; - _router = GlobalKey(); }); }); } return VRouter( - key: _router, + key: FluffyChatApp.routerKey, title: AppConfig.applicationName, theme: theme, scrollBehavior: CustomScrollBehavior(), @@ -86,7 +84,7 @@ class FluffyChatAppState extends State { routes: AppRoutes(columnMode ?? false).routes, builder: (context, child) => Matrix( context: context, - router: _router, + router: FluffyChatApp.routerKey, clients: widget.clients, child: child, ), diff --git a/lib/widgets/local_notifications_extension.dart b/lib/widgets/local_notifications_extension.dart index 37e8fbcf..54546ed9 100644 --- a/lib/widgets/local_notifications_extension.dart +++ b/lib/widgets/local_notifications_extension.dart @@ -13,6 +13,7 @@ import 'package:vrouter/vrouter.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions.dart/matrix_locals.dart'; +import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/widgets/matrix.dart'; extension LocalNotificationsExtension on MatrixState { @@ -20,7 +21,9 @@ extension LocalNotificationsExtension on MatrixState { final roomId = eventUpdate.roomID; if (activeRoomId == roomId) { if (kIsWeb && webHasFocus) return; - if (Platform.isLinux && DesktopLifecycle.instance.isActive.value) return; + if (PlatformInfos.isLinux && DesktopLifecycle.instance.isActive.value) { + return; + } } final room = client.getRoomById(roomId); if (room == null) { diff --git a/lib/widgets/matrix.dart b/lib/widgets/matrix.dart index 583a79bf..bb4ddbde 100644 --- a/lib/widgets/matrix.dart +++ b/lib/widgets/matrix.dart @@ -427,8 +427,7 @@ class MatrixState extends State with WidgetsBindingObserver { voipPlugin = null; return; } - voipPlugin = - webrtcIsSupported ? VoipPlugin(client: client, context: context) : null; + voipPlugin = webrtcIsSupported ? VoipPlugin(client) : null; } bool _firstStartup = true; diff --git a/pubspec.lock b/pubspec.lock index a4e38664..39bc2090 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -296,7 +296,7 @@ packages: name: dart_webrtc url: "https://pub.dartlang.org" source: hosted - version: "1.0.6" + version: "1.0.7" dbus: dependency: transitive description: @@ -538,6 +538,15 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.3.0" + flutter_foreground_task: + dependency: "direct main" + description: + path: "." + ref: "td/forceOpenOnTop" + resolved-ref: b5f429acbcddb8267d77dd2d76032357d78a725d + url: "https://github.com/Techno-Disaster/flutter_foreground_task.git" + source: git + version: "3.8.2" flutter_highlight: dependency: transitive description: @@ -743,7 +752,7 @@ packages: name: flutter_webrtc url: "https://pub.dartlang.org" source: hosted - version: "0.9.0" + version: "0.9.5" fuchsia_remote_debug_protocol: dependency: transitive description: flutter @@ -1246,6 +1255,41 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.11.1" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + url: "https://pub.dartlang.org" + source: hosted + version: "10.0.0" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + url: "https://pub.dartlang.org" + source: hosted + version: "10.0.0" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + url: "https://pub.dartlang.org" + source: hosted + version: "9.0.4" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "3.7.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.0" petitparser: dependency: transitive description: @@ -1497,21 +1541,21 @@ packages: name: shared_preferences url: "https://pub.dartlang.org" source: hosted - version: "2.0.13" + version: "2.0.15" shared_preferences_android: dependency: transitive description: name: shared_preferences_android url: "https://pub.dartlang.org" source: hosted - version: "2.0.11" + version: "2.0.12" shared_preferences_ios: dependency: transitive description: name: shared_preferences_ios url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.1.1" shared_preferences_linux: dependency: transitive description: @@ -1825,6 +1869,20 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.4.0" + vibration: + dependency: "direct main" + description: + name: vibration + url: "https://pub.dartlang.org" + source: hosted + version: "1.7.4-nullsafety.0" + vibration_web: + dependency: transitive + description: + name: vibration_web + url: "https://pub.dartlang.org" + source: hosted + version: "1.6.3-nullsafety.0" video_compress: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index ea6dfebd..4bd926ff 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,6 +35,7 @@ dependencies: flutter_app_lock: ^2.0.0 flutter_blurhash: ^0.7.0 flutter_cache_manager: ^3.3.0 + flutter_foreground_task: ^3.8.2 flutter_local_notifications: ^9.7.0 flutter_localizations: sdk: flutter @@ -48,7 +49,7 @@ dependencies: flutter_svg: ^0.22.0 flutter_typeahead: ^4.0.0 flutter_web_auth: ^0.4.0 - flutter_webrtc: 0.9.0 # Higher fails to build on iOS https://github.com/flutter-webrtc/flutter-webrtc/issues/1054 + flutter_webrtc: ^0.9.5 future_loading_dialog: ^0.2.3 geolocator: ^7.6.2 handy_window: ^0.1.6 @@ -69,6 +70,7 @@ dependencies: native_imaging: ^0.1.0 package_info_plus: ^1.3.0 path_provider: ^2.0.9 + permission_handler: ^10.0.0 pin_code_text_field: ^1.8.0 provider: ^6.0.2 punycode: ^1.0.0 @@ -87,6 +89,7 @@ dependencies: universal_html: ^2.0.8 url_launcher: ^6.0.20 uuid: ^3.0.6 + vibration: ^1.7.4-nullsafety.0 video_compress: ^3.1.1 video_player: ^2.2.18 vrouter: ^1.2.0+21 @@ -154,6 +157,13 @@ dependency_overrides: path: packages/connectivity_plus/connectivity_plus_web # Until all dependencies are compatible. Missing: file_picker_cross, flutter_matrix_html ffi: ^2.0.0 + + # upstream request at https://github.com/Dev-hwang/flutter_foreground_task/pull/102 + flutter_foreground_task: + git: + url: https://github.com/Techno-Disaster/flutter_foreground_task.git + ref: td/forceOpenOnTop + # fake secure storage plugin for Windows # See: https://gitlab.com/gitlab-com/gl-infra/reliability/-/issues/15161 flutter_secure_storage_windows: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index a621db73..22e9537d 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -10,6 +10,7 @@ #include #include #include +#include #include #include @@ -22,6 +23,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("DesktopLifecyclePlugin")); FlutterWebRTCPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterWebRTCPlugin")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); RecordWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b956de8e..229023b1 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST desktop_drop desktop_lifecycle flutter_webrtc + permission_handler_windows record_windows url_launcher_windows )