Skip to content
Merged
30 changes: 27 additions & 3 deletions src/Kp2aBusinessLogic/Io/CachingFileStorage.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// This file is part of Keepass2Android, Copyright 2025 Philipp Crocoll.
// This file is part of Keepass2Android, Copyright 2025 Philipp Crocoll.
//
// Keepass2Android is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
Expand Down Expand Up @@ -178,12 +178,14 @@ private string BaseVersionFilePath(IOConnectionInfo ioc)
public Stream OpenFileForRead(IOConnectionInfo ioc)
{
string cachedFilePath = CachedFilePath(ioc);
CacheDiffersFromRemoteAfterLastRead = false;
try
{
if (!IsCached(ioc)
|| GetLocalVersionHash(ioc) == GetBaseVersionHash(ioc))
{
Kp2aLog.Log("CFS: OpenWhenNoLocalChanges");
//reads the source file and updates the cache from it, so cache and source are in sync
return OpenFileForReadWhenNoLocalChanges(ioc, cachedFilePath);
}
else
Expand All @@ -206,6 +208,8 @@ public Stream OpenFileForRead(IOConnectionInfo ioc)
_cacheSupervisor.CouldntOpenFromRemote(ioc, ex);
}

//we're serving the cache without having seen the source file, so a sync is still pending
CacheDiffersFromRemoteAfterLastRead = true;
return File.OpenRead(cachedFilePath);
}
}
Expand All @@ -227,13 +231,19 @@ private Stream OpenFileForReadWhenLocalChanges(IOConnectionInfo ioc, string cach
_cacheSupervisor.UpdatedRemoteFileOnLoad(ioc);
Kp2aLog.Log("CFS: Updated remote file");
}
else
{
//the local changes are still only in the cache, so a sync is still pending
CacheDiffersFromRemoteAfterLastRead = true;
}
return File.OpenRead(cachedFilePath);
}
}
else
{
Kp2aLog.Log("CFS: Files in conflict");
//conflict: both files changed.
//conflict: both files changed, merging them is up to a subsequent synchronization
CacheDiffersFromRemoteAfterLastRead = true;
return OpenFileForReadWithConflict(ioc, cachedFilePath);
}

