2 * Copyright (c) 2021 Samsung Electronics Co., Ltd All Rights Reserved
4 * Licensed under the Apache License, Version 2.0 (the License);
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
8 * http://www.apache.org/licenses/LICENSE-2.0
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an AS IS BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
18 using System.Collections.ObjectModel;
19 using System.Collections.Generic;
20 using System.ComponentModel;
21 using System.Diagnostics;
22 using System.Runtime.InteropServices;
23 using System.Threading;
24 using System.Threading.Tasks;
25 using Tizen.Applications;
28 namespace Tizen.Multimedia.Remoting
30 internal static class WebRTCLog
32 internal const string Tag = "Tizen.Multimedia.WebRTC";
36 /// Provides the ability to control WebRTC.
38 /// <since_tizen> 9 </since_tizen>
39 public partial class WebRTC : IDisposable
41 private readonly WebRTCHandle _handle;
42 private List<MediaSource> _source;
45 /// Initializes a new instance of the <see cref="WebRTC"/> class.
47 /// <feature>http://tizen.org/feature/network.wifi</feature>
48 /// <feature>http://tizen.org/feature/network.telephony</feature>
49 /// <feature>http://tizen.org/feature/network.ethernet</feature>
50 /// <privilege>http://tizen.org/privilege/internet</privilege>
51 /// <exception cref="UnauthorizedAccessException">Thrown when the permission is denied.</exception>
52 /// <exception cref="NotSupportedException">The required feature is not supported.</exception>
53 /// <since_tizen> 9 </since_tizen>
56 if (!Features.IsSupported(WebRTCFeatures.Wifi) &&
57 !Features.IsSupported(WebRTCFeatures.Telephony) &&
58 !Features.IsSupported(WebRTCFeatures.Ethernet))
60 throw new NotSupportedException("Network features are not supported.");
63 NativeWebRTC.Create(out _handle).ThrowIfFailed("Failed to create webrtc");
65 Debug.Assert(_handle != null);
69 _source = new List<MediaSource>();
72 internal void ValidateWebRTCState(params WebRTCState[] desiredStates)
74 Debug.Assert(desiredStates.Length > 0);
76 ValidateNotDisposed();
78 WebRTCState curState = State;
79 if (!curState.IsAnyOf(desiredStates))
81 throw new InvalidOperationException("The WebRTC is not in a valid state. " +
82 $"Current State : { curState }, Valid State : { string.Join(", ", desiredStates) }.");
86 #region Dispose support
87 private bool _disposed;
90 /// Releases all resources used by the current instance.
92 /// <since_tizen> 9 </since_tizen>
96 GC.SuppressFinalize(this);
100 /// Releases the unmanaged resources used by the <see cref="WebRTC"/>.
102 /// <param name="disposing">
103 /// true to release both managed and unmanaged resources;
104 /// false to release only unmanaged resources.
106 [EditorBrowsable(EditorBrowsableState.Never)]
107 protected virtual void Dispose(bool disposing)
109 if (_disposed || !disposing)
119 internal void ValidateNotDisposed()
123 Log.Warn(WebRTCLog.Tag, "WebRTC was disposed");
124 throw new ObjectDisposedException(nameof(WebRTC));
128 internal bool IsDisposed => _disposed;
132 /// Starts the WebRTC.
135 /// The WebRTC must be in the <see cref="WebRTCState.Idle"/> state.<br/>
136 /// The WebRTC state will be <see cref="WebRTCState.Negotiating"/> state.<br/>
137 /// The user should check whether <see cref="State" /> is changed to <see cref="WebRTCState.Negotiating"/> state or not.
139 /// <exception cref="InvalidOperationException">The WebRTC is not in the valid state.</exception>
140 /// <exception cref="ObjectDisposedException">The WebRTC has already been disposed.</exception>
141 /// <see also="WebRTCState"/>
142 /// <see also="StateChanged"/>
143 /// <see also="CreateOffer"/>
144 /// <since_tizen> 9 </since_tizen>
147 ValidateWebRTCState(WebRTCState.Idle);
149 NativeWebRTC.Start(Handle).ThrowIfFailed("Failed to start the WebRTC");
153 /// Starts the WebRTC asynchronously.
156 /// The WebRTC must be in the <see cref="WebRTCState.Idle"/> state.<br/>
157 /// The WebRTC state will be <see cref="WebRTCState.Negotiating"/> state.<br/>
158 /// This ensures that <see cref="State" /> is changed to <see cref="WebRTCState.Negotiating"/> state.
160 /// <exception cref="InvalidOperationException">The WebRTC is not in the valid state.</exception>
161 /// <exception cref="ObjectDisposedException">The WebRTC has already been disposed.</exception>
162 /// <see also="WebRTCState"/>
163 /// <see also="CreateOffer"/>
164 /// <since_tizen> 9 </since_tizen>
165 public async Task StartAsync()
167 ValidateWebRTCState(WebRTCState.Idle);
169 var tcs = new TaskCompletionSource<bool>();
170 var error = WebRTCError.ConnectionFailed;
172 EventHandler<WebRTCStateChangedEventArgs> stateChangedEventHandler = (s, e) =>
174 if (e.Current == WebRTCState.Negotiating)
176 tcs.TrySetResult(true);
179 EventHandler<WebRTCErrorOccurredEventArgs> errorEventHandler = (s, e) =>
181 Log.Info(WebRTCLog.Tag, e.ToString());
183 tcs.TrySetResult(false);
188 StateChanged += stateChangedEventHandler;
189 ErrorOccurred += errorEventHandler;
191 NativeWebRTC.Start(Handle).ThrowIfFailed("Failed to start the WebRTC");
193 var result = await tcs.Task.ConfigureAwait(false);
198 throw new InvalidOperationException(error.ToString());
203 StateChanged -= stateChangedEventHandler;
204 ErrorOccurred -= errorEventHandler;
209 /// Stops the WebRTC.
212 /// The WebRTC must be in the <see cref="WebRTCState.Negotiating"/> or <see cref="WebRTCState.Playing"/> state.<br/>
213 /// The WebRTC state will be <see cref="WebRTCState.Idle"/> state.<br/>
214 /// The user should check whether <see cref="State" /> is changed to <see cref="WebRTCState.Idle"/> state or not.
216 /// <exception cref="InvalidOperationException">The WebRTC is not in the valid state.</exception>
217 /// <exception cref="ObjectDisposedException">The WebRTC has already been disposed.</exception>
218 /// <since_tizen> 9 </since_tizen>
221 ValidateWebRTCState(WebRTCState.Negotiating, WebRTCState.Playing);
223 NativeWebRTC.Stop(Handle).ThrowIfFailed("Failed to stop the WebRTC");
227 /// Creates SDP offer asynchronously to start a new WebRTC connection to a remote peer.
229 /// <remarks>The WebRTC must be in the <see cref="WebRTCState.Negotiating"/></remarks>
230 /// <returns>The SDP offer.</returns>
231 /// <exception cref="InvalidOperationException">The WebRTC is not in the valid state.</exception>
232 /// <exception cref="ObjectDisposedException">The WebRTC has already been disposed.</exception>
233 /// <seealso cref="CreateAnswerAsync()"/>
234 /// <since_tizen> 9 </since_tizen>
235 public async Task<string> CreateOfferAsync()
237 ValidateWebRTCState(WebRTCState.Negotiating);
239 var tcsSdpCreated = new TaskCompletionSource<string>();
241 NativeWebRTC.SdpCreatedCallback cb = (handle, sdp, _) =>
243 tcsSdpCreated.TrySetResult(sdp);
247 using (var cbKeeper = ObjectKeeper.Get(cb))
249 NativeWebRTC.CreateSDPOfferAsync(Handle, new SafeBundleHandle(), cb, IntPtr.Zero).
250 ThrowIfFailed("Failed to create offer asynchronously");
252 offer = await tcsSdpCreated.Task.ConfigureAwait(false);
260 /// Creates SDP answer asynchronously with option to an offer received from a remote peer.
262 /// <remarks>The WebRTC must be in the <see cref="WebRTCState.Negotiating"/></remarks>
263 /// <returns>The SDP answer.</returns>
264 /// <exception cref="InvalidOperationException">The WebRTC is not in the valid state.</exception>
265 /// <exception cref="ObjectDisposedException">The WebRTC has already been disposed.</exception>
266 /// <seealso cref="CreateOfferAsync()"/>
267 /// <since_tizen> 9 </since_tizen>
268 public async Task<string> CreateAnswerAsync()
270 ValidateWebRTCState(WebRTCState.Negotiating);
272 var tcsSdpCreated = new TaskCompletionSource<string>();
274 NativeWebRTC.SdpCreatedCallback cb = (handle, sdp, _) =>
276 tcsSdpCreated.TrySetResult(sdp);
279 string answer = null;
280 using (var cbKeeper = ObjectKeeper.Get(cb))
282 NativeWebRTC.CreateSDPAnswerAsync(Handle, new SafeBundleHandle(), cb, IntPtr.Zero).
283 ThrowIfFailed("Failed to create answer asynchronously");
285 answer = await tcsSdpCreated.Task.ConfigureAwait(false);
293 /// Sets the session description for a local peer.
295 /// <remarks>The WebRTC must be in the <see cref="WebRTCState.Negotiating"/>.</remarks>
296 /// <param name="description">The local session description.</param>
297 /// <exception cref="ArgumentException">The description is empty string.</exception>
298 /// <exception cref="ArgumentNullException">The description is null.</exception>
299 /// <exception cref="InvalidOperationException">The WebRTC is not in the valid state.</exception>
300 /// <exception cref="ObjectDisposedException">The WebRTC has already been disposed.</exception>
301 /// <seealso cref="CreateOfferAsync()"/>
302 /// <seealso cref="CreateAnswerAsync()"/>
303 /// <since_tizen> 9 </since_tizen>
304 public void SetLocalDescription(string description)
306 ValidateWebRTCState(WebRTCState.Negotiating);
308 ValidationUtil.ValidateIsNullOrEmpty(description, nameof(description));
310 NativeWebRTC.SetLocalDescription(Handle, description).ThrowIfFailed("Failed to set description.");
314 /// Sets the session description of the remote peer's current offer or answer.
316 /// <remarks>The WebRTC must be in the <see cref="WebRTCState.Negotiating"/>.</remarks>
317 /// <param name="description">The remote session description.</param>
318 /// <exception cref="ArgumentException">The description is empty string.</exception>
319 /// <exception cref="ArgumentNullException">The description is null.</exception>
320 /// <exception cref="InvalidOperationException">The WebRTC is not in the valid state.</exception>
321 /// <exception cref="ObjectDisposedException">The WebRTC has already been disposed.</exception>
322 /// <seealso cref="CreateOfferAsync()"/>
323 /// <seealso cref="CreateAnswerAsync()"/>
324 /// <since_tizen> 9 </since_tizen>
325 public void SetRemoteDescription(string description)
327 ValidateWebRTCState(WebRTCState.Negotiating);
329 ValidationUtil.ValidateIsNullOrEmpty(description, nameof(description));
331 NativeWebRTC.SetRemoteDescription(Handle, description).ThrowIfFailed("Failed to set description.");
335 /// Adds a new ICE candidate from the remote peer over its signaling channel.
337 /// <remarks>The WebRTC must be in the <see cref="WebRTCState.Negotiating"/>.</remarks>
338 /// <param name="iceCandidate">The ICE candidate.</param>
339 /// <exception cref="ArgumentException">The ICE candidate is empty string.</exception>
340 /// <exception cref="ArgumentNullException">The ICE candidate is null.</exception>
341 /// <exception cref="InvalidOperationException">The WebRTC is not in the valid state.</exception>
342 /// <exception cref="ObjectDisposedException">The WebRTC has already been disposed.</exception>
343 /// <since_tizen> 9 </since_tizen>
344 public void AddIceCandidate(string iceCandidate)
346 ValidateWebRTCState(WebRTCState.Negotiating);
348 ValidationUtil.ValidateIsNullOrEmpty(iceCandidate, nameof(iceCandidate));
350 NativeWebRTC.AddIceCandidate(Handle, iceCandidate).ThrowIfFailed("Failed to set ICE candidate.");
354 /// Adds new ICE candidates from the remote peer over its signaling channel.
356 /// <remarks>The WebRTC must be in the <see cref="WebRTCState.Negotiating"/>.</remarks>
357 /// <param name="iceCandidates">The ICE candidates.</param>
358 /// <exception cref="ArgumentException">The ICE candidate is empty string.</exception>
359 /// <exception cref="ArgumentNullException">The ICE candidate is null.</exception>
360 /// <exception cref="InvalidOperationException">The WebRTC is not in the valid state.</exception>
361 /// <exception cref="ObjectDisposedException">The WebRTC has already been disposed.</exception>
362 /// <since_tizen> 9 </since_tizen>
363 public void AddIceCandidates(IEnumerable<string> iceCandidates)
365 ValidateWebRTCState(WebRTCState.Negotiating);
367 ValidationUtil.ValidateIsAny(iceCandidates);
369 #pragma warning disable CA1062
370 foreach (string iceCandidate in iceCandidates)
372 AddIceCandidate(iceCandidate);
374 #pragma warning restore CA1062
378 /// Adds media source.
381 /// The WebRTC must be in the <see cref="WebRTCState.Idle"/>.<br/>
382 /// Each MediaSource requires different feature or privilege.<br/>
383 /// <see cref="MediaCameraSource"/> needs camera feature and privilege.<br/>
384 /// <see cref="MediaMicrophoneSource"/> needs microphone feature and recorder privilege.<br/>
386 /// <param name="source">The media sources to add.</param>
387 /// <feature>http://tizen.org/feature/camera</feature>
388 /// <feature>http://tizen.org/feature/microphone</feature>
389 /// <privilege>http://tizen.org/privilege/camera</privilege>
390 /// <privilege>http://tizen.org/privilege/mediastorage</privilege>
391 /// <privilege>http://tizen.org/privilege/externalstorage</privilege>
392 /// <privilege>http://tizen.org/privilege/recorder</privilege>
393 /// <exception cref="ArgumentNullException">The media source is null.</exception>
394 /// <exception cref="InvalidOperationException">
395 /// The WebRTC is not in the valid state.<br/>
397 /// All or one of <paramref name="source"/> was already detached.
399 /// <exception cref="NotSupportedException">The required feature is not supported.</exception>
400 /// <exception cref="ObjectDisposedException">The WebRTC has already been disposed.</exception>
401 /// <exception cref="UnauthorizedAccessException">Thrown when the permission is denied.</exception>
402 /// <seealso cref="MediaCameraSource"/>
403 /// <seealso cref="MediaMicrophoneSource"/>
404 /// <seealso cref="MediaTestSource"/>
405 /// <seealso cref="MediaPacketSource"/>
406 /// <seealso cref="AddSources"/>
407 /// <seealso cref="RemoveSource"/>
408 /// <seealso cref="RemoveSources"/>
409 /// <since_tizen> 9 </since_tizen>
410 public void AddSource(MediaSource source)
414 throw new ArgumentNullException(nameof(source), "source is null");
417 ValidateWebRTCState(WebRTCState.Idle);
419 source?.AttachTo(this);
425 /// Adds media sources.
428 /// The WebRTC must be in the <see cref="WebRTCState.Idle"/>.<br/>
429 /// Each MediaSource requires different feature or privilege.<br/>
430 /// <see cref="MediaCameraSource"/> needs camera feature and privilege.<br/>
431 /// <see cref="MediaMicrophoneSource"/> needs microphone feature and recorder privilege.<br/>
433 /// <param name="sources">The media sources to add.</param>
434 /// <feature>http://tizen.org/feature/camera</feature>
435 /// <feature>http://tizen.org/feature/microphone</feature>
436 /// <privilege>http://tizen.org/privilege/camera</privilege>
437 /// <privilege>http://tizen.org/privilege/mediastorage</privilege>
438 /// <privilege>http://tizen.org/privilege/externalstorage</privilege>
439 /// <privilege>http://tizen.org/privilege/recorder</privilege>
440 /// <exception cref="ArgumentNullException">The media source is null.</exception>
441 /// <exception cref="InvalidOperationException">
442 /// The WebRTC is not in the valid state.<br/>
444 /// All or one of <paramref name="sources"/> was already detached.
446 /// <exception cref="NotSupportedException">The required feature is not supported.</exception>
447 /// <exception cref="ObjectDisposedException">The WebRTC has already been disposed.</exception>
448 /// <exception cref="UnauthorizedAccessException">Thrown when the permission is denied.</exception>
449 /// <seealso cref="MediaCameraSource"/>
450 /// <seealso cref="MediaMicrophoneSource"/>
451 /// <seealso cref="MediaTestSource"/>
452 /// <seealso cref="MediaPacketSource"/>
453 /// <seealso cref="AddSource"/>
454 /// <seealso cref="RemoveSource"/>
455 /// <seealso cref="RemoveSources"/>
456 /// <since_tizen> 9 </since_tizen>
457 public void AddSources(params MediaSource[] sources)
461 throw new ArgumentNullException(nameof(sources), "sources are null");
464 foreach (var source in sources)
471 /// Removes media source.
474 /// The WebRTC must be in the <see cref="WebRTCState.Idle"/>.<br/>
475 /// If user want to use removed MediaSource again, user should create new instance for it.
477 /// <param name="source">The media source to remove.</param>
478 /// <exception cref="ArgumentNullException">The media source is null.</exception>
479 /// <exception cref="InvalidOperationException">The WebRTC is not in the valid state.</exception>
480 /// <exception cref="ObjectDisposedException">The WebRTC has already been disposed.</exception>
481 /// <seealso cref="MediaCameraSource"/>
482 /// <seealso cref="MediaMicrophoneSource"/>
483 /// <seealso cref="MediaTestSource"/>
484 /// <seealso cref="MediaPacketSource"/>
485 /// <seealso cref="AddSource"/>
486 /// <seealso cref="AddSources"/>
487 /// <seealso cref="RemoveSources"/>
488 /// <since_tizen> 9 </since_tizen>
489 public void RemoveSource(MediaSource source)
493 throw new ArgumentNullException(nameof(source), "source is null");
496 ValidateWebRTCState(WebRTCState.Idle);
498 source?.DetachFrom(this);
500 _source.Remove(source);
506 /// Removes media sources.
509 /// The WebRTC must be in the <see cref="WebRTCState.Idle"/>.<br/>
510 /// If user want to use removed MediaSource again, user should create new instance for it.
512 /// <param name="sources">The media source to remove.</param>
513 /// <exception cref="ArgumentNullException">The media source is null.</exception>
514 /// <exception cref="InvalidOperationException">The WebRTC is not in the valid state.</exception>
515 /// <exception cref="ObjectDisposedException">The WebRTC has already been disposed.</exception>
516 /// <seealso cref="MediaCameraSource"/>
517 /// <seealso cref="MediaMicrophoneSource"/>
518 /// <seealso cref="MediaTestSource"/>
519 /// <seealso cref="MediaPacketSource"/>
520 /// <seealso cref="AddSource"/>
521 /// <seealso cref="AddSources"/>
522 /// <seealso cref="RemoveSource"/>
523 /// <since_tizen> 9 </since_tizen>
524 public void RemoveSources(params MediaSource[] sources)
526 foreach (var source in sources)
528 RemoveSource(source);
533 /// Sets a turn server.
535 /// <exception cref="ArgumentNullException">The <paramref name="turnServer"/> is null.</exception>
536 /// <exception cref="ObjectDisposedException">The WebRTC has already been disposed.</exception>
537 /// <since_tizen> 9 </since_tizen>
538 public void SetTurnServer(string turnServer)
540 ValidateNotDisposed();
542 if (turnServer == null)
544 throw new ArgumentNullException(nameof(turnServer), "Turn server name is null.");
547 NativeWebRTC.AddTurnServer(Handle, turnServer).
548 ThrowIfFailed("Failed to add turn server");
552 /// Sets turn servers.
554 /// <exception cref="ArgumentNullException">The one of <paramref name="turnServers"/> is null.</exception>
555 /// <exception cref="ObjectDisposedException">The WebRTC has already been disposed.</exception>
556 /// <since_tizen> 9 </since_tizen>
557 public void SetTurnServers(params string[] turnServers)
559 ValidateNotDisposed();
561 if (turnServers == null)
563 throw new ArgumentNullException(nameof(turnServers), "Turn server names are null.");
566 foreach (var turnServer in turnServers)
568 SetTurnServer(turnServer);
573 /// Retrieves all turn servers.
575 /// <returns>The turn server list.</returns>
576 /// <exception cref="ObjectDisposedException">The WebRTC has already been disposed.</exception>
577 /// <since_tizen> 9 </since_tizen>
578 public ReadOnlyCollection<string> GetTurnServer()
580 ValidateNotDisposed();
582 var list = new List<string>();
584 NativeWebRTC.RetrieveTurnServerCallback cb = (server, _) =>
586 if (!string.IsNullOrWhiteSpace(server))
594 NativeWebRTC.ForeachTurnServer(Handle, cb).ThrowIfFailed("Failed to retrieve turn server");
596 return list.AsReadOnly();
600 /// Retrieves the current statistics information.
602 /// <remarks>The WebRTC must be in the <see cref="WebRTCState.Playing"/></remarks>
603 /// <returns>The WebRTC statistics informations.</returns>
604 /// <param name="category">The category of statistics to get.</param>
605 /// <exception cref="ObjectDisposedException">The WebRTC has already been disposed.</exception>
606 /// <exception cref="InvalidOperationException">The WebRTC is not in the valid state.</exception>
607 /// <since_tizen> 10 </since_tizen>
608 public ReadOnlyCollection<WebRTCStatistics> GetStatistics(WebRTCStatisticsCategory category)
610 ValidateWebRTCState(WebRTCState.Playing);
612 var stats = new List<WebRTCStatistics>();
613 Exception caught = null;
615 NativeWebRTC.RetrieveStatsCallback cb = (category_, prop, _) =>
619 stats.Add(new WebRTCStatistics(category_, prop));
630 using (var cbKeeper = ObjectKeeper.Get(cb))
632 NativeWebRTC.ForeachStats(Handle, (int)category, cb, IntPtr.Zero).
633 ThrowIfFailed("failed to retrieve stats");
641 return new ReadOnlyCollection<WebRTCStatistics>(stats);
645 /// Represents WebRTC statistics information.
647 /// <since_tizen> 10 </since_tizen>
648 public class WebRTCStatistics
650 internal WebRTCStatistics(WebRTCStatisticsCategory type, IntPtr prop)
652 var unmanagedStruct = Marshal.PtrToStructure<NativeWebRTC.StatsPropertyStruct>(prop);
655 Name = unmanagedStruct.name;
656 Property = unmanagedStruct.property;
658 switch (unmanagedStruct.propertyType)
660 case WebRTCStatsPropertyType.TypeBool:
661 Value = unmanagedStruct.value.@bool;
663 case WebRTCStatsPropertyType.TypeInt:
664 Value = unmanagedStruct.value.@int;
666 case WebRTCStatsPropertyType.TypeUint:
667 Value = unmanagedStruct.value.@uint;
669 case WebRTCStatsPropertyType.TypeInt64:
670 Value = unmanagedStruct.value.@long;
672 case WebRTCStatsPropertyType.TypeUint64:
673 Value = unmanagedStruct.value.@ulong;
675 case WebRTCStatsPropertyType.TypeFloat:
676 Value = unmanagedStruct.value.@float;
678 case WebRTCStatsPropertyType.TypeDouble:
679 Value = unmanagedStruct.value.@double;
681 case WebRTCStatsPropertyType.TypeString:
682 Value = Marshal.PtrToStringAnsi(unmanagedStruct.value.@string);
685 throw new InvalidOperationException($"No matching type [{unmanagedStruct.propertyType}]");
690 /// Gets the category of statistics.
692 /// <value>The category of WebRTC statistics information</value>
693 /// <since_tizen> 10 </since_tizen>
694 public WebRTCStatisticsCategory Category { get; }
697 /// Gets the name of statistics.
699 /// <value>The name of WebRTC statistics information</value>
700 /// <since_tizen> 10 </since_tizen>
701 public string Name { get; }
704 /// Gets the property of statistics.
706 /// <value>The property of WebRTC statistics information</value>
707 /// <since_tizen> 10 </since_tizen>
708 public WebRTCStatisticsProperty Property { get; }
711 /// Gets the value of statistics.
713 /// <value>The value of WebRTC statistics information</value>
714 /// <since_tizen> 10 </since_tizen>
715 public object Value { get; }
718 /// Returns a string that represents the current object.
720 /// <returns>A string that represents the current object.</returns>
721 /// <since_tizen> 10 </since_tizen>
722 public override string ToString() =>
723 $"Category={Category}, Name={Name}, Property={Property}, Value={Value}, Type={Value.GetType()}";