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
parent edb3adf208
commit e5c03ffb53
26 changed files with 1775 additions and 54 deletions

View File

@ -44,11 +44,12 @@ android {
defaultConfig {
applicationId "chat.fluffy.fluffychat"
minSdkVersion 21
minSdkVersion 16
targetSdkVersion 30
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
multiDexEnabled true
}
signingConfigs {
@ -85,6 +86,7 @@ dependencies {
androidTestImplementation 'androidx.test:runner:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
implementation 'com.github.UnifiedPush:android-connector:1.2.3' // needed for unifiedpush
implementation 'androidx.multidex:multidex:2.0.1'
}
//apply plugin: 'com.google.gms.google-services'

View File

@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="chat.fluffy.fluffychat">
<!-- io.flutter.app.FlutterApplication is an android.app.Application that
calls FlutterMain.startInitialization(this); in its onCreate method.
@ -14,20 +15,34 @@
<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" />
<uses-permission android:name="android.permission.BIND_TELECOM_CONNECTION_SERVICE"/>
<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-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"/>
<application
android:name=".Application"
android:label="FluffyChat"
android:icon="@mipmap/ic_launcher"
android:requestLegacyExternalStorage="true"
android:allowBackup="false"
android:fullBackupContent="false">
android:fullBackupContent="false"
>
<activity
android:name=".MainActivity"
android:launchMode="singleTask"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>

View File

@ -7,8 +7,14 @@ import android.content.Context
import android.os.Bundle
import android.util.Log
import android.view.WindowManager
import androidx.multidex.MultiDex
class MainActivity : FlutterActivity() {
override fun attachBaseContext(base: Context) {
super.attachBaseContext(base)
MultiDex.install(this)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
};

View File

@ -1,5 +1,5 @@
buildscript {
ext.kotlin_version = '1.4.32'
ext.kotlin_version = '1.6.10'
repositories {
google()
jcenter()

View File

@ -2725,5 +2725,9 @@
"pinMessage": "Pin to room",
"pinnedEventsError": "Error loading pinned messages",
"confirmEventUnpin": "Are you sure to permanently unpin the event?",
"emojis": "Emojis"
"emojis": "Emojis",
"placeCall": "Place call",
"voiceCall": "Voice call",
"unsupportedAndroidVersion": "Unsupported Android version",
"unsupportedAndroidVersionLong": "This feature required a never Android version. Please check for updates or Lineage OS support."
}

View File

@ -14,6 +14,7 @@ import 'package:vrouter/vrouter.dart';
import 'package:fluffychat/config/routes.dart';
import 'package:fluffychat/utils/client_manager.dart';
import 'package:fluffychat/utils/famedlysdk_store.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/utils/sentry_controller.dart';
import 'config/app_config.dart';
@ -49,6 +50,8 @@ void main() async {
.addAll(Uri.parse(html.window.location.href).queryParameters);
}
await Store.init();
runZonedGuarded(
() => runApp(PlatformInfos.isMobile
? AppLock(

View File

@ -2,12 +2,15 @@ import 'dart:async';
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:animations/animations.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
import 'package:file_picker_cross/file_picker_cross.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
@ -26,6 +29,8 @@ import 'package:fluffychat/pages/chat/widgets_bottom_sheet.dart';
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';
@ -165,8 +170,14 @@ class ChatController extends State<Chat> {
@override
void initState() {
scrollController.addListener(_updateScrollController);
inputFocus.addListener(_inputFocusListener);
if (!kIsWeb) {
WidgetsBinding.instance?.addPostFrameCallback((_) {
CallKeepManager().setVoipPlugin(Matrix.of(context).voipPlugin);
CallKeepManager().initialize().catchError((_) => true);
});
}
super.initState();
}
@ -939,6 +950,85 @@ class ChatController extends State<Chat> {
void showEventInfo([Event? event]) =>
(event ?? selectedEvents.single).showInfoDialog(context);
void onPhoneButtonTap() async {
// VoIP required Android SDK 21
if (PlatformInfos.isAndroid) {
DeviceInfoPlugin().androidInfo.then((value) {
if ((value.version.sdkInt ?? 16) < 21) {
Navigator.pop(context);
showModal(
context: context,
builder: (context) => AlertDialog(
title: Text(L10n.of(context)!.unsupportedAndroidVersion),
content: Text(L10n.of(context)!.unsupportedAndroidVersionLong),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: Text(L10n.of(context)!.ok))
],
),
);
}
});
}
final callType = await showDialog<CallType>(
context: context,
builder: (BuildContext context) {
return SimpleDialog(
title: Text(L10n.of(context)!.placeCall),
children: [
ListTile(
leading: const Icon(Icons.phone),
title: Text(L10n.of(context)!.voiceCall),
onTap: () {
Navigator.pop(context, CallType.kVoice);
},
),
ListTile(
leading: const Icon(Icons.videocam),
title: Text(L10n.of(context)!.videoCall),
onTap: () {
Navigator.pop(context, CallType.kVideo);
},
),
ListTile(
leading: const Icon(Icons.cancel),
title: Text(L10n.of(context)!.cancel),
onTap: () {
Navigator.pop(context, null);
},
),
],
);
},
useRootNavigator: false);
if (callType == null) {
return;
}
final success = await showFutureLoadingDialog(
context: context,
future: () =>
Matrix.of(context).voipPlugin.voip.requestTurnServerCredentials());
if (success.result != null) {
final voipPlugin = Matrix.of(context).voipPlugin;
await voipPlugin.voip.inviteToCall(room!.id, callType).catchError((e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.toString())
// Text(LocalizedExceptionExtension(context, e)),
),
);
});
} else {
await showOkAlertDialog(
context: context,
title: L10n.of(context)!.unavailable,
okLabel: L10n.of(context)!.next,
useRootNavigator: false,
);
}
}
void cancelReplyEventAction() => setState(() {
if (editEvent != null) {
inputText = sendController.text = pendingText;

View File

@ -120,6 +120,11 @@ class ChatView extends StatelessWidget {
icon: const Icon(Icons.widgets),
tooltip: L10n.of(context)!.matrixWidgets,
),
IconButton(
onPressed: controller.onPhoneButtonTap,
icon: const Icon(Icons.phone),
tooltip: L10n.of(context)!.placeCall,
),
EncryptionButton(controller.room!),
ChatSettingsPopupMenu(controller.room!, !controller.room!.isDirectChat),
];

View File

@ -0,0 +1,579 @@
/*
* Famedly
* Copyright (C) 2019, 2020, 2021 Famedly GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import 'dart:async';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:assets_audio_player/assets_audio_player.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:matrix/matrix.dart';
import 'package:pedantic/pedantic.dart';
import 'package:universal_html/html.dart' as darthtml;
import 'package:wakelock/wakelock.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'pip/pip_view.dart';
class _StreamView extends StatelessWidget {
const _StreamView(this.wrappedStream,
{Key? key, this.mainView = false, required this.matrixClient})
: super(key: key);
final WrappedMediaStream wrappedStream;
final Client matrixClient;
final bool mainView;
Uri? get avatarUrl => wrappedStream.getUser().avatarUrl;
String? get displayName => wrappedStream.displayName;
String get avatarName => wrappedStream.avatarName;
bool get isLocal => wrappedStream.isLocal();
bool get mirrored =>
wrappedStream.isLocal() &&
wrappedStream.purpose == SDPStreamMetadataPurpose.Usermedia;
bool get audioMuted => wrappedStream.audioMuted;
bool get videoMuted => wrappedStream.videoMuted;
bool get isScreenSharing =>
wrappedStream.purpose == SDPStreamMetadataPurpose.Screenshare;
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: Colors.black54,
),
child: Stack(
alignment: Alignment.center,
children: <Widget>[
if (videoMuted)
Container(
color: Colors.transparent,
),
if (!videoMuted)
RTCVideoView(
// yes, it must explicitly be casted even though I do not feel
// comfortable with it...
wrappedStream.renderer as RTCVideoRenderer,
mirror: mirrored,
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain,
),
if (videoMuted)
Positioned(
child: Avatar(
mxContent: avatarUrl,
name: displayName,
size: mainView ? 96 : 48,
client: matrixClient,
// textSize: mainView ? 36 : 24,
// matrixClient: matrixClient,
)),
if (!isScreenSharing)
Positioned(
left: 4.0,
bottom: 4.0,
child: Icon(audioMuted ? Icons.mic_off : Icons.mic,
color: Colors.white, size: 18.0),
)
],
));
}
}
class Calling extends StatefulWidget {
final VoidCallback? onClear;
final BuildContext context;
final String callId;
final CallSession call;
final Client client;
const Calling(
{required this.context,
required this.call,
required this.client,
required this.callId,
this.onClear,
Key? key})
: super(key: key);
@override
_MyCallingPage createState() => _MyCallingPage();
}
class _MyCallingPage extends State<Calling> {
Room? get room => call?.room;
String get displayName => call?.displayName ?? '';
String get callId => widget.callId;
CallSession? get call => widget.call;
MediaStream? get localStream {
if (call != null && call!.localUserMediaStream != null) {
return call!.localUserMediaStream!.stream!;
}
return null;
}
MediaStream? get remoteStream {
if (call != null && call!.getRemoteStreams.isNotEmpty) {
return call!.getRemoteStreams[0].stream!;
}
return null;
}
bool get speakerOn => call?.speakerOn ?? false;
bool get isMicrophoneMuted => call?.isMicrophoneMuted ?? false;
bool get isLocalVideoMuted => call?.isLocalVideoMuted ?? false;
bool get isScreensharingEnabled => call?.screensharingEnabled ?? false;
bool get isRemoteOnHold => call?.remoteOnHold ?? false;
bool get voiceonly => call == null || call?.type == CallType.kVoice;
bool get connecting => call?.state == CallState.kConnecting;
bool get connected => call?.state == CallState.kConnected;
bool get mirrored => call?.facingMode == 'user';
List<WrappedMediaStream> get streams => call?.streams ?? [];
double? _localVideoHeight;
double? _localVideoWidth;
EdgeInsetsGeometry? _localVideoMargin;
CallState? _state;
void _playCallSound() async {
const path = 'assets/sounds/call.wav';
if (kIsWeb) {
darthtml.AudioElement()
..src = 'assets/$path'
..autoplay = true
..load();
} else if (PlatformInfos.isMobile) {
await AssetsAudioPlayer.newPlayer().open(Audio(path));
} else {
Logs().w('Playing sound not implemented for this platform!');
}
}
@override
void initState() {
super.initState();
initialize();
_playCallSound();
}
void initialize() async {
final call = this.call;
if (call == null) return;
call.onCallStateChanged.listen(_handleCallState);
call.onCallEventChanged.listen((event) {
if (event == CallEvent.kFeedsChanged) {
setState(() {
call.tryRemoveStopedStreams();
});
} else if (event == CallEvent.kLocalHoldUnhold ||
event == CallEvent.kRemoteHoldUnhold) {
setState(() {});
Logs().i(
'Call hold event: local ${call.localHold}, remote ${call.remoteOnHold}');
}
});
_state = call.state;
if (call.type == CallType.kVideo) {
try {
// Enable wakelock (keep screen on)
unawaited(Wakelock.enable());
} catch (_) {}
}
}
void cleanUp() {
Timer(
const Duration(seconds: 2),
() => widget.onClear?.call(),
);
if (call?.type == CallType.kVideo) {
try {
unawaited(Wakelock.disable());
} catch (_) {}
}
}
@override
void dispose() {
super.dispose();
call?.cleanUp.call();
}
void _resizeLocalVideo(Orientation orientation) {
final shortSide = min(
MediaQuery.of(context).size.width, MediaQuery.of(context).size.height);
_localVideoMargin = remoteStream != null
? const EdgeInsets.only(top: 20.0, right: 20.0)
: EdgeInsets.zero;
_localVideoWidth = remoteStream != null
? shortSide / 3
: MediaQuery.of(context).size.width;
_localVideoHeight = remoteStream != null
? shortSide / 4
: MediaQuery.of(context).size.height;
}
void _handleCallState(CallState state) {
Logs().v('CallingPage::handleCallState: ${state.toString()}');
if (mounted) {
setState(() {
_state = state;
if (_state == CallState.kEnded) cleanUp();
});
}
}
void _answerCall() {
setState(() {
call?.answer();
});
}
void _hangUp() {
_playCallSound();
setState(() {
if (call != null && (call?.isRinging ?? false)) {
call?.reject();
} else {
call?.hangup();
}
});
}
void _muteMic() {
setState(() {
call?.setMicrophoneMuted(!call!.isMicrophoneMuted);
});
}
void _screenSharing() {
setState(() {
call?.setScreensharingEnabled(!call!.screensharingEnabled);
});
}
void _remoteOnHold() {
setState(() {
call?.setRemoteOnHold(!call!.remoteOnHold);
});
}
void _muteCamera() {
setState(() {
call?.setLocalVideoMuted(!call!.isLocalVideoMuted);
});
}
void _switchCamera() async {
if (call!.localUserMediaStream != null) {
await Helper.switchCamera(
call!.localUserMediaStream!.stream!.getVideoTracks()[0]);
if (PlatformInfos.isMobile) {
call!.facingMode == 'user'
? call!.facingMode = 'environment'
: call!.facingMode = 'user';
}
}
setState(() {});
}
/*
void _switchSpeaker() {
setState(() {
session.setSpeakerOn();
});
}
*/
List<Widget> _buildActionButtons(bool isFloating) {
if (isFloating || call == null) {
return [];
}
final switchCameraButton = FloatingActionButton(
heroTag: 'switchCamera',
onPressed: _switchCamera,
backgroundColor: Colors.black45,
child: const Icon(Icons.switch_camera),
);
/*
var switchSpeakerButton = FloatingActionButton(
heroTag: 'switchSpeaker',
child: Icon(_speakerOn ? Icons.volume_up : Icons.volume_off),
onPressed: _switchSpeaker,
foregroundColor: Colors.black54,
backgroundColor: Theme.of(context).backgroundColor,
);
*/
final hangupButton = FloatingActionButton(
heroTag: 'hangup',
onPressed: _hangUp,
tooltip: 'Hangup',
backgroundColor: _state == CallState.kEnded ? Colors.black45 : Colors.red,
child: const Icon(Icons.call_end),
);
final answerButton = FloatingActionButton(
heroTag: 'answer',
onPressed: _answerCall,
tooltip: 'Answer',
backgroundColor: Colors.green,
child: const Icon(Icons.phone),
);
final muteMicButton = FloatingActionButton(
heroTag: 'muteMic',
onPressed: _muteMic,
foregroundColor: isMicrophoneMuted ? Colors.black26 : Colors.white,
backgroundColor: isMicrophoneMuted ? Colors.white : Colors.black45,
child: Icon(isMicrophoneMuted ? Icons.mic_off : Icons.mic),
);
final screenSharingButton = FloatingActionButton(
heroTag: 'screenSharing',
onPressed: _screenSharing,
foregroundColor: isScreensharingEnabled ? Colors.black26 : Colors.white,
backgroundColor: isScreensharingEnabled ? Colors.white : Colors.black45,
child: const Icon(Icons.desktop_mac),
);
final holdButton = FloatingActionButton(
heroTag: 'hold',
onPressed: _remoteOnHold,
foregroundColor: isRemoteOnHold ? Colors.black26 : Colors.white,
backgroundColor: isRemoteOnHold ? Colors.white : Colors.black45,
child: const Icon(Icons.pause),
);
final muteCameraButton = FloatingActionButton(
heroTag: 'muteCam',
onPressed: _muteCamera,
foregroundColor: isLocalVideoMuted ? Colors.black26 : Colors.white,
backgroundColor: isLocalVideoMuted ? Colors.white : Colors.black45,
child: Icon(isLocalVideoMuted ? Icons.videocam_off : Icons.videocam),
);
switch (_state) {
case CallState.kRinging:
case CallState.kInviteSent:
case CallState.kCreateAnswer:
case CallState.kConnecting:
return call!.isOutgoing
? <Widget>[hangupButton]
: <Widget>[answerButton, hangupButton];
case CallState.kConnected:
return <Widget>[
muteMicButton,
//switchSpeakerButton,
if (!voiceonly && !kIsWeb) switchCameraButton,
if (!voiceonly) muteCameraButton,
if (kIsWeb) screenSharingButton,
holdButton,
hangupButton,
];
case CallState.kEnded:
return <Widget>[
hangupButton,
];
case CallState.kFledgling:
// TODO: Handle this case.
break;
case CallState.kWaitLocalMedia:
// TODO: Handle this case.
break;
case CallState.kCreateOffer:
// TODO: Handle this case.
break;
case null:
// TODO: Handle this case.
break;
}
return <Widget>[];
}
List<Widget> _buildContent(Orientation orientation, bool isFloating) {
final stackWidgets = <Widget>[];
final call = this.call;
if (call == null || call.callHasEnded) {
return stackWidgets;
}
if (call.localHold || call.remoteOnHold) {
var title = '';
if (call.localHold) {
title = '${call.displayName} held the call.';
} else if (call.remoteOnHold) {
title = 'You held the call.';
}
stackWidgets.add(Center(
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
const Icon(
Icons.pause,
size: 48.0,
color: Colors.white,
),
Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 24.0,
),
)
]),
));
return stackWidgets;
}
var primaryStream = call.remoteScreenSharingStream ??
call.localScreenSharingStream ??
call.remoteUserMediaStream ??
call.localUserMediaStream;
if (!connected) {
primaryStream = call.localUserMediaStream;
}
if (primaryStream != null) {
stackWidgets.add(Center(
child: _StreamView(primaryStream,
mainView: true, matrixClient: widget.client),
));
}
if (isFloating || !connected) {
return stackWidgets;
}
_resizeLocalVideo(orientation);
if (call.getRemoteStreams.isEmpty) {
return stackWidgets;
}
final secondaryStreamViews = <Widget>[];
if (call.remoteScreenSharingStream != null) {
final remoteUserMediaStream = call.remoteUserMediaStream;
secondaryStreamViews.add(SizedBox(
width: _localVideoWidth,
height: _localVideoHeight,
child: _StreamView(remoteUserMediaStream!, matrixClient: widget.client),
));
secondaryStreamViews.add(const SizedBox(height: 10));
}
final localStream =
call.localUserMediaStream ?? call.localScreenSharingStream;
if (localStream != null && !isFloating) {
secondaryStreamViews.add(SizedBox(
width: _localVideoWidth,
height: _localVideoHeight,
child: _StreamView(localStream, matrixClient: widget.client),
));
secondaryStreamViews.add(const SizedBox(height: 10));
}
if (call.localScreenSharingStream != null && !isFloating) {
secondaryStreamViews.add(SizedBox(
width: _localVideoWidth,
height: _localVideoHeight,
child: _StreamView(call.remoteUserMediaStream!,
matrixClient: widget.client),
));
secondaryStreamViews.add(const SizedBox(height: 10));
}
if (secondaryStreamViews.isNotEmpty) {
stackWidgets.add(Container(
padding: const EdgeInsets.fromLTRB(0, 20, 0, 120),
alignment: Alignment.bottomRight,
child: Container(
width: _localVideoWidth,
margin: _localVideoMargin,
child: Column(
children: secondaryStreamViews,
),
),
));
}
return stackWidgets;
}
@override
Widget build(BuildContext context) {
return PIPView(builder: (context, isFloating) {
return Scaffold(
resizeToAvoidBottomInset: !isFloating,
floatingActionButtonLocation:
FloatingActionButtonLocation.centerFloat,
floatingActionButton: SizedBox(
width: 320.0,
height: 150.0,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: _buildActionButtons(isFloating))),
body: OrientationBuilder(
builder: (BuildContext context, Orientation orientation) {
return Container(
decoration: const BoxDecoration(
color: Colors.black87,
),
child: Stack(children: [
..._buildContent(orientation, isFloating),
if (!isFloating)
Positioned(
top: 24.0,
left: 24.0,
child: IconButton(
color: Colors.black45,
icon: const Icon(Icons.arrow_back),
onPressed: () {
PIPView.of(context)?.setFloating(true);
},
))
]));
}));
});
}
}