Expand Down Expand Up @@ -345,7 +355,7 @@ private bool TryUpdateRemoteFile(Stream cachedData, IOConnectionInfo ioc, bool u
Kp2aLog.Log("couldn't save to remote " + GetDisplayName(ioc));
Kp2aLog.Log(e.ToString());
//notify the supervisor so it might display a warning or schedule a retry
if (TriggerWarningWhenFallingBackToCache)
if (WarnWhenSaveToRemoteFails)
{
_cacheSupervisor.CouldntSaveToRemote(ioc, e);
}
Expand Down Expand Up @@ -656,6 +666,20 @@ public bool IsOffline

public bool TriggerWarningWhenFallingBackToCache { get; set; }

//Default to true: a failed write to the source file means the user was told the database was
//saved while only the cache was updated, which they need to know about. Operations which
//deliberately postpone the upload (SaveDb/LoadDb with "sync in background") set this to false.
public bool WarnWhenSaveToRemoteFails { get; set; } = true;

/// <summary>
/// After the last OpenFileForRead call: true if the cache is not known to be identical to the
/// source file, because the source file couldn't be read, local changes couldn't be uploaded to
/// it, or both sides changed. Note that the returned stream always reads the cached file, also
/// when it was just updated from the source; this says whether the two are known to match.
/// It is up to the caller to decide what to do about it.
/// </summary>
public bool CacheDiffersFromRemoteAfterLastRead { get; private set; }

public void OnRequestPermissionsResult(IFileStorageSetupActivity fileStorageSetupActivity, int requestCode,
string[] permissions, Permission[] grantResults)
{
Expand Down
16 changes: 16 additions & 0 deletions src/Kp2aBusinessLogic/Io/JavaFileStorage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,22 @@ private Exception LogAndConvertJavaException(Exception e)

Java.Lang.Exception exception = e as Java.Lang.Exception;

//An unreachable server must not be reported as "interrupted": java.net.SocketTimeoutException
//derives from InterruptedIOException, so it used to be converted into an InterruptedException.
//Callers treat that as "the user cancelled" and stay silent - SynchronizeCachedDatabase
//deliberately doesn't call Finish() then - so a sync against an unreachable server left the
//progress dialog up without any error message.
//Also replace the message: okhttp reports a connect or read timeout either as a bare "timeout"
//or as "failed to connect to <host> ... from <local address> after 10000ms". The first says
//nothing, the second is noise which also exposes the device's local address, and the full text
//is in the log above anyway.
if ((exception is Java.Net.SocketTimeoutException)
|| (exception is Java.Net.UnknownHostException)
|| (exception is Java.Net.ConnectException))
{
return new Exception(_app.GetResourceString(UiStringKey.ServerNotReachable), e);
}

if ((exception is Java.Lang.InterruptedException) || (exception is Java.IO.InterruptedIOException))
{
throw new Java.Lang.InterruptedException(exception.Message);
Expand Down
7 changes: 7 additions & 0 deletions src/Kp2aBusinessLogic/Io/OfflineSwitchableFileStorage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ public interface IOfflineSwitchable
{
bool IsOffline { get; set; }
bool TriggerWarningWhenFallingBackToCache { get; set; }

/// <summary>
/// Whether to warn the user when the database could only be written to the cache. Default is
/// true; set to false by operations which postpone the upload on purpose.
/// </summary>
bool WarnWhenSaveToRemoteFails { get; set; }
}

/// <summary>
Expand All @@ -38,6 +44,7 @@ public class OfflineSwitchableFileStorage : IFileStorage, IOfflineSwitchable, IP
private readonly IFileStorage _baseStorage;
public bool IsOffline { get; set; }
public bool TriggerWarningWhenFallingBackToCache { get; set; }
public bool WarnWhenSaveToRemoteFails { get; set; } = true;

public OfflineSwitchableFileStorage(IFileStorage baseStorage)
{
Expand Down
10 changes: 6 additions & 4 deletions src/Kp2aBusinessLogic/Io/WebDavFileStorage.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,12 @@ public override string IocToPath(IOConnectionInfo ioc)
//legacy support. Some users may have stored IOCs with UserName inside.
return ((WebDavStorage)Jfs).BuildFullPath(ioc.Path, ioc.UserName, ioc.Password);
}
string path = base.IocToPath(ioc);
//make sure the path is normalized, e.g. spaces and umlauts are percent encoded.
string normalized = new Uri(path).AbsoluteUri;
return normalized;
//Don't normalize the path here (e.g. with new Uri(path).AbsoluteUri): that threw for stored
//paths which System.Uri cannot parse (crashing the app on startup via GetDisplayName) and it
//also rewrote the path, e.g. unescaping %7E/%2D/%5F/%2E, so we requested a different URL than
//before. Percent encoding of the request URL is done by okhttp when it parses the URL; only the
//Destination header of MOVE needs explicit encoding, see WebDavStorage.renameOrMoveWebDavResource.
return base.IocToPath(ioc);

}

Expand Down
48 changes: 39 additions & 9 deletions src/Kp2aBusinessLogic/OperationRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,27 @@ public void Run(IKp2aApp app, OperationWithFinishHandler operation, bool runBloc
TrySetupProgressDialog();
});
}
else
{
//Set up the progress ui for this task as well, don't just keep what the previous one
//used: the progress dialog of a preceding blocking task is dismissed when that task
//finishes, so a non-blocking task dequeued afterwards (e.g. the synchronization which
//SaveDb starts once the cache is written) would run without any visible progress at
//all until the active context happens to change.
app.UiThreadHandler.Post(
() =>
{
Context? context = app.ActiveContext;
if (context == null || context.ApplicationContext == context)
{
//No activity to show progress in. Keep whatever the progress ui is,
//like SetNewActiveContext does for the application context, so that
//returning to the foreground restores it instead of finding nothing.
return;
}
SetProgressUiFromActiveContext(app);
});
}

