Compare commits

...

4 Commits

Author SHA1 Message Date
Philipp Crocoll
057a7e2f7a another nonet fix 2025-07-15 13:12:18 +02:00
Philipp Crocoll
cfb5098b38 add support for transactional upload 2025-07-15 12:18:45 +02:00
Philipp Crocoll
913222d7cb allow chunked uploads, closes https://github.com/PhilippC/keepass2android/issues/2777 2025-07-15 11:07:40 +02:00
Philipp Crocoll
3e6d86c206 correctly check if an item is a folder or file. closes https://github.com/PhilippC/keepass2android/issues/2589 2025-07-15 09:10:41 +02:00
12 changed files with 236 additions and 26 deletions

View File

@@ -140,6 +140,10 @@ namespace keepass2android
#endif
int WebDavChunkedUploadSize
{
get;
}
}
}

View File

@@ -15,7 +15,9 @@ namespace keepass2android.Io
{
get { return false; }
}
}
static public bool IsConfigured => !string.IsNullOrEmpty(AppKey) && !string.IsNullOrEmpty(AppSecret);
}
public partial class DropboxAppFolderFileStorage: JavaFileStorage
{
@@ -29,6 +31,7 @@ namespace keepass2android.Io
get { return false; }
}
static public bool IsConfigured => !string.IsNullOrEmpty(AppKey) && !string.IsNullOrEmpty(AppSecret);
}
}

View File

@@ -123,7 +123,7 @@ namespace keepass2android.Io
}
public IWriteTransaction OpenWriteTransaction(IOConnectionInfo ioc, bool useFileTransaction)
public virtual IWriteTransaction OpenWriteTransaction(IOConnectionInfo ioc, bool useFileTransaction)
{
return new JavaFileStorageWriteTransaction(IocToPath(ioc), useFileTransaction, this);
}

View File

@@ -6,10 +6,12 @@ using System.Text;
using Android.App;
using Android.Content;
using Android.OS;
using Android.Preferences;
using Android.Runtime;
using Android.Views;
using Android.Widget;
#if !NoNet && !EXCLUDE_JAVAFILESTORAGE
using Keepass2android.Javafilestorage;
#endif
using KeePassLib.Serialization;
@@ -19,9 +21,15 @@ namespace keepass2android.Io
#if !NoNet && !EXCLUDE_JAVAFILESTORAGE
public class WebDavFileStorage: JavaFileStorage
{
public WebDavFileStorage(IKp2aApp app) : base(new Keepass2android.Javafilestorage.WebDavStorage(app.CertificateErrorHandler), app)
{
}
private readonly IKp2aApp _app;
private readonly WebDavStorage baseWebdavStorage;
public WebDavFileStorage(IKp2aApp app, int chunkSize) : base(new Keepass2android.Javafilestorage.WebDavStorage(app.CertificateErrorHandler, chunkSize), app)
{
_app = app;
baseWebdavStorage = (WebDavStorage)Jfs;
}
public override IEnumerable<string> SupportedProtocols
{
@@ -75,6 +83,15 @@ namespace keepass2android.Io
}
return base.IocToPath(ioc);
}
}
public override IWriteTransaction OpenWriteTransaction(IOConnectionInfo ioc, bool useFileTransaction)
{
baseWebdavStorage.SetUploadChunkSize(_app.WebDavChunkedUploadSize);
return base.OpenWriteTransaction(ioc, useFileTransaction);
}
}
#endif
}

View File