View File

@ -0,0 +1 @@
const defaultAnimationDuration = Duration(milliseconds: 200);

View File

@ -0,0 +1,5 @@
import 'package:flutter/material.dart';
void dismissKeyboard(BuildContext context) {
FocusScope.of(context).requestFocus(FocusNode());
}

View File

@ -0,0 +1,343 @@
import 'package:flutter/material.dart';
import 'constants.dart';
import 'dismiss_keyboard.dart';
class PIPView extends StatefulWidget {
final PIPViewCorner initialCorner;
final double? floatingWidth;
final double? floatingHeight;
final bool avoidKeyboard;
final Widget Function(
BuildContext context,
bool isFloating,
) builder;
const PIPView({
Key? key,
required this.builder,
this.initialCorner = PIPViewCorner.topRight,
this.floatingWidth,
this.floatingHeight,
this.avoidKeyboard = true,
}) : super(key: key);
@override
PIPViewState createState() => PIPViewState();
static PIPViewState? of(BuildContext context) {
return context.findAncestorStateOfType<PIPViewState>();
}
}
class PIPViewState extends State<PIPView> with TickerProviderStateMixin {
late AnimationController _toggleFloatingAnimationController;
late AnimationController _dragAnimationController;
late PIPViewCorner _corner;
Offset _dragOffset = Offset.zero;
bool _isDragging = false;
bool _floating = false;
Map<PIPViewCorner, Offset> _offsets = {};
@override
void initState() {
super.initState();
_corner = widget.initialCorner;
_toggleFloatingAnimationController = AnimationController(
duration: defaultAnimationDuration,
vsync: this,
);
_dragAnimationController = AnimationController(
duration: defaultAnimationDuration,
vsync: this,
);
}
void _updateCornersOffsets({
required Size spaceSize,
required Size widgetSize,
required EdgeInsets windowPadding,
}) {
_offsets = _calculateOffsets(
spaceSize: spaceSize,
widgetSize: widgetSize,
windowPadding: windowPadding,
);
}
bool _isAnimating() {
return _toggleFloatingAnimationController.isAnimating ||
_dragAnimationController.isAnimating;
}
void setFloating(bool floating) {
if (_isAnimating()) return;
dismissKeyboard(context);
setState(() {
_floating = floating;
});
_toggleFloatingAnimationController.forward();
}
void stopFloating() {
if (_isAnimating()) return;
dismissKeyboard(context);
_toggleFloatingAnimationController.reverse().whenCompleteOrCancel(() {
if (mounted) {
setState(() {
_floating = false;
});
}
});
}
void _onPanUpdate(DragUpdateDetails details) {
if (!_isDragging) return;
setState(() {
_dragOffset = _dragOffset.translate(
details.delta.dx,
details.delta.dy,
);
});
}
void _onPanCancel() {
if (!_isDragging) return;
setState(() {
_dragAnimationController.value = 0;
_dragOffset = Offset.zero;
_isDragging = false;
});
}
void _onPanEnd(_) {
if (!_isDragging) return;
final nearestCorner = _calculateNearestCorner(
offset: _dragOffset,
offsets: _offsets,
);
setState(() {
_corner = nearestCorner;
_isDragging = false;
});
_dragAnimationController.forward().whenCompleteOrCancel(() {
_dragAnimationController.value = 0;
_dragOffset = Offset.zero;
});
}
void _onPanStart(_) {
if (_isAnimating()) return;
setState(() {
_dragOffset = _offsets[_corner]!;
_isDragging = true;
});
}
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
var windowPadding = mediaQuery.padding;
if (widget.avoidKeyboard) {
windowPadding += mediaQuery.viewInsets;
}
final isFloating = _floating;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final height = constraints.maxHeight;
var floatingWidth = widget.floatingWidth;
var floatingHeight = widget.floatingHeight;
if (floatingWidth == null && floatingHeight != null) {
floatingWidth = width / height * floatingHeight;
}
floatingWidth ??= 100.0;
floatingHeight ??= height / width * floatingWidth;
final floatingWidgetSize = Size(floatingWidth, floatingHeight);
final fullWidgetSize = Size(width, height);
_updateCornersOffsets(
spaceSize: fullWidgetSize,
widgetSize: floatingWidgetSize,
windowPadding: windowPadding,
);
final calculatedOffset = _offsets[_corner];
// BoxFit.cover
final widthRatio = floatingWidth / width;
final heightRatio = floatingHeight / height;
final scaledDownScale = widthRatio > heightRatio
? floatingWidgetSize.width / fullWidgetSize.width
: floatingWidgetSize.height / fullWidgetSize.height;
return Stack(
children: <Widget>[
AnimatedBuilder(
animation: Listenable.merge([
_toggleFloatingAnimationController,
_dragAnimationController,
]),
builder: (context, child) {
final animationCurve = CurveTween(
curve: Curves.easeInOutQuad,
);
final dragAnimationValue = animationCurve.transform(
_dragAnimationController.value,
);
final toggleFloatingAnimationValue = animationCurve.transform(
_toggleFloatingAnimationController.value,
);
final floatingOffset = _isDragging
? _dragOffset
: Tween<Offset>(
begin: _dragOffset,
end: calculatedOffset,
).transform(_dragAnimationController.isAnimating
? dragAnimationValue
: toggleFloatingAnimationValue);
final borderRadius = Tween<double>(
begin: 0,
end: 10,
).transform(toggleFloatingAnimationValue);
final width = Tween<double>(
begin: fullWidgetSize.width,
end: floatingWidgetSize.width,
).transform(toggleFloatingAnimationValue);
final height = Tween<double>(
begin: fullWidgetSize.height,
end: floatingWidgetSize.height,
).transform(toggleFloatingAnimationValue);
final scale = Tween<double>(
begin: 1,
end: scaledDownScale,
).transform(toggleFloatingAnimationValue);
return Positioned(
left: floatingOffset.dx,
top: floatingOffset.dy,
child: GestureDetector(
onPanStart: isFloating ? _onPanStart : null,
onPanUpdate: isFloating ? _onPanUpdate : null,
onPanCancel: isFloating ? _onPanCancel : null,
onPanEnd: isFloating ? _onPanEnd : null,
onTap: isFloating ? stopFloating : null,
child: Material(
elevation: 10,
borderRadius: BorderRadius.circular(borderRadius),
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(borderRadius),
),
width: width,
height: height,
child: Transform.scale(
scale: scale,
child: OverflowBox(
maxHeight: fullWidgetSize.height,
maxWidth: fullWidgetSize.width,
child: IgnorePointer(
ignoring: isFloating,
child: child,
),
),
),
),
),
),
);
},
child: Builder(
builder: (context) => widget.builder(context, isFloating),
),
),
],
);
},
);
}
}
enum PIPViewCorner {
topLeft,
topRight,
bottomLeft,
bottomRight,
}
class _CornerDistance {
final PIPViewCorner corner;
final double distance;
_CornerDistance({
required this.corner,
required this.distance,
});
}
PIPViewCorner _calculateNearestCorner({
required Offset offset,
required Map<PIPViewCorner, Offset> offsets,
}) {
_CornerDistance calculateDistance(PIPViewCorner corner) {
final distance = offsets[corner]!
.translate(
-offset.dx,
-offset.dy,
)
.distanceSquared;
return _CornerDistance(
corner: corner,
distance: distance,
);
}
final distances = PIPViewCorner.values.map(calculateDistance).toList();
distances.sort((cd0, cd1) => cd0.distance.compareTo(cd1.distance));
return distances.first.corner;
}
Map<PIPViewCorner, Offset> _calculateOffsets({
required Size spaceSize,
required Size widgetSize,
required EdgeInsets windowPadding,
}) {
Offset getOffsetForCorner(PIPViewCorner corner) {
const spacing = 16;
final left = spacing + windowPadding.left;
final top = spacing + windowPadding.top;
final right =
spaceSize.width - widgetSize.width - windowPadding.right - spacing;
final bottom =
spaceSize.height - widgetSize.height - windowPadding.bottom - spacing;
switch (corner) {
case PIPViewCorner.topLeft:
return Offset(left, top);
case PIPViewCorner.topRight:
return Offset(right, top);
case PIPViewCorner.bottomLeft:
return Offset(left, bottom);
case PIPViewCorner.bottomRight:
return Offset(right, bottom);
default:
throw Exception('Not implemented.');
}
}
const corners = PIPViewCorner.values;
final offsets = <PIPViewCorner, Offset>{};
for (final corner in corners) {
offsets[corner] = getOffsetForCorner(corner);
}
return offsets;
}

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart';
import 'package:permission_handler/permission_handler.dart';
@ -7,6 +8,7 @@ import 'package:permission_handler/permission_handler.dart';
import 'package:fluffychat/pages/new_private_chat/new_private_chat_view.dart';
import 'package:fluffychat/pages/new_private_chat/qr_scanner_modal.dart';
import 'package:fluffychat/utils/fluffy_share.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/utils/url_launcher.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -22,21 +24,26 @@ class NewPrivateChatController extends State<NewPrivateChat> {
final FocusNode textFieldFocus = FocusNode();
final formKey = GlobalKey<FormState>();
bool loading = false;
bool hideFab = false;
bool _hideFab = true;
bool _qrUnsupported = true;
bool get hideFab => !_qrUnsupported && _hideFab;
static const Set<String> supportedSigils = {'@', '!', '#'};
static const String prefix = 'https://matrix.to/#/';
void setHideFab() {
if (textFieldFocus.hasFocus != hideFab) {
setState(() => hideFab = textFieldFocus.hasFocus);
if (textFieldFocus.hasFocus != _hideFab) {
setState(() => _hideFab = textFieldFocus.hasFocus);
}
}
@override
void initState() {
super.initState();
_checkQrSupported();
textFieldFocus.addListener(setHideFab);
}
@ -83,4 +90,13 @@ class NewPrivateChatController extends State<NewPrivateChat> {
@override
Widget build(BuildContext context) => NewPrivateChatView(this);
// checks whether Android < 21 in order to support Android KitKat
void _checkQrSupported() {
if (!PlatformInfos.isAndroid) _qrUnsupported = false;
DeviceInfoPlugin().androidInfo.then(
(info) =>
setState(() => _qrUnsupported = (info.version.sdkInt ?? 16) < 21),
);
}
}

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;
}
}
}