var originalFinishedHandler = _currentlyRunningTask.Value.Operation.operationFinishedHandler;
_currentlyRunningTask.Value.Operation.operationFinishedHandler = new ActionOnOperationFinished(app, (
Expand Down Expand Up @@ -153,6 +174,23 @@ public void Run(IKp2aApp app, OperationWithFinishHandler operation, bool runBloc
}


/// <summary>
/// Shows progress in the progress ui which the currently active context provides (e.g. the inline
/// view of a GroupBaseActivity), instead of in a modal progress dialog.
/// </summary>
private void SetProgressUiFromActiveContext(IKp2aApp app)
{
var progressUi = (app.ActiveContext as IProgressUiProvider)?.ProgressUi;
if (_statusLogger == null)
{
_statusLogger = new ProgressUiAsStatusLoggerAdapter(progressUi, app);
}
else
{
_statusLogger.SetNewProgressUi(progressUi);
}
}

private bool TrySetupProgressDialog()
{
string currentMessage = "Initializing...";
Expand Down Expand Up @@ -213,15 +251,7 @@ public void SetNewActiveContext(IKp2aApp app)
}
else
{
var progressUi = (context as IProgressUiProvider)?.ProgressUi;
if (_statusLogger == null)
{
_statusLogger = new ProgressUiAsStatusLoggerAdapter(progressUi, app);
}
else
{
_statusLogger.SetNewProgressUi(progressUi);
}
SetProgressUiFromActiveContext(app);
}

foreach (var task in _taskQueue.Concat(_currentlyRunningTask == null ?
Expand Down
5 changes: 5 additions & 0 deletions src/Kp2aBusinessLogic/ProgressUiAsStatusLoggerAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ public ProgressUiAsStatusLoggerAdapter(IProgressUi progressUi, IKp2aApp app)

public void SetNewProgressUi(IProgressUi progressUi)
{
if (ReferenceEquals(_progressUi, progressUi))
{
//nothing to switch to: hiding and showing the same ui again would only make it flicker
return;
}
_progressUi?.Hide();
_progressUi = progressUi;
if (_isVisible)
Expand Down
1 change: 1 addition & 0 deletions src/Kp2aBusinessLogic/UiStringKey.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ public enum UiStringKey
AskDeletePermanentlyItemsNoRecycle,
InOfflineMode,
DocumentAccessRevoked,
ServerNotReachable,
DuplicateTitle,
TemplateTitle_IdCard,
TemplateField_IdCard_Name,
Expand Down
4 changes: 3 additions & 1 deletion src/Kp2aBusinessLogic/database/SynchronizeCachedDatabase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,9 @@ public override void Run()

}
});
var _loadDb = new LoadDb(_app, ioc, Task.FromResult(remoteData),
//remoteData comes from the source file, so no further sync is required afterwards
var _loadDb = new LoadDb(_app, ioc,
Task.FromResult(new PreloadedDatabaseData { Data = remoteData, RequiresSubsequentSync = false }),
_database.KpDatabase.MasterKey, null, onFinished, true, false, _modificationWatcher);
_loadDb.SetStatusLogger(StatusLogger);
_loadDb.DoNotSetStatusLoggerMessage = true; //Keep "sync db" as main message
Expand Down
1 change: 1 addition & 0 deletions src/Kp2aBusinessLogic/database/edit/AddGroup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ public override void Run()

// Commit to disk
SaveDb save = new SaveDb(_app, _app.CurrentDb, operationFinishedHandler, DontSave, null);
save.SyncInBackground = _app.SyncInBackgroundPreference;
save.SetStatusLogger(StatusLogger);
save.Run();
}
Expand Down
1 change: 1 addition & 0 deletions src/Kp2aBusinessLogic/database/edit/DeleteRunnable.cs
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ public override void Run()
// Commit database
SaveDb save = new SaveDb(App, Db, operationFinishedHandler, false, null);
save.ShowDatabaseIocInStatus = ShowDatabaseIocInStatus;
save.SyncInBackground = App.SyncInBackgroundPreference;

save.SetStatusLogger(StatusLogger);
save.Run();
Expand Down
27 changes: 21 additions & 6 deletions src/Kp2aBusinessLogic/database/edit/LoadDB.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,22 @@ You should have received a copy of the GNU General Public License

namespace keepass2android
{
/// <summary>
/// Database contents which were loaded before the password was entered, together with the
/// information whether they actually came from the source file. If the source file could not be
/// reached, the preloaded data is cache content and a synchronization is still pending - which
/// the preloading itself cannot do because the database is not open yet.
/// </summary>
public class PreloadedDatabaseData
{
public MemoryStream Data { get; set; }
public bool RequiresSubsequentSync { get; set; }
}

public class LoadDb : OperationWithFinishHandler
{
private readonly IOConnectionInfo _ioc;
private readonly Task<MemoryStream> _databaseData;
private readonly Task<PreloadedDatabaseData> _databaseData;
private readonly CompositeKey _compositeKey;
private readonly string? _keyfileOrProvider;
private readonly IKp2aApp _app;
Expand All @@ -44,7 +56,7 @@ public class LoadDb : OperationWithFinishHandler
public bool DoNotSetStatusLoggerMessage = false;


public LoadDb(IKp2aApp app, IOConnectionInfo ioc, Task<MemoryStream> databaseData, CompositeKey compositeKey,
public LoadDb(IKp2aApp app, IOConnectionInfo ioc, Task<PreloadedDatabaseData> databaseData, CompositeKey compositeKey,
string keyfileOrProvider, OnOperationFinishedHandler operationFinishedHandler,
bool updateLastUsageTimestamp, bool makeCurrent, IDatabaseModificationWatcher modificationWatcher = null) : base(app, operationFinishedHandler)
{
Expand Down Expand Up @@ -85,12 +97,15 @@ public override void Run()
}

//get the stream data into a single stream variable (databaseStream) regardless whether its preloaded or not:
MemoryStream preloadedMemoryStream = _databaseData == null ? null : _databaseData.Result;
PreloadedDatabaseData? preloadedData = _databaseData?.Result;
MemoryStream databaseStream;
if (preloadedMemoryStream != null)
if (preloadedData?.Data != null)
{
//note: if the stream has been loaded already, we don't need to trigger another sync later on
databaseStream = preloadedMemoryStream;
//The stream has been loaded already. That doesn't mean we're in sync: if the source file
//couldn't be reached, the preloading fell back to the cache, and then we still have to
//trigger a sync later on - nothing else would, until the user modifies an entry.
RequiresSubsequentSync = preloadedData.RequiresSubsequentSync;
databaseStream = preloadedData.Data;
}
else
{
Expand Down
2 changes: 2 additions & 0 deletions src/Kp2aBusinessLogic/database/edit/MoveElements.cs
Original file line number Diff line number Diff line change
Expand Up @@ -147,13 +147,15 @@ void ContinueSave(bool success, string message, Context activeActivity)
return;
}
SaveDb saveDb = new SaveDb(_app, allDatabasesToSave[indexToSave], new ActionOnOperationFinished(_app, ContinueSave), false, null);
saveDb.SyncInBackground = _app.SyncInBackgroundPreference;
saveDb.SetStatusLogger(StatusLogger);
saveDb.ShowDatabaseIocInStatus = allDatabasesToSave.Count > 1;
saveDb.Run();
}


SaveDb save = new SaveDb(_app, allDatabasesToSave[0], new ActionOnOperationFinished(_app, ContinueSave), false, null);
save.SyncInBackground = _app.SyncInBackgroundPreference;
save.SetStatusLogger(StatusLogger);
save.ShowDatabaseIocInStatus = allDatabasesToSave.Count > 1;
save.Run();
Expand Down
1 change: 1 addition & 0 deletions src/Kp2aBusinessLogic/database/edit/SaveDB.cs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ public override void Run()
offlineSwitchable.IsOffline = true;
//no warning. We'll trigger a sync later.
offlineSwitchable.TriggerWarningWhenFallingBackToCache = false;
offlineSwitchable.WarnWhenSaveToRemoteFails = false;
requiresSubsequentSync = true;

}
Expand Down
Loading
Loading