@@ -1,6 +1,7 @@
package keepass2android.javafilestorage;
import android.content.Context;
import java.math.BigInteger;
import android.content.Intent;
import android.net.Uri;
@@ -15,7 +16,10 @@ import com.burgstaller.okhttp.basic.BasicAuthenticator;
import com.burgstaller.okhttp.digest.CachingAuthenticator;
import com.burgstaller.okhttp.digest.DigestAuthenticator;
import java.io.ByteArrayInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.StringReader;
import java.io.UnsupportedEncodingException;
@@ -24,6 +28,7 @@ import java.security.KeyManagementException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
@@ -44,23 +49,33 @@ import keepass2android.javafilestorage.webdav.DecoratedTrustManager;
import keepass2android.javafilestorage.webdav.PropfindXmlParser;
import keepass2android.javafilestorage.webdav.WebDavUtil;
import okhttp3.MediaType;
import okhttp3.MultipartBody;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.internal.tls.OkHostnameVerifier;
import okio.BufferedSink;
public class WebDavStorage extends JavaFileStorageBase {
private final ICertificateErrorHandler mCertificateErrorHandler;
private Context appContext;
public WebDavStorage(ICertificateErrorHandler certificateErrorHandler)
int chunkSize;
public WebDavStorage(ICertificateErrorHandler certificateErrorHandler, int chunkSize)
{
this.chunkSize = chunkSize;
mCertificateErrorHandler = certificateErrorHandler;
}
public void setUploadChunkSize(int chunkSize)
{
this.chunkSize = chunkSize;
}
public String buildFullPath(String url, String username, String password) throws UnsupportedEncodingException {
String scheme = url.substring(0, url.indexOf("://"));
url = url.substring(scheme.length() + 3);
@@ -181,21 +196,119 @@ public class WebDavStorage extends JavaFileStorageBase {
return client;
}
public void renameOrMoveWebDavResource(String sourcePath, String destinationPath, boolean overwrite) throws Exception {
ConnectionInfo sourceCi = splitStringToConnectionInfo(sourcePath);
ConnectionInfo destinationCi = splitStringToConnectionInfo(destinationPath);
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
// Add Overwrite header
if (overwrite) {
requestBuilder.header("Overwrite", "T"); // 'T' for true
} else {
requestBuilder.header("Overwrite", "F"); // 'F' for false
}
Request request = requestBuilder.build();
Response response = getClient(sourceCi).newCall(request).execute();
// Check the status code
if (response.isSuccessful()) {
// 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.
}
else
{
throw new Exception("Rename/Move failed for " + sourceCi.URL + " to " + destinationCi.URL + ": " + response.code() + " " + response.message());
}
}
public static String generateRandomHexString(int length) {
SecureRandom secureRandom = new SecureRandom();
// Generate enough bytes to ensure we can get the desired number of hex characters.
// Each byte converts to two hex characters.
// For 8 hex characters, we need 4 bytes.
int numBytes = (int) Math.ceil(length / 2.0);
byte[] randomBytes = new byte[numBytes];
secureRandom.nextBytes(randomBytes);
// Convert the byte array to a hexadecimal string
// BigInteger(1, randomBytes) treats the byte array as a positive number.
// toString(16) converts it to a hexadecimal string.
String hexString = new BigInteger(1, randomBytes).toString(16);
// Pad with leading zeros if necessary (e.g., if the generated number is small)
// and then take the first 'length' characters.
// Using String.format to ensure leading zeros if the hexString is shorter.
return String.format("%0" + length + "d", new BigInteger(hexString, 16)).substring(0, length);
}
@Override
public void uploadFile(String path, byte[] data, boolean writeTransactional)
throws Exception {
if (writeTransactional)
{
String randomSuffix = ".tmp." + generateRandomHexString(8);
uploadFile(path + randomSuffix, data, false);
renameOrMoveWebDavResource(path+randomSuffix, path, true);
return;
}
try {
ConnectionInfo ci = splitStringToConnectionInfo(path);
RequestBody requestBody;
if (chunkSize > 0)
{
// use chunked upload
requestBody = new RequestBody() {
@Override
public MediaType contentType() {
return MediaType.parse("application/binary");
}
@Override
public void writeTo(BufferedSink sink) throws IOException {
try (InputStream in = new ByteArrayInputStream(data)) {
byte[] buffer = new byte[chunkSize];
int read;
while ((read = in.read(buffer)) != -1) {
sink.write(buffer, 0, read);
sink.flush();
}
}
}
@Override
public long contentLength() {
return -1; // use chunked upload
}
};
}
else
{
requestBody = new MultipartBody.Builder()
.addPart(RequestBody.create(data, MediaType.parse("application/binary")))
.build();
}
Request request = new Request.Builder()
.url(new URL(ci.URL))
.put(RequestBody.create(MediaType.parse("application/binary"), data))
.put(requestBody)
.build();
//TODO consider writeTransactional
//TODO check for error
Response response = getClient(ci).newCall(request).execute();
checkStatus(response);
@@ -290,7 +403,10 @@ public class WebDavStorage extends JavaFileStorageBase {
e.sizeInBytes = -1;
}
}
e.isDirectory = r.href.endsWith("/");
e.isDirectory = r.href.endsWith("/") || okprop.IsCollection;
e.displayName = okprop.DisplayName;
if (e.displayName == null)
@@ -519,3 +635,4 @@ public class WebDavStorage extends JavaFileStorageBase {
}
}

View File

@@ -57,6 +57,8 @@ public class PropfindXmlParser
public String DisplayName;
public String LastModified;
public String ContentLength;
public boolean IsCollection;
}
public String status;
public Prop prop;
@@ -191,6 +193,8 @@ public class PropfindXmlParser
continue;
}
String name = parser.getName();
String namespace = parser.getNamespace();
android.util.Log.d("PARSE", "4name = " + name);
if (name.equals("getcontentlength"))
@@ -200,6 +204,9 @@ public class PropfindXmlParser
prop.LastModified = readText(parser);
} else if (name.equals("displayname")) {
prop.DisplayName = readText(parser);
} else if (name.equals("resourcetype") && namespace.equals(ns)) {
// We found the <d:resourcetype> tag
prop.IsCollection = readResourceType(parser);
} else {
skip(parser);
}
@@ -208,6 +215,37 @@ public class PropfindXmlParser
return prop;
}
private boolean readResourceType(XmlPullParser parser) throws IOException, XmlPullParserException {
boolean isCollection = false;
parser.require(XmlPullParser.START_TAG, ns, "resourcetype");
while (parser.next() != XmlPullParser.END_TAG) {
if (parser.getEventType() != XmlPullParser.START_TAG) {
continue;
}
String name = parser.getName();
String namespace = parser.getNamespace();
if (name.equals("collection") && namespace.equals(ns)) {
// We found <d:collection/>, so it's a folder
isCollection = true;
// Since <d:collection/> is usually an empty tag, just consume it.
// It might contain text if there's whitespace, so consume text then end tag.
if (parser.next() == XmlPullParser.TEXT) {
parser.nextTag(); // Move to the end tag
}
parser.require(XmlPullParser.END_TAG, ns, "collection");
} else {
// Skip any other unexpected tags within <d:resourcetype>
skip(parser);
}
}
// After reading all children of <d:resourcetype>, ensure we are at its END_TAG
parser.require(XmlPullParser.END_TAG, ns, "resourcetype");
return isCollection;
}
private void skip(XmlPullParser parser) throws XmlPullParserException, IOException {
android.util.Log.d("PARSE", "skipping " + parser.getName());

View File

@@ -548,7 +548,7 @@ public class MainActivity extends Activity implements JavaFileStorage.FileStorag
//storageToTest = new GoogleDriveAppDataFileStorage();
/*storageToTest = new WebDavStorage(new ICertificateErrorHandler() {
storageToTest = new WebDavStorage(new ICertificateErrorHandler() {
@Override
public boolean onValidationError(String error) {
return false;
@@ -558,12 +558,12 @@ public class MainActivity extends Activity implements JavaFileStorage.FileStorag
public boolean alwaysFailOnValidationError() {
return false;
}
});
*/
}, 64*1024);
//storageToTest = new DropboxV2Storage(ctx,"4ybka4p4a1027n6", "1z5lv528un9nre8", !simulateRestart);
//storageToTest = new DropboxFileStorage(ctx,"4ybka4p4a1027n6", "1z5lv528un9nre8", !simulateRestart);
//storageToTest = new DropboxAppFolderFileStorage(ctx,"ax0268uydp1ya57", "3s86datjhkihwyc", true);
storageToTest = new GoogleDriveFullFileStorage();
// storageToTest = new GoogleDriveFullFileStorage();
return storageToTest;