View File

@ -25,6 +25,7 @@ import 'package:fluffychat/utils/client_manager.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/utils/sentry_controller.dart';
import 'package:fluffychat/utils/uia_request_manager.dart';
import 'package:fluffychat/utils/voip_plugin.dart';
import '../config/app_config.dart';
import '../config/setting_keys.dart';
import '../pages/key_verification/key_verification_dialog.dart';
@ -82,6 +83,8 @@ class MatrixState extends State<Matrix> with WidgetsBindingObserver {
return widget.clients[_activeClient];
}
VoipPlugin get voipPlugin => VoipPlugin(client: client, context: context);
bool get isMultiAccount => widget.clients.length > 1;
int getClientIndexByMatrixId(String matrixId) =>

View File

@ -5,13 +5,18 @@
import FlutterMacOS
import Foundation
import assets_audio_player
import assets_audio_player_web
import audioplayers
import connectivity_plus_macos
import desktop_drop
import device_info_plus_macos
import emoji_picker_flutter
import file_selector_macos
import flutter_local_notifications
import flutter_secure_storage_macos
import flutter_web_auth
import flutter_webrtc
import geolocator_apple
import package_info
import package_info_plus_macos
@ -23,13 +28,18 @@ import video_compress
import wakelock_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AssetsAudioPlayerPlugin.register(with: registry.registrar(forPlugin: "AssetsAudioPlayerPlugin"))
AssetsAudioPlayerWebPlugin.register(with: registry.registrar(forPlugin: "AssetsAudioPlayerWebPlugin"))
AudioplayersPlugin.register(with: registry.registrar(forPlugin: "AudioplayersPlugin"))
ConnectivityPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlugin"))
DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin"))
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
EmojiPickerFlutterPlugin.register(with: registry.registrar(forPlugin: "EmojiPickerFlutterPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterSecureStorageMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageMacosPlugin"))
FlutterWebAuthPlugin.register(with: registry.registrar(forPlugin: "FlutterWebAuthPlugin"))
FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin"))
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
FLTPackageInfoPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlugin"))
FLTPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlusPlugin"))

