mirror of
https://gitlab.com/famedly/fluffychat.git
synced 2024-11-23 20:49:26 +01:00
feat: implement WebRTC calls
Signed-off-by: TheOneWithTheBraid <the-one@with-the-braid.cf>
This commit is contained in:
parent
edb3adf208
commit
e5c03ffb53
@ -44,11 +44,12 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "chat.fluffy.fluffychat"
|
applicationId "chat.fluffy.fluffychat"
|
||||||
minSdkVersion 21
|
minSdkVersion 16
|
||||||
targetSdkVersion 30
|
targetSdkVersion 30
|
||||||
versionCode flutterVersionCode.toInteger()
|
versionCode flutterVersionCode.toInteger()
|
||||||
versionName flutterVersionName
|
versionName flutterVersionName
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
multiDexEnabled true
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
@ -85,6 +86,7 @@ dependencies {
|
|||||||
androidTestImplementation 'androidx.test:runner:1.1.1'
|
androidTestImplementation 'androidx.test:runner:1.1.1'
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
|
||||||
implementation 'com.github.UnifiedPush:android-connector:1.2.3' // needed for unifiedpush
|
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'
|
//apply plugin: 'com.google.gms.google-services'
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
package="chat.fluffy.fluffychat">
|
package="chat.fluffy.fluffychat">
|
||||||
<!-- io.flutter.app.FlutterApplication is an android.app.Application that
|
<!-- io.flutter.app.FlutterApplication is an android.app.Application that
|
||||||
calls FlutterMain.startInitialization(this); in its onCreate method.
|
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.ACCESS_COARSE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<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
|
<application
|
||||||
android:name=".Application"
|
android:name=".Application"
|
||||||
android:label="FluffyChat"
|
android:label="FluffyChat"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:requestLegacyExternalStorage="true"
|
android:requestLegacyExternalStorage="true"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:fullBackupContent="false">
|
android:fullBackupContent="false"
|
||||||
|
>
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:theme="@style/LaunchTheme"
|
android:theme="@style/LaunchTheme"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize"
|
||||||
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
@ -7,8 +7,14 @@ import android.content.Context
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
|
import androidx.multidex.MultiDex
|
||||||
|
|
||||||
class MainActivity : FlutterActivity() {
|
class MainActivity : FlutterActivity() {
|
||||||
|
override fun attachBaseContext(base: Context) {
|
||||||
|
super.attachBaseContext(base)
|
||||||
|
MultiDex.install(this)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
};
|
};
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
buildscript {
|
buildscript {
|
||||||
ext.kotlin_version = '1.4.32'
|
ext.kotlin_version = '1.6.10'
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
jcenter()
|
jcenter()
|
||||||
|
@ -2725,5 +2725,9 @@
|
|||||||
"pinMessage": "Pin to room",
|
"pinMessage": "Pin to room",
|
||||||
"pinnedEventsError": "Error loading pinned messages",
|
"pinnedEventsError": "Error loading pinned messages",
|
||||||
"confirmEventUnpin": "Are you sure to permanently unpin the event?",
|
"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."
|
||||||
}
|
}
|
||||||
|
@ -14,6 +14,7 @@ import 'package:vrouter/vrouter.dart';
|
|||||||
|
|
||||||
import 'package:fluffychat/config/routes.dart';
|
import 'package:fluffychat/config/routes.dart';
|
||||||
import 'package:fluffychat/utils/client_manager.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/platform_infos.dart';
|
||||||
import 'package:fluffychat/utils/sentry_controller.dart';
|
import 'package:fluffychat/utils/sentry_controller.dart';
|
||||||
import 'config/app_config.dart';
|
import 'config/app_config.dart';
|
||||||
@ -49,6 +50,8 @@ void main() async {
|
|||||||
.addAll(Uri.parse(html.window.location.href).queryParameters);
|
.addAll(Uri.parse(html.window.location.href).queryParameters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await Store.init();
|
||||||
|
|
||||||
runZonedGuarded(
|
runZonedGuarded(
|
||||||
() => runApp(PlatformInfos.isMobile
|
() => runApp(PlatformInfos.isMobile
|
||||||
? AppLock(
|
? AppLock(
|
||||||
|
@ -2,12 +2,15 @@ import 'dart:async';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import 'package:adaptive_dialog/adaptive_dialog.dart';
|
import 'package:adaptive_dialog/adaptive_dialog.dart';
|
||||||
|
import 'package:animations/animations.dart';
|
||||||
import 'package:desktop_drop/desktop_drop.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:emoji_picker_flutter/emoji_picker_flutter.dart';
|
||||||
import 'package:file_picker_cross/file_picker_cross.dart';
|
import 'package:file_picker_cross/file_picker_cross.dart';
|
||||||
import 'package:flutter_gen/gen_l10n/l10n.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/event_extension.dart';
|
||||||
import 'package:fluffychat/utils/matrix_sdk_extensions.dart/ios_badge_client_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/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 'package:fluffychat/widgets/matrix.dart';
|
||||||
import '../../utils/account_bundles.dart';
|
import '../../utils/account_bundles.dart';
|
||||||
import '../../utils/localized_exception_extension.dart';
|
import '../../utils/localized_exception_extension.dart';
|
||||||
@ -165,8 +170,14 @@ class ChatController extends State<Chat> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
scrollController.addListener(_updateScrollController);
|
scrollController.addListener(_updateScrollController);
|
||||||
|
|
||||||
inputFocus.addListener(_inputFocusListener);
|
inputFocus.addListener(_inputFocusListener);
|
||||||
|
|
||||||
|
if (!kIsWeb) {
|
||||||
|
WidgetsBinding.instance?.addPostFrameCallback((_) {
|
||||||
|
CallKeepManager().setVoipPlugin(Matrix.of(context).voipPlugin);
|
||||||
|
CallKeepManager().initialize().catchError((_) => true);
|
||||||
|
});
|
||||||
|
}
|
||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -939,6 +950,85 @@ class ChatController extends State<Chat> {
|
|||||||
void showEventInfo([Event? event]) =>
|
void showEventInfo([Event? event]) =>
|
||||||
(event ?? selectedEvents.single).showInfoDialog(context);
|
(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(() {
|
void cancelReplyEventAction() => setState(() {
|
||||||
if (editEvent != null) {
|
if (editEvent != null) {
|
||||||
inputText = sendController.text = pendingText;
|
inputText = sendController.text = pendingText;
|
||||||
|
@ -120,6 +120,11 @@ class ChatView extends StatelessWidget {
|
|||||||
icon: const Icon(Icons.widgets),
|
icon: const Icon(Icons.widgets),
|
||||||
tooltip: L10n.of(context)!.matrixWidgets,
|
tooltip: L10n.of(context)!.matrixWidgets,
|
||||||
),
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: controller.onPhoneButtonTap,
|
||||||
|
icon: const Icon(Icons.phone),
|
||||||
|
tooltip: L10n.of(context)!.placeCall,
|
||||||
|
),
|
||||||
EncryptionButton(controller.room!),
|
EncryptionButton(controller.room!),
|
||||||
ChatSettingsPopupMenu(controller.room!, !controller.room!.isDirectChat),
|
ChatSettingsPopupMenu(controller.room!, !controller.room!.isDirectChat),
|
||||||
];
|
];
|
||||||
|
579
lib/pages/dialer/dialer.dart
Normal file
579
lib/pages/dialer/dialer.dart
Normal 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);
|
||||||
|
},
|
||||||
|
))
|
||||||
|
]));
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
1
lib/pages/dialer/pip/constants.dart
Normal file
1
lib/pages/dialer/pip/constants.dart
Normal file
@ -0,0 +1 @@
|
|||||||
|
const defaultAnimationDuration = Duration(milliseconds: 200);
|
5
lib/pages/dialer/pip/dismiss_keyboard.dart
Normal file
5
lib/pages/dialer/pip/dismiss_keyboard.dart
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
void dismissKeyboard(BuildContext context) {
|
||||||
|
FocusScope.of(context).requestFocus(FocusNode());
|
||||||
|
}
|
343
lib/pages/dialer/pip/pip_view.dart
Normal file
343
lib/pages/dialer/pip/pip_view.dart
Normal 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;
|
||||||
|
}
|
@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
import 'package:permission_handler/permission_handler.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/new_private_chat_view.dart';
|
||||||
import 'package:fluffychat/pages/new_private_chat/qr_scanner_modal.dart';
|
import 'package:fluffychat/pages/new_private_chat/qr_scanner_modal.dart';
|
||||||
import 'package:fluffychat/utils/fluffy_share.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/utils/url_launcher.dart';
|
||||||
import 'package:fluffychat/widgets/matrix.dart';
|
import 'package:fluffychat/widgets/matrix.dart';
|
||||||
|
|
||||||
@ -22,21 +24,26 @@ class NewPrivateChatController extends State<NewPrivateChat> {
|
|||||||
final FocusNode textFieldFocus = FocusNode();
|
final FocusNode textFieldFocus = FocusNode();
|
||||||
final formKey = GlobalKey<FormState>();
|
final formKey = GlobalKey<FormState>();
|
||||||
bool loading = false;
|
bool loading = false;
|
||||||
bool hideFab = false;
|
|
||||||
|
bool _hideFab = true;
|
||||||
|
bool _qrUnsupported = true;
|
||||||
|
|
||||||
|
bool get hideFab => !_qrUnsupported && _hideFab;
|
||||||
|
|
||||||
static const Set<String> supportedSigils = {'@', '!', '#'};
|
static const Set<String> supportedSigils = {'@', '!', '#'};
|
||||||
|
|
||||||
static const String prefix = 'https://matrix.to/#/';
|
static const String prefix = 'https://matrix.to/#/';
|
||||||
|
|
||||||
void setHideFab() {
|
void setHideFab() {
|
||||||
if (textFieldFocus.hasFocus != hideFab) {
|
if (textFieldFocus.hasFocus != _hideFab) {
|
||||||
setState(() => hideFab = textFieldFocus.hasFocus);
|
setState(() => _hideFab = textFieldFocus.hasFocus);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_checkQrSupported();
|
||||||
textFieldFocus.addListener(setHideFab);
|
textFieldFocus.addListener(setHideFab);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,4 +90,13 @@ class NewPrivateChatController extends State<NewPrivateChat> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => NewPrivateChatView(this);
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,15 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:core';
|
import 'dart:core';
|
||||||
|
|
||||||
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:localstorage/localstorage.dart';
|
import 'package:localstorage/localstorage.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
import 'package:fluffychat/utils/platform_infos.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
|
// see https://github.com/mogol/flutter_secure_storage/issues/161#issuecomment-704578453
|
||||||
class AsyncMutex {
|
class AsyncMutex {
|
||||||
Completer<void>? _completer;
|
Completer<void>? _completer;
|
||||||
@ -28,13 +31,24 @@ class AsyncMutex {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Store {
|
class Store {
|
||||||
LocalStorage? storage;
|
static FlutterSecureStorage? secureStorage;
|
||||||
final FlutterSecureStorage? secureStorage;
|
|
||||||
static final _mutex = AsyncMutex();
|
|
||||||
|
|
||||||
Store()
|
static FutureOr<void> init() {
|
||||||
: secureStorage =
|
if (PlatformInfos.isMobile) {
|
||||||
PlatformInfos.isMobile ? const FlutterSecureStorage() : null;
|
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 {
|
Future<void> _setupLocalStorage() async {
|
||||||
if (storage == null) {
|
if (storage == null) {
|
||||||
|
@ -20,6 +20,7 @@ extension ResizeImage on MatrixFile {
|
|||||||
MediaInfo? mediaInfo;
|
MediaInfo? mediaInfo;
|
||||||
await tmpFile.writeAsBytes(bytes);
|
await tmpFile.writeAsBytes(bytes);
|
||||||
try {
|
try {
|
||||||
|
// will throw an error e.g. on Android SDK < 18
|
||||||
mediaInfo = await VideoCompress.compressVideo(tmpFile.path);
|
mediaInfo = await VideoCompress.compressVideo(tmpFile.path);
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
SentryController.captureException(e, s);
|
SentryController.captureException(e, s);
|
||||||
|
312
lib/utils/voip/callkeep_manager.dart
Normal file
312
lib/utils/voip/callkeep_manager.dart
Normal 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!);
|
||||||
|
}
|
||||||
|
}
|
27
lib/utils/voip/user_media_manager.dart
Normal file
27
lib/utils/voip/user_media_manager.dart
Normal 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
136
lib/utils/voip_plugin.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -25,6 +25,7 @@ import 'package:fluffychat/utils/client_manager.dart';
|
|||||||
import 'package:fluffychat/utils/platform_infos.dart';
|
import 'package:fluffychat/utils/platform_infos.dart';
|
||||||
import 'package:fluffychat/utils/sentry_controller.dart';
|
import 'package:fluffychat/utils/sentry_controller.dart';
|
||||||
import 'package:fluffychat/utils/uia_request_manager.dart';
|
import 'package:fluffychat/utils/uia_request_manager.dart';
|
||||||
|
import 'package:fluffychat/utils/voip_plugin.dart';
|
||||||
import '../config/app_config.dart';
|
import '../config/app_config.dart';
|
||||||
import '../config/setting_keys.dart';
|
import '../config/setting_keys.dart';
|
||||||
import '../pages/key_verification/key_verification_dialog.dart';
|
import '../pages/key_verification/key_verification_dialog.dart';
|
||||||
@ -82,6 +83,8 @@ class MatrixState extends State<Matrix> with WidgetsBindingObserver {
|
|||||||
return widget.clients[_activeClient];
|
return widget.clients[_activeClient];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
VoipPlugin get voipPlugin => VoipPlugin(client: client, context: context);
|
||||||
|
|
||||||
bool get isMultiAccount => widget.clients.length > 1;
|
bool get isMultiAccount => widget.clients.length > 1;
|
||||||
|
|
||||||
int getClientIndexByMatrixId(String matrixId) =>
|
int getClientIndexByMatrixId(String matrixId) =>
|
||||||
|
@ -5,13 +5,18 @@
|
|||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import assets_audio_player
|
||||||
|
import assets_audio_player_web
|
||||||
import audioplayers
|
import audioplayers
|
||||||
|
import connectivity_plus_macos
|
||||||
import desktop_drop
|
import desktop_drop
|
||||||
|
import device_info_plus_macos
|
||||||
import emoji_picker_flutter
|
import emoji_picker_flutter
|
||||||
import file_selector_macos
|
import file_selector_macos
|
||||||
import flutter_local_notifications
|
import flutter_local_notifications
|
||||||
import flutter_secure_storage_macos
|
import flutter_secure_storage_macos
|
||||||
import flutter_web_auth
|
import flutter_web_auth
|
||||||
|
import flutter_webrtc
|
||||||
import geolocator_apple
|
import geolocator_apple
|
||||||
import package_info
|
import package_info
|
||||||
import package_info_plus_macos
|
import package_info_plus_macos
|
||||||
@ -23,13 +28,18 @@ import video_compress
|
|||||||
import wakelock_macos
|
import wakelock_macos
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
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"))
|
AudioplayersPlugin.register(with: registry.registrar(forPlugin: "AudioplayersPlugin"))
|
||||||
|
ConnectivityPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlugin"))
|
||||||
DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin"))
|
DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin"))
|
||||||
|
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||||
EmojiPickerFlutterPlugin.register(with: registry.registrar(forPlugin: "EmojiPickerFlutterPlugin"))
|
EmojiPickerFlutterPlugin.register(with: registry.registrar(forPlugin: "EmojiPickerFlutterPlugin"))
|
||||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
FlutterSecureStorageMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageMacosPlugin"))
|
FlutterSecureStorageMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageMacosPlugin"))
|
||||||
FlutterWebAuthPlugin.register(with: registry.registrar(forPlugin: "FlutterWebAuthPlugin"))
|
FlutterWebAuthPlugin.register(with: registry.registrar(forPlugin: "FlutterWebAuthPlugin"))
|
||||||
|
FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin"))
|
||||||
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
||||||
FLTPackageInfoPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlugin"))
|
FLTPackageInfoPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlugin"))
|
||||||
FLTPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlusPlugin"))
|
FLTPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlusPlugin"))
|
||||||
|
153
pubspec.lock
153
pubspec.lock
@ -71,6 +71,20 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
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:
|
async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -127,6 +141,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.1"
|
version: "1.0.1"
|
||||||
|
callkeep:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: callkeep
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.2"
|
||||||
canonical_json:
|
canonical_json:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -176,6 +197,48 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.15.0"
|
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:
|
convert:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -232,13 +295,20 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.1"
|
version: "2.2.1"
|
||||||
dbus:
|
dart_webrtc:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dart_webrtc
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.4"
|
||||||
|
dbus:
|
||||||
|
dependency: "direct overridden"
|
||||||
description:
|
description:
|
||||||
name: dbus
|
name: dbus
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.8"
|
version: "0.7.1"
|
||||||
desktop_drop:
|
desktop_drop:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -252,7 +322,49 @@ packages:
|
|||||||
name: desktop_notifications
|
name: desktop_notifications
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
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:
|
disk_space:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -390,7 +502,7 @@ packages:
|
|||||||
name: flutter_blurhash
|
name: flutter_blurhash
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.0"
|
version: "0.6.4"
|
||||||
flutter_cache_manager:
|
flutter_cache_manager:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -479,7 +591,7 @@ packages:
|
|||||||
name: flutter_native_splash
|
name: flutter_native_splash
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.2"
|
version: "2.0.3+1"
|
||||||
flutter_olm:
|
flutter_olm:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -501,6 +613,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.5"
|
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:
|
flutter_secure_storage:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -581,6 +700,13 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
frontend_server_client:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -706,7 +832,7 @@ packages:
|
|||||||
name: image_picker
|
name: image_picker
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.8.4+6"
|
version: "0.8.4+8"
|
||||||
image_picker_for_web:
|
image_picker_for_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -797,7 +923,7 @@ packages:
|
|||||||
name: lottie
|
name: lottie
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.1"
|
version: "1.2.2"
|
||||||
markdown:
|
markdown:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -875,6 +1001,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
|
nm:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: nm
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.4.4"
|
||||||
node_preamble:
|
node_preamble:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -986,7 +1119,7 @@ packages:
|
|||||||
name: path_provider
|
name: path_provider
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.8"
|
version: "2.0.9"
|
||||||
path_provider_android:
|
path_provider_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -1530,7 +1663,7 @@ packages:
|
|||||||
name: url_launcher
|
name: url_launcher
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.18"
|
version: "6.0.20"
|
||||||
url_launcher_android:
|
url_launcher_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -1719,7 +1852,7 @@ packages:
|
|||||||
name: webrtc_interface
|
name: webrtc_interface
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.1"
|
version: "1.0.2"
|
||||||
win32:
|
win32:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
59
pubspec.yaml
59
pubspec.yaml
@ -7,19 +7,23 @@ environment:
|
|||||||
sdk: '>=2.12.0 <3.0.0'
|
sdk: '>=2.12.0 <3.0.0'
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
adaptive_dialog: ^1.1.0
|
adaptive_dialog: ^1.3.0
|
||||||
adaptive_theme: ^2.2.0
|
adaptive_theme: ^2.3.0
|
||||||
animations: ^2.0.2
|
animations: ^2.0.2
|
||||||
|
assets_audio_player: ^3.0.4+1
|
||||||
audioplayers: ^0.20.1
|
audioplayers: ^0.20.1
|
||||||
blurhash_dart: ^1.1.0
|
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
|
chewie: ^1.2.2
|
||||||
collection: ^1.15.0-nullsafety.4
|
collection: ^1.15.0-nullsafety.4
|
||||||
|
connectivity_plus: ^2.2.0
|
||||||
cupertino_icons: any
|
cupertino_icons: any
|
||||||
desktop_drop: ^0.3.0
|
desktop_drop: ^0.3.2
|
||||||
desktop_notifications: ^0.6.1
|
desktop_notifications: ^0.6.3
|
||||||
|
device_info_plus: ^3.2.1
|
||||||
email_validator: ^2.0.1
|
email_validator: ^2.0.1
|
||||||
emoji_picker_flutter: ^1.0.7
|
emoji_picker_flutter: ^1.1.1
|
||||||
encrypt: ^5.0.1
|
encrypt: ^5.0.1
|
||||||
#fcm_shared_isolate:
|
#fcm_shared_isolate:
|
||||||
# git: https://gitlab.com/famedly/libraries/fcm_shared_isolate.git
|
# git: https://gitlab.com/famedly/libraries/fcm_shared_isolate.git
|
||||||
@ -28,8 +32,8 @@ dependencies:
|
|||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_app_badger: ^1.3.0
|
flutter_app_badger: ^1.3.0
|
||||||
flutter_app_lock: ^2.0.0
|
flutter_app_lock: ^2.0.0
|
||||||
flutter_blurhash: ^0.6.0
|
flutter_blurhash: ^0.6.4
|
||||||
flutter_cache_manager: ^3.1.2
|
flutter_cache_manager: ^3.3.0
|
||||||
flutter_local_notifications: ^8.2.0
|
flutter_local_notifications: ^8.2.0
|
||||||
flutter_localizations:
|
flutter_localizations:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
@ -37,52 +41,54 @@ dependencies:
|
|||||||
flutter_matrix_html: ^1.1.0
|
flutter_matrix_html: ^1.1.0
|
||||||
flutter_olm: ^1.2.0
|
flutter_olm: ^1.2.0
|
||||||
flutter_openssl_crypto: ^0.1.0
|
flutter_openssl_crypto: ^0.1.0
|
||||||
|
flutter_ringtone_player: ^3.1.1
|
||||||
flutter_secure_storage: ^5.0.2
|
flutter_secure_storage: ^5.0.2
|
||||||
flutter_slidable: ^1.1.0
|
flutter_slidable: ^1.2.0
|
||||||
flutter_svg: ^0.22.0
|
flutter_svg: ^0.22.0
|
||||||
flutter_typeahead: ^3.2.1
|
flutter_typeahead: ^3.2.4
|
||||||
flutter_web_auth: ^0.4.0
|
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
|
geolocator: ^7.6.2
|
||||||
hive_flutter: ^1.1.0
|
hive_flutter: ^1.1.0
|
||||||
image: ^3.0.8
|
image: ^3.1.1
|
||||||
image_picker: ^0.8.4+2
|
image_picker: ^0.8.4+8
|
||||||
intl: any
|
intl: any
|
||||||
localstorage: ^4.0.0+1
|
localstorage: ^4.0.0+1
|
||||||
lottie: ^1.2.1
|
lottie: ^1.2.2
|
||||||
matrix: ^0.8.9
|
matrix: ^0.8.9
|
||||||
matrix_link_text: ^1.0.2
|
matrix_link_text: ^1.0.2
|
||||||
open_noti_settings: ^0.4.0
|
open_noti_settings: ^0.4.0
|
||||||
package_info_plus: ^1.2.1
|
package_info_plus: ^1.3.0
|
||||||
path_provider: ^2.0.5
|
path_provider: ^2.0.9
|
||||||
permission_handler: ^8.3.0
|
permission_handler: ^8.3.0
|
||||||
pin_code_text_field: ^1.8.0
|
pin_code_text_field: ^1.8.0
|
||||||
provider: ^6.0.1
|
provider: ^6.0.2
|
||||||
punycode: ^1.0.0
|
punycode: ^1.0.0
|
||||||
qr_code_scanner: ^0.6.1
|
qr_code_scanner: ^0.6.1
|
||||||
qr_flutter: ^4.0.0
|
qr_flutter: ^4.0.0
|
||||||
receive_sharing_intent: ^1.4.5
|
receive_sharing_intent: ^1.4.5
|
||||||
record: ^3.0.2
|
record: ^3.0.2
|
||||||
salomon_bottom_bar: ^3.1.0
|
salomon_bottom_bar: ^3.2.0
|
||||||
scroll_to_index: ^2.1.0
|
scroll_to_index: ^2.1.1
|
||||||
sentry: ^6.0.1
|
sentry: ^6.3.0
|
||||||
share: ^2.0.4
|
share: ^2.0.4
|
||||||
shared_preferences: ^2.0.12
|
shared_preferences: ^2.0.13
|
||||||
slugify: ^2.0.0
|
slugify: ^2.0.0
|
||||||
swipe_to_action: ^0.2.0
|
swipe_to_action: ^0.2.0
|
||||||
uni_links: ^0.5.1
|
uni_links: ^0.5.1
|
||||||
unifiedpush: ^3.0.1
|
unifiedpush: ^3.0.1
|
||||||
universal_html: ^2.0.8
|
universal_html: ^2.0.8
|
||||||
url_launcher: ^6.0.12
|
url_launcher: ^6.0.20
|
||||||
video_compress: ^3.1.0
|
video_compress: ^3.1.0
|
||||||
video_player: ^2.2.10
|
video_player: ^2.2.18
|
||||||
vrouter: ^1.2.0+15
|
vrouter: ^1.2.0+21
|
||||||
wakelock: ^0.5.6
|
wakelock: ^0.5.6
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
dart_code_metrics: ^4.2.0-dev.3
|
dart_code_metrics: ^4.10.1
|
||||||
flutter_lints: ^1.0.4
|
flutter_lints: ^1.0.4
|
||||||
flutter_native_splash: ^2.0.1+1
|
flutter_native_splash: ^2.0.3+1
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
import_sorter: ^4.6.0
|
import_sorter: ^4.6.0
|
||||||
@ -114,6 +120,7 @@ flutter:
|
|||||||
- asset: fonts/NotoEmoji/NotoColorEmoji.ttf
|
- asset: fonts/NotoEmoji/NotoColorEmoji.ttf
|
||||||
|
|
||||||
dependency_overrides:
|
dependency_overrides:
|
||||||
|
dbus: ^0.7.1
|
||||||
geolocator_android:
|
geolocator_android:
|
||||||
hosted:
|
hosted:
|
||||||
name: geolocator_android
|
name: geolocator_android
|
||||||
|
@ -1,2 +1,2 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
flutter build apk --debug -v
|
flutter build apk --debug
|
||||||
|
@ -2,7 +2,7 @@ diff --git a/android/app/build.gradle b/android/app/build.gradle
|
|||||||
index 39c920e8..e27a49f5 100644
|
index 39c920e8..e27a49f5 100644
|
||||||
--- a/android/app/build.gradle
|
--- a/android/app/build.gradle
|
||||||
+++ b/android/app/build.gradle
|
+++ b/android/app/build.gradle
|
||||||
@@ -80,11 +80,11 @@ flutter {
|
@@ -81,11 +81,11 @@ flutter {
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
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:runner:1.1.1'
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
|
||||||
implementation 'com.github.UnifiedPush:android-connector:1.2.3' // needed for unifiedpush
|
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'
|
-//apply plugin: 'com.google.gms.google-services'
|
||||||
@ -92,7 +93,7 @@ index a1442ed2..ee0ce757 100644
|
|||||||
+++ b/pubspec.yaml
|
+++ b/pubspec.yaml
|
||||||
@@ -21,8 +21,8 @@ dependencies:
|
@@ -21,8 +21,8 @@ dependencies:
|
||||||
email_validator: ^2.0.1
|
email_validator: ^2.0.1
|
||||||
emoji_picker_flutter: ^1.0.7
|
emoji_picker_flutter: ^1.1.1
|
||||||
encrypt: ^5.0.1
|
encrypt: ^5.0.1
|
||||||
- #fcm_shared_isolate:
|
- #fcm_shared_isolate:
|
||||||
- # git: https://gitlab.com/famedly/libraries/fcm_shared_isolate.git
|
- # git: https://gitlab.com/famedly/libraries/fcm_shared_isolate.git
|
||||||
|
@ -6,18 +6,24 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <connectivity_plus_windows/connectivity_plus_windows_plugin.h>
|
||||||
#include <desktop_drop/desktop_drop_plugin.h>
|
#include <desktop_drop/desktop_drop_plugin.h>
|
||||||
#include <file_selector_windows/file_selector_plugin.h>
|
#include <file_selector_windows/file_selector_plugin.h>
|
||||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_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>
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
|
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
||||||
DesktopDropPluginRegisterWithRegistrar(
|
DesktopDropPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("DesktopDropPlugin"));
|
registry->GetRegistrarForPlugin("DesktopDropPlugin"));
|
||||||
FileSelectorPluginRegisterWithRegistrar(
|
FileSelectorPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FileSelectorPlugin"));
|
registry->GetRegistrarForPlugin("FileSelectorPlugin"));
|
||||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||||
|
FlutterWebRTCPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("FlutterWebRTCPlugin"));
|
||||||
UrlLauncherWindowsRegisterWithRegistrar(
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||||
}
|
}
|
||||||
|
@ -3,9 +3,11 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
connectivity_plus_windows
|
||||||
desktop_drop
|
desktop_drop
|
||||||
file_selector_windows
|
file_selector_windows
|
||||||
flutter_secure_storage_windows
|
flutter_secure_storage_windows
|
||||||
|
flutter_webrtc
|
||||||
url_launcher_windows
|
url_launcher_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user