View File

@@ -9,7 +9,9 @@ using System.Text;
using Android.App;
using Android.Content;
using Android.Content.Res;
using Android.OS;
using Android.Preferences;
using Android.Runtime;
using Android.Views;
using Android.Widget;
@@ -319,7 +321,7 @@ namespace keepass2android
View dlgContents = activity.LayoutInflater.Inflate(Resource.Layout.httpcredentials, null);
if (!defaultPath.EndsWith(_schemeSeparator))
{
var webdavStorage = new Keepass2android.Javafilestorage.WebDavStorage(App.Kp2a.CertificateErrorHandler);
var webdavStorage = CreateWebdavStorage(activity);
var connInfo = webdavStorage.SplitStringToConnectionInfo(defaultPath);
dlgContents.FindViewById<EditText>(Resource.Id.http_url).Text = connInfo.Url;
dlgContents.FindViewById<EditText>(Resource.Id.http_user).Text = connInfo.Username;
@@ -339,7 +341,7 @@ namespace keepass2android
string scheme = defaultPath.Substring(0, defaultPath.IndexOf(_schemeSeparator, StringComparison.Ordinal));
if (host.Contains(_schemeSeparator) == false)
host = scheme + _schemeSeparator + host;
string httpPath = new Keepass2android.Javafilestorage.WebDavStorage(null).BuildFullPath(host, user,
string httpPath = CreateWebdavStorage(activity).BuildFullPath(host, user,
password);
onStartBrowse(httpPath);
});
@@ -352,8 +354,13 @@ namespace keepass2android
dialog.Show();
#endif
}
private void ShowFtpDialog(Activity activity, Util.FileSelectedHandler onStartBrowse, Action onCancel, string defaultPath)
#if !NoNet
private static WebDavStorage CreateWebdavStorage(Activity activity)
{
return new WebDavStorage(App.Kp2a.CertificateErrorHandler, App.Kp2a.WebDavChunkedUploadSize);
}
#endif
private void ShowFtpDialog(Activity activity, Util.FileSelectedHandler onStartBrowse, Action onCancel, string defaultPath)
{
#if !NoNet
MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(activity);
@@ -518,7 +525,7 @@ namespace keepass2android
string scheme = defaultPath.Substring(0,defaultPath.IndexOf(_schemeSeparator, StringComparison.Ordinal));
if (host.Contains(_schemeSeparator) == false)
host = scheme + _schemeSeparator + host;
string httpPath = new Keepass2android.Javafilestorage.WebDavStorage(null).BuildFullPath(WebDavFileStorage.Owncloud2Webdav(host, subtype == "owncloud" ? WebDavFileStorage.owncloudPrefix : WebDavFileStorage.nextcloudPrefix), user,
string httpPath = CreateWebdavStorage(activity).BuildFullPath(WebDavFileStorage.Owncloud2Webdav(host, subtype == "owncloud" ? WebDavFileStorage.owncloudPrefix : WebDavFileStorage.nextcloudPrefix), user,
password);
onStartBrowse(httpPath);
});