View File

@ -71,6 +71,20 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0"
assets_audio_player:
dependency: "direct main"
description:
name: assets_audio_player
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.4+1"
assets_audio_player_web:
dependency: transitive
description:
name: assets_audio_player_web
url: "https://pub.dartlang.org"
source: hosted
version: "3.0.4+1"
async:
dependency: transitive
description:
@ -127,6 +141,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
callkeep:
dependency: "direct main"
description:
name: callkeep
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.2"
canonical_json:
dependency: transitive
description:
@ -176,6 +197,48 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.15.0"
connectivity_plus:
dependency: "direct main"
description:
name: connectivity_plus
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.0"
connectivity_plus_linux:
dependency: transitive
description:
name: connectivity_plus_linux
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.0"
connectivity_plus_macos:
dependency: transitive
description:
name: connectivity_plus_macos
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.1"
connectivity_plus_platform_interface:
dependency: transitive
description:
name: connectivity_plus_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.0"
connectivity_plus_web:
dependency: transitive
description:
name: connectivity_plus_web
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.0"
connectivity_plus_windows:
dependency: transitive
description:
name: connectivity_plus_windows
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.0"
convert:
dependency: transitive
description:
@ -232,13 +295,20 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.1"
dbus:
dart_webrtc:
dependency: transitive
description:
name: dart_webrtc
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.4"
dbus:
dependency: "direct overridden"
description:
name: dbus
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.8"
version: "0.7.1"
desktop_drop:
dependency: "direct main"
description:
@ -252,7 +322,49 @@ packages:
name: desktop_notifications
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.1"
version: "0.6.3"
device_info_plus:
dependency: "direct main"
description:
name: device_info_plus
url: "https://pub.dartlang.org"
source: hosted
version: "3.2.1"
device_info_plus_linux:
dependency: transitive
description:
name: device_info_plus_linux
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.1"
device_info_plus_macos:
dependency: transitive
description:
name: device_info_plus_macos
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.2"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.3.0+1"
device_info_plus_web:
dependency: transitive
description:
name: device_info_plus_web
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
device_info_plus_windows:
dependency: transitive
description:
name: device_info_plus_windows
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.1"
disk_space:
dependency: transitive
description:
@ -390,7 +502,7 @@ packages:
name: flutter_blurhash
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.0"
version: "0.6.4"
flutter_cache_manager:
dependency: "direct main"
description:
@ -479,7 +591,7 @@ packages:
name: flutter_native_splash
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.2"
version: "2.0.3+1"
flutter_olm:
dependency: "direct main"
description:
@ -501,6 +613,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.5"
flutter_ringtone_player:
dependency: "direct main"
description:
name: flutter_ringtone_player
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.1"
flutter_secure_storage:
dependency: "direct main"
description:
@ -581,6 +700,13 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_webrtc:
dependency: "direct main"
description:
name: flutter_webrtc
url: "https://pub.dartlang.org"
source: hosted
version: "0.8.2"
frontend_server_client:
dependency: transitive
description:
@ -706,7 +832,7 @@ packages:
name: image_picker
url: "https://pub.dartlang.org"
source: hosted
version: "0.8.4+6"
version: "0.8.4+8"
image_picker_for_web:
dependency: transitive
description:
@ -797,7 +923,7 @@ packages:
name: lottie
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.1"
version: "1.2.2"
markdown:
dependency: transitive
description:
@ -875,6 +1001,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
nm:
dependency: transitive
description:
name: nm
url: "https://pub.dartlang.org"
source: hosted
version: "0.4.4"
node_preamble:
dependency: transitive
description:
@ -986,7 +1119,7 @@ packages:
name: path_provider
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.8"
version: "2.0.9"
path_provider_android:
dependency: transitive
description:
@ -1530,7 +1663,7 @@ packages:
name: url_launcher
url: "https://pub.dartlang.org"
source: hosted
version: "6.0.18"
version: "6.0.20"
url_launcher_android:
dependency: transitive
description:
@ -1719,7 +1852,7 @@ packages:
name: webrtc_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
version: "1.0.2"
win32:
dependency: transitive
description:

