mirror of
https://github.com/moparisthebest/keepass2android
synced 2024-11-25 10:42:17 -05:00
Added tests and functionality to ensure that caching and syncing works when the remote file is removed.
Added UI strings for sync and cache functionality
This commit is contained in:
parent
8693dfe9f4
commit
289e10e1c4
@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
|
||||
using Android.App;
|
||||
@ -57,7 +58,19 @@ namespace keepass2android.Io
|
||||
|
||||
public Stream OpenFileForRead(IOConnectionInfo ioc)
|
||||
{
|
||||
return IOConnection.OpenRead(ioc);
|
||||
try
|
||||
{
|
||||
return IOConnection.OpenRead(ioc);
|
||||
}
|
||||
catch (WebException ex)
|
||||
{
|
||||
if ((ex.Response is HttpWebResponse) && (((HttpWebResponse)ex.Response).StatusCode == HttpStatusCode.NotFound))
|
||||
{
|
||||
throw new FileNotFoundException("404!", ioc.Path, ex);
|
||||
}
|
||||
throw;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public IWriteTransaction OpenWriteTransaction(IOConnectionInfo ioc, bool useFileTransaction)
|
||||
|
@ -39,6 +39,7 @@ namespace keepass2android
|
||||
DownloadingRemoteFile,
|
||||
UploadingFile,
|
||||
FilesInSync,
|
||||
SynchronizedDatabaseSuccessfully
|
||||
SynchronizedDatabaseSuccessfully,
|
||||
RestoringRemoteFile
|
||||
}
|
||||
}
|
@ -13,6 +13,7 @@ namespace keepass2android
|
||||
{
|
||||
private readonly Context _context;
|
||||
private readonly IKp2aApp _app;
|
||||
private SaveDb _saveDb;
|
||||
|
||||
public SynchronizeCachedDatabase(Context context, IKp2aApp app, OnFinish finish)
|
||||
: base(finish)
|
||||
@ -37,10 +38,19 @@ namespace keepass2android
|
||||
//download file from remote location and calculate hash:
|
||||
StatusLogger.UpdateSubMessage(_app.GetResourceString(UiStringKey.DownloadingRemoteFile));
|
||||
string hash;
|
||||
//todo: catch filenotfound and upload then
|
||||
MemoryStream remoteData = cachingFileStorage.GetRemoteDataAndHash(ioc, out hash);
|
||||
|
||||
//todo: what happens if something fails here?
|
||||
|
||||
MemoryStream remoteData;
|
||||
try
|
||||
{
|
||||
remoteData = cachingFileStorage.GetRemoteDataAndHash(ioc, out hash);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
StatusLogger.UpdateSubMessage(_app.GetResourceString(UiStringKey.RestoringRemoteFile));
|
||||
cachingFileStorage.UpdateRemoteFile(ioc, _app.GetBooleanPreference(PreferenceKey.UseFileTransactions));
|
||||
Finish(true, _app.GetResourceString(UiStringKey.SynchronizedDatabaseSuccessfully));
|
||||
return;
|
||||
}
|
||||
|
||||
//check if remote file was modified:
|
||||
if (cachingFileStorage.GetBaseVersionHash(ioc) != hash)
|
||||
@ -49,7 +59,7 @@ namespace keepass2android
|
||||
if (cachingFileStorage.HasLocalChanges(ioc))
|
||||
{
|
||||
//conflict! need to merge
|
||||
SaveDb saveDb = new SaveDb(_context, _app, new ActionOnFinish((success, result) =>
|
||||
_saveDb = new SaveDb(_context, _app, new ActionOnFinish((success, result) =>
|
||||
{
|
||||
if (!success)
|
||||
{
|
||||
@ -59,8 +69,9 @@ namespace keepass2android
|
||||
{
|
||||
Finish(true, _app.GetResourceString(UiStringKey.SynchronizedDatabaseSuccessfully));
|
||||
}
|
||||
_saveDb = null;
|
||||
}), false, remoteData);
|
||||
saveDb.Run();
|
||||
_saveDb.Run();
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -94,5 +105,11 @@ namespace keepass2android
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public void JoinWorkerThread()
|
||||
{
|
||||
if (_saveDb != null)
|
||||
_saveDb.JoinWorkerThread();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -20,6 +20,8 @@ namespace Kp2aUnitTests
|
||||
// Run all tests from this assembly
|
||||
runner.AddTests(Assembly.GetExecutingAssembly());
|
||||
//runner.AddTests(new List<Type> { typeof(TestSynchronizeCachedDatabase) });
|
||||
//runner.AddTests(new List<Type> { typeof(TestLoadDb) });}}
|
||||
//runner.AddTests(new List<Type> { typeof(TestCachingFileStorage) });
|
||||
//runner.AddTests(typeof(TestCachingFileStorage).GetMethod("TestSaveToRemote"));
|
||||
//runner.AddTests(typeof(TestLoadDb).GetMethod("TestLoadKdbpWithPasswordOnly"));
|
||||
//runner.AddTests(typeof(TestSaveDb).GetMethod("TestLoadKdbxAndSaveKdbp_TestIdenticalFiles"));
|
||||
|
@ -168,7 +168,32 @@ namespace Kp2aUnitTests
|
||||
Assert.IsFalse(_testCacheSupervisor.CouldntOpenFromRemoteCalled);
|
||||
Assert.IsFalse(_testCacheSupervisor.CouldntSaveToRemoteCalled);
|
||||
|
||||
Assert.AreEqual(newContent, File.ReadAllText(CachingTestFile));
|
||||
Assert.AreEqual(newContent, File.ReadAllText(CachingTestFile));
|
||||
}
|
||||
|
||||
|
||||
[TestMethod]
|
||||
public void TestLoadFromRemoteWhenRemoteDeleted()
|
||||
{
|
||||
SetupFileStorage();
|
||||
|
||||
//read the file once. Should now be in the cache.
|
||||
ReadToMemoryStream(_fileStorage, CachingTestFile);
|
||||
|
||||
//delete remote file:
|
||||
_testFileStorage.DeleteFile(IocForCacheFile);
|
||||
|
||||
//read again. shouldn't throw and give the same result:
|
||||
var memStream = ReadToMemoryStream(_fileStorage, CachingTestFile);
|
||||
|
||||
//check if we received the correct content:
|
||||
Assert.AreEqual(_defaultCacheFileContents, MemoryStreamToString(memStream));
|
||||
|
||||
Assert.IsTrue(_testCacheSupervisor.CouldntOpenFromRemoteCalled);
|
||||
Assert.IsFalse(_testCacheSupervisor.CouldntSaveToRemoteCalled);
|
||||
Assert.IsFalse(_testCacheSupervisor.RestoredRemoteCalled);
|
||||
|
||||
|
||||
}
|
||||
|
||||
private void WriteContentToCacheFile(string newContent)
|
||||
|
@ -1,10 +1,13 @@
|
||||
using System.Linq;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Android.App;
|
||||
using Android.OS;
|
||||
using KeePassLib.Serialization;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using keepass2android;
|
||||
using keepass2android.Io;
|
||||
|
||||
namespace Kp2aUnitTests
|
||||
{
|
||||
@ -91,6 +94,135 @@ namespace Kp2aUnitTests
|
||||
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void LoadFromRemote1and1()
|
||||
{
|
||||
var ioc = RemoteIoc1and1; //note: this property is defined in "TestLoadDbCredentials.cs" which is deliberately excluded from Git because the credentials are not public!
|
||||
IKp2aApp app = new TestKp2aApp();
|
||||
app.CreateNewDatabase();
|
||||
|
||||
bool loadSuccesful = false;
|
||||
LoadDb task = new LoadDb(app, ioc, "test", null, new ActionOnFinish((success, message) =>
|
||||
{
|
||||
if (!success)
|
||||
Android.Util.Log.Debug("KP2ATest", "error loading db: " + message);
|
||||
loadSuccesful = success;
|
||||
})
|
||||
);
|
||||
ProgressTask pt = new ProgressTask(app, Application.Context, task);
|
||||
Android.Util.Log.Debug("KP2ATest", "Running ProgressTask");
|
||||
pt.Run();
|
||||
pt.JoinWorkerThread();
|
||||
Android.Util.Log.Debug("KP2ATest", "PT.run finished");
|
||||
Assert.IsTrue(loadSuccesful, "didn't succesfully load database :-(");
|
||||
|
||||
}
|
||||
|
||||
|
||||
[TestMethod]
|
||||
public void LoadFromRemote1and1NonExisting()
|
||||
{
|
||||
var ioc = RemoteIoc1and1NonExisting; //note: this property is defined in "TestLoadDbCredentials.cs" which is deliberately excluded from Git because the credentials are not public!
|
||||
IKp2aApp app = new TestKp2aApp();
|
||||
app.CreateNewDatabase();
|
||||
|
||||
bool loadSuccesful = false;
|
||||
bool gotError = false;
|
||||
LoadDb task = new LoadDb(app, ioc, "test", null, new ActionOnFinish((success, message) =>
|
||||
{
|
||||
if (!success)
|
||||
{
|
||||
Android.Util.Log.Debug("KP2ATest", "error loading db: " + message);
|
||||
gotError = true;
|
||||
}
|
||||
loadSuccesful = success;
|
||||
})
|
||||
);
|
||||
ProgressTask pt = new ProgressTask(app, Application.Context, task);
|
||||
Android.Util.Log.Debug("KP2ATest", "Running ProgressTask");
|
||||
pt.Run();
|
||||
pt.JoinWorkerThread();
|
||||
Android.Util.Log.Debug("KP2ATest", "PT.run finished");
|
||||
Assert.IsFalse(loadSuccesful);
|
||||
Assert.IsTrue(gotError);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void LoadFromRemote1and1WrongCredentials()
|
||||
{
|
||||
var ioc = RemoteIoc1and1WrongCredentials; //note: this property is defined in "TestLoadDbCredentials.cs" which is deliberately excluded from Git because the credentials are not public!
|
||||
IKp2aApp app = new TestKp2aApp();
|
||||
app.CreateNewDatabase();
|
||||
|
||||
bool loadSuccesful = false;
|
||||
bool gotError = false;
|
||||
LoadDb task = new LoadDb(app, ioc, "test", null, new ActionOnFinish((success, message) =>
|
||||
{
|
||||
if (!success)
|
||||
{
|
||||
Android.Util.Log.Debug("KP2ATest", "error loading db: " + message);
|
||||
gotError = true;
|
||||
}
|
||||
loadSuccesful = success;
|
||||
})
|
||||
);
|
||||
ProgressTask pt = new ProgressTask(app, Application.Context, task);
|
||||
Android.Util.Log.Debug("KP2ATest", "Running ProgressTask");
|
||||
pt.Run();
|
||||
pt.JoinWorkerThread();
|
||||
Android.Util.Log.Debug("KP2ATest", "PT.run finished");
|
||||
Assert.IsFalse(loadSuccesful);
|
||||
Assert.IsTrue(gotError);
|
||||
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FileNotFoundExceptionWithWebDav()
|
||||
{
|
||||
var fileStorage = new BuiltInFileStorage();
|
||||
|
||||
//should work:
|
||||
using (var stream = fileStorage.OpenFileForRead(RemoteIoc1and1))
|
||||
{
|
||||
stream.CopyTo(new MemoryStream());
|
||||
}
|
||||
|
||||
//shouldn't give FileNotFound:
|
||||
bool gotException = false;
|
||||
try
|
||||
{
|
||||
using (var stream = fileStorage.OpenFileForRead(RemoteIoc1and1WrongCredentials))
|
||||
{
|
||||
stream.CopyTo(new MemoryStream());
|
||||
}
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
Assert.Fail("shouldn't get FileNotFound with wrong credentials");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Kp2aLog.Log("received "+e);
|
||||
gotException = true;
|
||||
}
|
||||
Assert.IsTrue(gotException);
|
||||
//should give FileNotFound:
|
||||
gotException = false;
|
||||
try
|
||||
{
|
||||
using (var stream = fileStorage.OpenFileForRead(RemoteIoc1and1NonExisting))
|
||||
{
|
||||
stream.CopyTo(new MemoryStream());
|
||||
}
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
gotException = true;
|
||||
}
|
||||
Assert.IsTrue(gotException);
|
||||
}
|
||||
|
||||
|
||||
[TestMethod]
|
||||
public void TestLoadKdbpWithPasswordOnly()
|
||||
{
|
||||
|
@ -22,15 +22,6 @@ namespace Kp2aUnitTests
|
||||
private TestCacheSupervisor _testCacheSupervisor = new TestCacheSupervisor();
|
||||
private TestFileStorage _testFileStorage = new TestFileStorage();
|
||||
|
||||
[TestMethod]
|
||||
public void TestTodos()
|
||||
{
|
||||
Assert.IsFalse(true, "Wird immer ManagedTransform benutzt??");
|
||||
Assert.IsFalse(true, "TODOs in SyncDb");
|
||||
Assert.IsFalse(true, "FileNotFound");
|
||||
Assert.IsFalse(true, "Test merge files");
|
||||
}
|
||||
|
||||
protected override TestKp2aApp CreateTestKp2aApp()
|
||||
{
|
||||
TestKp2aApp app = base.CreateTestKp2aApp();
|
||||
@ -100,6 +91,87 @@ namespace Kp2aUnitTests
|
||||
AssertDatabasesAreEqual(app.GetDb().KpDatabase, appRemoteLoaded.GetDb().KpDatabase);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TestSyncWhenRemoteDeleted()
|
||||
{
|
||||
//create the default database:
|
||||
TestKp2aApp app = SetupAppWithDefaultDatabase();
|
||||
|
||||
IOConnection.DeleteFile(new IOConnectionInfo {Path = DefaultFilename});
|
||||
//save it and reload it so we have a base version ("remote" and in the cache)
|
||||
SaveDatabase(app);
|
||||
app = LoadDatabase(DefaultFilename, DefaultPassword, DefaultKeyfile);
|
||||
|
||||
//delete remote:
|
||||
IOConnection.DeleteFile(new IOConnectionInfo { Path = DefaultFilename });
|
||||
|
||||
string resultMessage;
|
||||
bool wasSuccessful;
|
||||
|
||||
//sync:
|
||||
Synchronize(app, out wasSuccessful, out resultMessage);
|
||||
Assert.IsTrue(wasSuccessful);
|
||||
Assert.AreEqual(resultMessage, app.GetResourceString(UiStringKey.SynchronizedDatabaseSuccessfully));
|
||||
|
||||
//ensure the file is back here:
|
||||
var app2 = LoadDatabase(DefaultFilename, DefaultPassword, DefaultKeyfile);
|
||||
AssertDatabasesAreEqual(app.GetDb().KpDatabase, app2.GetDb().KpDatabase);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TestSyncWhenConflict()
|
||||
{
|
||||
//create the default database:
|
||||
TestKp2aApp app = SetupAppWithDefaultDatabase();
|
||||
|
||||
IOConnection.DeleteFile(new IOConnectionInfo {Path = DefaultFilename});
|
||||
//save it and reload it so we have a base version ("remote" and in the cache)
|
||||
SaveDatabase(app);
|
||||
app = LoadDatabase(DefaultFilename, DefaultPassword, DefaultKeyfile);
|
||||
var app2 = LoadDatabase(DefaultFilename, DefaultPassword, DefaultKeyfile);
|
||||
app2.FileStorage = _testFileStorage; //give app2 direct access to the remote file
|
||||
|
||||
//go offline:
|
||||
_testFileStorage.Offline = true;
|
||||
|
||||
|
||||
string resultMessage;
|
||||
bool wasSuccessful;
|
||||
|
||||
//modify the database by adding a group in both apps:
|
||||
PwGroup newGroup1 = new PwGroup(true, true, "TestGroup", PwIcon.Apple);
|
||||
app.GetDb().KpDatabase.RootGroup.AddGroup(newGroup1, true);
|
||||
PwGroup newGroup2 = new PwGroup(true, true, "TestGroupApp2", PwIcon.Apple);
|
||||
app2.GetDb().KpDatabase.RootGroup.AddGroup(newGroup2, true);
|
||||
//save the database again (will be saved locally only for "app")
|
||||
SaveDatabase(app);
|
||||
Assert.IsTrue(_testCacheSupervisor.CouldntSaveToRemoteCalled);
|
||||
_testCacheSupervisor.CouldntSaveToRemoteCalled = false;
|
||||
|
||||
//go online again:
|
||||
_testFileStorage.Offline = false;
|
||||
|
||||
//...and remote only for "app2":
|
||||
SaveDatabase(app2);
|
||||
|
||||
//try to sync:
|
||||
Synchronize(app, out wasSuccessful, out resultMessage);
|
||||
|
||||
Assert.IsTrue(wasSuccessful);
|
||||
Assert.AreEqual(UiStringKey.SynchronizedDatabaseSuccessfully.ToString(), resultMessage);
|
||||
|
||||
//build app2 with the newGroup1:
|
||||
app2.GetDb().KpDatabase.RootGroup.AddGroup(newGroup1, true);
|
||||
|
||||
var app3 = LoadDatabase(DefaultFilename, DefaultPassword, DefaultKeyfile);
|
||||
|
||||
AssertDatabasesAreEqual(app.GetDb().KpDatabase, app2.GetDb().KpDatabase);
|
||||
AssertDatabasesAreEqual(app.GetDb().KpDatabase, app3.GetDb().KpDatabase);
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
private void Synchronize(TestKp2aApp app, out bool wasSuccessful, out string resultMessage)
|
||||
{
|
||||
bool success = false;
|
||||
@ -110,6 +182,7 @@ namespace Kp2aUnitTests
|
||||
result = _result;
|
||||
}));
|
||||
sync.Run();
|
||||
sync.JoinWorkerThread();
|
||||
wasSuccessful = success;
|
||||
resultMessage = result;
|
||||
}
|
||||
|
58
src/keepass2android/Resources/Resource.designer.cs
generated
58
src/keepass2android/Resources/Resource.designer.cs
generated
@ -1302,32 +1302,32 @@ namespace keepass2android
|
||||
// aapt resource value: 0x7f0800fa
|
||||
public const int BinaryDirectory_title = 2131230970;
|
||||
|
||||
// aapt resource value: 0x7f080143
|
||||
public const int ChangeLog = 2131231043;
|
||||
|
||||
// aapt resource value: 0x7f080142
|
||||
public const int ChangeLog_0_7 = 2131231042;
|
||||
|
||||
// aapt resource value: 0x7f080140
|
||||
public const int ChangeLog_0_8 = 2131231040;
|
||||
|
||||
// aapt resource value: 0x7f08013f
|
||||
public const int ChangeLog_0_8_1 = 2131231039;
|
||||
|
||||
// aapt resource value: 0x7f08013e
|
||||
public const int ChangeLog_0_8_2 = 2131231038;
|
||||
|
||||
// aapt resource value: 0x7f08013d
|
||||
public const int ChangeLog = 2131231037;
|
||||
public const int ChangeLog_0_8_3 = 2131231037;
|
||||
|
||||
// aapt resource value: 0x7f08013c
|
||||
public const int ChangeLog_0_7 = 2131231036;
|
||||
public const int ChangeLog_0_8_4 = 2131231036;
|
||||
|
||||
// aapt resource value: 0x7f08013a
|
||||
public const int ChangeLog_0_8 = 2131231034;
|
||||
|
||||
// aapt resource value: 0x7f080139
|
||||
public const int ChangeLog_0_8_1 = 2131231033;
|
||||
|
||||
// aapt resource value: 0x7f080138
|
||||
public const int ChangeLog_0_8_2 = 2131231032;
|
||||
|
||||
// aapt resource value: 0x7f080137
|
||||
public const int ChangeLog_0_8_3 = 2131231031;
|
||||
|
||||
// aapt resource value: 0x7f080136
|
||||
public const int ChangeLog_0_8_4 = 2131231030;
|
||||
// aapt resource value: 0x7f080141
|
||||
public const int ChangeLog_keptDonate = 2131231041;
|
||||
|
||||
// aapt resource value: 0x7f08013b
|
||||
public const int ChangeLog_keptDonate = 2131231035;
|
||||
|
||||
// aapt resource value: 0x7f080135
|
||||
public const int ChangeLog_title = 2131231029;
|
||||
public const int ChangeLog_title = 2131231035;
|
||||
|
||||
// aapt resource value: 0x7f08002a
|
||||
public const int CheckForFileChangesOnSave_key = 2131230762;
|
||||
@ -1359,9 +1359,15 @@ namespace keepass2android
|
||||
// aapt resource value: 0x7f080129
|
||||
public const int DeletingGroup = 2131231017;
|
||||
|
||||
// aapt resource value: 0x7f080136
|
||||
public const int DownloadingRemoteFile = 2131231030;
|
||||
|
||||
// aapt resource value: 0x7f080083
|
||||
public const int FileNotFound = 2131230851;
|
||||
|
||||
// aapt resource value: 0x7f080139
|
||||
public const int FilesInSync = 2131231033;
|
||||
|
||||
// aapt resource value: 0x7f080096
|
||||
public const int InvalidPassword = 2131230870;
|
||||
|
||||
@ -1437,6 +1443,9 @@ namespace keepass2android
|
||||
// aapt resource value: 0x7f0800e5
|
||||
public const int RememberRecentFiles_title = 2131230949;
|
||||
|
||||
// aapt resource value: 0x7f080138
|
||||
public const int RestoringRemoteFile = 2131231032;
|
||||
|
||||
// aapt resource value: 0x7f0800ff
|
||||
public const int SaveAttachmentDialog_open = 2131230975;
|
||||
|
||||
@ -1482,6 +1491,12 @@ namespace keepass2android
|
||||
// aapt resource value: 0x7f08002c
|
||||
public const int SuggestionsURL = 2131230764;
|
||||
|
||||
// aapt resource value: 0x7f08013a
|
||||
public const int SynchronizedDatabaseSuccessfully = 2131231034;
|
||||
|
||||
// aapt resource value: 0x7f080135
|
||||
public const int SynchronizingCachedDatabase = 2131231029;
|
||||
|
||||
// aapt resource value: 0x7f080132
|
||||
public const int SynchronizingDatabase = 2131231026;
|
||||
|
||||
@ -1506,6 +1521,9 @@ namespace keepass2android
|
||||
// aapt resource value: 0x7f08012b
|
||||
public const int UndoingChanges = 2131231019;
|
||||
|
||||
// aapt resource value: 0x7f080137
|
||||
public const int UploadingFile = 2131231031;
|
||||
|
||||
// aapt resource value: 0x7f080027
|
||||
public const int UsageCount_key = 2131230759;
|
||||
|
||||
|
@ -264,7 +264,14 @@
|
||||
<string name="YesSynchronize">Yes, merge</string>
|
||||
<string name="NoOverwrite">No, overwrite</string>
|
||||
|
||||
<string name="ChangeLog_title">Change log</string>
|
||||
<string name="SynchronizingCachedDatabase">Synchronizing cached database...</string>
|
||||
<string name="DownloadingRemoteFile">Downloading remote file...</string>
|
||||
<string name="UploadingFile">Uploading file...</string>
|
||||
<string name="RestoringRemoteFile">Restoring remote file...</string>
|
||||
<string name="FilesInSync">Files are in sync.</string>
|
||||
<string name="SynchronizedDatabaseSuccessfully">Database synchronized successfully!</string>
|
||||
|
||||
<string name="ChangeLog_title">Change log</string>
|
||||
<string name="ChangeLog_0_8_4">
|
||||
<b>Version 0.8.4</b>\n
|
||||
* External database changes are detected and merged when saving\n
|
||||
|
Loading…
Reference in New Issue
Block a user