Added Main Project

This commit is contained in:
2025-03-25 22:43:13 +01:00
parent 3272781a12
commit 724c410f36
30 changed files with 3849 additions and 0 deletions
@@ -0,0 +1,121 @@
using CLMGRLib;
namespace TeamsNetphoneLink.Netphone
{
public class ClientSdkEventArgs : EventArgs
{
public CLMgrMessage Msg;
public int Param;
public ClientSdkEventArgs(CLMgrMessage msg, int param)
{
Msg = msg;
Param = param;
}
}
public delegate void LineManagerMessageHandler(ClientSdkEventArgs e);
public class ClientSdkEventSink
{
private ClientLineMgrClass ConnectedLineManager;
private IClientLineMgrEventsPub_PubOnLineMgrNotificationEventHandler EventHandler;
private LineManagerMessageHandler LineManagerMessageDelegateOfForm;
public ClientSdkEventSink()
{
EventHandler = new IClientLineMgrEventsPub_PubOnLineMgrNotificationEventHandler(clmgr_EventSink);
}
public void Connect(ClientLineMgrClass lineManager, LineManagerMessageHandler lineManagerMessageDelegateOfForm)
{
ConnectedLineManager = lineManager;
LineManagerMessageDelegateOfForm = lineManagerMessageDelegateOfForm;
//add eventhandler for the PubOnlineMgrNotification Events
ConnectedLineManager.PubOnLineMgrNotification += EventHandler;
}
public void Disconnect()
{
//remove eventhandler for the PubOnlineMgrNotification Events
ConnectedLineManager.PubOnLineMgrNotification -= EventHandler;
ConnectedLineManager = null;
LineManagerMessageDelegateOfForm = null;
}
private void clmgr_EventSink(int msg, int param)
{
//this method receives the COM events from the client line manger
if ((LineManagerMessageDelegateOfForm != null))
{
LineManagerMessageDelegateOfForm(new ClientSdkEventArgs((CLMgrMessage)msg, param));
}
}
}
public enum CLMgrMessage
{
CLMgrLineStateChangedMessage = 0, //state of at least one line has changed
CLMgrLineSelectionChangedMessage = 1, //line in focus has changed
CLMgrLineDetailsChangedMessage = 2, //details of at least one line have changed
CLMgrCallDetailsMessage = 4, //details of last call are available, post mortem for logging purpose
CLMgrServerDownMessage = 5, //server goes down, keep line manager, wait for ServerUp message
CLMgrServerUpMessage = 6, //server is up again, keep interfaces to line manger
CLMgrWaveDeviceChanged = 7, //speaker / micro has been switched on / off
CLMgrGroupCallNotificationMessage = 8, //notification about group call
CLMgrNumberOfLinesChangedMessage = 10, //the number of lines has changed
CLMgrClientShutDownRequest = 11, //Client Line Manager requests client to shutdown and release all interfaces
CLMgrLineStateChangedMessageEx = 28, //state of certain line has changed, lParam: LOWORD: line index of line that changed its state (starting with 0) HIWORD: new state of this line
CLMgrSIPRegistrationStateChanged = 30, //registration state of SIP account has changed
//lParam: LOBYTE: Account index
// HIBYTE: new state
CLMgrWaveFilePlayed = 31, //wave file playback finished
//lParam: line index;
//if -1, the message is related to a LineMgr function PlaySoundFile or PlayToRtp
//if >=0 the message is related to a line function PlaySoundFile of line with this index
PubCLMgrFirstDataReceived = 32 //first RTP data received on line, might be silence
//lParam: line index;
}
public enum LineState
{
Inactive = 0, //line is inactive
HookOffInternal = 1, //off hook, internal dialtone
HookOffExternal = 2, //off hook, external dialtone
Ringing = 3, //incoming call, ringing
Dialing = 4, //outgoing call, we are dialing, no sound
Alerting = 5, //outgoing call, alerting = ringing on destination
Knocking = 6, //outgoing call, knocking = second call ringing on destination
Busy = 7, //outgoing call, destination is busy
Active = 8, //incoming / outgoing call, logical and physical connection is established
OnHold = 9, //incoming / outgoing call, logical connection is established, destination gets music on hold
ConferenceActive = 10, //incoming / outgoing conference, logical and physical connection is established
ConferenceOnHold = 11, //incoming / outgoing conference, logical connection is established, not physcically connected
Terminated = 12, //incoming / outgoing connection / call has been disconnected
Transferring = 13, //special LSOnHold, call is awaiting to be transferred, peer gets special music on hold
Disabled = 14 //special LSInactive: wrap up time
}
public enum DisconnectReason
{
Normal = 0,
Busy = 1,
Rejected = 2,
Cancelled = 3,
Transferred = 4,
JoinedConference = 5,
NoAnswer = 6,
TooLate = 7,
DirectCallImpossible = 8,
WrongNumber = 9,
Unreachable = 10,
CallDiverted = 11,
CallRoutingFailed = 12,
PermissionDenied = 13,
NetworkCongestion = 14,
NoChannelAvailable = 15,
NumberChanged = 16,
IncompatibleDestination = 17
}
}
@@ -0,0 +1,200 @@
using CLMGRLib;
using System.Diagnostics;
using TeamsLocalLibary.EventArgs;
namespace TeamsNetphoneLink.Netphone
{
public delegate void LineStateChangedEventHandler(LineState newLineState);
public delegate void LoggedInStateEventHandler(bool loggedInState);
public class NetPhoneEvents : IDisposable
{
private ClientLine SelectedLine;
private ClientLineMgrClass pCLMgr;
private ClientSdkEventSink MyEventSink;
private LineState LastLineState;
private bool lastLoggedInState;
private CancellationTokenSource aliveCheckTokenSource;
private CancellationToken aliveCheckToken;
private Dictionary<LineState, List<LineStateChangedEventHandler>> lineStateEvents = new Dictionary<LineState, List<LineStateChangedEventHandler>>();
private List<LoggedInStateEventHandler> loggedInStateEvents = new List<LoggedInStateEventHandler>();
private Timer loggedInStateTimer;
private Timer aliveTimer;
public event EventHandler<TokenReceivedEventArgs>? TokenReceived;
public NetPhoneEvents()
{
loggedInStateTimer = new Timer(CheckLoggedInState, null, Timeout.Infinite, 1000);
aliveTimer = new Timer(AliveCheck, null, Timeout.Infinite, 1000);
}
public void Dispose()
{
loggedInStateTimer?.Change(Timeout.Infinite, 0);
loggedInStateTimer?.Dispose();
RemoveAllEventHandlers();
}
public async Task<bool> Initialize(bool waitForNetphone = false, CancellationToken cancellationToken = default)
{
if (Process.GetProcessesByName("CLMgr").Length == 0 && !waitForNetphone)
return false;
while (Process.GetProcessesByName("CLMgr").Length == 0)
await Task.Delay(1000, cancellationToken);
Console.WriteLine("new interface");
pCLMgr = new ClientLineMgrClass();
MyEventSink = new ClientSdkEventSink();
MyEventSink.Connect(pCLMgr, new LineManagerMessageHandler(OnLineManagerMessage));
aliveTimer.Change(0, 1000);
loggedInStateTimer.Change(0, 1500);
return true;
}
public void AliveCheck(object state)
{
if (Process.GetProcessesByName("CLMgr").Length == 0)
{
_ = Initialize(true).GetAwaiter().GetResult();
}
}
private void CheckLoggedInState(object state)
{
bool currentLoggedInState = false;
try
{
currentLoggedInState = pCLMgr.DispIsLoggedIn != 0;
}
catch (Exception ex)
{
currentLoggedInState = false;
}
if (currentLoggedInState != lastLoggedInState)
{
lastLoggedInState = currentLoggedInState;
OnLoggedInStateChanged(currentLoggedInState);
}
}
private void OnLoggedInStateChanged(bool isLoggedIn)
{
foreach (var handler in loggedInStateEvents)
{
handler?.Invoke(isLoggedIn);
}
}
public void AddLineStateEventHandler(LineState lineState, LineStateChangedEventHandler handler)
{
lock (lineStateEvents)
{
if (!lineStateEvents.ContainsKey(lineState))
{
lineStateEvents[lineState] = new List<LineStateChangedEventHandler>();
}
lineStateEvents[lineState].Add(handler);
}
}
public void AddLoggedInStateEventHandler(LoggedInStateEventHandler handler)
{
lock (loggedInStateEvents)
{
loggedInStateEvents.Add(handler);
}
}
public void RemoveLineStateEventHandler(LineState lineState, LineStateChangedEventHandler handler)
{
lock (lineStateEvents)
{
if (lineStateEvents.ContainsKey(lineState))
{
lineStateEvents[lineState].Remove(handler);
}
}
}
public void RemoveLoggedInStateEventHandler(LoggedInStateEventHandler handler)
{
lock (loggedInStateEvents)
{
loggedInStateEvents.Remove(handler);
}
}
public void RemoveAllEventHandlers()
{
lock (lineStateEvents)
{
lineStateEvents.Clear();
}
lock (loggedInStateEvents)
{
loggedInStateEvents.Clear();
}
}
private void OnLineManagerMessage(ClientSdkEventArgs e)
{
SelectedLine = (ClientLine)pCLMgr.DispSelectedLine;
if (e.Msg == CLMgrMessage.CLMgrClientShutDownRequest)
{
//aliveCheckTokenSource.Cancel();
//MyEventSink.Disconnect();
//OnConnectionStateChanged(false,true);
}
if (e.Msg == CLMgrMessage.CLMgrLineStateChangedMessageEx)
{
int line = e.Param & 0xff;
int high = e.Param >> 8;
LineState NewLineState = (LineState)high;
if (LastLineState != NewLineState)
{
LastLineState = NewLineState;
lock (lineStateEvents)
{
if (lineStateEvents.ContainsKey(NewLineState))
{
foreach (var handler in lineStateEvents[NewLineState])
{
handler?.Invoke(NewLineState);
}
}
}
}
}
}
public void SetRichPresenceStatus(int away, int dnd, DateTime expires)
{
var clientConfig = (ClientConfig)this.pCLMgr.ClientConfig;
clientConfig.SetRichPresenceStatus(away, dnd, expires);
}
public void SetAppointmentText(string appointmentText, DateTime expires)
{
var clientConfig = (ClientConfig)this.pCLMgr.ClientConfig;
clientConfig.SetAppointmentText(appointmentText, expires);
}
}
}
@@ -0,0 +1,290 @@
using Azure.Core;
using Azure.Identity;
using Azure.Identity.Broker;
using Microsoft.Graph;
using Microsoft.Graph.Drives.Item.Items.Item.Workbook.Functions.Cosh;
using Microsoft.Graph.Me.Presence.SetUserPreferredPresence;
using Microsoft.Identity.Client;
using Microsoft.Identity.Client.Broker;
using Microsoft.Identity.Client.Extensions.Msal;
using System.Net.Http.Headers;
using System.Net.Http;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Windows;
using TeamsLocalLibary;
using static System.Formats.Asn1.AsnWriter;
using Microsoft.Identity.Client.NativeInterop;
using TeamsNetphoneLink.WPF;
using TeamsNetphoneLink.Communication;
using System;
using System.Text.RegularExpressions;
namespace TeamsNetphoneLink.Teams
{
public delegate void PresenceStausEventHandler(Microsoft.Graph.Models.Presence presence);
public class TeamsGraph : TeamsGraphEventHandlers
{
private GraphServiceClient graphClient;
public bool Authenticated { get; private set; }
private Timer presenceStatusTimer;
private Microsoft.Graph.Models.Presence lastPresence;
public void CheckPresenceStatusTimer()
{
presenceStatusTimer = new Timer(CheckPresenceStatus, null, Timeout.Infinite, 2000);
presenceStatusTimer.Change(0, 1000);
}
public void CheckPresenceStatus(object state)
{
if (graphClient is not null && Authenticated)
{
try
{
var presence = graphClient.Me.Presence.GetAsync().GetAwaiter().GetResult();
// If lastPresence is null, initialize it with the current presence
if (lastPresence == null)
{
lastPresence = presence;
}
// Check if Availability has changed
if (lastPresence.Availability != presence.Availability)
{
OnAvailabilityChanged(presence);
}
// Check if Activity has changed
if (lastPresence.Activity != presence.Activity)
{
OnActivityChanged(presence);
}
// Update lastPresence to the current presence
lastPresence = presence;
}
catch (Exception ex)
{
Console.WriteLine($"Error checking presence state: {ex.Message}");
}
}
}
public async Task<bool> IsCachedAccounts()
{
IPublicClientApplication app = PublicClientApplicationBuilder.Create(Settings.Default.AppID)
.WithDefaultRedirectUri()
.WithAuthority(String.Format("https://login.microsoftonline.com/{0}", Settings.Default.TenantID))
.Build();
// Register MSAL cache
var storage = new StorageCreationPropertiesBuilder("teamsnetphonelink.msal.cache", MsalCacheHelper.UserRootDirectory).Build();
var cacheHelper = await MsalCacheHelper.CreateAsync(storage);
cacheHelper.RegisterCache(app.UserTokenCache);
IEnumerable<IAccount> accounts = await app.GetAccountsAsync();
return accounts.Any();
}
// Authentifizierungsmethode mit Azure Identity
public async Task<bool> AuthenticateAsync(bool clearCache = false, bool silent = true)
{
try
{
// Check if caching is enabled in settings
bool useCache = Settings.Default.SaveEntraCredentials;
IPublicClientApplication app = PublicClientApplicationBuilder.Create(Settings.Default.AppID)
.WithDefaultRedirectUri()
.WithAuthority(String.Format("https://login.microsoftonline.com/{0}", Settings.Default.TenantID))
.Build();
// Register MSAL cache only if caching is enabled
if (useCache)
{
var storage = new StorageCreationPropertiesBuilder("teamsnetphonelink.msal.cache", MsalCacheHelper.UserRootDirectory).Build();
var cacheHelper = await MsalCacheHelper.CreateAsync(storage);
cacheHelper.RegisterCache(app.UserTokenCache);
}
IEnumerable<IAccount> accounts = await app.GetAccountsAsync();
//Alle Accounts aus dem Cache entfernen wenn clearCache gesetzt ist
if (clearCache)
{
foreach(var account in accounts)
await app.RemoveAsync(account);
}
// Try to use the previously signed-in account from the cache
var existingAccount = accounts.FirstOrDefault();
AuthenticationResult authentication;
if (existingAccount is not null && useCache && !clearCache)
{
Console.WriteLine("Attempting to acquire token silently using cached account.");
try
{
authentication = await app.AcquireTokenSilent(new[] { "Presence.ReadWrite", "offline_access" }, existingAccount)
.ExecuteAsync();
await InitializeGraphClient(authentication.AccessToken);
return Authenticated;
}
catch (MsalUiRequiredException)
{
Console.WriteLine("Silent token acquisition failed. Falling back to interactive authentication.");
}
}
// If no cached account or silent authentication fails, prompt the user for authentication
Console.WriteLine("Prompting user for authentication.");
authentication = await app.AcquireTokenInteractive(new[] { "Presence.ReadWrite", "offline_access" })
.ExecuteAsync();
await InitializeGraphClient(authentication.AccessToken);
return Authenticated;
}
catch (Exception ex)
{
ExceptionWindowHelper.Show(this.GetType().Name, MethodBase.GetCurrentMethod().Name, "Fehler bei der Authentifizierung", ex.Message, ex.StackTrace);
Console.WriteLine($"Error during authentication: {ex.Message}");
return Authenticated;
}
}
// Helper method to initialize GraphServiceClient
private async Task InitializeGraphClient(string accessToken)
{
graphClient = new GraphServiceClient(new HttpClient(new AuthHandler(accessToken, new HttpClientHandler())));
Authenticated = await TestAuthentication();
}
// Testung der Anmeldung und Zugriffsrechte
public async Task<bool> TestAuthentication()
{
try
{
// Test-Anfrage, um Authentifizierung zu validieren
await graphClient.Me.Presence.GetAsync();
return true; // Anmeldung und Zugriffsrechte korrekt
}
catch (Exception ex)
{
ExceptionWindowHelper.Show(this.GetType().Name, MethodBase.GetCurrentMethod().Name, "Authentifizierung fehlerhaft", ex.Message, ex.StackTrace);
return false; // Anmeldung und Zugriffsrechte felerhaft
}
}
// Methode zum Setzen des Präsenzstatus
public async Task<bool> SetPresenceAsync(PresenceState presenceState)
{
if (graphClient is null || !Authenticated)
{
Console.WriteLine("Authentifizierung nicht abgeschlossen. Präsenz kann nicht gesetzt werden.");
return false;
}
// Zuordnen des Präsenzstatus zu Verfügbarkeit und Aktivität
var presenceMap = new Dictionary<PresenceState, (string Availability, string Activity)>
{
{ PresenceState.Available, ("Available", "Available") },
{ PresenceState.Busy, ("Busy", "Busy") },
{ PresenceState.DoNotDisturb, ("DoNotDisturb", "DoNotDisturb") },
{ PresenceState.BeRightBack, ("BeRightBack", "BeRightBack") },
{ PresenceState.Away, ("Away", "Away") },
{ PresenceState.Offline, ("Offline", "OffWork") }
};
if (!presenceMap.ContainsKey(presenceState))
{
Console.WriteLine("Ungültiger Präsenzstatus.");
ExceptionWindowHelper.Show(this.GetType().Name, MethodBase.GetCurrentMethod().Name, "Ungültiger Präsenzstatus", presenceState.ToString());
return false; // Ungültiger Status
}
var (availability, activity) = presenceMap[presenceState];
var requestBody = new SetUserPreferredPresencePostRequestBody
{
Availability = availability,
Activity = activity,
ExpirationDuration = TimeSpan.FromHours(2) // Ablaufzeit der Präsenz
};
try
{
await graphClient.Me.Presence.SetUserPreferredPresence.PostAsync(requestBody);
return true; // Präsenz erfolgreich gesetzt
}
catch (Exception ex)
{
if(ex.GetType() != typeof(Microsoft.Graph.Models.ODataErrors.ODataError))
{
ExceptionWindowHelper.Show(this.GetType().Name, MethodBase.GetCurrentMethod().Name, "Fehler beim Setzen der Präsenz", ex.Message, ex.StackTrace);
}
return false; // Fehler beim Setzen der Präsenz
}
}
// Enum für die verschiedenen Präsenzstatus
public enum PresenceState
{
Available,
Busy,
DoNotDisturb,
BeRightBack,
Away,
Offline
}
private static Regex isGuid =
new Regex(@"^(\{){0,1}[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}(\}){0,1}$", RegexOptions.Compiled);
public static bool IsGuid(string candidate)
{
bool isValid = false;
if (candidate != null)
{
if (isGuid.IsMatch(candidate))
{
isValid = true;
}
}
return isValid;
}
}
// Custom HttpMessageHandler to inject the access token
public class AuthHandler : DelegatingHandler
{
private readonly string _accessToken;
public AuthHandler(string accessToken, HttpMessageHandler innerHandler)
: base(innerHandler)
{
_accessToken = accessToken;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
// Add the access token to the request headers
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
return await base.SendAsync(request, cancellationToken);
}
}
}
@@ -0,0 +1,97 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace TeamsNetphoneLink.Communication
{
public class TeamsGraphEventHandlers
{
private readonly object _lock = new object();
private readonly List<EventHandler<PresenceChangedEventArgs>> _activityChangedHandlers = new List<EventHandler<PresenceChangedEventArgs>>();
private readonly List<EventHandler<PresenceChangedEventArgs>> _availabilityChangedHandlers = new List<EventHandler<PresenceChangedEventArgs>>();
public event EventHandler<PresenceChangedEventArgs> ActivityChanged
{
add
{
lock (_lock)
{
_activityChangedHandlers.Add(value);
}
}
remove
{
lock (_lock)
{
_activityChangedHandlers.Remove(value);
}
}
}
public event EventHandler<PresenceChangedEventArgs> AvailabilityChanged
{
add
{
lock (_lock)
{
_availabilityChangedHandlers.Add(value);
}
}
remove
{
lock (_lock)
{
_availabilityChangedHandlers.Remove(value);
}
}
}
protected void OnActivityChanged(Microsoft.Graph.Models.Presence presence)
{
lock (_lock)
{
var args = new PresenceChangedEventArgs(presence);
foreach (var handler in _activityChangedHandlers)
{
handler?.Invoke(this, args);
}
}
}
protected void OnAvailabilityChanged(Microsoft.Graph.Models.Presence presence)
{
lock (_lock)
{
var args = new PresenceChangedEventArgs(presence);
foreach (var handler in _availabilityChangedHandlers)
{
handler?.Invoke(this, args);
}
}
}
public void RemoveAllEventHandlers()
{
foreach (var handler in _activityChangedHandlers)
{
_availabilityChangedHandlers.Remove(handler);
}
foreach (var handler in _availabilityChangedHandlers)
{
_availabilityChangedHandlers.Remove(handler);
}
}
}
public class PresenceChangedEventArgs : EventArgs
{
public Microsoft.Graph.Models.Presence Presence { get; }
public PresenceChangedEventArgs(Microsoft.Graph.Models.Presence presence)
{
Presence = presence;
}
}
}
@@ -0,0 +1,118 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Net.WebSockets;
using System.Reflection;
using TeamsLocalLibary;
using TeamsLocalLibary.EventArgs;
namespace TeamsNetphoneLink.Teams
{
public class TeamsLocalAPI
{
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionState;
public event EventHandler<TokenReceivedEventArgs>? TokenReceived;
private Client teamsClient;
private string token = Settings.Default.Token != string.Empty ? Settings.Default.Token : null;
private Dictionary<string, List<PropertyChangedEventHandler>> propertyChangedHandlers = new Dictionary<string, List<PropertyChangedEventHandler>>();
private CancellationTokenSource cts = new();
// Destructor
~TeamsLocalAPI()
{
RemoveAllEventHandlers();
}
public async Task<bool> Initialize()
{
// Initialize the TeamsClient with token and no auto-connect
teamsClient = new Client(autoConnect: false, token: token) ?? throw new Exception("Could not create client");
// Event-handler for token reception
teamsClient.TokenReceived += (_, args) =>
{
Settings.Default.Token = args.Token;
Settings.Default.Save();
TokenReceived?.Invoke(_, args);
};
WebSocketState lastConnectionState = WebSocketState.None;
teamsClient.ConnectionState += (sender, args) =>
{
if (args.WebSocketState != lastConnectionState)
{
lastConnectionState = args.WebSocketState;
// Raise the ConnectionState event in TeamsLocalAPI
ConnectionState?.Invoke(sender, args);
}
};
teamsClient.PropertyChanged += HandlePropertyChanged;
teamsClient.ErrorReceived += (_, args) => Console.WriteLine("Event: ErrorReceived: {0}", args.ErrorMessage);
return await teamsClient.Connect(true, cts.Token);
}
public void AddTokenRecievedHandler(EventHandler<TokenReceivedEventArgs> handler)
{
TokenReceived += handler;
}
public void RemoveTokenRecievedHandler(EventHandler<TokenReceivedEventArgs> handler)
{
TokenReceived -= handler;
}
public void AddConnectionStateHandler(EventHandler<ConnectionStateChangedEventArgs> handler)
{
ConnectionState += handler;
}
public void RemoveConnectionStateHandler(EventHandler<ConnectionStateChangedEventArgs> handler)
{
ConnectionState -= handler;
}
public async void SendDummyCommand()
{
var dummy = teamsClient.IsMuted = true;
}
public void HandlePropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is not null && propertyChangedHandlers.ContainsKey(e.PropertyName))
{
foreach (var handler in propertyChangedHandlers[e.PropertyName])
{
handler?.Invoke(sender, e);
}
}
}
public void AddEventHandler(string propertyName, PropertyChangedEventHandler handler)
{
if (!propertyChangedHandlers.ContainsKey(propertyName))
{
propertyChangedHandlers[propertyName] = new List<PropertyChangedEventHandler>();
}
propertyChangedHandlers[propertyName].Add(handler);
}
public void RemoveEventHandler(string propertyName, PropertyChangedEventHandler handler)
{
if (propertyChangedHandlers.ContainsKey(propertyName))
{
propertyChangedHandlers[propertyName].Remove(handler);
}
}
public void RemoveAllEventHandlers()
{
propertyChangedHandlers.Clear();
}
}
}