View File

@ -7,19 +7,23 @@ environment:
sdk: '>=2.12.0 <3.0.0'
dependencies:
adaptive_dialog: ^1.1.0
adaptive_theme: ^2.2.0
adaptive_dialog: ^1.3.0
adaptive_theme: ^2.3.0
animations: ^2.0.2
assets_audio_player: ^3.0.4+1
audioplayers: ^0.20.1
blurhash_dart: ^1.1.0
cached_network_image: ^3.1.0
cached_network_image: ^3.2.0
callkeep: ^0.3.2
chewie: ^1.2.2
collection: ^1.15.0-nullsafety.4
connectivity_plus: ^2.2.0
cupertino_icons: any
desktop_drop: ^0.3.0
desktop_notifications: ^0.6.1
desktop_drop: ^0.3.2
desktop_notifications: ^0.6.3
device_info_plus: ^3.2.1
email_validator: ^2.0.1
emoji_picker_flutter: ^1.0.7
emoji_picker_flutter: ^1.1.1
encrypt: ^5.0.1
#fcm_shared_isolate:
# git: https://gitlab.com/famedly/libraries/fcm_shared_isolate.git
@ -28,8 +32,8 @@ dependencies:
sdk: flutter
flutter_app_badger: ^1.3.0
flutter_app_lock: ^2.0.0
flutter_blurhash: ^0.6.0
flutter_cache_manager: ^3.1.2
flutter_blurhash: ^0.6.4
flutter_cache_manager: ^3.3.0
flutter_local_notifications: ^8.2.0
flutter_localizations:
sdk: flutter
@ -37,52 +41,54 @@ dependencies:
flutter_matrix_html: ^1.1.0
flutter_olm: ^1.2.0
flutter_openssl_crypto: ^0.1.0
flutter_ringtone_player: ^3.1.1
flutter_secure_storage: ^5.0.2
flutter_slidable: ^1.1.0
flutter_slidable: ^1.2.0
flutter_svg: ^0.22.0
flutter_typeahead: ^3.2.1
flutter_typeahead: ^3.2.4
flutter_web_auth: ^0.4.0
future_loading_dialog: ^0.2.2
flutter_webrtc: ^0.8.2
future_loading_dialog: ^0.2.3
geolocator: ^7.6.2
hive_flutter: ^1.1.0
image: ^3.0.8
image_picker: ^0.8.4+2
image: ^3.1.1
image_picker: ^0.8.4+8
intl: any
localstorage: ^4.0.0+1
lottie: ^1.2.1
lottie: ^1.2.2
matrix: ^0.8.9
matrix_link_text: ^1.0.2
open_noti_settings: ^0.4.0
package_info_plus: ^1.2.1
path_provider: ^2.0.5
package_info_plus: ^1.3.0
path_provider: ^2.0.9
permission_handler: ^8.3.0
pin_code_text_field: ^1.8.0
provider: ^6.0.1
provider: ^6.0.2
punycode: ^1.0.0
qr_code_scanner: ^0.6.1
qr_flutter: ^4.0.0
receive_sharing_intent: ^1.4.5
record: ^3.0.2
salomon_bottom_bar: ^3.1.0
scroll_to_index: ^2.1.0
sentry: ^6.0.1
salomon_bottom_bar: ^3.2.0
scroll_to_index: ^2.1.1
sentry: ^6.3.0
share: ^2.0.4
shared_preferences: ^2.0.12
shared_preferences: ^2.0.13
slugify: ^2.0.0
swipe_to_action: ^0.2.0
uni_links: ^0.5.1
unifiedpush: ^3.0.1
universal_html: ^2.0.8
url_launcher: ^6.0.12
url_launcher: ^6.0.20
video_compress: ^3.1.0
video_player: ^2.2.10
vrouter: ^1.2.0+15
video_player: ^2.2.18
vrouter: ^1.2.0+21
wakelock: ^0.5.6
dev_dependencies:
dart_code_metrics: ^4.2.0-dev.3
dart_code_metrics: ^4.10.1
flutter_lints: ^1.0.4
flutter_native_splash: ^2.0.1+1
flutter_native_splash: ^2.0.3+1
flutter_test:
sdk: flutter
import_sorter: ^4.6.0
@ -114,6 +120,7 @@ flutter:
- asset: fonts/NotoEmoji/NotoColorEmoji.ttf
dependency_overrides:
dbus: ^0.7.1
geolocator_android:
hosted:
name: geolocator_android

