mirror of
https://github.com/Kopano-dev/kopano-ol-extension.git
synced 2023-10-10 13:37:40 +02:00
[KOE-159] Implemented start-up sync time frame checking
This commit is contained in:
parent
fdf62e0037
commit
0bce4d8c22
@ -397,6 +397,7 @@
|
||||
<Compile Include="Utils\DisposableWrapper.cs" />
|
||||
<Compile Include="Utils\ImageUtils.cs" />
|
||||
<Compile Include="Utils\RegistryUtil.cs" />
|
||||
<Compile Include="Utils\SizeUtil.cs" />
|
||||
<Compile Include="ZPush\API\SharedFolders\AvailableFolder.cs" />
|
||||
<Compile Include="ZPush\API\SharedFolders\SharedFolder.cs" />
|
||||
<Compile Include="ZPush\API\SharedFolders\Types.cs" />
|
||||
|
@ -420,34 +420,58 @@ namespace Acacia.Features.SyncState
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private class UserStoreInfo
|
||||
{
|
||||
public string emailaddress;
|
||||
public string fullname;
|
||||
public long foldercount;
|
||||
public long storesize;
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return string.Format("emailaddress={0}\nfullname={1}\nfoldercount={2}\nstoresize={3}",
|
||||
emailaddress, fullname, foldercount, storesize);
|
||||
}
|
||||
}
|
||||
private class GetUserStoreInfoRequest : SoapRequest<UserStoreInfo>
|
||||
{
|
||||
}
|
||||
|
||||
private void CheckSyncState(ZPushAccount account)
|
||||
{
|
||||
// TODO: we probably want one invocation for all accounts
|
||||
using (ZPushConnection connection = account.Connect())
|
||||
using (ZPushWebServiceDevice deviceService = connection.DeviceService)
|
||||
{
|
||||
// Fetch
|
||||
DeviceDetails details = deviceService.Execute(new GetDeviceDetailsRequest());
|
||||
if (details != null)
|
||||
// Check total size
|
||||
CheckTotalSize(connection);
|
||||
|
||||
// Update sync state
|
||||
using (ZPushWebServiceDevice deviceService = connection.DeviceService)
|
||||
{
|
||||
bool wasSyncing = false;
|
||||
|
||||
// Create or update session
|
||||
SyncSession session = account.GetFeatureData<SyncSession>(this, null);
|
||||
if (session == null)
|
||||
session = new SyncSession(this, account);
|
||||
else
|
||||
wasSyncing = session.IsSyncing;
|
||||
|
||||
session.Add(details);
|
||||
|
||||
// Store with the account
|
||||
account.SetFeatureData(this, null, session);
|
||||
|
||||
if (wasSyncing != session.IsSyncing)
|
||||
// Fetch
|
||||
DeviceDetails details = deviceService.Execute(new GetDeviceDetailsRequest());
|
||||
if (details != null)
|
||||
{
|
||||
// Sync state has changed, update the schedule
|
||||
Watcher.Sync.SetTaskSchedule(_task, account, session.IsSyncing ? CheckPeriodSync : (TimeSpan?)null);
|
||||
bool wasSyncing = false;
|
||||
|
||||
// Create or update session
|
||||
SyncSession session = account.GetFeatureData<SyncSession>(this, null);
|
||||
if (session == null)
|
||||
session = new SyncSession(this, account);
|
||||
else
|
||||
wasSyncing = session.IsSyncing;
|
||||
|
||||
session.Add(details);
|
||||
|
||||
// Store with the account
|
||||
account.SetFeatureData(this, null, session);
|
||||
|
||||
if (wasSyncing != session.IsSyncing)
|
||||
{
|
||||
// Sync state has changed, update the schedule
|
||||
Watcher.Sync.SetTaskSchedule(_task, account, session.IsSyncing ? CheckPeriodSync : (TimeSpan?)null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -739,7 +763,7 @@ namespace Acacia.Features.SyncState
|
||||
|
||||
public bool SupportsSyncTimeFrame(ZPushAccount account)
|
||||
{
|
||||
return account?.ZPushVersion.IsAtLeast(2, 4) == true;
|
||||
return account?.ZPushVersion?.IsAtLeast(2, 4) == true;
|
||||
}
|
||||
|
||||
private class SetDeviceOptionsRequest : SoapRequest<bool>
|
||||
@ -752,7 +776,6 @@ namespace Acacia.Features.SyncState
|
||||
|
||||
public void SetDeviceOptions(ZPushAccount account, SyncTimeFrame timeFrame)
|
||||
{
|
||||
|
||||
try
|
||||
{
|
||||
Logger.Instance.Debug(this, "Setting sync time frame for {0} to {1}", account, timeFrame);
|
||||
@ -778,5 +801,126 @@ namespace Acacia.Features.SyncState
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
#region Size checking
|
||||
|
||||
[AcaciaOption("Disable checking of store size to suggest a sync time frame.")]
|
||||
public bool CheckStoreSize
|
||||
{
|
||||
get { return GetOption(OPTION_CHECK_STORE_SIZE); }
|
||||
set { SetOption(OPTION_CHECK_STORE_SIZE, value); }
|
||||
}
|
||||
private static readonly BoolOption OPTION_CHECK_STORE_SIZE = new BoolOption("CheckStoreSize", true);
|
||||
|
||||
private bool _checkedStoreSize = false;
|
||||
|
||||
private const string KEY_CHECKED_SIZE = "KOE Size Checked";
|
||||
|
||||
private void CheckTotalSize(ZPushConnection connection)
|
||||
{
|
||||
if (!CheckStoreSize || _checkedStoreSize)
|
||||
return;
|
||||
|
||||
// Only works on 2.5
|
||||
// If we don't have the version yet, try again later
|
||||
if (connection.Account.ZPushVersion == null)
|
||||
return;
|
||||
|
||||
// Only check once
|
||||
_checkedStoreSize = true;
|
||||
|
||||
// If it's not 2.5, don't check again
|
||||
if (!connection.Account.ZPushVersion.IsAtLeast(2, 5))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
Logger.Instance.Debug(this, "Fetching size information for account {0}", connection.Account);
|
||||
using (ZPushWebServiceInfo infoService = connection.InfoService)
|
||||
{
|
||||
UserStoreInfo info = infoService.Execute(new GetUserStoreInfoRequest());
|
||||
Logger.Instance.Debug(this, "Size information: {0}", info);
|
||||
SyncTimeFrame suggested = SuggestSyncTimeFrame(info);
|
||||
|
||||
if (suggested.IsShorterThan(connection.Account.SyncTimeFrame))
|
||||
{
|
||||
// Suggest shorter time frame, if not already done
|
||||
string lastCheckedSize = connection.Account.Account[KEY_CHECKED_SIZE];
|
||||
if (!string.IsNullOrWhiteSpace(lastCheckedSize))
|
||||
{
|
||||
try
|
||||
{
|
||||
SyncTimeFrame old = (SyncTimeFrame)int.Parse(lastCheckedSize);
|
||||
if (old >= suggested)
|
||||
{
|
||||
Logger.Instance.Trace(this, "Not suggesting reduced sync time frame again: {0}: {2} -> {1}", info, suggested, old);
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
Logger.Instance.Warning(this, "Invalid lastCheckedSize: {0}: {1}", lastCheckedSize, e);
|
||||
}
|
||||
}
|
||||
|
||||
Logger.Instance.Debug(this, "Suggesting reduced sync time frame: {0}: {2} -> {1}", info, suggested, connection.Account.SyncTimeFrame);
|
||||
|
||||
// Suggest a shorter timeframe
|
||||
DialogResult result = MessageBox.Show(ThisAddIn.Instance.Window,
|
||||
string.Format(Properties.Resources.SyncState_StoreSize_Body,
|
||||
info.storesize.ToSizeString(SizeUtil.Size.MB),
|
||||
suggested.ToDisplayString()),
|
||||
Properties.Resources.SyncState_StoreSize_Caption,
|
||||
MessageBoxButtons.OKCancel, MessageBoxIcon.Information);
|
||||
|
||||
if (result == DialogResult.OK)
|
||||
{
|
||||
// Set the sync time frame
|
||||
Logger.Instance.Debug(this, "Applying reduced sync time frame: {0}: {2} -> {1}", info, suggested, connection.Account.SyncTimeFrame);
|
||||
connection.Account.SyncTimeFrame = suggested;
|
||||
}
|
||||
}
|
||||
|
||||
connection.Account.Account[KEY_CHECKED_SIZE] = ((int)suggested).ToString();
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
Logger.Instance.Warning(this, "Error suggesting size: {0}", e);
|
||||
}
|
||||
}
|
||||
|
||||
private struct SuggestedSyncTimeFrame
|
||||
{
|
||||
public long storeSize;
|
||||
public SyncTimeFrame timeFrame;
|
||||
|
||||
public SuggestedSyncTimeFrame(long storeSize, SyncTimeFrame timeFrame) : this()
|
||||
{
|
||||
this.storeSize = storeSize;
|
||||
this.timeFrame = timeFrame;
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly SuggestedSyncTimeFrame[] SUGGESTED_SYNC_TIME_FRAMES =
|
||||
{
|
||||
new SuggestedSyncTimeFrame(10.WithSize(SizeUtil.Size.GB), SyncTimeFrame.MONTH_1),
|
||||
new SuggestedSyncTimeFrame(5.WithSize(SizeUtil.Size.GB), SyncTimeFrame.MONTH_3),
|
||||
new SuggestedSyncTimeFrame(2.WithSize(SizeUtil.Size.GB), SyncTimeFrame.MONTH_6)
|
||||
};
|
||||
|
||||
private SyncTimeFrame SuggestSyncTimeFrame(UserStoreInfo info)
|
||||
{
|
||||
foreach(SuggestedSyncTimeFrame suggested in SUGGESTED_SYNC_TIME_FRAMES)
|
||||
{
|
||||
if (info.storesize >= suggested.storeSize)
|
||||
return suggested.timeFrame;
|
||||
}
|
||||
return SyncTimeFrame.ALL;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -1346,6 +1346,99 @@ namespace Acacia.Properties {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to KOE has detected that the current store size exceeds to recommended value for synced data and will therefore reduce the time synced.
|
||||
///
|
||||
///Current store size: {0}
|
||||
///New sync window: {1}.
|
||||
/// </summary>
|
||||
internal static string SyncState_StoreSize_Body {
|
||||
get {
|
||||
return ResourceManager.GetString("SyncState_StoreSize_Body", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Store size.
|
||||
/// </summary>
|
||||
internal static string SyncState_StoreSize_Caption {
|
||||
get {
|
||||
return ResourceManager.GetString("SyncState_StoreSize_Caption", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to All.
|
||||
/// </summary>
|
||||
internal static string SyncTimeFrame_ALL {
|
||||
get {
|
||||
return ResourceManager.GetString("SyncTimeFrame_ALL", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to 1 day.
|
||||
/// </summary>
|
||||
internal static string SyncTimeFrame_DAY_1 {
|
||||
get {
|
||||
return ResourceManager.GetString("SyncTimeFrame_DAY_1", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to 3 days.
|
||||
/// </summary>
|
||||
internal static string SyncTimeFrame_DAY_3 {
|
||||
get {
|
||||
return ResourceManager.GetString("SyncTimeFrame_DAY_3", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to 1 month.
|
||||
/// </summary>
|
||||
internal static string SyncTimeFrame_MONTH_1 {
|
||||
get {
|
||||
return ResourceManager.GetString("SyncTimeFrame_MONTH_1", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to 3 months.
|
||||
/// </summary>
|
||||
internal static string SyncTimeFrame_MONTH_3 {
|
||||
get {
|
||||
return ResourceManager.GetString("SyncTimeFrame_MONTH_3", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to 6 months.
|
||||
/// </summary>
|
||||
internal static string SyncTimeFrame_MONTH_6 {
|
||||
get {
|
||||
return ResourceManager.GetString("SyncTimeFrame_MONTH_6", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to 1 week.
|
||||
/// </summary>
|
||||
internal static string SyncTimeFrame_WEEK_1 {
|
||||
get {
|
||||
return ResourceManager.GetString("SyncTimeFrame_WEEK_1", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to 2 weeks.
|
||||
/// </summary>
|
||||
internal static string SyncTimeFrame_WEEK_2 {
|
||||
get {
|
||||
return ResourceManager.GetString("SyncTimeFrame_WEEK_2", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Kopano.
|
||||
/// </summary>
|
||||
|
@ -551,4 +551,37 @@ Please contact your system administrator for any required changes.</value>
|
||||
<data name="SharedFolders_TooManyFolders_Title" xml:space="preserve">
|
||||
<value>Shared Folders</value>
|
||||
</data>
|
||||
<data name="SyncState_StoreSize_Body" xml:space="preserve">
|
||||
<value>KOE has detected that the current store size exceeds to recommended value for synced data and will therefore reduce the time synced.
|
||||
|
||||
Current store size: {0}
|
||||
New sync window: {1}</value>
|
||||
</data>
|
||||
<data name="SyncState_StoreSize_Caption" xml:space="preserve">
|
||||
<value>Store size</value>
|
||||
</data>
|
||||
<data name="SyncTimeFrame_ALL" xml:space="preserve">
|
||||
<value>All</value>
|
||||
</data>
|
||||
<data name="SyncTimeFrame_DAY_1" xml:space="preserve">
|
||||
<value>1 day</value>
|
||||
</data>
|
||||
<data name="SyncTimeFrame_DAY_3" xml:space="preserve">
|
||||
<value>3 days</value>
|
||||
</data>
|
||||
<data name="SyncTimeFrame_MONTH_1" xml:space="preserve">
|
||||
<value>1 month</value>
|
||||
</data>
|
||||
<data name="SyncTimeFrame_MONTH_3" xml:space="preserve">
|
||||
<value>3 months</value>
|
||||
</data>
|
||||
<data name="SyncTimeFrame_MONTH_6" xml:space="preserve">
|
||||
<value>6 months</value>
|
||||
</data>
|
||||
<data name="SyncTimeFrame_WEEK_1" xml:space="preserve">
|
||||
<value>1 week</value>
|
||||
</data>
|
||||
<data name="SyncTimeFrame_WEEK_2" xml:space="preserve">
|
||||
<value>2 weeks</value>
|
||||
</data>
|
||||
</root>
|
50
src/AcaciaZPushPlugin/AcaciaZPushPlugin/Utils/SizeUtil.cs
Normal file
50
src/AcaciaZPushPlugin/AcaciaZPushPlugin/Utils/SizeUtil.cs
Normal file
@ -0,0 +1,50 @@
|
||||
/// Copyright 2018 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.Threading.Tasks;
|
||||
|
||||
namespace Acacia.Utils
|
||||
{
|
||||
public static class SizeUtil
|
||||
{
|
||||
public enum Size
|
||||
{
|
||||
KB = 1024,
|
||||
MB = 1024 * 1024,
|
||||
GB = 1024 * 1024 * 1024
|
||||
}
|
||||
|
||||
public static long WithSize(this long size, Size unit)
|
||||
{
|
||||
return size * (int)unit;
|
||||
}
|
||||
|
||||
public static long WithSize(this int size, Size unit)
|
||||
{
|
||||
return WithSize((long)size, unit);
|
||||
}
|
||||
|
||||
public static string ToSizeString(this long size, Size unit)
|
||||
{
|
||||
double value = (double)size / (int)unit;
|
||||
return string.Format("{0:0.00}{1}", value, unit.ToString());
|
||||
}
|
||||
}
|
||||
}
|
@ -201,6 +201,37 @@ namespace Acacia.ZPush.Connect.Soap
|
||||
public TypeHandlerLong() : base(SoapConstants.XMLNS_XSD, "int", typeof(long)) { }
|
||||
}
|
||||
|
||||
|
||||
abstract private class TypeHandlerReal : TypeHandler
|
||||
{
|
||||
public TypeHandlerReal(string xmlns, string name, Type baseType) : base(xmlns, name, baseType)
|
||||
{
|
||||
}
|
||||
|
||||
public override void Serialize(string name, object value, StringBuilder s)
|
||||
{
|
||||
s.Append(string.Format("<{0} xsi:type=\"xsd:{2}\">{1}</{0}>", name, value, HandlesType == typeof(float) ? "float" : "double"));
|
||||
}
|
||||
|
||||
protected override object DeserializeContents(XmlNode node, Type expectedType)
|
||||
{
|
||||
double value = double.Parse(node.InnerText);
|
||||
if (expectedType == typeof(float))
|
||||
return (float)value;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
private class TypeHandlerFloat : TypeHandlerReal
|
||||
{
|
||||
public TypeHandlerFloat() : base(SoapConstants.XMLNS_XSD, "float", typeof(float)) { }
|
||||
}
|
||||
|
||||
private class TypeHandlerDouble : TypeHandlerReal
|
||||
{
|
||||
public TypeHandlerDouble() : base(SoapConstants.XMLNS_XSD, "double", typeof(double)) { }
|
||||
}
|
||||
|
||||
private class TypeHandlerString : TypeHandler
|
||||
{
|
||||
public TypeHandlerString() : base(SoapConstants.XMLNS_XSD, "string", typeof(string)) { }
|
||||
@ -486,6 +517,8 @@ namespace Acacia.ZPush.Connect.Soap
|
||||
// What is called an int in soap might actually be a long here.
|
||||
RegisterTypeHandler(new TypeHandlerLong());
|
||||
RegisterTypeHandler(new TypeHandlerInt());
|
||||
RegisterTypeHandler(new TypeHandlerFloat());
|
||||
RegisterTypeHandler(new TypeHandlerDouble());
|
||||
RegisterTypeHandler(new TypeHandlerString());
|
||||
RegisterTypeHandler(new TypeHandlerList());
|
||||
RegisterTypeHandler(new TypeHandlerStruct());
|
||||
|
@ -348,7 +348,7 @@ namespace Acacia.ZPush
|
||||
|
||||
SyncTimeFrame frame = (SyncTimeFrame)val;
|
||||
// If the timeframe exceeds one month, but Outlook is set to one month, only one month will be synced.
|
||||
if (!IsSyncOneMonthOrLess(frame) && EASSyncOneMonth)
|
||||
if (!frame.IsOneMonthOrLess() && EASSyncOneMonth)
|
||||
return SyncTimeFrame.MONTH_1;
|
||||
return frame;
|
||||
}
|
||||
@ -358,7 +358,7 @@ namespace Acacia.ZPush
|
||||
if (value != SyncTimeFrame)
|
||||
{
|
||||
// Set the outlook property
|
||||
EASSyncOneMonth = IsSyncOneMonthOrLess(value);
|
||||
EASSyncOneMonth = value.IsOneMonthOrLess();
|
||||
// And the registry value
|
||||
RegistryUtil.SetValueDword(Account.RegistryBaseKey, OutlookConstants.REG_VAL_SYNC_TIMEFRAME, (int)value);
|
||||
}
|
||||
@ -381,11 +381,6 @@ namespace Acacia.ZPush
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsSyncOneMonthOrLess(SyncTimeFrame sync)
|
||||
{
|
||||
return sync <= SyncTimeFrame.MONTH_1 && sync != SyncTimeFrame.ALL;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Send as
|
||||
|
@ -163,4 +163,30 @@ namespace Acacia.ZPush
|
||||
MONTH_3,
|
||||
MONTH_6
|
||||
}
|
||||
|
||||
public static class SyncTimeFrameMethods
|
||||
{
|
||||
public static bool IsOneMonthOrLess(this SyncTimeFrame sync)
|
||||
{
|
||||
return sync <= SyncTimeFrame.MONTH_1 && sync != SyncTimeFrame.ALL;
|
||||
}
|
||||
|
||||
public static bool IsShorterThan(this SyncTimeFrame _this, SyncTimeFrame other)
|
||||
{
|
||||
if (_this == SyncTimeFrame.ALL)
|
||||
return false; // ALL can not be shorter than anything
|
||||
if (other == SyncTimeFrame.ALL)
|
||||
return true; // Always true, if this was ALL, already returned above, so this must be shorter
|
||||
|
||||
return (int)_this < (int)other;
|
||||
}
|
||||
|
||||
public static string ToDisplayString(this SyncTimeFrame _this)
|
||||
{
|
||||
string s = Properties.Resources.ResourceManager.GetString("SyncTimeFrame_" + _this.ToString());
|
||||
if (s == null)
|
||||
return _this.ToString();
|
||||
return s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,7 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Acacia.ZPush
|
||||
@ -42,17 +43,20 @@ namespace Acacia.ZPush
|
||||
if (string.IsNullOrWhiteSpace(version))
|
||||
return null;
|
||||
|
||||
string[] parts = version.Split('.');
|
||||
try
|
||||
{
|
||||
int major = int.Parse(parts[0]);
|
||||
int minor = int.Parse(parts[1]);
|
||||
return new ZPushVersion(major, minor, version);
|
||||
Match match = new Regex(@"(\d+)[.](\d+)[.](\d+)[.]").Match(version);
|
||||
if (match.Success)
|
||||
{
|
||||
int major = int.Parse(match.Groups[1].Value);
|
||||
int minor = int.Parse(match.Groups[2].Value);
|
||||
return new ZPushVersion(major, minor, version);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public bool IsAtLeast(int major, int minor)
|
||||
|
Loading…
Reference in New Issue
Block a user