View File

@@ -209,6 +209,7 @@
<string name="ShowUnlockedNotification_key">ShowUnlockedNotification</string>
<bool name="ShowUnlockedNotification_default">true</bool>
<integer name="WebDavChunkedUploadSize_default">65536</integer>
<string name="PreloadDatabaseEnabled_key">PreloadDatabaseEnabled</string>
<bool name="PreloadDatabaseEnabled_default">true</bool>

View File

@@ -729,6 +729,9 @@
<string name="EntryChannel_desc">Notification to simplify access to the currently selected entry.</string>
<string name="CloseDbAfterFailedAttempts">Close database after three failed biometric unlock attempts.</string>
<string name="WarnFingerprintInvalidated">Warning! Biometric authentication can be invalidated by Android, e.g. after adding a new fingerprint in your device settings. Make sure you always know how to unlock with your master password!</string>
<string name="webdav_chunked_upload_size_title">Chunk size for WebDav upload</string>
<string name="webdav_chunked_upload_size_summary">Size of chunks when uploading to WebDav servers in bytes. Use 0 to disable chunked upload.</string>
<string-array name="ChangeLog_1_13">
<item>Improved password quality estimation by considering most popular passwords.</item>

View File

@@ -45,6 +45,14 @@
android:title="@string/UseFileTransactions_title"
android:key="@string/UseFileTransactions_key" />
<EditTextPreference
android:key="WebDavChunkedUploadSize_str"
android:title="@string/webdav_chunked_upload_size_title"
android:summary="@string/webdav_chunked_upload_size_title"
android:defaultValue="@integer/WebDavChunkedUploadSize_default"
android:inputType="number"
/>
<CheckBoxPreference
android:enabled="true"
android:persistent="true"
@@ -80,5 +88,6 @@
android:defaultValue="true"
android:title="@string/CheckForDuplicateUuids_title"
android:key="@string/CheckForDuplicateUuids_key" />
</PreferenceScreen>

