694 lines
26 KiB
C#
694 lines
26 KiB
C#
/// Copyright 2016 Kopano b.v.
|
|
///
|
|
/// This program is free software: you can redistribute it and/or modify
|
|
/// it under the terms of the GNU Affero General Public License, version 3,
|
|
/// as published by the Free Software Foundation.
|
|
///
|
|
/// 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<http://www.gnu.org/licenses/>.
|
|
///
|
|
/// Consult LICENSE file for details
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
using System.Threading.Tasks;
|
|
using Acacia.Stubs;
|
|
using Acacia.ZPush;
|
|
using Acacia.Utils;
|
|
using System.Collections;
|
|
using static Acacia.DebugOptions;
|
|
|
|
namespace Acacia.Features.GAB
|
|
{
|
|
public class GABHandler : LogContext
|
|
{
|
|
public string LogContextId { get { return "GAB"; } }
|
|
private readonly FeatureGAB _feature;
|
|
|
|
#region Contacts
|
|
|
|
private readonly Func<IFolder, IAddressBook> _contactsProvider;
|
|
private readonly Action<IAddressBook> _contactsDisposer;
|
|
|
|
private IAddressBook _contacts;
|
|
public IAddressBook Contacts
|
|
{
|
|
get
|
|
{
|
|
if (_contacts == null)
|
|
{
|
|
_contacts = _contactsProvider(Folder);
|
|
}
|
|
return _contacts;
|
|
}
|
|
}
|
|
|
|
private IStorageItem GetIndexItem()
|
|
{
|
|
return Contacts?.GetStorageItem(Constants.ZPUSH_GAB_INDEX);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Accounts & Folders
|
|
|
|
public ZPushAccount ActiveAccount
|
|
{
|
|
get
|
|
{
|
|
return ThisAddIn.Instance.Watcher.Accounts.GetAccount(Folder);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The list of accounts that are associated with this GAB.
|
|
/// </summary>
|
|
private readonly List<ZPushAccount> _accounts = new List<ZPushAccount>();
|
|
private readonly List<IFolder> _accountFolders = new List<IFolder>();
|
|
|
|
private IFolder Folder
|
|
{
|
|
get
|
|
{
|
|
if (!HasAccounts)
|
|
return null;
|
|
return _accountFolders.FirstOrDefault();
|
|
}
|
|
}
|
|
|
|
public void AddAccount(ZPushAccount account, IFolder folder)
|
|
{
|
|
_accounts.Add(account);
|
|
_accountFolders.Add(folder);
|
|
}
|
|
|
|
public void RemoveAccount(ZPushAccount account)
|
|
{
|
|
int i = _accounts.IndexOf(account);
|
|
if (i >= 0)
|
|
{
|
|
_accounts.RemoveAt(i);
|
|
_accountFolders.RemoveAt(i);
|
|
}
|
|
}
|
|
|
|
internal bool HasAccounts
|
|
{
|
|
get
|
|
{
|
|
return _accounts.Count > 0;
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
public GABHandler(FeatureGAB feature, Func<IFolder, IAddressBook> contactsProvider, Action<IAddressBook> contactsDisposer)
|
|
{
|
|
this._feature = feature;
|
|
this._contactsProvider = contactsProvider;
|
|
this._contactsDisposer = contactsDisposer;
|
|
}
|
|
|
|
public string DisplayName
|
|
{
|
|
get
|
|
{
|
|
using(IStore store = Folder.Store)
|
|
return store.DisplayName;
|
|
}
|
|
}
|
|
|
|
#region Processing
|
|
|
|
public void FullResync()
|
|
{
|
|
ClearContacts();
|
|
Process(null);
|
|
}
|
|
|
|
private void ClearContacts()
|
|
{
|
|
if (Contacts != null)
|
|
{
|
|
try
|
|
{
|
|
Contacts.Delete();
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Logger.Instance.Warning(this, "Error clearing contacts folder for {0}: {1}", DisplayName, e);
|
|
// There was an error deleting the contacts folder, try clearing it
|
|
using (IStorageItem index = GetIndexItem())
|
|
{
|
|
index?.Delete();
|
|
}
|
|
Contacts.Clear();
|
|
}
|
|
CleanupContactsObject();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Processes the GAB message(s).
|
|
/// </summary>
|
|
/// <param name="item">If specified, this item has changed. If null, means a global check should be performed</param>
|
|
public void Process(IZPushItem item)
|
|
{
|
|
try
|
|
{
|
|
if (item == null)
|
|
{
|
|
if (Folder != null)
|
|
ProcessMessages();
|
|
}
|
|
else
|
|
{
|
|
ProcessMessage(item);
|
|
}
|
|
}
|
|
catch(Exception e)
|
|
{
|
|
Logger.Instance.Error(this, "Exception in GAB.Process: {0}", e);
|
|
}
|
|
}
|
|
|
|
private void ProcessMessages()
|
|
{
|
|
if (!_feature.ProcessFolder)
|
|
return;
|
|
|
|
DetermineSequence();
|
|
if (CurrentSequence == null)
|
|
return; // No messages to process
|
|
|
|
if (!_feature.ProcessItems)
|
|
return;
|
|
|
|
// Process the messages
|
|
foreach (IItem item in Folder.Items)
|
|
{
|
|
// TODO: make type-checking iterator?
|
|
if (item is IZPushItem)
|
|
{
|
|
string entryId = item.EntryId;
|
|
Logger.Instance.Trace(this, "Checking chunk: {0}", item.Subject);
|
|
if (_feature.ProcessItems2)
|
|
{
|
|
Tasks.Task(_feature, "ProcessChunk", () =>
|
|
{
|
|
using (IItem item2 = Folder.GetItemById(entryId))
|
|
{
|
|
if (item2 != null)
|
|
ProcessMessage((IZPushItem)item2);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public const string PROP_LAST_PROCESSED = "ZPushLastProcessed";
|
|
public const string PROP_SEQUENCE = "ZPushSequence";
|
|
public const string PROP_CHUNK = "ZPushChunk";
|
|
public const string PROP_GAB_ID = "ZPushId";
|
|
public const string PROP_CURRENT_SEQUENCE = "ZPushCurrentSequence";
|
|
|
|
private void ProcessMessage(IZPushItem item)
|
|
{
|
|
if (!_feature.ProcessMessage)
|
|
return;
|
|
|
|
// Check if the message is for the current sequence
|
|
ChunkIndex? optionalIndex = ChunkIndex.Parse(item.Subject);
|
|
if (optionalIndex == null)
|
|
{
|
|
Logger.Instance.Trace(this, "Not a chunk: {0}", item.Subject);
|
|
return;
|
|
}
|
|
|
|
if (optionalIndex.Value.numberOfChunks != CurrentSequence)
|
|
{
|
|
// Try to update the current sequence; this message may indicate that it has changed
|
|
DetermineSequence();
|
|
|
|
// If it is still not for the current sequence, it's an old message
|
|
if (optionalIndex.Value.numberOfChunks != CurrentSequence)
|
|
{
|
|
Logger.Instance.Trace(this, "Skipping, wrong sequence: {0}", item.Subject);
|
|
return;
|
|
}
|
|
}
|
|
ChunkIndex index = optionalIndex.Value;
|
|
|
|
// Check if the message is up to date
|
|
string lastProcessed = GetChunkStateString(index);
|
|
if (lastProcessed == item.Location)
|
|
{
|
|
Logger.Instance.Trace(this, "Already up to date: {0} - {1}", item.Subject, item.Location);
|
|
return;
|
|
}
|
|
|
|
// Process it
|
|
Logger.Instance.Trace(this, "Processing: {0} - {1} - {2}", item.Subject, item.Location, lastProcessed);
|
|
_feature?.BeginProcessing();
|
|
try
|
|
{
|
|
// Delete the old contacts from this chunk
|
|
ISearch<IItem> search = Contacts.Search<IItem>();
|
|
search.AddField(PROP_SEQUENCE, true).SetOperation(SearchOperation.Equal, index.numberOfChunks);
|
|
search.AddField(PROP_CHUNK, true).SetOperation(SearchOperation.Equal, index.chunk);
|
|
foreach (IItem oldItem in search.Search())
|
|
{
|
|
// TODO: Search should handle this, like folder enumeration
|
|
using (oldItem)
|
|
{
|
|
Logger.Instance.Trace(this, "Deleting GAB entry: {0}", oldItem.Subject);
|
|
oldItem.Delete();
|
|
}
|
|
}
|
|
|
|
// Create the new contacts
|
|
ProcessChunkBody(item, index);
|
|
|
|
// Update the state
|
|
SetChunkStateString(index, item.Location);
|
|
}
|
|
finally
|
|
{
|
|
_feature?.EndProcessing();
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Sequence
|
|
|
|
public int? CurrentSequence
|
|
{
|
|
get
|
|
{
|
|
using (IStorageItem index = GetIndexItem())
|
|
{
|
|
return index?.GetUserProperty<int>(PROP_CURRENT_SEQUENCE)?.Value;
|
|
}
|
|
}
|
|
set
|
|
{
|
|
using (IStorageItem index = GetIndexItem())
|
|
{
|
|
if (value != null)
|
|
{
|
|
index.GetUserProperty<int>(PROP_CURRENT_SEQUENCE, true).Value = value.Value;
|
|
}
|
|
else
|
|
{
|
|
index.Delete();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private IItem FindNewestChunk()
|
|
{
|
|
if (Folder == null)
|
|
return null;
|
|
|
|
// Scan a few of the newest items, in case there is some junk in the ZPush folder
|
|
// TODO: this shouldn't happen in production.
|
|
int i = 0;
|
|
foreach(IItem item in Folder.ItemsSorted("LastModificationTime", true))
|
|
{
|
|
if (ChunkIndex.Parse(item.Subject) != null)
|
|
return item;
|
|
item.Dispose();
|
|
if (i > Constants.ZPUSH_GAB_NEWEST_MAX_CHECK)
|
|
return null;
|
|
++i;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
public void DetermineSequence()
|
|
{
|
|
try
|
|
{
|
|
// Find the newest chunk
|
|
using (IItem newest = FindNewestChunk())
|
|
{
|
|
if (newest == null)
|
|
CurrentSequence = null;
|
|
else
|
|
{
|
|
Logger.Instance.Trace(this, "Newest chunk: {0}", newest.Subject);
|
|
ChunkIndex? newestChunkIndex = ChunkIndex.Parse(newest.Subject);
|
|
|
|
if (!CurrentSequence.HasValue || CurrentSequence.Value != newestChunkIndex?.numberOfChunks)
|
|
{
|
|
// Sequence has changed. Delete contacts
|
|
Logger.Instance.Trace(this, "Rechunked, deleting contacts");
|
|
ClearContacts();
|
|
|
|
// Determine new sequence
|
|
if (newestChunkIndex == null)
|
|
{
|
|
using (IStorageItem index = GetIndexItem())
|
|
{
|
|
if (index != null)
|
|
index.Delete();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
int numberOfChunks = newestChunkIndex.Value.numberOfChunks;
|
|
using (IStorageItem index = GetIndexItem())
|
|
{
|
|
index.GetUserProperty<int>(PROP_CURRENT_SEQUENCE, true).Value = numberOfChunks;
|
|
index.GetUserProperty<string>(PROP_LAST_PROCESSED, true).Value = CreateChunkStateString(numberOfChunks);
|
|
index.Save();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch(Exception e)
|
|
{
|
|
Logger.Instance.Trace(this, "Exception determining sequence: {0}", e);
|
|
// Delete the index item
|
|
using (IStorageItem index = GetIndexItem())
|
|
index?.Delete();
|
|
return;
|
|
}
|
|
Logger.Instance.Trace(this, "Current sequence: {0}", CurrentSequence);
|
|
}
|
|
|
|
private string CreateChunkStateString(int count)
|
|
{
|
|
string[] defaultValues = new string[count];
|
|
return string.Join(";", defaultValues);
|
|
}
|
|
|
|
private string GetChunkStateString(ChunkIndex index)
|
|
{
|
|
using (IStorageItem item = GetIndexItem())
|
|
{
|
|
if (item == null)
|
|
return null;
|
|
string state = item.GetUserProperty<string>(PROP_LAST_PROCESSED)?.Value;
|
|
if (string.IsNullOrEmpty(state))
|
|
return null;
|
|
|
|
string[] parts = state.Split(';');
|
|
if (parts.Length != index.numberOfChunks)
|
|
{
|
|
Logger.Instance.Error(this, "Wrong number of chunks, got {0}, expected {1}: {2}",
|
|
parts.Length, index.numberOfChunks, state);
|
|
}
|
|
return parts[index.chunk];
|
|
}
|
|
}
|
|
|
|
private void SetChunkStateString(ChunkIndex index, string partState)
|
|
{
|
|
using (IStorageItem item = GetIndexItem())
|
|
{
|
|
string state = item.GetUserProperty<string>(PROP_LAST_PROCESSED)?.Value;
|
|
string[] parts;
|
|
if (string.IsNullOrEmpty(state))
|
|
parts = new string[index.numberOfChunks];
|
|
else
|
|
parts = state.Split(';');
|
|
if (parts.Length != index.numberOfChunks)
|
|
{
|
|
Logger.Instance.Error(this, "Wrong number of chunks, got {0}, expected {1}: {2}",
|
|
parts.Length, index.numberOfChunks, state);
|
|
}
|
|
parts[index.chunk] = partState;
|
|
string combined = string.Join(";", parts);
|
|
|
|
item.GetUserProperty<string>(PROP_LAST_PROCESSED, true).Value = combined;
|
|
item.Save();
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Message parsing
|
|
|
|
|
|
private ValueType Get<ValueType>(Dictionary<string, object> values, string name)
|
|
where ValueType : class
|
|
{
|
|
object value;
|
|
values.TryGetValue(name, out value);
|
|
return value as ValueType;
|
|
}
|
|
|
|
private void ProcessChunkBody(IZPushItem item, ChunkIndex index)
|
|
{
|
|
// Process the body
|
|
foreach (var entry in JSONUtils.Deserialise(item.Body))
|
|
{
|
|
string id = entry.Key;
|
|
Dictionary<string, object> value = (Dictionary<string, object>)entry.Value;
|
|
Tasks.Task(_feature, "CreateItem", () => CreateObject(index, id, value));
|
|
}
|
|
}
|
|
|
|
private void CreateObject(ChunkIndex index, string id, Dictionary<string, object> value)
|
|
{
|
|
try
|
|
{
|
|
_feature?.BeginProcessing();
|
|
|
|
string type = Get<string>(value, "type");
|
|
if (type == "contact")
|
|
CreateContact(id, value, index, 0);
|
|
else if (type == "group")
|
|
CreateGroup(id, value, index);
|
|
else if (type == "equipment")
|
|
CreateContact(id, value, index, OutlookConstants.DT_EQUIPMENT);
|
|
else if (type == "room")
|
|
CreateContact(id, value, index, OutlookConstants.DT_ROOM);
|
|
else
|
|
{
|
|
Logger.Instance.Warning(this, "Unknown entry type: {0}", type);
|
|
}
|
|
}
|
|
catch (System.Exception e)
|
|
{
|
|
Logger.Instance.Error(this, "Error creating entry: {0}: {1}", id, e);
|
|
}
|
|
finally
|
|
{
|
|
_feature?.EndProcessing();
|
|
}
|
|
}
|
|
|
|
private void CreateContact(string id, Dictionary<string, object> value, ChunkIndex index, int resourceType)
|
|
{
|
|
if (!_feature.CreateContacts)
|
|
return;
|
|
|
|
using (IContactItem contact = Contacts.Create<IContactItem>())
|
|
{
|
|
Logger.Instance.Trace(this, "Creating contact: {0}", id);
|
|
contact.CustomerID = id;
|
|
|
|
// Create the contact data
|
|
if (Get<string>(value, "displayName") != null) contact.FullName = Get<string>(value, "displayName");
|
|
if (Get<string>(value, "givenName") != null) contact.FirstName = Get<string>(value, "givenName");
|
|
if (Get<string>(value, "initials") != null) contact.Initials = Get<string>(value, "initials");
|
|
if (Get<string>(value, "surname") != null) contact.LastName = Get<string>(value, "surname");
|
|
if (Get<string>(value, "title") != null) contact.JobTitle = Get<string>(value, "title");
|
|
|
|
if (Get<string>(value, "smtpAddress") != null)
|
|
{
|
|
contact.Email1Address = Get<string>(value, "smtpAddress");
|
|
contact.Email1AddressType = "SMTP";
|
|
}
|
|
if (Get<string>(value, "companyName") != null) contact.CompanyName = Get<string>(value, "companyName");
|
|
if (Get<string>(value, "officeLocation") != null) contact.OfficeLocation = Get<string>(value, "officeLocation");
|
|
if (Get<string>(value, "businessTelephoneNumber") != null) contact.BusinessTelephoneNumber = Get<string>(value, "businessTelephoneNumber");
|
|
if (Get<string>(value, "mobileTelephoneNumber") != null) contact.MobileTelephoneNumber = Get<string>(value, "mobileTelephoneNumber");
|
|
if (Get<string>(value, "homeTelephoneNumber") != null) contact.HomeTelephoneNumber = Get<string>(value, "homeTelephoneNumber");
|
|
if (Get<string>(value, "beeperTelephoneNumber") != null) contact.PagerNumber = Get<string>(value, "beeperTelephoneNumber");
|
|
if (Get<string>(value, "primaryFaxNumber") != null) contact.BusinessFaxNumber = Get<string>(value, "primaryFaxNumber");
|
|
if (Get<string>(value, "organizationalIdNumber") != null) contact.OrganizationalIDNumber = Get<string>(value, "organizationalIdNumber");
|
|
if (Get<string>(value, "postalAddress") != null) contact.BusinessAddress = Get<string>(value, "postalAddress");
|
|
if (Get<string>(value, "businessAddressCity") != null) contact.BusinessAddressCity = Get<string>(value, "businessAddressCity");
|
|
if (Get<string>(value, "businessAddressPostalCode") != null) contact.BusinessAddressPostalCode = Get<string>(value, "businessAddressPostalCode");
|
|
if (Get<string>(value, "businessAddressPostOfficeBox") != null) contact.BusinessAddressPostOfficeBox = Get<string>(value, "businessAddressPostOfficeBox");
|
|
if (Get<string>(value, "businessAddressStateOrProvince") != null) contact.BusinessAddressState = Get<string>(value, "businessAddressStateOrProvince");
|
|
if (Get<string>(value, "language") != null) contact.Language = Get<string>(value, "language");
|
|
|
|
// Thumbnail
|
|
string photoData = Get<string>(value, "thumbnailPhoto");
|
|
if (photoData != null)
|
|
{
|
|
string path = null;
|
|
try
|
|
{
|
|
byte[] data = Convert.FromBase64String(photoData);
|
|
path = System.IO.Path.GetTempFileName();
|
|
Logger.Instance.Trace(this, "Contact image: {0}", path);
|
|
System.IO.File.WriteAllBytes(path, data);
|
|
contact.SetPicture(path);
|
|
}
|
|
catch (Exception) { }
|
|
finally
|
|
{
|
|
try
|
|
{
|
|
if (path != null)
|
|
System.IO.File.Delete(path);
|
|
}
|
|
catch (Exception) { }
|
|
}
|
|
}
|
|
|
|
// Resource flags
|
|
if (resourceType != 0)
|
|
{
|
|
contact.SetProperty(OutlookConstants.PR_DISPLAY_TYPE, 0);
|
|
contact.SetProperty(OutlookConstants.PR_DISPLAY_TYPE_EX, resourceType);
|
|
}
|
|
|
|
// Standard properties
|
|
SetItemStandard(contact, id, value, index);
|
|
contact.Save();
|
|
|
|
// Update the groups
|
|
AddItemToGroups(contact, id, value, index);
|
|
}
|
|
|
|
}
|
|
|
|
private void CreateGroup(string id, Dictionary<string, object> value, ChunkIndex index)
|
|
{
|
|
if (!_feature.CreateGroups)
|
|
return;
|
|
|
|
using (IDistributionList group = Contacts.Create<IDistributionList>())
|
|
{
|
|
Logger.Instance.Debug(this, "Creating group: {0}", id);
|
|
group.DLName = Get<string>(value, "displayName");
|
|
if (Get<string>(value, "smtpAddress") != null)
|
|
{
|
|
group.SMTPAddress = Get<string>(value, "smtpAddress");
|
|
}
|
|
|
|
SetItemStandard(group, id, value, index);
|
|
group.Save();
|
|
|
|
if (_feature.GroupMembers)
|
|
{
|
|
ArrayList members = Get<ArrayList>(value, "members");
|
|
if (members != null)
|
|
{
|
|
foreach (string memberId in members)
|
|
{
|
|
using (IItem item = FindItemById(memberId))
|
|
{
|
|
Logger.Instance.Debug(this, "Finding member {0} of {1}: {2}", memberId, id, item?.EntryId);
|
|
if (item != null)
|
|
AddGroupMember(group, item);
|
|
}
|
|
}
|
|
}
|
|
group.Save();
|
|
}
|
|
|
|
AddItemToGroups(group, id, value, index);
|
|
}
|
|
}
|
|
|
|
private IItem FindItemById(string id)
|
|
{
|
|
ISearch<IItem> search = Contacts.Search<IItem>();
|
|
search.AddField(PROP_GAB_ID, true).SetOperation(SearchOperation.Equal, id);
|
|
return search.SearchOne();
|
|
}
|
|
|
|
private void SetItemStandard(IItem item, string id, Dictionary<string, object> value, ChunkIndex index)
|
|
{
|
|
// Set the chunk data
|
|
item.GetUserProperty<int>(PROP_SEQUENCE, true).Value = index.numberOfChunks;
|
|
item.GetUserProperty<int>(PROP_CHUNK, true).Value = index.chunk;
|
|
item.GetUserProperty<string>(PROP_GAB_ID, true).Value = id;
|
|
}
|
|
|
|
private void AddGroupMember(IDistributionList group, IItem item)
|
|
{
|
|
if (!_feature.GroupMembersAdd)
|
|
return;
|
|
|
|
if (item is IDistributionList)
|
|
{
|
|
if (!_feature.NestedGroups)
|
|
return;
|
|
}
|
|
|
|
group.AddMember(item);
|
|
}
|
|
|
|
private void AddItemToGroups(IItem item, string id, Dictionary<string, object> value, ChunkIndex index)
|
|
{
|
|
if (!_feature.GroupMembers)
|
|
return;
|
|
|
|
// Find the groups
|
|
if (Get<ArrayList>(value, "memberOf") != null)
|
|
{
|
|
ArrayList members = Get<ArrayList>(value, "memberOf");
|
|
foreach (string memberOf in members)
|
|
{
|
|
using (IItem groupItem = FindItemById(memberOf))
|
|
{
|
|
Logger.Instance.Debug(this, "Finding group {0} for {1}: {2}", memberOf, id, groupItem?.EntryId);
|
|
if (groupItem is IDistributionList)
|
|
{
|
|
AddGroupMember((IDistributionList)groupItem, item);
|
|
groupItem.Save();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Removal
|
|
|
|
public void Remove()
|
|
{
|
|
if (_contacts != null)
|
|
{
|
|
_contacts.Delete();
|
|
}
|
|
CleanupContactsObject();
|
|
}
|
|
|
|
private void CleanupContactsObject()
|
|
{
|
|
if (_contacts != null)
|
|
{
|
|
if (_contactsDisposer != null)
|
|
_contactsDisposer(_contacts);
|
|
_contacts.Dispose();
|
|
_contacts = null;
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
}
|