Merge branch 'td/voip' into 'main'

feat: background and terminated calls [android]

Closes #874

See merge request famedly/fluffychat!911
This commit is contained in:
Krille Fear 2022-09-10 11:45:17 +00:00
commit b06684111e
18 changed files with 421 additions and 195 deletions

View File

@ -32,7 +32,7 @@ if (keystorePropertiesFile.exists()) {
}
android {
compileSdkVersion 32
compileSdkVersion 33
sourceSets {
main.java.srcDirs += 'src/main/kotlin'

View File

@ -14,8 +14,6 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
@ -23,10 +21,11 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-sdk
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"/>
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"/>
<application
android:label="FluffyChat"
android:icon="@mipmap/ic_launcher"
@ -40,6 +39,8 @@
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:showOnLockScreen="false"
android:turnScreenOn="true"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
@ -109,6 +110,19 @@
</intent-filter>
</service>
<service android:name="com.pravera.flutter_foreground_task.service.ForegroundService"
android:foregroundServiceType="camera|microphone|mediaProjection">
</service>
<service android:name="io.wazo.callkeep.VoiceConnectionService"
android:label="Wazo"
android:foregroundServiceType="camera|microphone|mediaProjection"
android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE">
<intent-filter>
<action android:name="android.telecom.ConnectionService" />
</intent-filter>
</service>
<receiver android:name="org.unifiedpush.flutter.connector.UnifiedPushReceiver"
tools:replace="android:enabled"
android:enabled="false">

View File

@ -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",

View File

@ -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

View File

@ -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<Chat> {
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();
}

View File

@ -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<ChatList>
scrollController.addListener(_onScroll);
_waitForFirstSync();
_hackyWebRTCFixForWeb();
CallKeepManager().initialize();
WidgetsBinding.instance.addPostFrameCallback((_) async {
searchServer = await Store().getItem(_serverStoreNamespace);
});
@ -670,7 +672,7 @@ class ChatListController extends State<ChatList>
}
void _hackyWebRTCFixForWeb() {
Matrix.of(context).voipPlugin?.context = context;
ChatList.contextForVoip = context;
}
Future<void> _checkTorBrowser() async {

View File

@ -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<Calling> {
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<WrappedMediaStream> get streams => call?.streams ?? [];
List<WrappedMediaStream> get streams => call.streams;
double? _localVideoHeight;
double? _localVideoWidth;
EdgeInsetsGeometry? _localVideoMargin;
@ -190,8 +193,6 @@ class MyCallingPage extends State<Calling> {
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<Calling> {
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<Calling> {
@override
void dispose() {
super.dispose();
call?.cleanUp.call();
call.cleanUp.call();
}
void _resizeLocalVideo(Orientation orientation) {
@ -249,6 +250,14 @@ class MyCallingPage extends State<Calling> {
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<Calling> {
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<Calling> {
*/
List<Widget> _buildActionButtons(bool isFloating) {
if (isFloating || call == null) {
if (isFloating) {
return [];
}
@ -391,7 +417,7 @@ class MyCallingPage extends State<Calling> {
case CallState.kInviteSent:
case CallState.kCreateAnswer:
case CallState.kConnecting:
return call!.isOutgoing
return call.isOutgoing
? <Widget>[hangupButton]
: <Widget>[answerButton, hangupButton];
case CallState.kConnected:
@ -429,7 +455,7 @@ class MyCallingPage extends State<Calling> {
final stackWidgets = <Widget>[];
final call = this.call;
if (call == null || call.callHasEnded) {
if (call.callHasEnded) {
return stackWidgets;
}

View File

@ -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'),

View File

@ -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<void> pushHelper(
PushNotification notification, {
@ -110,11 +111,29 @@ Future<void> _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;
}

View File

@ -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<String?, CallKeeper> calls = <String?, CallKeeper>{};
class CallKeepManager {
factory CallKeepManager() {
return _instance;
@ -81,32 +85,29 @@ class CallKeepManager {
late FlutterCallkeep _callKeep;
VoipPlugin? _voipPlugin;
Map<String, CallKeeper> calls = <String, CallKeeper>{};
String newUUID() => const Uuid().v4();
String get appName => 'Famedly';
String get appName => 'FluffyChat';
Future<bool> get hasPhoneAccountEnabled async =>
await _callKeep.hasPhoneAccount();
Map<String, dynamic> get alertOptions => <String, dynamic>{
'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<void> showCallkitIncoming(CallSession call) async {
if (!setupDone) {
await _callKeep.setup(
null,
<String, dynamic>{
@ -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<void> hangup(String callUUID) async {
@ -193,10 +186,10 @@ class CallKeepManager {
await _callKeep.rejectCall(callUUID);
}
Future<void> answer(String callUUID) async {
final keeper = calls[callUUID];
if (!keeper!.connected) {
await _callKeep.answerIncomingCall(callUUID);
Future<void> 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<void> 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<CallKeeper> 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<void> 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, <String, dynamic>{
'ios': <String, dynamic>{
'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<void> endCall(CallKeepPerformEndCallAction event) async {
final keeper = calls[event.callUUID];
keeper?.call?.hangup();
removeCall(event.callUUID!);
keeper?.call.hangup();
removeCall(event.callUUID);
}
Future<void> didPerformDTMFAction(CallKeepDidPerformDTMFAction event) async {
final keeper = calls[event.callUUID]!;
keeper.call?.sendDTMF(event.digits!);
keeper.call.sendDTMF(event.digits!);
}
Future<void> 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<void> 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<void> 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);
}
}

View File

@ -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<CallSession>? 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<bool> 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;
}
}
}

View File

@ -17,7 +17,7 @@ class FluffyChatApp extends StatefulWidget {
final Widget? testWidget;
final List<Client> clients;
final Map<String, String>? queryParameters;
static final GlobalKey<VRouterState> routerKey = GlobalKey<VRouterState>();
const FluffyChatApp({
Key? key,
this.testWidget,
@ -35,7 +35,6 @@ class FluffyChatApp extends StatefulWidget {
}
class FluffyChatAppState extends State<FluffyChatApp> {
GlobalKey<VRouterState>? _router;
bool? columnMode;
String? _initialUrl;
@ -67,14 +66,13 @@ class FluffyChatAppState extends State<FluffyChatApp> {
Logs().v('Set Column Mode = $isColumnMode');
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
_initialUrl = _router?.currentState?.url;
_initialUrl = FluffyChatApp.routerKey.currentState?.url;
columnMode = isColumnMode;
_router = GlobalKey<VRouterState>();
});
});
}
return VRouter(
key: _router,
key: FluffyChatApp.routerKey,
title: AppConfig.applicationName,
theme: theme,
scrollBehavior: CustomScrollBehavior(),
@ -86,7 +84,7 @@ class FluffyChatAppState extends State<FluffyChatApp> {
routes: AppRoutes(columnMode ?? false).routes,
builder: (context, child) => Matrix(
context: context,
router: _router,
router: FluffyChatApp.routerKey,
clients: widget.clients,
child: child,
),

View File

@ -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) {

View File

@ -427,8 +427,7 @@ class MatrixState extends State<Matrix> with WidgetsBindingObserver {
voipPlugin = null;
return;
}
voipPlugin =
webrtcIsSupported ? VoipPlugin(client: client, context: context) : null;
voipPlugin = webrtcIsSupported ? VoipPlugin(client) : null;
}
bool _firstStartup = true;

View File

@ -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:

View File

@ -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:

View File

@ -10,6 +10,7 @@
#include <desktop_drop/desktop_drop_plugin.h>
#include <desktop_lifecycle/desktop_lifecycle_plugin.h>
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <record_windows/record_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
@ -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(

View File

@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
desktop_drop
desktop_lifecycle
flutter_webrtc
permission_handler_windows
record_windows
url_launcher_windows
)