View File

@ -1,2 +1,2 @@
#!/usr/bin/env bash
flutter build apk --debug -v
flutter build apk --debug

View File

@ -2,7 +2,7 @@ diff --git a/android/app/build.gradle b/android/app/build.gradle
index 39c920e8..e27a49f5 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -80,11 +80,11 @@ flutter {
@@ -81,11 +81,11 @@ flutter {
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
@ -12,6 +12,7 @@ index 39c920e8..e27a49f5 100644
androidTestImplementation 'androidx.test:runner:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
implementation 'com.github.UnifiedPush:android-connector:1.2.3' // needed for unifiedpush
implementation 'androidx.multidex:multidex:2.0.1'
}
-//apply plugin: 'com.google.gms.google-services'
@ -92,7 +93,7 @@ index a1442ed2..ee0ce757 100644
+++ b/pubspec.yaml
@@ -21,8 +21,8 @@ dependencies:
email_validator: ^2.0.1
emoji_picker_flutter: ^1.0.7
emoji_picker_flutter: ^1.1.1
encrypt: ^5.0.1
- #fcm_shared_isolate:
- # git: https://gitlab.com/famedly/libraries/fcm_shared_isolate.git

View File

@ -6,18 +6,24 @@
#include "generated_plugin_registrant.h"
#include <connectivity_plus_windows/connectivity_plus_windows_plugin.h>
#include <desktop_drop/desktop_drop_plugin.h>
#include <file_selector_windows/file_selector_plugin.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
DesktopDropPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("DesktopDropPlugin"));
FileSelectorPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorPlugin"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
FlutterWebRTCPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterWebRTCPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
}

View File

@ -3,9 +3,11 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus_windows
desktop_drop
file_selector_windows
flutter_secure_storage_windows
flutter_webrtc
url_launcher_windows
)