💄 Optimized voice chat
This commit is contained in:
		| @@ -24,8 +24,8 @@ | |||||||
|             android:name="de.julianassmann.flutter_background.IsolateHolderService" |             android:name="de.julianassmann.flutter_background.IsolateHolderService" | ||||||
|             android:enabled="true" |             android:enabled="true" | ||||||
|             android:exported="false" |             android:exported="false" | ||||||
|  |  | ||||||
|             android:foregroundServiceType="mediaProjection" /> |             android:foregroundServiceType="mediaProjection" /> | ||||||
|  |  | ||||||
|         <activity |         <activity | ||||||
|             android:name=".MainActivity" |             android:name=".MainActivity" | ||||||
|             android:exported="true" |             android:exported="true" | ||||||
|   | |||||||
| @@ -147,12 +147,12 @@ class _ChatCallState extends State<ChatCall> { | |||||||
|  |  | ||||||
|   void autoPublish() async { |   void autoPublish() async { | ||||||
|     try { |     try { | ||||||
|       if (_enableVideo) await _callRoom.localParticipant?.setCameraEnabled(true); |       if(_enableVideo) await _callRoom.localParticipant?.setCameraEnabled(true); | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
|       await context.showErrorDialog(error); |       await context.showErrorDialog(error); | ||||||
|     } |     } | ||||||
|     try { |     try { | ||||||
|       if (_enableAudio) await _callRoom.localParticipant?.setMicrophoneEnabled(true); |       if(_enableAudio) await _callRoom.localParticipant?.setMicrophoneEnabled(true); | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
|       await context.showErrorDialog(error); |       await context.showErrorDialog(error); | ||||||
|     } |     } | ||||||
| @@ -204,17 +204,23 @@ class _ChatCallState extends State<ChatCall> { | |||||||
|     List<ParticipantTrack> userMediaTracks = []; |     List<ParticipantTrack> userMediaTracks = []; | ||||||
|     List<ParticipantTrack> screenTracks = []; |     List<ParticipantTrack> screenTracks = []; | ||||||
|     for (var participant in _callRoom.remoteParticipants.values) { |     for (var participant in _callRoom.remoteParticipants.values) { | ||||||
|       for (var t in participant.videoTrackPublications) { |       for (var t in participant.trackPublications.values) { | ||||||
|         if (t.isScreenShare) { |         if (t.isScreenShare) { | ||||||
|           screenTracks.add(ParticipantTrack( |           screenTracks.add(ParticipantTrack( | ||||||
|             participant: participant, |             participant: participant, | ||||||
|             videoTrack: t.track, |             videoTrack: t.track as VideoTrack, | ||||||
|             isScreenShare: true, |             isScreenShare: true, | ||||||
|           )); |           )); | ||||||
|  |         } else if (t.track is VideoTrack) { | ||||||
|  |           userMediaTracks.add(ParticipantTrack( | ||||||
|  |             participant: participant, | ||||||
|  |             videoTrack: t.track as VideoTrack, | ||||||
|  |             isScreenShare: false, | ||||||
|  |           )); | ||||||
|         } else { |         } else { | ||||||
|           userMediaTracks.add(ParticipantTrack( |           userMediaTracks.add(ParticipantTrack( | ||||||
|             participant: participant, |             participant: participant, | ||||||
|             videoTrack: t.track, |             videoTrack: null, | ||||||
|             isScreenShare: false, |             isScreenShare: false, | ||||||
|           )); |           )); | ||||||
|         } |         } | ||||||
| @@ -247,19 +253,25 @@ class _ChatCallState extends State<ChatCall> { | |||||||
|       return a.participant.joinedAt.millisecondsSinceEpoch - b.participant.joinedAt.millisecondsSinceEpoch; |       return a.participant.joinedAt.millisecondsSinceEpoch - b.participant.joinedAt.millisecondsSinceEpoch; | ||||||
|     }); |     }); | ||||||
|  |  | ||||||
|     final localParticipantTracks = _callRoom.localParticipant?.videoTrackPublications; |     final localParticipantTracks = _callRoom.localParticipant?.trackPublications.values; | ||||||
|     if (localParticipantTracks != null) { |     if (localParticipantTracks != null) { | ||||||
|       for (var t in localParticipantTracks) { |       for (var t in localParticipantTracks) { | ||||||
|         if (t.isScreenShare) { |         if (t.isScreenShare) { | ||||||
|           screenTracks.add(ParticipantTrack( |           screenTracks.add(ParticipantTrack( | ||||||
|             participant: _callRoom.localParticipant!, |             participant: _callRoom.localParticipant!, | ||||||
|             videoTrack: t.track, |             videoTrack: t.track as VideoTrack, | ||||||
|             isScreenShare: true, |             isScreenShare: true, | ||||||
|           )); |           )); | ||||||
|  |         } else if (t.track is VideoTrack) { | ||||||
|  |           userMediaTracks.add(ParticipantTrack( | ||||||
|  |             participant: _callRoom.localParticipant!, | ||||||
|  |             videoTrack: t.track as VideoTrack, | ||||||
|  |             isScreenShare: false, | ||||||
|  |           )); | ||||||
|         } else { |         } else { | ||||||
|           userMediaTracks.add(ParticipantTrack( |           userMediaTracks.add(ParticipantTrack( | ||||||
|             participant: _callRoom.localParticipant!, |             participant: _callRoom.localParticipant!, | ||||||
|             videoTrack: t.track, |             videoTrack: null, | ||||||
|             isScreenShare: false, |             isScreenShare: false, | ||||||
|           )); |           )); | ||||||
|         } |         } | ||||||
| @@ -363,7 +375,6 @@ class _ChatCallState extends State<ChatCall> { | |||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     return IndentWrapper( |     return IndentWrapper( | ||||||
|       title: AppLocalizations.of(context)!.chatCall, |       title: AppLocalizations.of(context)!.chatCall, | ||||||
|       noSafeArea: true, |  | ||||||
|       hideDrawer: true, |       hideDrawer: true, | ||||||
|       child: FutureBuilder( |       child: FutureBuilder( | ||||||
|         future: exchangeToken(), |         future: exchangeToken(), | ||||||
| @@ -377,15 +388,14 @@ class _ChatCallState extends State<ChatCall> { | |||||||
|               Column( |               Column( | ||||||
|                 children: [ |                 children: [ | ||||||
|                   Expanded( |                   Expanded( | ||||||
|                     child: _participantTracks.isNotEmpty |                     child: Container( | ||||||
|                         ? ParticipantWidget.widgetFor(_participantTracks.first) |                       color: Theme.of(context).colorScheme.surfaceVariant, | ||||||
|                         : Container(), |                       child: _participantTracks.isNotEmpty | ||||||
|  |                           ? ParticipantWidget.widgetFor(_participantTracks.first) | ||||||
|  |                           : Container(), | ||||||
|  |                     ), | ||||||
|                   ), |                   ), | ||||||
|                   if (_callRoom.localParticipant != null) |                   if (_callRoom.localParticipant != null) ControlsWidget(_callRoom, _callRoom.localParticipant!), | ||||||
|                     SafeArea( |  | ||||||
|                       top: false, |  | ||||||
|                       child: ControlsWidget(_callRoom, _callRoom.localParticipant!), |  | ||||||
|                     ) |  | ||||||
|                 ], |                 ], | ||||||
|               ), |               ), | ||||||
|               Positioned( |               Positioned( | ||||||
| @@ -398,7 +408,7 @@ class _ChatCallState extends State<ChatCall> { | |||||||
|                     scrollDirection: Axis.horizontal, |                     scrollDirection: Axis.horizontal, | ||||||
|                     itemCount: math.max(0, _participantTracks.length - 1), |                     itemCount: math.max(0, _participantTracks.length - 1), | ||||||
|                     itemBuilder: (BuildContext context, int index) => SizedBox( |                     itemBuilder: (BuildContext context, int index) => SizedBox( | ||||||
|                       width: 180, |                       width: 120, | ||||||
|                       height: 120, |                       height: 120, | ||||||
|                       child: ParticipantWidget.widgetFor(_participantTracks[index + 1]), |                       child: ParticipantWidget.widgetFor(_participantTracks[index + 1]), | ||||||
|                     ), |                     ), | ||||||
| @@ -423,8 +433,8 @@ class _ChatCallState extends State<ChatCall> { | |||||||
|     WakelockPlus.disable(); |     WakelockPlus.disable(); | ||||||
|     (() async { |     (() async { | ||||||
|       _callRoom.removeListener(onRoomDidUpdate); |       _callRoom.removeListener(onRoomDidUpdate); | ||||||
|       await _callRoom.disconnect(); |  | ||||||
|       await _callListener.dispose(); |       await _callListener.dispose(); | ||||||
|  |       await _callRoom.disconnect(); | ||||||
|       await _callRoom.dispose(); |       await _callRoom.dispose(); | ||||||
|     })(); |     })(); | ||||||
|     super.dispose(); |     super.dispose(); | ||||||
|   | |||||||
| @@ -294,7 +294,7 @@ class _ControlsWidgetState extends State<ControlsWidget> { | |||||||
|                     value: null, |                     value: null, | ||||||
|                     onTap: disableVideo, |                     onTap: disableVideo, | ||||||
|                     child: ListTile( |                     child: ListTile( | ||||||
|                       leading: const Icon(Icons.videocam_off, color: Colors.white), |                       leading: const Icon(Icons.videocam_off), | ||||||
|                       title: Text(AppLocalizations.of(context)!.chatCallVideoOff), |                       title: Text(AppLocalizations.of(context)!.chatCallVideoOff), | ||||||
|                     ), |                     ), | ||||||
|                   ), |                   ), | ||||||
|   | |||||||
							
								
								
									
										75
									
								
								lib/widgets/chat/call/no_content.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								lib/widgets/chat/call/no_content.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,75 @@ | |||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter_animate/flutter_animate.dart'; | ||||||
|  | import 'package:solian/models/account.dart'; | ||||||
|  | import 'package:solian/widgets/account/avatar.dart'; | ||||||
|  | import 'dart:math' as math; | ||||||
|  |  | ||||||
|  | class NoContentWidget extends StatefulWidget { | ||||||
|  |   final Account? userinfo; | ||||||
|  |   final bool isSpeaking; | ||||||
|  |  | ||||||
|  |   const NoContentWidget({super.key, this.userinfo, required this.isSpeaking}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   State<NoContentWidget> createState() => _NoContentWidgetState(); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class _NoContentWidgetState extends State<NoContentWidget> with SingleTickerProviderStateMixin { | ||||||
|  |   late final AnimationController _animationController; | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void initState() { | ||||||
|  |     super.initState(); | ||||||
|  |     _animationController = AnimationController(vsync: this); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void didUpdateWidget(NoContentWidget old) { | ||||||
|  |     super.didUpdateWidget(old); | ||||||
|  |     if (widget.isSpeaking) { | ||||||
|  |       _animationController.repeat(reverse: true); | ||||||
|  |     } else { | ||||||
|  |       _animationController.animateTo(0, duration: 300.ms).then((_) => _animationController.reset()); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     final radius = math.min(MediaQuery.of(context).size.width, MediaQuery.of(context).size.height) * 0.1; | ||||||
|  |  | ||||||
|  |     return Container( | ||||||
|  |       alignment: Alignment.center, | ||||||
|  |       child: Center( | ||||||
|  |         child: Animate( | ||||||
|  |             autoPlay: false, | ||||||
|  |             controller: _animationController, | ||||||
|  |             effects: [ | ||||||
|  |               CustomEffect( | ||||||
|  |                 begin: widget.isSpeaking ? 2 : 0, | ||||||
|  |                 end: 8, | ||||||
|  |                 curve: Curves.easeInOut, | ||||||
|  |                 duration: 1250.ms, | ||||||
|  |                 builder: (context, value, child) => Container( | ||||||
|  |                   decoration: BoxDecoration( | ||||||
|  |                     borderRadius: BorderRadius.all(Radius.circular(radius + 8)), | ||||||
|  |                     border: value > 0 ? Border.all(color: Colors.green, width: value) : null, | ||||||
|  |                   ), | ||||||
|  |                   child: child, | ||||||
|  |                 ), | ||||||
|  |               ) | ||||||
|  |             ], | ||||||
|  |             child: AccountAvatar( | ||||||
|  |               source: widget.userinfo!.avatar, | ||||||
|  |               radius: radius, | ||||||
|  |               direct: true, | ||||||
|  |             )), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   void dispose() { | ||||||
|  |     _animationController.dispose(); | ||||||
|  |     super.dispose(); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -1,18 +0,0 @@ | |||||||
| import 'package:flutter/material.dart'; |  | ||||||
| import 'dart:math' as math; |  | ||||||
|  |  | ||||||
| class NoVideoWidget extends StatelessWidget { |  | ||||||
|   const NoVideoWidget({super.key}); |  | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   Widget build(BuildContext context) => Container( |  | ||||||
|     alignment: Alignment.center, |  | ||||||
|     child: LayoutBuilder( |  | ||||||
|       builder: (ctx, constraints) => Icon( |  | ||||||
|         Icons.videocam_off_outlined, |  | ||||||
|         color: Theme.of(context).colorScheme.primary, |  | ||||||
|         size: math.min(constraints.maxHeight, constraints.maxWidth) * 0.3, |  | ||||||
|       ), |  | ||||||
|     ), |  | ||||||
|   ); |  | ||||||
| } |  | ||||||
| @@ -1,8 +1,11 @@ | |||||||
|  | import 'dart:convert'; | ||||||
|  |  | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:flutter_webrtc/flutter_webrtc.dart'; | import 'package:flutter_webrtc/flutter_webrtc.dart'; | ||||||
| import 'package:livekit_client/livekit_client.dart'; | import 'package:livekit_client/livekit_client.dart'; | ||||||
|  | import 'package:solian/models/account.dart'; | ||||||
| import 'package:solian/models/call.dart'; | import 'package:solian/models/call.dart'; | ||||||
| import 'package:solian/widgets/chat/call/no_video.dart'; | import 'package:solian/widgets/chat/call/no_content.dart'; | ||||||
| import 'package:solian/widgets/chat/call/participant_info.dart'; | import 'package:solian/widgets/chat/call/participant_info.dart'; | ||||||
| import 'package:solian/widgets/chat/call/participant_stats.dart'; | import 'package:solian/widgets/chat/call/participant_stats.dart'; | ||||||
|  |  | ||||||
| @@ -83,6 +86,8 @@ abstract class _ParticipantWidgetState<T extends ParticipantWidget> extends Stat | |||||||
|  |  | ||||||
|   TrackPublication? get _firstAudioPublication; |   TrackPublication? get _firstAudioPublication; | ||||||
|  |  | ||||||
|  |   Account? _userinfoMetadata; | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   void initState() { |   void initState() { | ||||||
|     super.initState(); |     super.initState(); | ||||||
| @@ -104,65 +109,65 @@ abstract class _ParticipantWidgetState<T extends ParticipantWidget> extends Stat | |||||||
|     super.didUpdateWidget(oldWidget); |     super.didUpdateWidget(oldWidget); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   void onParticipantChanged() => setState(() {}); |   void onParticipantChanged() { | ||||||
|  |     setState(() { | ||||||
|  |       if (widget.participant.metadata != null) { | ||||||
|  |         _userinfoMetadata = Account.fromJson(jsonDecode(widget.participant.metadata!)); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   List<Widget> extraWidgets(bool isScreenShare) => []; |   List<Widget> extraWidgets(bool isScreenShare) => []; | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext ctx) => Container( |   Widget build(BuildContext ctx) { | ||||||
|         foregroundDecoration: BoxDecoration( |     return Container( | ||||||
|           border: widget.participant.isSpeaking && !widget.isScreenShare |       child: Stack( | ||||||
|               ? Border.all( |         children: [ | ||||||
|                   width: 5, |           // Video | ||||||
|                   color: Theme.of(context).colorScheme.primary, |           InkWell( | ||||||
|                 ) |             onTap: () => setState(() => _visible = !_visible), | ||||||
|               : null, |             child: _activeVideoTrack != null && !_activeVideoTrack!.muted | ||||||
|         ), |                 ? VideoTrackRenderer( | ||||||
|         decoration: BoxDecoration( |                     _activeVideoTrack!, | ||||||
|           color: Theme.of(ctx).cardColor, |                     fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain, | ||||||
|         ), |                   ) | ||||||
|         child: Stack( |                 : NoContentWidget( | ||||||
|           children: [ |                     userinfo: _userinfoMetadata, | ||||||
|             // Video |                     isSpeaking: widget.participant.isSpeaking, | ||||||
|             InkWell( |  | ||||||
|               onTap: () => setState(() => _visible = !_visible), |  | ||||||
|               child: _activeVideoTrack != null && !_activeVideoTrack!.muted |  | ||||||
|                   ? VideoTrackRenderer( |  | ||||||
|                       _activeVideoTrack!, |  | ||||||
|                       fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain, |  | ||||||
|                     ) |  | ||||||
|                   : const NoVideoWidget(), |  | ||||||
|             ), |  | ||||||
|             if (widget.showStatsLayer) |  | ||||||
|               Positioned( |  | ||||||
|                   top: 30, |  | ||||||
|                   right: 30, |  | ||||||
|                   child: ParticipantStatsWidget( |  | ||||||
|                     participant: widget.participant, |  | ||||||
|                   )), |  | ||||||
|             // Bottom bar |  | ||||||
|             Align( |  | ||||||
|               alignment: Alignment.bottomCenter, |  | ||||||
|               child: Column( |  | ||||||
|                 crossAxisAlignment: CrossAxisAlignment.stretch, |  | ||||||
|                 mainAxisSize: MainAxisSize.min, |  | ||||||
|                 children: [ |  | ||||||
|                   ...extraWidgets(widget.isScreenShare), |  | ||||||
|                   ParticipantInfoWidget( |  | ||||||
|                     title: widget.participant.name.isNotEmpty |  | ||||||
|                         ? '${widget.participant.name} (${widget.participant.identity})' |  | ||||||
|                         : widget.participant.identity, |  | ||||||
|                     audioAvailable: |  | ||||||
|                         _firstAudioPublication?.muted == false && _firstAudioPublication?.subscribed == true, |  | ||||||
|                     connectionQuality: widget.participant.connectionQuality, |  | ||||||
|                     isScreenShare: widget.isScreenShare, |  | ||||||
|                   ), |                   ), | ||||||
|                 ], |           ), | ||||||
|  |           if (widget.showStatsLayer) | ||||||
|  |             Positioned( | ||||||
|  |               top: 30, | ||||||
|  |               right: 30, | ||||||
|  |               child: ParticipantStatsWidget( | ||||||
|  |                 participant: widget.participant, | ||||||
|               ), |               ), | ||||||
|             ), |             ), | ||||||
|           ], |           // Bottom bar | ||||||
|         ), |           Align( | ||||||
|       ); |             alignment: Alignment.bottomCenter, | ||||||
|  |             child: Column( | ||||||
|  |               crossAxisAlignment: CrossAxisAlignment.stretch, | ||||||
|  |               mainAxisSize: MainAxisSize.min, | ||||||
|  |               children: [ | ||||||
|  |                 ...extraWidgets(widget.isScreenShare), | ||||||
|  |                 ParticipantInfoWidget( | ||||||
|  |                   title: widget.participant.name.isNotEmpty | ||||||
|  |                       ? '${widget.participant.name} (${widget.participant.identity})' | ||||||
|  |                       : widget.participant.identity, | ||||||
|  |                   audioAvailable: _firstAudioPublication?.muted == false && _firstAudioPublication?.subscribed == true, | ||||||
|  |                   connectionQuality: widget.participant.connectionQuality, | ||||||
|  |                   isScreenShare: widget.isScreenShare, | ||||||
|  |                 ), | ||||||
|  |               ], | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |         ], | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| class _LocalParticipantWidgetState extends _ParticipantWidgetState<LocalParticipantWidget> { | class _LocalParticipantWidgetState extends _ParticipantWidgetState<LocalParticipantWidget> { | ||||||
| @@ -202,7 +207,6 @@ class _RemoteParticipantWidgetState extends _ParticipantWidgetState<RemotePartic | |||||||
|                 pub: _firstAudioPublication!, |                 pub: _firstAudioPublication!, | ||||||
|                 icon: Icons.volume_up, |                 icon: Icons.volume_up, | ||||||
|               ), |               ), | ||||||
|             // Menu for RemoteTrackPublication<RemoteVideoTrack> |  | ||||||
|             if (_videoPublication != null) |             if (_videoPublication != null) | ||||||
|               RemoteTrackPublicationMenuWidget( |               RemoteTrackPublicationMenuWidget( | ||||||
|                 pub: _videoPublication!, |                 pub: _videoPublication!, | ||||||
|   | |||||||
| @@ -17,7 +17,7 @@ class ParticipantInfoWidget extends StatelessWidget { | |||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) => Container( |   Widget build(BuildContext context) => Container( | ||||||
|         color: Colors.black.withOpacity(0.3), |         color: Theme.of(context).colorScheme.onSurface.withOpacity(0.75), | ||||||
|         padding: const EdgeInsets.symmetric( |         padding: const EdgeInsets.symmetric( | ||||||
|           vertical: 7, |           vertical: 7, | ||||||
|           horizontal: 10, |           horizontal: 10, | ||||||
| @@ -31,6 +31,7 @@ class ParticipantInfoWidget extends StatelessWidget { | |||||||
|                 child: Text( |                 child: Text( | ||||||
|                   title!, |                   title!, | ||||||
|                   overflow: TextOverflow.ellipsis, |                   overflow: TextOverflow.ellipsis, | ||||||
|  |                   style: const TextStyle(color: Colors.white), | ||||||
|                 ), |                 ), | ||||||
|               ), |               ), | ||||||
|             isScreenShare |             isScreenShare | ||||||
|   | |||||||
| @@ -30,5 +30,11 @@ | |||||||
| 	<string>public.app-category.social-networking</string> | 	<string>public.app-category.social-networking</string> | ||||||
| 	<key>NSPrincipalClass</key> | 	<key>NSPrincipalClass</key> | ||||||
| 	<string>NSApplication</string> | 	<string>NSApplication</string> | ||||||
|  |     <key>NSPhotoLibraryUsageDescription</key> | ||||||
|  |     <string>Allow you add photo to your message or post</string> | ||||||
|  |     <key>NSCameraUsageDescription</key> | ||||||
|  |     <string>Allow you take photo/video for your message or post</string> | ||||||
|  |     <key>NSMicrophoneUsageDescription</key> | ||||||
|  |     <string>Allow you record audio for your message or post</string> | ||||||
| </dict> | </dict> | ||||||
| </plist> | </plist> | ||||||
|   | |||||||
| @@ -14,6 +14,8 @@ | |||||||
| 	<true/> | 	<true/> | ||||||
| 	<key>com.apple.security.network.client</key> | 	<key>com.apple.security.network.client</key> | ||||||
| 	<true/> | 	<true/> | ||||||
|  | 	<key>com.apple.security.network.server</key> | ||||||
|  | 	<true/> | ||||||
| 	<key>keychain-access-groups</key> | 	<key>keychain-access-groups</key> | ||||||
| 	<array/> | 	<array/> | ||||||
| </dict> | </dict> | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user