From d9b481396bd834963c605685d0939c5b2bfcfbe0 Mon Sep 17 00:00:00 2001 From: Philipp Crocoll Date: Mon, 12 Oct 2026 15:54:19 +0200 Subject: [PATCH 01/10] use canonicalization for Destination header only --- src/Kp2aBusinessLogic/Io/WebDavFileStorage.cs | 10 ++++++---- .../javafilestorage/WebDavStorage.java | 13 +++++++++++-- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/Kp2aBusinessLogic/Io/WebDavFileStorage.cs b/src/Kp2aBusinessLogic/Io/WebDavFileStorage.cs index 6fe55ed14..e0763d53c 100644 --- a/src/Kp2aBusinessLogic/Io/WebDavFileStorage.cs +++ b/src/Kp2aBusinessLogic/Io/WebDavFileStorage.cs @@ -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); } diff --git a/src/java/JavaFileStorage/app/src/main/java/keepass2android/javafilestorage/WebDavStorage.java b/src/java/JavaFileStorage/app/src/main/java/keepass2android/javafilestorage/WebDavStorage.java index 7a605ccc4..849082f3f 100644 --- a/src/java/JavaFileStorage/app/src/main/java/keepass2android/javafilestorage/WebDavStorage.java +++ b/src/java/JavaFileStorage/app/src/main/java/keepass2android/javafilestorage/WebDavStorage.java @@ -32,6 +32,7 @@ import com.burgstaller.okhttp.digest.CachingAuthenticator; import com.burgstaller.okhttp.digest.DigestAuthenticator; +import okhttp3.HttpUrl; import okhttp3.Interceptor; import okhttp3.Response; import okhttp3.Request; @@ -54,6 +55,7 @@ import java.util.Arrays; import java.util.Date; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; @@ -167,7 +169,8 @@ public InputStream openFileForRead(String path) throws Exception { private OkHttpClient getClient(ConnectionInfo ci) throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException, IOException { - if (ci.URL.startsWith("http://") && !PreferenceManager.getDefaultSharedPreferences(appContext).getBoolean("permit_cleartext_traffic", false)) + //compare case insensitive: the scheme may be stored as e.g. "HTTP://" and must not slip through + if (ci.URL.toLowerCase(Locale.ROOT).startsWith("http://") && !PreferenceManager.getDefaultSharedPreferences(appContext).getBoolean("permit_cleartext_traffic", false)) { throw new IOException("Cleartext HTTP is disabled by user preference. Go to app settings/File handling if you really want to use HTTP."); } @@ -227,10 +230,16 @@ public void renameOrMoveWebDavResource(String sourcePath, String destinationPath ConnectionInfo sourceCi = splitStringToConnectionInfo(sourcePath); ConnectionInfo destinationCi = splitStringToConnectionInfo(destinationPath); + //The Destination header must be a valid URI and okhttp rejects header values outside + //0x20..0x7e, so a path with non-ascii characters would throw here (issue #3030). Let okhttp + //canonicalize it: that is the same encoding it applies to the request URL below, it percent + //encodes non-ascii characters and spaces, and it leaves already encoded paths untouched. + String destinationUrl = HttpUrl.get(destinationCi.URL).toString(); + Request.Builder requestBuilder = new Request.Builder() .url(new URL(sourceCi.URL)) .method("MOVE", null) // "MOVE" is the HTTP method - .header("Destination", destinationCi.URL); // New URI for the resource + .header("Destination", destinationUrl); // New URI for the resource // Use delete-then-move strategy to avoid HTTP 409 conflicts if (overwrite) { From d771333c0e6a9ac16238a6e7f9d6806a8a37d252 Mon Sep 17 00:00:00 2001 From: Philipp Crocoll Date: Mon, 12 Oct 2026 15:59:13 +0200 Subject: [PATCH 02/10] make path splitting more robust --- .../javafilestorage/WebDavStorage.java | 52 ++++++++++++++++--- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/src/java/JavaFileStorage/app/src/main/java/keepass2android/javafilestorage/WebDavStorage.java b/src/java/JavaFileStorage/app/src/main/java/keepass2android/javafilestorage/WebDavStorage.java index 849082f3f..ba9788bd6 100644 --- a/src/java/JavaFileStorage/app/src/main/java/keepass2android/javafilestorage/WebDavStorage.java +++ b/src/java/JavaFileStorage/app/src/main/java/keepass2android/javafilestorage/WebDavStorage.java @@ -110,22 +110,59 @@ public ConnectionInfo splitStringToConnectionInfo(String filename) String scheme = filename.substring(0, filename.indexOf("://")); filename = filename.substring(scheme.length() + 3); - int idxAt = filename.indexOf('@'); + int idxAt = indexOfCredentialSeparator(filename); if (idxAt >= 0) { String userPwd = filename.substring(0, idxAt); int idxColon = userPwd.indexOf(":"); - if (idxColon >= 0); + if (idxColon >= 0) { ci.username = decode(userPwd.substring(0, idxColon)); ci.password = decode(userPwd.substring(idxColon + 1)); } + filename = filename.substring(idxAt + 1); } - ci.URL = scheme + "://" +filename.substring(filename.indexOf('@') + 1); + ci.URL = scheme + "://" + filename; return ci; } + /** + * Index of the '@' which separates the credentials from the host, or -1 if there are none. + * Only the authority is searched, so an '@' inside the path (e.g. Synology's "@eaDir" folders) + * is not mistaken for that separator, and the last '@' wins so that an unencoded '@' in a + * legacy username doesn't cut the credentials short. + * @param urlWithoutScheme the URL with the leading "scheme://" already removed + */ + private static int indexOfCredentialSeparator(String urlWithoutScheme) + { + int endOfAuthority = urlWithoutScheme.length(); + for (int i = 0; i < urlWithoutScheme.length(); i++) + { + char c = urlWithoutScheme.charAt(i); + if ((c == '/') || (c == '?') || (c == '#')) + { + endOfAuthority = i; + break; + } + } + return urlWithoutScheme.lastIndexOf('@', endOfAuthority - 1); + } + + /** + * The "scheme://credentials@" prefix of the given URL, or null if it doesn't contain credentials. + */ + private static String getCredentialPrefix(String url) + { + int idxScheme = url.indexOf("://"); + if (idxScheme < 0) + return null; + int idxAt = indexOfCredentialSeparator(url.substring(idxScheme + 3)); + if (idxAt < 0) + return null; + return url.substring(0, idxScheme + 3 + idxAt + 1); + } + private static final String HTTP_PROTOCOL_ID = "http"; private static final String HTTPS_PROTOCOL_ID = "https"; @@ -274,7 +311,7 @@ public void renameOrMoveWebDavResource(String sourcePath, String destinationPath else { int statusCode = response.code(); - String errorMessage = "Rename/Move failed for " + sourceCi.URL + " to " + destinationCi.URL + ": " + statusCode + " " + response.message(); + String errorMessage = "Rename/Move failed for " + sourceCi.URL + " to " + destinationUrl + ": " + statusCode + " " + response.message(); // If we get a 409 conflict and overwrite is true, try retry with enhanced cleanup if (overwrite && statusCode == 409) { @@ -291,7 +328,7 @@ public void renameOrMoveWebDavResource(String sourcePath, String destinationPath retryResponse.close(); return; // Success on retry } else { - errorMessage = "Rename/Move failed even after retry for " + sourceCi.URL + " to " + destinationCi.URL + ": " + retryResponse.code() + " " + retryResponse.message(); + errorMessage = "Rename/Move failed even after retry for " + sourceCi.URL + " to " + destinationUrl + ": " + retryResponse.code() + " " + retryResponse.message(); retryResponse.close(); } } catch (Exception retryException) { @@ -572,10 +609,11 @@ public List listFiles(String parentPath, int depth) throws Exception //relative path: e.path = buildPathFromHref(parentPath, r.href); } - if ( (parentPath.indexOf("@") != -1) && (e.path.indexOf("@") == -1)) + String credentialPrefix = getCredentialPrefix(parentPath); + if ((credentialPrefix != null) && (getCredentialPrefix(e.path) == null)) { //username/password not contained in .href response. Add it back from parentPath: - e.path = parentPath.substring(0, parentPath.indexOf("@")+1) + e.path.substring(e.path.indexOf("://")+3); + e.path = credentialPrefix + e.path.substring(e.path.indexOf("://")+3); } if ((depth == 1) && e.isDirectory) From 2acbcb8ff1c3f3aee530ec16c9431883380b1ce9 Mon Sep 17 00:00:00 2001 From: Philipp Crocoll Date: Mon, 12 Oct 2026 16:09:49 +0200 Subject: [PATCH 03/10] make file transaction implementation robust against mod_negotiation behavior --- .../keepass2android/javafilestorage/WebDavStorage.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/java/JavaFileStorage/app/src/main/java/keepass2android/javafilestorage/WebDavStorage.java b/src/java/JavaFileStorage/app/src/main/java/keepass2android/javafilestorage/WebDavStorage.java index ba9788bd6..799fc53f1 100644 --- a/src/java/JavaFileStorage/app/src/main/java/keepass2android/javafilestorage/WebDavStorage.java +++ b/src/java/JavaFileStorage/app/src/main/java/keepass2android/javafilestorage/WebDavStorage.java @@ -435,13 +435,16 @@ public void uploadFile(String path, byte[] data, boolean writeTransactional) if (writeTransactional) { - // Generate a unique temporary file suffix - String tempSuffix = ".tmp." + generateRandomHexString(8); + // Generate a unique temporary file suffix. Don't separate it with dots: Apache's + // mod_negotiation (Options MultiViews) strips unknown extensions from the right, so + // "file.tmp.abc12345.kdbx" is reduced to the base name "file" and the request is then + // resolved against "file.kdbx" instead, which fails with "none could be negotiated". + String tempSuffix = "_tmp_" + generateRandomHexString(8); String tempPath; // Preserve the original file extension for WebDAV providers that don't support // changing extensions. Insert temp suffix before the extension instead of after it. - // Example: "file.kdbx" -> "file.tmp.abc12345.kdbx" (instead of "file.kdbx.tmp.abc12345") + // Example: "file.kdbx" -> "file_tmp_abc12345.kdbx" (instead of "file.kdbx.tmp.abc12345") int lastDot = path.lastIndexOf('.'); int lastSlash = path.lastIndexOf('/'); if (lastDot > lastSlash && lastDot >= 0) { From 2985bed942cf3b0ef627480cc1ecfa3c7bcd1b45 Mon Sep 17 00:00:00 2001 From: Philipp Crocoll Date: Mon, 12 Oct 2026 20:50:45 +0200 Subject: [PATCH 04/10] change transaction logic: first try MOVE, then delete and MOVE again if first move fails. --- .../javafilestorage/WebDavStorage.java | 87 ++++++++++--------- 1 file changed, 46 insertions(+), 41 deletions(-) diff --git a/src/java/JavaFileStorage/app/src/main/java/keepass2android/javafilestorage/WebDavStorage.java b/src/java/JavaFileStorage/app/src/main/java/keepass2android/javafilestorage/WebDavStorage.java index 799fc53f1..1c543e38d 100644 --- a/src/java/JavaFileStorage/app/src/main/java/keepass2android/javafilestorage/WebDavStorage.java +++ b/src/java/JavaFileStorage/app/src/main/java/keepass2android/javafilestorage/WebDavStorage.java @@ -278,17 +278,6 @@ public void renameOrMoveWebDavResource(String sourcePath, String destinationPath .method("MOVE", null) // "MOVE" is the HTTP method .header("Destination", destinationUrl); // New URI for the resource - // Use delete-then-move strategy to avoid HTTP 409 conflicts - if (overwrite) { - try { - // Try to delete the destination file first if it exists - deleteFileIfExists(destinationCi); - } catch (Exception e) { - // Ignore deletion errors - the file might not exist or we might not have permission - // The MOVE operation will fail if the destination can't be overwritten - Log.d("WebDavStorage", "Failed to delete destination file before move (this may be normal): " + e.getMessage()); - } - } // Add Overwrite header (but don't rely on it solely) if (overwrite) { @@ -299,45 +288,48 @@ public void renameOrMoveWebDavResource(String sourcePath, String destinationPath Request request = requestBuilder.build(); + int statusCode; + String responseMessage; Response response = getClient(sourceCi).newCall(request).execute(); - - // Check the status code - if (response.isSuccessful()) { + try { // WebDAV MOVE can return 201 (Created) if a new resource was created at dest, // or 204 (No Content) if moved to a pre-existing destination (e.g., just renamed). // A 200 OK might also be returned by some servers, though 201/204 are more common. - + if (response.isSuccessful()) + return; + statusCode = response.code(); + responseMessage = response.message(); + } finally { + response.close(); } - else - { - int statusCode = response.code(); - String errorMessage = "Rename/Move failed for " + sourceCi.URL + " to " + destinationUrl + ": " + statusCode + " " + response.message(); - // If we get a 409 conflict and overwrite is true, try retry with enhanced cleanup - if (overwrite && statusCode == 409) { + String errorMessage = "Rename/Move failed for " + sourceCi.URL + " to " + destinationUrl + ": " + statusCode + " " + responseMessage; + + if (overwrite && ((statusCode == 409) || (statusCode == 412))) { + // Only now, with the destination identified as the obstacle, delete it and move again: + // 409 is reported by servers which refuse to overwrite an existing destination even with + // Overwrite: T (see issue #3078), 412 is the status RFC 4918 defines for that case. Any + // other failure is left alone so that the existing destination file survives it. + try { + deleteFileIfExists(destinationCi); + // Small delay to ensure server processes the deletion + Thread.sleep(100); + + // Retry the MOVE operation + Response retryResponse = getClient(sourceCi).newCall(request).execute(); try { - response.close(); - // Force delete destination and retry - deleteFileIfExists(destinationCi); - // Small delay to ensure server processes the deletion - Thread.sleep(100); - - // Retry the MOVE operation - Response retryResponse = getClient(sourceCi).newCall(request).execute(); - if (retryResponse.isSuccessful()) { - retryResponse.close(); + if (retryResponse.isSuccessful()) return; // Success on retry - } else { - errorMessage = "Rename/Move failed even after retry for " + sourceCi.URL + " to " + destinationUrl + ": " + retryResponse.code() + " " + retryResponse.message(); - retryResponse.close(); - } - } catch (Exception retryException) { - errorMessage = "Rename/Move failed and retry attempt also failed: " + errorMessage + " (Retry error: " + retryException.getMessage() + ")"; + errorMessage = "Rename/Move failed even after deleting the destination for " + sourceCi.URL + " to " + destinationUrl + ": " + retryResponse.code() + " " + retryResponse.message(); + } finally { + retryResponse.close(); } + } catch (Exception retryException) { + errorMessage = "Rename/Move failed and retry attempt also failed: " + errorMessage + " (Retry error: " + retryException.getMessage() + ")"; } - - throw new Exception(errorMessage); } + + throw new Exception(errorMessage); } /** @@ -454,8 +446,21 @@ public void uploadFile(String path, byte[] data, boolean writeTransactional) // No extension: append temp suffix at the end tempPath = path + tempSuffix; } - uploadFile(tempPath, data, false); - renameOrMoveWebDavResource(tempPath, path, true); + long startUpload = System.currentTimeMillis(); + try { + uploadFile(tempPath, data, false); + } finally { + logDebug("WebDav transaction: upload of " + data.length + " bytes took " + + (System.currentTimeMillis() - startUpload) + "ms"); + } + + long startMove = System.currentTimeMillis(); + try { + renameOrMoveWebDavResource(tempPath, path, true); + } finally { + logDebug("WebDav transaction: move took " + + (System.currentTimeMillis() - startMove) + "ms"); + } return; } From 82b4b3637b05bedae786e5f95fcf4182195fc66a Mon Sep 17 00:00:00 2001 From: Philipp Crocoll Date: Mon, 12 Oct 2026 21:03:06 +0200 Subject: [PATCH 05/10] make sure to show a message if server cannot be reached --- src/Kp2aBusinessLogic/Io/JavaFileStorage.cs | 16 ++++++++++++++++ src/Kp2aBusinessLogic/UiStringKey.cs | 1 + .../Resources/values-de/strings.xml | 1 + .../Resources/values-es/strings.xml | 1 + .../Resources/values-fr/strings.xml | 1 + .../Resources/values-it/strings.xml | 1 + .../Resources/values-pt-rBR/strings.xml | 1 + .../Resources/values-ru/strings.xml | 1 + .../Resources/values/strings.xml | 1 + 9 files changed, 24 insertions(+) diff --git a/src/Kp2aBusinessLogic/Io/JavaFileStorage.cs b/src/Kp2aBusinessLogic/Io/JavaFileStorage.cs index fd0d68cb7..213a3c852 100644 --- a/src/Kp2aBusinessLogic/Io/JavaFileStorage.cs +++ b/src/Kp2aBusinessLogic/Io/JavaFileStorage.cs @@ -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 ... from 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); diff --git a/src/Kp2aBusinessLogic/UiStringKey.cs b/src/Kp2aBusinessLogic/UiStringKey.cs index ea1cde5e5..9a40fb359 100644 --- a/src/Kp2aBusinessLogic/UiStringKey.cs +++ b/src/Kp2aBusinessLogic/UiStringKey.cs @@ -79,6 +79,7 @@ public enum UiStringKey AskDeletePermanentlyItemsNoRecycle, InOfflineMode, DocumentAccessRevoked, + ServerNotReachable, DuplicateTitle, TemplateTitle_IdCard, TemplateField_IdCard_Name, diff --git a/src/keepass2android-app/Resources/values-de/strings.xml b/src/keepass2android-app/Resources/values-de/strings.xml index d944532d8..6a7c82a5d 100644 --- a/src/keepass2android-app/Resources/values-de/strings.xml +++ b/src/keepass2android-app/Resources/values-de/strings.xml @@ -499,6 +499,7 @@ Der Android Robot wird genutzt und wurde modifiziert basierend auf Arbeiten, die Zwischengespeicherte Kopie mit der Quelldatei synchronisieren Datenbank wird vom internen Cache geladen. Änderungen werden nur im internen Cache gespeichert und nur synchronisiert, wenn eine Datenbanksynchronisierung manuell ausgewählt wird. Nur internen Zwischenspeicher verwenden. + Der Server ist nicht erreichbar. Bitte die Internetverbindung und die Serveradresse prüfen. Zwischengespeicherte Datenbank synchronisieren … Quelldatei wird geladen … Datei speichern … diff --git a/src/keepass2android-app/Resources/values-es/strings.xml b/src/keepass2android-app/Resources/values-es/strings.xml index 88d700663..b47f3d727 100644 --- a/src/keepass2android-app/Resources/values-es/strings.xml +++ b/src/keepass2android-app/Resources/values-es/strings.xml @@ -497,6 +497,7 @@ Sincronizar la copia caché con la fuente Se ha cargado la base de datos de la caché interna. Los cambios se almacenan en la caché interna sólo y sólamente se sincronizarán cuando seleccione Sincronizar copia caché con fuente. Trabajando con la cache interna sólo. + No se ha podido conectar con el servidor. Compruebe su conexión a Internet y la dirección del servidor. Sincronización de base de datos en caché… Cargando fichero fuente… Guardar archivo… diff --git a/src/keepass2android-app/Resources/values-fr/strings.xml b/src/keepass2android-app/Resources/values-fr/strings.xml index eb5c3fc84..87982d59f 100644 --- a/src/keepass2android-app/Resources/values-fr/strings.xml +++ b/src/keepass2android-app/Resources/values-fr/strings.xml @@ -497,6 +497,7 @@ Synchroniser la copie en cache avec la source La base de données est chargée depuis le cache interne. Les changements sont enregistrée dans le cache interne uniquement et ne seront synchronisés qu\'à partir de l\'usage de la fonction \"Synchroniser la copie en cache avec la source\". Utilisation en cours du cache interne uniquement. + Le serveur est inaccessible. Veuillez vérifier votre connexion Internet et l’adresse du serveur. Synchronisation de la base de données mise en cache… Chargement du fichier source… Enregistrer le fichier… diff --git a/src/keepass2android-app/Resources/values-it/strings.xml b/src/keepass2android-app/Resources/values-it/strings.xml index c093b73ab..125647027 100644 --- a/src/keepass2android-app/Resources/values-it/strings.xml +++ b/src/keepass2android-app/Resources/values-it/strings.xml @@ -497,6 +497,7 @@ Sincronizzare la copia cache con l\'originale Il database viene caricato dalla cache interna. Le modifiche vengono memorizzate solo nella cache interna e verranno sincronizzate solamente selezionando Sincronizzare la copia cache con l\'originale. Sto usando solo la cache interna. + Non è stato possibile raggiungere il server. Controlla la connessione a Internet e l’indirizzo del server. Sincronizzazione dei database nella cache… Caricamento del file di origine… Salvo il file… diff --git a/src/keepass2android-app/Resources/values-pt-rBR/strings.xml b/src/keepass2android-app/Resources/values-pt-rBR/strings.xml index 3b5c72b14..9641cd9c8 100644 --- a/src/keepass2android-app/Resources/values-pt-rBR/strings.xml +++ b/src/keepass2android-app/Resources/values-pt-rBR/strings.xml @@ -499,6 +499,7 @@ Sincronizar cópia do cache com a origem A base de dados é carregada do cache interno. As alterações são armazenadas só no cache interno e só serão sincronizadas ao selecionar Sincronizar cópia de cache com origem. Trabalhando só no cache interno. + Não foi possível acessar o servidor. Verifique sua conexão com a internet e o endereço do servidor. Sincronizando banco de dados em cache… Carregando arquivo de orígem… Salvar arquivo… diff --git a/src/keepass2android-app/Resources/values-ru/strings.xml b/src/keepass2android-app/Resources/values-ru/strings.xml index 3cb9a1dc3..23a663063 100644 --- a/src/keepass2android-app/Resources/values-ru/strings.xml +++ b/src/keepass2android-app/Resources/values-ru/strings.xml @@ -491,6 +491,7 @@ Синхронизировать кэшированную копию с удалённым файлом База данных загружена из внутреннего кэша. Изменения сохраняются только во внутреннем кэше и будут синхронизироваться только при выборе Синхронизация кэшированной копии с удалённым файлом. Работа только с локальной копией базы. + Не удалось подключиться к серверу. Проверьте подключение к интернету и адрес сервера. Синхронизация кэшированной базы данных… Загружается удалённый файл… Сохранить файл… diff --git a/src/keepass2android-app/Resources/values/strings.xml b/src/keepass2android-app/Resources/values/strings.xml index 25467a0d7..dbc5c7e65 100644 --- a/src/keepass2android-app/Resources/values/strings.xml +++ b/src/keepass2android-app/Resources/values/strings.xml @@ -502,6 +502,7 @@ Synchronize cache copy with source Database is loaded from internal cache. Changes are stored in the internal cache only and will only be synchronized when selecting Synchronize cache copy with source. Working on internal cache only. + The server could not be reached. Please check your internet connection and the server address. Synchronizing cached database… Loading source file… Save file… From ce3cab0d53ddf0e8b497169687491b22b7b74dd0 Mon Sep 17 00:00:00 2001 From: Philipp Crocoll Date: Tue, 13 Oct 2026 16:57:56 +0200 Subject: [PATCH 06/10] bug fixes for background sync --- .../Io/CachingFileStorage.cs | 30 ++++++++++++++-- .../Io/OfflineSwitchableFileStorage.cs | 7 ++++ .../database/SynchronizeCachedDatabase.cs | 4 ++- src/Kp2aBusinessLogic/database/edit/LoadDB.cs | 27 ++++++++++---- src/Kp2aBusinessLogic/database/edit/SaveDB.cs | 1 + .../LifecycleAwareActivity.cs | 3 ++ src/keepass2android-app/PasswordActivity.cs | 35 +++++++++++++++---- src/keepass2android-app/app/App.cs | 11 ++++-- 8 files changed, 100 insertions(+), 18 deletions(-) diff --git a/src/Kp2aBusinessLogic/Io/CachingFileStorage.cs b/src/Kp2aBusinessLogic/Io/CachingFileStorage.cs index 0df385305..195975458 100644 --- a/src/Kp2aBusinessLogic/Io/CachingFileStorage.cs +++ b/src/Kp2aBusinessLogic/Io/CachingFileStorage.cs @@ -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 @@ -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 @@ -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); } } @@ -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); } @@ -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); } @@ -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; + + /// + /// 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. + /// + public bool CacheDiffersFromRemoteAfterLastRead { get; private set; } + public void OnRequestPermissionsResult(IFileStorageSetupActivity fileStorageSetupActivity, int requestCode, string[] permissions, Permission[] grantResults) { diff --git a/src/Kp2aBusinessLogic/Io/OfflineSwitchableFileStorage.cs b/src/Kp2aBusinessLogic/Io/OfflineSwitchableFileStorage.cs index 7df7c7975..d31fe64f3 100644 --- a/src/Kp2aBusinessLogic/Io/OfflineSwitchableFileStorage.cs +++ b/src/Kp2aBusinessLogic/Io/OfflineSwitchableFileStorage.cs @@ -27,6 +27,12 @@ public interface IOfflineSwitchable { bool IsOffline { get; set; } bool TriggerWarningWhenFallingBackToCache { get; set; } + + /// + /// 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. + /// + bool WarnWhenSaveToRemoteFails { get; set; } } /// @@ -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) { diff --git a/src/Kp2aBusinessLogic/database/SynchronizeCachedDatabase.cs b/src/Kp2aBusinessLogic/database/SynchronizeCachedDatabase.cs index 12735f673..6843d11c4 100644 --- a/src/Kp2aBusinessLogic/database/SynchronizeCachedDatabase.cs +++ b/src/Kp2aBusinessLogic/database/SynchronizeCachedDatabase.cs @@ -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 diff --git a/src/Kp2aBusinessLogic/database/edit/LoadDB.cs b/src/Kp2aBusinessLogic/database/edit/LoadDB.cs index ba15af21f..5587962bf 100644 --- a/src/Kp2aBusinessLogic/database/edit/LoadDB.cs +++ b/src/Kp2aBusinessLogic/database/edit/LoadDB.cs @@ -31,10 +31,22 @@ You should have received a copy of the GNU General Public License namespace keepass2android { + /// + /// 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. + /// + public class PreloadedDatabaseData + { + public MemoryStream Data { get; set; } + public bool RequiresSubsequentSync { get; set; } + } + public class LoadDb : OperationWithFinishHandler { private readonly IOConnectionInfo _ioc; - private readonly Task _databaseData; + private readonly Task _databaseData; private readonly CompositeKey _compositeKey; private readonly string? _keyfileOrProvider; private readonly IKp2aApp _app; @@ -44,7 +56,7 @@ public class LoadDb : OperationWithFinishHandler public bool DoNotSetStatusLoggerMessage = false; - public LoadDb(IKp2aApp app, IOConnectionInfo ioc, Task databaseData, CompositeKey compositeKey, + public LoadDb(IKp2aApp app, IOConnectionInfo ioc, Task databaseData, CompositeKey compositeKey, string keyfileOrProvider, OnOperationFinishedHandler operationFinishedHandler, bool updateLastUsageTimestamp, bool makeCurrent, IDatabaseModificationWatcher modificationWatcher = null) : base(app, operationFinishedHandler) { @@ -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 { diff --git a/src/Kp2aBusinessLogic/database/edit/SaveDB.cs b/src/Kp2aBusinessLogic/database/edit/SaveDB.cs index b0b8ec96d..6648e1031 100644 --- a/src/Kp2aBusinessLogic/database/edit/SaveDB.cs +++ b/src/Kp2aBusinessLogic/database/edit/SaveDB.cs @@ -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; } diff --git a/src/keepass2android-app/LifecycleAwareActivity.cs b/src/keepass2android-app/LifecycleAwareActivity.cs index 701e5fbf5..6850772ff 100644 --- a/src/keepass2android-app/LifecycleAwareActivity.cs +++ b/src/keepass2android-app/LifecycleAwareActivity.cs @@ -88,6 +88,9 @@ public override bool OnSupportNavigateUp() protected override void OnResume() { base.OnResume(); + + App.Kp2a.ActiveContext = this; + App.Kp2a.PerformPendingActions(_instanceId); Kp2aLog.Log(ClassName + ".OnResume " + ID); diff --git a/src/keepass2android-app/PasswordActivity.cs b/src/keepass2android-app/PasswordActivity.cs index a4d129a32..b08438376 100644 --- a/src/keepass2android-app/PasswordActivity.cs +++ b/src/keepass2android-app/PasswordActivity.cs @@ -1,4 +1,4 @@ -/* +/* This file is part of Keepass2Android, Copyright 2013 Philipp Crocoll. This file is based on Keepassdroid, Copyright Brian Pellin. Keepass2Android is free software: you can redistribute it and/or modify @@ -110,7 +110,7 @@ enum KeyProviders public const int ResultSelectOtherFile = (int)Result.FirstUser; - private Task _loadDbFileTask; + private Task _loadDbFileTask; private bool _loadDbTaskOffline; //indicate if preloading was started with offline mode private IOConnectionInfo _ioConnection; @@ -1637,17 +1637,40 @@ protected override void OnStart() } - private MemoryStream PreloadDbFile() + private PreloadedDatabaseData PreloadDbFile() { Kp2aLog.Log("Pre-loading database file starting"); var fileStorage = App.Kp2a.GetFileStorage(_ioConnection); + + //Preloading happens before the user even entered their password, so don't report problems with + //the source file here: if it couldn't be read or written, we report RequiresSubsequentSync + //below and the synchronization which is then started reports the error with proper context. + if (fileStorage is IOfflineSwitchable switchable) + { + switchable.TriggerWarningWhenFallingBackToCache = false; + switchable.WarnWhenSaveToRemoteFails = false; + } + + //With "synchronize offline cache in background", the database should open immediately from the + //cache instead of waiting for the source file. + if (App.Kp2a.SyncInBackgroundPreference + && fileStorage is CachingFileStorage cachingFileStorageForPreload + && cachingFileStorageForPreload.IsCached(_ioConnection)) + { + cachingFileStorageForPreload.IsOffline = true; + } + var stream = fileStorage.OpenFileForRead(_ioConnection); var memoryStream = Util.StreamToMemoryStream(stream); - Kp2aLog.Log("Pre-loading database file completed"); + //If cache and source file may differ, we need to synchronize. Nothing else would notice that, + //so remember it and let LoadDb trigger a sync once the database is open. + bool requiresSubsequentSync = (fileStorage as CachingFileStorage)?.CacheDiffersFromRemoteAfterLastRead == true; + + Kp2aLog.Log("Pre-loading database file completed, requiresSubsequentSync=" + requiresSubsequentSync); - return memoryStream; + return new PreloadedDatabaseData { Data = memoryStream, RequiresSubsequentSync = requiresSubsequentSync }; } protected override void OnSaveInstanceState(Bundle outState) @@ -2299,7 +2322,7 @@ class SaveOtpAuxFileAndLoadDb : LoadDb private readonly PasswordActivity _act; - public SaveOtpAuxFileAndLoadDb(IKp2aApp app, IOConnectionInfo ioc, Task databaseData, CompositeKey compositeKey, string keyfileOrProvider, OnOperationFinishedHandler operationFinishedHandler, PasswordActivity act, bool updateLastUsageTimestamp, bool makeCurrent) : base(app, ioc, databaseData, compositeKey, keyfileOrProvider, operationFinishedHandler, updateLastUsageTimestamp, makeCurrent) + public SaveOtpAuxFileAndLoadDb(IKp2aApp app, IOConnectionInfo ioc, Task databaseData, CompositeKey compositeKey, string keyfileOrProvider, OnOperationFinishedHandler operationFinishedHandler, PasswordActivity act, bool updateLastUsageTimestamp, bool makeCurrent) : base(app, ioc, databaseData, compositeKey, keyfileOrProvider, operationFinishedHandler, updateLastUsageTimestamp, makeCurrent) { _act = act; } diff --git a/src/keepass2android-app/app/App.cs b/src/keepass2android-app/app/App.cs index 2ebd5f7c7..85e14e024 100644 --- a/src/keepass2android-app/app/App.cs +++ b/src/keepass2android-app/app/App.cs @@ -1279,7 +1279,13 @@ public IFileStorage GetOtpAuxFileStorage(IOConnectionInfo iocInfo) if (DatabaseCacheEnabled) { - return new OtpAuxCachingFileStorage(innerFileStorage, LocaleManager.LocalizedAppContext, new OtpAuxCacheSupervisor(this)); + //This storage is constructed directly and never passes through GetFileStorage, so set the + //warning behavior explicitly: keep the OTP aux file quiet as it always was. Problems with + //it are secondary to the database itself, which reports its own errors. + return new OtpAuxCachingFileStorage(innerFileStorage, LocaleManager.LocalizedAppContext, new OtpAuxCacheSupervisor(this)) + { + WarnWhenSaveToRemoteFails = false + }; } else { @@ -1625,7 +1631,8 @@ public void PerformPendingActions(int instanceId) { if (_pendingActionsForContextInstances.TryGetValue(instanceId, out var actions)) { - foreach (var action in actions) + //Iterate a copy: an action may re-register itself for the same instance + foreach (var action in actions.ToList()) { try { From 94b7c0490da89fb939d7fd9f4b8102ac51e35c63 Mon Sep 17 00:00:00 2001 From: Philipp Crocoll Date: Tue, 13 Oct 2026 16:58:59 +0200 Subject: [PATCH 07/10] another bug fix to bg sync --- src/keepass2android-app/SyncUtil.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/keepass2android-app/SyncUtil.cs b/src/keepass2android-app/SyncUtil.cs index 139731688..ddca12673 100644 --- a/src/keepass2android-app/SyncUtil.cs +++ b/src/keepass2android-app/SyncUtil.cs @@ -103,7 +103,13 @@ public void StartSynchronizeDatabase(Database database, bool forceSynchronizatio OperationWithFinishHandler task; - OnOperationFinishedHandler onOperationFinishedHandler = new ActionInContextInstanceOnOperationFinished(_activity.ContextInstanceId, App.Kp2a, (success, message, context) => + //Deliberately not an ActionInContextInstanceOnOperationFinished: nothing in here needs the + //activity which started the synchronization. ShowMessage ignores the context and queues the + //message if no UI is attached, the list refresh applies to whichever group list is on screen, + //and the OTP aux synchronization needs no context at all. Waiting for one specific activity + //instance would only postpone all of that - and a sync started while opening the database + //outlives the activity which triggered it, so it would never run at all. + OnOperationFinishedHandler onOperationFinishedHandler = new ActionOnOperationFinished(App.Kp2a, (success, message, context) => { App.Kp2a.UiThreadHandler.Post(() => { From 9cec89ebb485c5133f26e0084ca660e773c160c2 Mon Sep 17 00:00:00 2001 From: Philipp Crocoll Date: Wed, 14 Oct 2026 06:43:10 +0200 Subject: [PATCH 08/10] fix missing sync UI in some cases --- src/Kp2aBusinessLogic/OperationRunner.cs | 48 +++++++++++++++---- .../ProgressUiAsStatusLoggerAdapter.cs | 5 ++ 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/Kp2aBusinessLogic/OperationRunner.cs b/src/Kp2aBusinessLogic/OperationRunner.cs index 1af49c04d..8cfbcb31e 100644 --- a/src/Kp2aBusinessLogic/OperationRunner.cs +++ b/src/Kp2aBusinessLogic/OperationRunner.cs @@ -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, ( @@ -153,6 +174,23 @@ public void Run(IKp2aApp app, OperationWithFinishHandler operation, bool runBloc } + /// + /// 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. + /// + 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..."; @@ -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 ? diff --git a/src/Kp2aBusinessLogic/ProgressUiAsStatusLoggerAdapter.cs b/src/Kp2aBusinessLogic/ProgressUiAsStatusLoggerAdapter.cs index 98f75bfa5..21edcda55 100644 --- a/src/Kp2aBusinessLogic/ProgressUiAsStatusLoggerAdapter.cs +++ b/src/Kp2aBusinessLogic/ProgressUiAsStatusLoggerAdapter.cs @@ -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) From acb07142f8e3614475ccdc186d77a6e2386f4ba7 Mon Sep 17 00:00:00 2001 From: Philipp Crocoll Date: Wed, 14 Oct 2026 07:12:51 +0200 Subject: [PATCH 09/10] enable bg sync for more operations --- src/Kp2aBusinessLogic/database/edit/AddGroup.cs | 1 + src/Kp2aBusinessLogic/database/edit/DeleteRunnable.cs | 1 + src/Kp2aBusinessLogic/database/edit/MoveElements.cs | 2 ++ 3 files changed, 4 insertions(+) diff --git a/src/Kp2aBusinessLogic/database/edit/AddGroup.cs b/src/Kp2aBusinessLogic/database/edit/AddGroup.cs index 64a28fc94..23e4b7317 100644 --- a/src/Kp2aBusinessLogic/database/edit/AddGroup.cs +++ b/src/Kp2aBusinessLogic/database/edit/AddGroup.cs @@ -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(); } diff --git a/src/Kp2aBusinessLogic/database/edit/DeleteRunnable.cs b/src/Kp2aBusinessLogic/database/edit/DeleteRunnable.cs index f91e37eb3..e7f34e7f9 100644 --- a/src/Kp2aBusinessLogic/database/edit/DeleteRunnable.cs +++ b/src/Kp2aBusinessLogic/database/edit/DeleteRunnable.cs @@ -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(); diff --git a/src/Kp2aBusinessLogic/database/edit/MoveElements.cs b/src/Kp2aBusinessLogic/database/edit/MoveElements.cs index 276be3a56..78aaa1790 100644 --- a/src/Kp2aBusinessLogic/database/edit/MoveElements.cs +++ b/src/Kp2aBusinessLogic/database/edit/MoveElements.cs @@ -147,6 +147,7 @@ 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(); @@ -154,6 +155,7 @@ void ContinueSave(bool success, string message, Context activeActivity) 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(); From 07f8ec1934b06411f8b7e5eed649ea89783d3cb0 Mon Sep 17 00:00:00 2001 From: Philipp Crocoll Date: Wed, 14 Oct 2026 07:13:33 +0200 Subject: [PATCH 10/10] increase Webdav timeout --- .../javafilestorage/WebDavStorage.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/java/JavaFileStorage/app/src/main/java/keepass2android/javafilestorage/WebDavStorage.java b/src/java/JavaFileStorage/app/src/main/java/keepass2android/javafilestorage/WebDavStorage.java index 1c543e38d..ec0a7eeb1 100644 --- a/src/java/JavaFileStorage/app/src/main/java/keepass2android/javafilestorage/WebDavStorage.java +++ b/src/java/JavaFileStorage/app/src/main/java/keepass2android/javafilestorage/WebDavStorage.java @@ -228,6 +228,12 @@ private OkHttpClient getClient(ConnectionInfo ci) throws NoSuchAlgorithmExceptio builder = builder.authenticator(new CachingAuthenticatorDecorator(authenticator, authCache)) .addInterceptor(new AuthenticationCacheInterceptor(authCache)); + + //Set the timeouts for every client. without this, okhttp's defaults of 10 seconds apply. + builder.connectTimeout(25, TimeUnit.SECONDS); + builder.readTimeout(60, TimeUnit.SECONDS); + builder.writeTimeout(60, TimeUnit.SECONDS); + if ((mCertificateErrorHandler != null) && (!mCertificateErrorHandler.alwaysFailOnValidationError())) { @@ -248,11 +254,6 @@ private OkHttpClient getClient(ConnectionInfo ci) throws NoSuchAlgorithmExceptio builder = builder.sslSocketFactory(sslSocketFactory, trustManager) .hostnameVerifier(new DecoratedHostnameVerifier(OkHostnameVerifier.INSTANCE, mCertificateErrorHandler)); - - - builder.connectTimeout(25, TimeUnit.SECONDS); - builder.readTimeout(25, TimeUnit.SECONDS); - builder.writeTimeout(25, TimeUnit.SECONDS); }