View File

@@ -836,8 +836,8 @@ namespace keepass2android
new AndroidContentStorage(LocaleManager.LocalizedAppContext),
#if !EXCLUDE_JAVAFILESTORAGE
#if !NoNet
new DropboxFileStorage(LocaleManager.LocalizedAppContext, this),
new DropboxAppFolderFileStorage(LocaleManager.LocalizedAppContext, this),
DropboxFileStorage.IsConfigured ? new DropboxFileStorage(LocaleManager.LocalizedAppContext, this) : null,
DropboxAppFolderFileStorage.IsConfigured ? new DropboxAppFolderFileStorage(LocaleManager.LocalizedAppContext, this): null,
GoogleApiAvailability.Instance.IsGooglePlayServicesAvailable(LocaleManager.LocalizedAppContext)==ConnectionResult.Success ? new GoogleDriveFileStorage(LocaleManager.LocalizedAppContext, this) : null,
GoogleApiAvailability.Instance.IsGooglePlayServicesAvailable(LocaleManager.LocalizedAppContext)==ConnectionResult.Success ? new GoogleDriveAppDataFileStorage(LocaleManager.LocalizedAppContext, this) : null,
new OneDriveFileStorage(this),
@@ -846,8 +846,8 @@ namespace keepass2android
new OneDrive2AppFolderFileStorage(),
new SftpFileStorage(LocaleManager.LocalizedAppContext, this, IsFtpDebugEnabled()),
new NetFtpFileStorage(LocaleManager.LocalizedAppContext, this, IsFtpDebugEnabled),
new WebDavFileStorage(this),
new PCloudFileStorage(LocaleManager.LocalizedAppContext, this),
new WebDavFileStorage(this, WebDavChunkedUploadSize),
new PCloudFileStorage(LocaleManager.LocalizedAppContext, this),
new PCloudFileStorageAll(LocaleManager.LocalizedAppContext, this),
new MegaFileStorage(App.Context),
//new LegacyWebDavStorage(this),
@@ -1333,6 +1333,18 @@ namespace keepass2android
}
}
public int WebDavChunkedUploadSize
{
get
{
return int.Parse(PreferenceManager.GetDefaultSharedPreferences(LocaleManager.LocalizedAppContext)
.GetString("WebDavChunkedUploadSize_str",
LocaleManager.LocalizedAppContext.Resources
.GetInteger(Resource.Integer.WebDavChunkedUploadSize_default).ToString()));
}
}
}
@@ -1458,8 +1470,7 @@ namespace keepass2android
{
Kp2aLog.LogUnexpectedError(e.Exception);
}
}
}
}