feat: implement WebRTC calls

Signed-off-by: TheOneWithTheBraid <the-one@with-the-braid.cf>
This commit is contained in:
TheOneWithTheBraid 2022-02-15 09:25:13 +01:00
commit 34600ac165
26 changed files with 1774 additions and 53 deletions

View file

@ -1,12 +1,15 @@
import 'dart:async';
import 'dart:core';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:localstorage/localstorage.dart';
import 'package:path_provider/path_provider.dart';
import 'package:fluffychat/utils/platform_infos.dart';
// import 'package:flutter_secure_storage/flutter_secure_storage.dart';
// see https://github.com/mogol/flutter_secure_storage/issues/161#issuecomment-704578453
class AsyncMutex {
Completer<void>? _completer;
@ -28,13 +31,24 @@ class AsyncMutex {
}
class Store {
LocalStorage? storage;
final FlutterSecureStorage? secureStorage;
static final _mutex = AsyncMutex();
static FlutterSecureStorage? secureStorage;
Store()
: secureStorage =
PlatformInfos.isMobile ? const FlutterSecureStorage() : null;
static FutureOr<void> init() {
if (PlatformInfos.isMobile) {
if (PlatformInfos.isAndroid) {
return DeviceInfoPlugin().androidInfo.then((info) {
if ((info.version.sdkInt ?? 16) >= 19) {
secureStorage = const FlutterSecureStorage();
}
});
} else {
secureStorage = const FlutterSecureStorage();
}
}
}
LocalStorage? storage;
static final _mutex = AsyncMutex();
Future<void> _setupLocalStorage() async {
if (storage == null) {

View file

@ -20,6 +20,7 @@ extension ResizeImage on MatrixFile {
MediaInfo? mediaInfo;
await tmpFile.writeAsBytes(bytes);
try {
// will throw an error e.g. on Android SDK < 18
mediaInfo = await VideoCompress.compressVideo(tmpFile.path);
} catch (e, s) {
SentryController.captureException(e, s);

View file

@ -0,0 +1,312 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:callkeep/callkeep.dart';
import 'package:matrix/matrix.dart';
import 'package:uuid/uuid.dart';
import 'package:fluffychat/utils/voip_plugin.dart';
class CallKeeper {
CallKeeper(this.callKeepManager, this.uuid, this.number, this.call) {
call?.onCallStateChanged.listen(_handleCallState);
}
CallKeepManager callKeepManager;
String number;
String uuid;
bool held = false;
bool muted = false;
bool connected = false;
CallSession? call;
void _handleCallState(CallState state) {
Logs().v('CallKeepManager::handleCallState: ${state.toString()}');
switch (state) {
case CallState.kConnecting:
break;
case CallState.kConnected:
if (!connected) {
callKeepManager.answer(uuid);
} else {
callKeepManager.setMutedCall(uuid, false);
callKeepManager.setOnHold(uuid, false);
}
break;
case CallState.kEnded:
callKeepManager.hangup(uuid);
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;
}
}
}
class CallKeepManager {
factory CallKeepManager() {
return _instance;
}
CallKeepManager._internal() {
_callKeep = FlutterCallkeep();
}
static final CallKeepManager _instance = CallKeepManager._internal();
late FlutterCallkeep _callKeep;
VoipPlugin? _voipPlugin;
Map<String, CallKeeper> calls = <String, CallKeeper>{};
String newUUID() => const Uuid().v4();
String get appName => 'Famedly';
Map<String, dynamic> get alertOptions => <String, dynamic>{
'alertTitle': 'Permissions required',
'alertDescription': '$appName needs to access your phone accounts!',
'cancelButton': 'Cancel',
'okButton': 'ok',
// Required to get audio in background when using Android 11
'foregroundService': {
'channelId': 'com.famedly.talk',
'channelName': 'Foreground service for my app',
'notificationTitle': '$appName is running on background',
'notificationIcon': 'mipmap/ic_notification_launcher',
},
};
void setVoipPlugin(VoipPlugin plugin) {
if (kIsWeb) {
throw 'Not support callkeep for flutter web';
}
_voipPlugin = plugin;
_voipPlugin!.onIncomingCall = (CallSession call) async {
await _callKeep.setup(
null,
<String, dynamic>{
'ios': <String, dynamic>{
'appName': appName,
},
'android': alertOptions,
},
backgroundMode: true);
await displayIncomingCall(call);
call.onCallStateChanged.listen((state) {
if (state == CallState.kEnded) {
_callKeep.endAllCalls();
}
});
call.onCallEventChanged.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) {
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) {
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, callUUID, number!, null));
}
void onPushKitToken(CallKeepPushKitToken event) {
Logs().v('[onPushKitToken] token => ${event.token}');
}
Future<void> 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);
}
Future<void> hangup(String callUUID) async {
await _callKeep.endCall(callUUID);
removeCall(callUUID);
}
Future<void> reject(String callUUID) async {
await _callKeep.rejectCall(callUUID);
}
Future<void> answer(String callUUID) async {
final keeper = calls[callUUID];
if (!keeper!.connected) {
await _callKeep.answerIncomingCall(callUUID);
keeper.connected = true;
}
}
Future<void> setOnHold(String callUUID, bool held) async {
await _callKeep.setOnHold(callUUID, held);
setCallHeld(callUUID, held);
}
Future<void> setMutedCall(String callUUID, bool muted) async {
await _callKeep.setMutedCall(callUUID, muted);
setCallMuted(callUUID, muted);
}
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);
} else {
await _callKeep.updateDisplay(callUUID,
displayName: number, 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);
return callKeeper;
}
Future<void> checkoutPhoneAccountSetting(BuildContext context) async {
await _callKeep.setup(context, <String, dynamic>{
'ios': <String, dynamic>{
'appName': appName,
},
'android': alertOptions,
});
final hasPhoneAccount = await _callKeep.hasPhoneAccount();
if (!hasPhoneAccount) {
await _callKeep.hasDefaultPhoneAccount(context, alertOptions);
}
}
/// CallActions.
Future<void> answerCall(CallKeepPerformAnswerCallAction event) async {
final callUUID = event.callUUID;
final keeper = calls[event.callUUID]!;
if (!keeper.connected) {
// Answer Call
keeper.call!.answer();
keeper.connected = true;
}
Timer(const Duration(seconds: 1), () {
_callKeep.setCurrentCallActive(callUUID!);
});
}
Future<void> endCall(CallKeepPerformEndCallAction event) async {
final keeper = calls[event.callUUID];
keeper?.call?.hangup();
removeCall(event.callUUID!);
}
Future<void> didPerformDTMFAction(CallKeepDidPerformDTMFAction event) async {
final keeper = calls[event.callUUID]!;
keeper.call?.sendDTMF(event.digits!);
}
Future<void> didReceiveStartCallAction(
CallKeepDidReceiveStartCallAction event) async {
if (event.handle == null) {
// @TODO: sometime we receive `didReceiveStartCallAction` with handle` undefined`
return;
}
final callUUID = event.callUUID ?? newUUID();
if (event.callUUID == null) {
final call =
await _voipPlugin!.voip.inviteToCall(event.handle!, CallType.kVideo);
addCall(callUUID, CallKeeper(this, callUUID, call.displayName!, call));
}
await _callKeep.startCall(callUUID, event.handle!, event.handle!);
Timer(const Duration(seconds: 1), () {
_callKeep.setCurrentCallActive(callUUID);
});
}
Future<void> didPerformSetMutedCallAction(
CallKeepDidPerformSetMutedCallAction event) async {
final keeper = calls[event.callUUID]!;
if (event.muted ?? false) {
keeper.call?.setMicrophoneMuted(true);
} else {
keeper.call?.setMicrophoneMuted(false);
}
setCallMuted(event.callUUID!, event.muted!);
}
Future<void> didToggleHoldCallAction(
CallKeepDidToggleHoldAction event) async {
final keeper = calls[event.callUUID]!;
if (event.hold ?? false) {
keeper.call?.setRemoteOnHold(true);
} else {
keeper.call?.setRemoteOnHold(false);
}
setCallHeld(event.callUUID!, event.hold!);
}
}

View file

@ -0,0 +1,27 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_ringtone_player/flutter_ringtone_player.dart';
class UserMediaManager {
factory UserMediaManager() {
return _instance;
}
UserMediaManager._internal();
static final UserMediaManager _instance = UserMediaManager._internal();
Future<void> startRingingTone() {
if (kIsWeb) {
throw 'Platform [web] not supported';
}
return FlutterRingtonePlayer.playRingtone(volume: 80);
}
Future<void> stopRingingTone() {
if (kIsWeb) {
throw 'Platform [web] not supported';
}
return FlutterRingtonePlayer.stop();
}
}

136
lib/utils/voip_plugin.dart Normal file
View file

@ -0,0 +1,136 @@
import 'dart:core';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart' as webrtc_impl;
import 'package:matrix/matrix.dart';
import 'package:webrtc_interface/webrtc_interface.dart';
import 'package:fluffychat/pages/dialer/dialer.dart';
import '../../utils/voip/user_media_manager.dart';
class VoipPlugin extends WidgetsBindingObserver implements WebRTCDelegate {
VoipPlugin({required this.client, required this.context}) {
voip = VoIP(client, this);
Connectivity()
.onConnectivityChanged
.listen(_handleNetworkChanged)
.onError((e) => _currentConnectivity = ConnectivityResult.none);
Connectivity()
.checkConnectivity()
.then((result) => _currentConnectivity = result)
.catchError((e) => _currentConnectivity = ConnectivityResult.none);
if (!kIsWeb) {
final wb = WidgetsBinding.instance;
wb?.addObserver(this);
if (wb != null) {
didChangeAppLifecycleState(wb.lifecycleState!);
}
}
}
final Client client;
bool background = false;
bool speakerOn = false;
late VoIP voip;
ConnectivityResult? _currentConnectivity;
ValueChanged<CallSession>? onIncomingCall;
OverlayEntry? overlayEntry;
final BuildContext context;
void _handleNetworkChanged(ConnectivityResult result) async {
/// Got a new connectivity status!
if (_currentConnectivity != result) {
voip.calls.forEach((_, sess) {
sess.restartIce();
});
}
_currentConnectivity = result;
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
Logs().v('AppLifecycleState = $state');
background = !(state != AppLifecycleState.detached &&
state != AppLifecycleState.paused);
}
void addCallingOverlay(
BuildContext context, String callId, CallSession call) {
if (overlayEntry != null) {
Logs().w('[VOIP] addCallingOverlay: The call session already exists?');
overlayEntry?.remove();
}
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 isBackgroud => background;
@override
bool get isWeb => kIsWeb;
@override
Future<RTCPeerConnection> createPeerConnection(
Map<String, dynamic> configuration,
[Map<String, dynamic> constraints = const {}]) =>
webrtc_impl.createPeerConnection(configuration, constraints);
@override
VideoRenderer createRenderer() {
return webrtc_impl.RTCVideoRenderer();
}
@override
void playRingtone() async {
if (!background) {
try {
await UserMediaManager().startRingingTone();
} catch (_) {}
}
}
@override
void stopRingtone() async {
if (!background) {
try {
await UserMediaManager().stopRingingTone();
} catch (_) {}
}
}
@override
void handleNewCall(CallSession call) async {
/// Popup CallingPage for incoming call.
if (!background) {
addCallingOverlay(context, call.callId, call);
} else {
onIncomingCall?.call(call);
}
}
@override
void handleCallEnded(CallSession session) async {
if (overlayEntry != null) {
overlayEntry?.remove();
overlayEntry = null;
}
}
}