diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 09cb36b7..e5ca3589 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,10 +3,9 @@ env: NAME: 'Release' on: - push: - tags: - - "v1.*" - workflow_dispatch: # Allows manual triggering of the workflow + # the workflow is always triggered manually. This allows to test the apks + # before publishing the release and not having a broken tag in the repo if that test fails. + workflow_dispatch: jobs: build-release: diff --git a/src/Kp2aBusinessLogic/IKp2aApp.cs b/src/Kp2aBusinessLogic/IKp2aApp.cs index 9a8357f8..6e4e9c1f 100644 --- a/src/Kp2aBusinessLogic/IKp2aApp.cs +++ b/src/Kp2aBusinessLogic/IKp2aApp.cs @@ -140,6 +140,10 @@ namespace keepass2android #endif + int WebDavChunkedUploadSize + { + get; + } } } \ No newline at end of file diff --git a/src/Kp2aBusinessLogic/Io/DropboxFileStorage.cs b/src/Kp2aBusinessLogic/Io/DropboxFileStorage.cs index 4ad64472..0f354a5c 100644 --- a/src/Kp2aBusinessLogic/Io/DropboxFileStorage.cs +++ b/src/Kp2aBusinessLogic/Io/DropboxFileStorage.cs @@ -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); } } diff --git a/src/Kp2aBusinessLogic/Io/JavaFileStorage.cs b/src/Kp2aBusinessLogic/Io/JavaFileStorage.cs index e526928e..2c39e594 100644 --- a/src/Kp2aBusinessLogic/Io/JavaFileStorage.cs +++ b/src/Kp2aBusinessLogic/Io/JavaFileStorage.cs @@ -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); } diff --git a/src/Kp2aBusinessLogic/Io/NetFtpFileStorage.cs b/src/Kp2aBusinessLogic/Io/NetFtpFileStorage.cs index 9cd314b8..46ea23da 100644 --- a/src/Kp2aBusinessLogic/Io/NetFtpFileStorage.cs +++ b/src/Kp2aBusinessLogic/Io/NetFtpFileStorage.cs @@ -8,6 +8,7 @@ using Android.Content; using Android.OS; using FluentFTP; using FluentFTP.Exceptions; +using FluentFTP.GnuTLS; using KeePass.Util; using KeePassLib; using KeePassLib.Serialization; @@ -140,6 +141,7 @@ namespace keepass2android.Io var settings = ConnectionSettings.FromIoc(ioc); FtpClient client = new FtpClient(); + client.Config.CustomStream = typeof(GnuTlsStream); client.Config.RetryAttempts = 3; if ((settings.Username.Length > 0) || (settings.Password.Length > 0)) client.Credentials = new NetworkCredential(settings.Username, settings.Password); diff --git a/src/Kp2aBusinessLogic/Io/SmbFileStorage.cs b/src/Kp2aBusinessLogic/Io/SmbFileStorage.cs new file mode 100644 index 00000000..11eec514 --- /dev/null +++ b/src/Kp2aBusinessLogic/Io/SmbFileStorage.cs @@ -0,0 +1,617 @@ +#if !NoNet +using System.Net; +using Android.Content; +using keepass2android; +using keepass2android.Io; +using KeePassLib.Serialization; +using SMBLibrary.Client; +using SMBLibrary; +using FileAttributes = SMBLibrary.FileAttributes; +using KeePassLib.Utility; +using Java.Nio.FileNio; + +namespace Kp2aBusinessLogic.Io +{ + public class SmbFileStorage : IFileStorage + { + public IEnumerable SupportedProtocols + { + get { yield return "smb"; } + } + + public bool UserShouldBackup + { + get { return false; } + } + + public void Delete(IOConnectionInfo ioc) + { + throw new NotImplementedException(); + } + + public bool CheckForFileChangeFast(IOConnectionInfo ioc, string previousFileVersion) + { + return false; + } + + public string GetCurrentFileVersionFast(IOConnectionInfo ioc) + { + return null; + } + + public struct SmbConnectionInfo + { + public string Host; + public string Username; + public string Password; + public string? Domain; + public string? Share; + public string? LocalPath; + + public static SmbConnectionInfo FromUrlAndCredentials(string url, string username, string password, string? domain) + { + string userDomain = username; + if (domain != null) + { + userDomain = domain + "\\" + username; + } + if (url.StartsWith("smb://")) + { + url = url.Substring(6); + } + + if (url.StartsWith("\\\\")) + { + url = url.Substring(2); + } + + url = url.Replace("\\", "/"); + + string fullPath = "smb://" + WebUtility.UrlEncode(userDomain) + ":" + WebUtility.UrlEncode(password) + "@" + url; + return new SmbConnectionInfo(new IOConnectionInfo() { Path = fullPath} ); + } + + + public SmbConnectionInfo(IOConnectionInfo ioc) + { + string fullpath = ioc.Path; + if (!fullpath.StartsWith("smb://")) + { + throw new Exception("Invalid smb path!"); + } + + fullpath = fullpath.Substring(6); + string[] authAndPath = fullpath.Split('@'); + if (authAndPath.Length != 2) + { + throw new Exception("Invalid smb path!"); + } + + string[] userAndPwd = authAndPath[0].Split(':'); + if (userAndPwd.Length != 2) + { + throw new Exception("Invalid smb path!"); + } + + string[] pathParts = authAndPath[1].Split('/'); + if (pathParts.Length < 1) + { + throw new Exception("Invalid smb path!"); + } + + Host = pathParts[0]; + if (pathParts.Length > 1) + { + Share = pathParts[1]; + } + LocalPath = String.Join("/", pathParts.Skip(2)); + if (LocalPath.EndsWith("/")) + { + LocalPath = LocalPath.Substring(0, LocalPath.Length - 1); + } + + Username = WebUtility.UrlDecode(userAndPwd[0]); + if (Username.Contains("\\")) + { + string[] domainAndUser = Username.Split('\\'); + Domain = domainAndUser[0]; + Username = domainAndUser[1]; + } + else Domain = null; + + Password = WebUtility.UrlDecode(userAndPwd[1]); + } + + public string ToPath() + { + string domainUser = Username; + if (Domain != null) + { + domainUser = Domain + "\\" + Username; + } + + return "smb://" + WebUtility.UrlEncode(domainUser) + ":" + WebUtility.UrlEncode(Password) + "@" + Host + + "/" + Share + "/" + LocalPath; + } + + public string GetPathWithoutCredentials() + { + return "smb://" + Host + "/" + Share + "/" + LocalPath; + } + + public string GetLocalSmbPath() + { + return LocalPath?.Replace("/", "\\") ?? ""; + } + + public SmbConnectionInfo GetParent() + { + SmbConnectionInfo parent = new SmbConnectionInfo + { + Host = Host, + Username = Username, + Password = Password, + Domain = Domain, + Share = Share + }; + string[] pathParts = LocalPath?.Split('/') ?? []; + if (pathParts.Length > 0) + { + parent.LocalPath = string.Join("/", pathParts.Take(pathParts.Length - 1)); + } + else + { + parent.LocalPath = ""; + parent.Share = ""; + } + + return parent; + } + + public string Stem() + { + return LocalPath?.Split('/').Last() ?? ""; + } + + + public SmbConnectionInfo GetChild(string childName) + { + SmbConnectionInfo child = new SmbConnectionInfo(); + child.Host = Host; + child.Username = Username; + child.Password = Password; + child.Domain = Domain; + if (string.IsNullOrEmpty(Share)) + { + child.Share = childName; + } + else + { + + child.Share = Share; + var pathPartsList = LocalPath?.Split('/').Where(p => !string.IsNullOrEmpty(p)).ToList() ?? []; + pathPartsList.Add(childName); + child.LocalPath = string.Join("/", pathPartsList); + } + + return child; + } + + public string ToDisplayString() + { + return "smb://" + Host + "/" + Share + "/" + LocalPath; + + } + } + + + class SmbConnection: IDisposable + { + public SmbConnection(SmbConnectionInfo info) + { + _isLoggedIn = false; + var isConnected = Client.Connect(info.Host, SMBTransportType.DirectTCPTransport); + if (!isConnected) + { + throw new Exception($"Failed to connect to SMB server {info.Host}"); + } + + var status = Client.Login(info.Domain ?? string.Empty, info.Username, info.Password); + if (status != NTStatus.STATUS_SUCCESS) + { + throw new Exception($"Failed to login to SMB as {info.Username}"); + } + + _isLoggedIn = true; + + if (!string.IsNullOrEmpty(info.Share)) + { + FileStore = Client.TreeConnect(info.Share, out status); + } + + } + + + + public readonly SMB2Client Client = new SMB2Client(); + + + public readonly ISMBFileStore? FileStore; + private readonly bool _isLoggedIn; + + public void Dispose() + { + FileStore?.Disconnect(); + + if (_isLoggedIn) + Client.Logoff(); + + if (!Client.IsConnected) return; + Client.Disconnect(); + + + } + } + + + + public Stream OpenFileForRead(IOConnectionInfo ioc) + { + + SmbConnectionInfo info = new SmbConnectionInfo(ioc); + using SmbConnection conn = new SmbConnection(info); + + if (conn.FileStore == null) + { + throw new Exception($"Failed to read to {info.GetPathWithoutCredentials()}"); + } + + + NTStatus status = conn.FileStore.CreateFile(out var fileHandle, out _, info.GetLocalSmbPath(), + AccessMask.GENERIC_READ | AccessMask.SYNCHRONIZE, FileAttributes.Normal, ShareAccess.Read, + CreateDisposition.FILE_OPEN, + CreateOptions.FILE_NON_DIRECTORY_FILE | CreateOptions.FILE_SYNCHRONOUS_IO_ALERT, null); + + if (status != NTStatus.STATUS_SUCCESS) + { + throw new Exception($"Failed to open file {info.LocalPath}"); + } + + var stream = new MemoryStream(); + long bytesRead = 0; + while (true) + { + status = conn.FileStore.ReadFile(out var data, fileHandle, bytesRead, (int)conn.Client.MaxReadSize); + if (status != NTStatus.STATUS_SUCCESS && status != NTStatus.STATUS_END_OF_FILE) + { + throw new Exception("Failed to read from file"); + } + + if (status == NTStatus.STATUS_END_OF_FILE || data.Length == 0) + { + break; + } + + bytesRead += data.Length; + stream.Write(data, 0, data.Length); + } + + stream.Seek(0, SeekOrigin.Begin); + return stream; + } + + + class SmbFileStorageWriteTransaction : IWriteTransaction + { + private bool UseFileTransaction { get; } + private readonly string _path; + private readonly string _uploadPath; + private readonly SmbFileStorage _fileStorage; + private MemoryStream? _memoryStream; + + public SmbFileStorageWriteTransaction(string path, SmbFileStorage fileStorage, bool useFileTransaction) + { + UseFileTransaction = useFileTransaction; + _path = path; + if (useFileTransaction) + { + _uploadPath = _path + Guid.NewGuid().ToString().Substring(0, 8) + ".tmp"; + } + else + { + _uploadPath = _path; + } + + + _fileStorage = fileStorage; + _memoryStream = null; + } + + public void Dispose() + { + _memoryStream?.Dispose(); + } + + public Stream OpenFile() + { + _memoryStream = new MemoryStream(); + return _memoryStream; + } + + public void CommitWrite() + { + _fileStorage.UploadData(new MemoryStream(_memoryStream!.ToArray()), new SmbConnectionInfo(new IOConnectionInfo() { Path = _uploadPath})); + if (UseFileTransaction) + { + SmbConnectionInfo uploadPath = new SmbConnectionInfo(new IOConnectionInfo() { Path = _uploadPath }); + SmbConnectionInfo finalPath = new SmbConnectionInfo(new IOConnectionInfo() { Path = _path }); + _fileStorage.RenameFile(uploadPath, finalPath); + } + + + } + } + + private void RenameFile(SmbConnectionInfo fromPath, SmbConnectionInfo toPath) + { + using var connection = new SmbConnection(fromPath); + + // Open existing file + var status = connection.FileStore!.CreateFile(out var handle, out _, fromPath.GetLocalSmbPath(), AccessMask.MAXIMUM_ALLOWED, 0, ShareAccess.Read, CreateDisposition.FILE_OPEN, CreateOptions.FILE_NON_DIRECTORY_FILE, null); + if (status != NTStatus.STATUS_SUCCESS) + throw new Exception($"Failed to open {fromPath.LocalPath} for renaming!"); + + FileRenameInformationType2 renameInfo = new FileRenameInformationType2 + { + FileName = toPath.GetLocalSmbPath(), + ReplaceIfExists = true + }; + connection.FileStore.SetFileInformation(handle, renameInfo); + connection.FileStore.CloseFile(handle); + + } + + private void UploadData(Stream data, SmbConnectionInfo uploadPath) + { + using var connection = new SmbConnection(uploadPath); + var status = connection.FileStore!.CreateFile(out var fileHandle, out _, uploadPath.GetLocalSmbPath(), AccessMask.GENERIC_WRITE | AccessMask.SYNCHRONIZE, FileAttributes.Normal, ShareAccess.None, CreateDisposition.FILE_CREATE, CreateOptions.FILE_NON_DIRECTORY_FILE | CreateOptions.FILE_SYNCHRONOUS_IO_ALERT, null); + if (status == NTStatus.STATUS_OBJECT_NAME_COLLISION) + status = connection.FileStore!.CreateFile(out fileHandle, out _, uploadPath.GetLocalSmbPath(), AccessMask.GENERIC_WRITE | AccessMask.SYNCHRONIZE, FileAttributes.Normal, ShareAccess.None, CreateDisposition.FILE_OVERWRITE, CreateOptions.FILE_NON_DIRECTORY_FILE | CreateOptions.FILE_SYNCHRONOUS_IO_ALERT, null); + if (status != NTStatus.STATUS_SUCCESS) + { + throw new Exception($"Failed to open {uploadPath.LocalPath} for writing!"); + } + + long writeOffset = 0; + while (data.Position < data.Length) + { + byte[] buffer = new byte[(int)connection.Client.MaxWriteSize]; + int bytesRead = data.Read(buffer, 0, buffer.Length); + if (bytesRead < (int)connection.Client.MaxWriteSize) + { + Array.Resize(ref buffer, bytesRead); + } + + status = connection.FileStore.WriteFile(out _, fileHandle, writeOffset, buffer); + if (status != NTStatus.STATUS_SUCCESS) + { + throw new Exception("Failed to write to file"); + } + writeOffset += bytesRead; + } + + } + + public IWriteTransaction OpenWriteTransaction(IOConnectionInfo ioc, bool useFileTransaction) + { + return new SmbFileStorageWriteTransaction(ioc.Path, this, useFileTransaction); + } + + public string GetFilenameWithoutPathAndExt(IOConnectionInfo ioc) + { + return UrlUtil.StripExtension( + UrlUtil.GetFileName(ioc.Path)); + + } + + public string GetFileExtension(IOConnectionInfo ioc) + { + return UrlUtil.GetExtension(ioc.Path); + } + + public bool RequiresCredentials(IOConnectionInfo ioc) + { + return false; + } + + public void CreateDirectory(IOConnectionInfo ioc, string newDirName) + { + throw new NotImplementedException(); + } + + private static IEnumerable ListShares(SmbConnection conn, SmbConnectionInfo parent) + { + foreach (string share in conn.Client.ListShares(out _)) + { + yield return new FileDescription() + { + CanRead = true, + CanWrite = true, + DisplayName = share, + IsDirectory = true, + Path = parent.GetChild(share).ToPath() + }; + } + + } + + public IEnumerable ListContents(IOConnectionInfo ioc) + { + List result = []; + SmbConnectionInfo info = new SmbConnectionInfo(ioc); + using SmbConnection conn = new SmbConnection(info); + if (string.IsNullOrEmpty(info.Share)) + { + var shares = ListShares(conn, info).ToList(); + return shares; + } + + NTStatus status = conn.FileStore!.CreateFile(out var directoryHandle, out _, info.GetLocalSmbPath(), AccessMask.GENERIC_READ, FileAttributes.Directory, ShareAccess.Read | ShareAccess.Write, CreateDisposition.FILE_OPEN, CreateOptions.FILE_DIRECTORY_FILE, null); + if (status == NTStatus.STATUS_SUCCESS) + { + conn.FileStore.QueryDirectory(out List fileList, directoryHandle, "*", FileInformationClass.FileDirectoryInformation); + foreach (var fi in fileList) + { + var fileDirectoryInformation = fi as FileDirectoryInformation; + if (fileDirectoryInformation == null) + continue; + + if (fileDirectoryInformation.FileName is "." or "..") + continue; + + var fileDescription = FileDescriptionConvert(ioc, fileDirectoryInformation); + + result.Add(fileDescription); + } + conn.FileStore.CloseFile(directoryHandle); + } + + return result; + } + + private FileDescription FileDescriptionConvert(IOConnectionInfo parentIoc, + FileDirectoryInformation fileDirectoryInformation) + { + FileDescription fileDescription = new FileDescription + { + CanRead = true, + CanWrite = true, + IsDirectory = (fileDirectoryInformation.FileAttributes & FileAttributes.Directory) != 0, + DisplayName = fileDirectoryInformation.FileName + }; + fileDescription.Path = CreateFilePath(parentIoc.Path, fileDescription.DisplayName); + fileDescription.LastModified = fileDirectoryInformation.LastWriteTime; + + fileDescription.SizeInBytes = fileDirectoryInformation.EndOfFile; + return fileDescription; + } + + public FileDescription GetFileDescription(IOConnectionInfo ioc) + { + SmbConnectionInfo info = new SmbConnectionInfo(ioc); + + if (string.IsNullOrEmpty(info.Share)) + { + return new FileDescription + { + CanRead = true, CanWrite = true, + DisplayName = info.Host, + IsDirectory = true, + Path = info.ToPath() + }; + } + + using SmbConnection conn = new SmbConnection(info); + NTStatus status = conn.FileStore!.CreateFile(out var directoryHandle, out _, info.GetParent().GetLocalSmbPath(), AccessMask.GENERIC_READ, FileAttributes.Directory, ShareAccess.Read | ShareAccess.Write, CreateDisposition.FILE_OPEN, CreateOptions.FILE_DIRECTORY_FILE, null); + if (status != NTStatus.STATUS_SUCCESS) throw new Exception($"Failed to query details for {info.LocalPath}"); + conn.FileStore.QueryDirectory(out List fileList, directoryHandle, info.Stem(), FileInformationClass.FileDirectoryInformation); + foreach (var fi in fileList) + { + var fileDirectoryInformation = fi as FileDirectoryInformation; + if (fileDirectoryInformation == null) + continue; + + if (fileDirectoryInformation.FileName is "." or "..") + continue; + + return FileDescriptionConvert(ioc, fileDirectoryInformation); + + + } + conn.FileStore.CloseFile(directoryHandle); + + throw new Exception($"Failed to query details for {info.LocalPath}"); + } + + public bool RequiresSetup(IOConnectionInfo ioConnection) + { + return false; + } + + public string IocToPath(IOConnectionInfo ioc) + { + return ioc.Path; + } + + public void StartSelectFile(IFileStorageSetupInitiatorActivity activity, bool isForSave, int requestCode, string protocolId) + { + activity.PerformManualFileSelect(isForSave, requestCode, protocolId); + + } + + public void PrepareFileUsage(IFileStorageSetupInitiatorActivity activity, IOConnectionInfo ioc, int requestCode, + bool alwaysReturnSuccess) + { + Intent intent = new Intent(); + activity.IocToIntent(intent, ioc); + activity.OnImmediateResult(requestCode, (int)FileStorageResults.FileUsagePrepared, intent); + } + + public void PrepareFileUsage(Context ctx, IOConnectionInfo ioc) + { + + } + + public void OnCreate(IFileStorageSetupActivity activity, Bundle savedInstanceState) + { + + } + + public void OnResume(IFileStorageSetupActivity activity) + { + + } + + public void OnStart(IFileStorageSetupActivity activity) + { + + } + + public void OnActivityResult(IFileStorageSetupActivity activity, int requestCode, int resultCode, Intent data) + { + + } + + public string GetDisplayName(IOConnectionInfo ioc) + { + return new SmbConnectionInfo(ioc).ToDisplayString(); + } + + public string CreateFilePath(string parent, string newFilename) + { + return new SmbConnectionInfo(new IOConnectionInfo() { Path = parent}).GetChild(newFilename).ToPath(); + } + + public IOConnectionInfo GetParentPath(IOConnectionInfo ioc) + { + SmbConnectionInfo connectionInfo = new SmbConnectionInfo(ioc); + return new IOConnectionInfo() { Path = connectionInfo.GetParent().ToPath() }; + } + + public IOConnectionInfo GetFilePath(IOConnectionInfo folderPath, string filename) + { + return new IOConnectionInfo() { Path = CreateFilePath(folderPath.Path, filename)}; + } + + public bool IsPermanentLocation(IOConnectionInfo ioc) + { + return true; + } + + public bool IsReadOnly(IOConnectionInfo ioc, OptionalOut reason = null) + { + return false; + } + } +} +#endif \ No newline at end of file diff --git a/src/Kp2aBusinessLogic/Io/WebDavFileStorage.cs b/src/Kp2aBusinessLogic/Io/WebDavFileStorage.cs index 6cce6119..2d80e18a 100644 --- a/src/Kp2aBusinessLogic/Io/WebDavFileStorage.cs +++ b/src/Kp2aBusinessLogic/Io/WebDavFileStorage.cs @@ -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 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 } \ No newline at end of file diff --git a/src/Kp2aBusinessLogic/Kp2aBusinessLogic.csproj b/src/Kp2aBusinessLogic/Kp2aBusinessLogic.csproj index fc343d93..de807952 100644 --- a/src/Kp2aBusinessLogic/Kp2aBusinessLogic.csproj +++ b/src/Kp2aBusinessLogic/Kp2aBusinessLogic.csproj @@ -10,10 +10,12 @@ - + + + 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 26710fdf..fb8ec597 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 @@ -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 { } } + diff --git a/src/java/JavaFileStorage/app/src/main/java/keepass2android/javafilestorage/webdav/PropfindXmlParser.java b/src/java/JavaFileStorage/app/src/main/java/keepass2android/javafilestorage/webdav/PropfindXmlParser.java index 59ed2034..f424fb27 100644 --- a/src/java/JavaFileStorage/app/src/main/java/keepass2android/javafilestorage/webdav/PropfindXmlParser.java +++ b/src/java/JavaFileStorage/app/src/main/java/keepass2android/javafilestorage/webdav/PropfindXmlParser.java @@ -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 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 , so it's a folder + isCollection = true; + // Since 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 + skip(parser); + } + } + // After reading all children of , 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()); diff --git a/src/java/JavaFileStorageTest-AS/app/src/main/java/com/crocoapps/javafilestoragetest2/MainActivity.java b/src/java/JavaFileStorageTest-AS/app/src/main/java/com/crocoapps/javafilestoragetest2/MainActivity.java index 1c81a604..5e8fcc35 100644 --- a/src/java/JavaFileStorageTest-AS/app/src/main/java/com/crocoapps/javafilestoragetest2/MainActivity.java +++ b/src/java/JavaFileStorageTest-AS/app/src/main/java/com/crocoapps/javafilestoragetest2/MainActivity.java @@ -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; diff --git a/src/keepass2android-app/ChangeLog.cs b/src/keepass2android-app/ChangeLog.cs index 1c1a3723..7fa17098 100644 --- a/src/keepass2android-app/ChangeLog.cs +++ b/src/keepass2android-app/ChangeLog.cs @@ -31,6 +31,10 @@ namespace keepass2android MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(ctx); builder.SetTitle(ctx.GetString(Resource.String.ChangeLog_title)); List changeLog = new List{ + #if !NoNet + BuildChangelogString(ctx, new List{Resource.Array.ChangeLog_1_14_net}, "1.14"), + #endif + BuildChangelogString(ctx, new List{Resource.Array.ChangeLog_1_13}, "1.13"), BuildChangelogString(ctx, new List{Resource.Array.ChangeLog_1_12 diff --git a/src/keepass2android-app/FileSelectHelper.cs b/src/keepass2android-app/FileSelectHelper.cs index 911b5300..d3937911 100644 --- a/src/keepass2android-app/FileSelectHelper.cs +++ b/src/keepass2android-app/FileSelectHelper.cs @@ -4,12 +4,15 @@ using System.Linq; using System.Net; #if !NoNet using FluentFTP; +using static Kp2aBusinessLogic.Io.SmbFileStorage; #endif 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; @@ -23,6 +26,7 @@ using Keepass2android.Javafilestorage; using KeePassLib.Serialization; using KeePassLib.Utility; + namespace keepass2android { public class FileSelectHelper @@ -274,7 +278,8 @@ namespace keepass2android builder.SetNegativeButton(Android.Resource.String.Cancel, evtH); builder.SetTitle(activity.GetString(Resource.String.enter_sftp_login_title)); - Dialog dialog = builder.Create(); + builder.SetCancelable(false); + Dialog dialog = builder.Create(); dialog.Show(); #endif @@ -319,7 +324,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(Resource.Id.http_url).Text = connInfo.Url; dlgContents.FindViewById(Resource.Id.http_user).Text = connInfo.Username; @@ -339,7 +344,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); }); @@ -347,13 +352,61 @@ namespace keepass2android builder.SetNegativeButton(Android.Resource.String.Cancel, evtH); builder.SetTitle(activity.GetString(Resource.String.enter_http_login_title)); - Dialog dialog = builder.Create(); + builder.SetCancelable(false); + Dialog dialog = builder.Create(); dialog.Show(); #endif } - private void ShowFtpDialog(Activity activity, Util.FileSelectedHandler onStartBrowse, Action onCancel, string defaultPath) + + private void ShowSmbDialog(Activity activity, Util.FileSelectedHandler onStartBrowse, Action onCancel, string defaultPath) + { +#if !EXCLUDE_JAVAFILESTORAGE && !NoNet + MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(activity); + View dlgContents = activity.LayoutInflater.Inflate(Resource.Layout.smbcredentials, null); + if (!defaultPath.EndsWith(_schemeSeparator)) + { + SmbConnectionInfo ci = new SmbConnectionInfo(new IOConnectionInfo() { Path = defaultPath }); + + dlgContents.FindViewById(Resource.Id.smb_url).Text = ci.GetPathWithoutCredentials(); + dlgContents.FindViewById(Resource.Id.smb_domain).Text = ci.Domain; + dlgContents.FindViewById(Resource.Id.smb_user).Text = ci.Username; + dlgContents.FindViewById(Resource.Id.smb_password).Text = ci.Password; + + + } + builder.SetView(dlgContents); + builder.SetPositiveButton(Android.Resource.String.Ok, + (sender, args) => + { + string url = dlgContents.FindViewById(Resource.Id.smb_url).Text; + + string user = dlgContents.FindViewById(Resource.Id.smb_user).Text; + string password = dlgContents.FindViewById(Resource.Id.smb_password).Text; + string domain = dlgContents.FindViewById(Resource.Id.smb_domain).Text; + + string fullPath = SmbConnectionInfo.FromUrlAndCredentials(url, user, password, domain).ToPath(); + onStartBrowse(fullPath); + }); + builder.SetCancelable(false); + + EventHandler evtH = new EventHandler((sender, e) => onCancel()); + + builder.SetNegativeButton(Android.Resource.String.Cancel, evtH); + builder.SetTitle(activity.GetString(Resource.String.enter_smb_login_title)); + Dialog dialog = builder.Create(); + + dialog.Show(); +#endif + } +#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); @@ -403,7 +456,8 @@ namespace keepass2android builder.SetNegativeButton(Android.Resource.String.Cancel, evtH); builder.SetTitle(activity.GetString(Resource.String.enter_ftp_login_title)); - Dialog dialog = builder.Create(); + builder.SetCancelable(false); + Dialog dialog = builder.Create(); dialog.Show(); #endif @@ -463,7 +517,8 @@ namespace keepass2android builder.SetNegativeButton(Android.Resource.String.Cancel, evtH); builder.SetTitle(activity.GetString(Resource.String.enter_mega_login_title)); - Dialog dialog = builder.Create(); + builder.SetCancelable(false); + Dialog dialog = builder.Create(); dialog.Show(); #endif @@ -479,7 +534,9 @@ namespace keepass2android ShowFtpDialog(_activity, ReturnFileOrStartFileChooser, ReturnCancel, defaultPath); else if ((defaultPath.StartsWith("http://")) || (defaultPath.StartsWith("https://"))) ShowHttpDialog(_activity, ReturnFileOrStartFileChooser, ReturnCancel, defaultPath); - else if (defaultPath.StartsWith("owncloud://")) + else if ((defaultPath.StartsWith("smb://"))) + ShowSmbDialog(_activity, ReturnFileOrStartFileChooser, ReturnCancel, defaultPath); + else if (defaultPath.StartsWith("owncloud://")) ShowOwncloudDialog(_activity, ReturnFileOrStartFileChooser, ReturnCancel, defaultPath, "owncloud"); else if (defaultPath.StartsWith("nextcloud://")) ShowOwncloudDialog(_activity, ReturnFileOrStartFileChooser, ReturnCancel, defaultPath, "nextcloud"); @@ -518,7 +575,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); }); @@ -526,7 +583,8 @@ namespace keepass2android builder.SetNegativeButton(Android.Resource.String.Cancel, evtH); builder.SetTitle(activity.GetString(subtype == "owncloud" ? Resource.String.enter_owncloud_login_title : Resource.String.enter_nextcloud_login_title)); - Dialog dialog = builder.Create(); + builder.SetCancelable(false); + Dialog dialog = builder.Create(); dlgContents.FindViewById(Resource.Id.owncloud_url).SetHint(subtype == "owncloud" ? Resource.String.hint_owncloud_url : Resource.String.hint_nextcloud_url); dialog.Show(); #endif diff --git a/src/keepass2android-app/Manifests/AndroidManifest_net.xml b/src/keepass2android-app/Manifests/AndroidManifest_net.xml index b6d42535..d0f38079 100644 --- a/src/keepass2android-app/Manifests/AndroidManifest_net.xml +++ b/src/keepass2android-app/Manifests/AndroidManifest_net.xml @@ -1,7 +1,7 @@  diff --git a/src/keepass2android-app/Manifests/AndroidManifest_nonet.xml b/src/keepass2android-app/Manifests/AndroidManifest_nonet.xml index 54ecad50..bb1ea140 100644 --- a/src/keepass2android-app/Manifests/AndroidManifest_nonet.xml +++ b/src/keepass2android-app/Manifests/AndroidManifest_nonet.xml @@ -1,7 +1,7 @@  diff --git a/src/keepass2android-app/Resources/drawable-xhdpi/ic_storage_smb.png b/src/keepass2android-app/Resources/drawable-xhdpi/ic_storage_smb.png new file mode 100644 index 00000000..40387b21 Binary files /dev/null and b/src/keepass2android-app/Resources/drawable-xhdpi/ic_storage_smb.png differ diff --git a/src/keepass2android-app/Resources/layout/smbcredentials.axml b/src/keepass2android-app/Resources/layout/smbcredentials.axml new file mode 100644 index 00000000..cbde2d3c --- /dev/null +++ b/src/keepass2android-app/Resources/layout/smbcredentials.axml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/keepass2android-app/Resources/values-cs/strings.xml b/src/keepass2android-app/Resources/values-cs/strings.xml index 4b2dc52c..553f382d 100644 --- a/src/keepass2android-app/Resources/values-cs/strings.xml +++ b/src/keepass2android-app/Resources/values-cs/strings.xml @@ -93,6 +93,8 @@ Zakázat Biometrické odemknutí Povolit plné Biometrické odemknutí Povolit Biometrické odemknutí pro Rychlé odemknutí + Rychlé odemknutí pomocí hesla není k dispozici + Funkce Rychlé odemknutí pomocí části hesla je blokována, protože na vašem zařízení není aktivováno zamykání obrazovky. Toto opatření slouží k vaší ochraně pro případ, že by někdo sledoval zadávání klíče pro Rychlé odemknutí. Biometrické odemknutí selhalo. Dešifrovací klíč byl zneplatněn systémem Android. To se obvykle stává, pokud došlo ke změně biometrického ověření nebo bylo změněno bezpečnostní nastavení. Odemknutí databáze se nezdařilo: neplatný složený klíč. Biometrické odemknutí bylo zakázáno z důvodu pravděpodobného vypršení platnosti hlavního hesla. Prosím, povolte znovu Biometrické odemknutí pro nové hlavní heslo. @@ -319,6 +321,7 @@ Vložte kód Rychlého odemknutí: Rychlé odemknutí Zavřít databázi + Povolit zámek obrazovky Ve výchozím nastavení povolit Rychlé odemknutí Určuje, zda je Rychlé odemknutí ve výchozím nastavení povoleno nebo ne. Chránit zobrazení databáze @@ -718,6 +721,11 @@ Upozornění pro usnadnění přístupu k momentálně zvolené položce. Zavřít databázi po třech neúspěšných pokusech o odemknutí. Varování! Biometrické ověření může být zneplatněno Androidem, např. po přidání nového otisku prstu do nastavení zařízení. Ujistěte se, že vždy víte, jak odemknout pomocí hlavního hesla! + + Vylepšené hodnocení kvality hesel založené na nejčastěji používaných heslech. + Pokud zařízení nemá aktivovaný zámek obrazovky, blokuje se funkce Rychlé odemknutí pomocí hesla (z bezpečnostních důvodů). + Aktualizovat konfiguraci zabezpečení sítě pro vypnutí přenosu textu v nešifrované podobě. + Aktualizováno z Xamarin Android na .net 8 Aktualizováno na Target SDK 34 diff --git a/src/keepass2android-app/Resources/values-el/strings.xml b/src/keepass2android-app/Resources/values-el/strings.xml index ba806ef4..9600d615 100644 --- a/src/keepass2android-app/Resources/values-el/strings.xml +++ b/src/keepass2android-app/Resources/values-el/strings.xml @@ -93,6 +93,8 @@ Απενεργοποίηση βιομετρικού ξεκλειδώματος Ενεργοποίηση πλήρους βιομετρικού ξεκλειδώματος Ενεργοποίηση βιομετρικού ξεκλειδώματος για QuickUnlock + Το QuickUnlock βασισμένο σε συνθηματικό δεν είναι διαθέσιμο. + Το QuickUnlock που χρησιμοποιεί ένα τμήμα του συνθηματικού έχει αποκλειστεί, επειδή το κλείδωμα οθόνης είναι απενεργοποιημένο στη συσκευή σας. Αυτό σας προστατεύει σε περίπτωση που κάποιος σας παρακολουθεί όταν εισάγετε το QuickUnlock. Αποτυχία βιομετρικού ξεκλειδώματος. Το κλειδί αποκρυπτογράφησης ακυρώθηκε από το Android. Αυτό συμβαίνει συνήθως αν αλλάξει η βιομετρική αυθεντικοποίηση ή οι ρυθμίσεις ασφάλειας. Το ξεκλείδωμα της βάσης δεδομένων απέτυχε: άκυρο σύνθετο κλειδί. Το βιομετρικό ξεκλείδωμα απενεργοποιήθηκε επειδή το αποθηκευμένο πρωτεύον συνθηματικό δεν είναι πλέον έγκυρο. Ενεργοποιήστε ξανά το βιομετρικό ξεκλείδωμα για το νέο πρωτεύον συνθηματικό. @@ -251,7 +253,6 @@ Εξαίρεση παρόμοιων χαρακτήρων Προφίλ Εισάγετε το όνομα του προφίλ που θα αποθηκευτεί. Εισάγετε ένα υπάρχον όνομα για αντικατάσταση. - Αριθμός λέξεων συνθηματικής φράσης Διαχωριστικό λέξεων Συνθηματικό @@ -319,6 +320,7 @@ Εισάγετε τον κωδικό QuickUnlock: QuickUnlock! Κλείσιμο βάσης δεδομένων + Ενεργοποίηση κλειδώματος οθόνης Ενεργοποίηση QuickUnlock εξ ορισμού Ορίζει αν το QuickUnlock είναι ενεργό εξ ορισμού ή όχι. Προστασία προβολής βάσης δεδομένων @@ -332,7 +334,7 @@ Απόκρυψη μήκους QuickUnlock Αν ενεργοποιηθεί, αποκρύπτει το μήκος του κωδικού QuickUnlock στη σχετική οθόνη. Κλειδί QuickUnlock από τη βάση δεδομένων - Εάν η ενεργή βάση δεδομένων περιέχει μια καταχώριση με τίτλο QuickUnlock στην ομάδα ρίζας της, ο κωδικός πρόσβασης αυτής της καταχώρισης χρησιμοποιείται ως κωδικός QuickUnlock. + Εάν η ενεργή βάση δεδομένων περιέχει μια καταχώριση με τίτλο QuickUnlock στην ομάδα ρίζας της, το συνθηματικό αυτής της καταχώρισης χρησιμοποιείται ως κωδικός QuickUnlock. Αποτυχία QuickUnlock: λανθασμένο συνθηματικό! Αποθήκευση συνημμένου Επιλέξτε πού θα αποθηκεύσετε το συνημμένο. @@ -427,7 +429,7 @@ Άνοιγμα ρυθμίσεων Το Keepass2Android μπορεί να εμφανίζει μια ειδοποίηση συστήματος ενόσω η βάση δεδομένων σας παραμένει ξεκλείδωτη. Για να λειτουργήσει αυτό, χορηγήστε την άδεια. Δεν με νοιάζει - Το αρχείο δεν είναι πλέον προσπελάσιμο στο Keepass2Android. Είτε διαγράφτηκε ή ανακηθηκαν τα δικαιώματα πρόσβασης. Δοκιμάστε να ξανα-ανοίξετε το αρχείο, πχ με Αλλαγή βάσης δεδομένων. + Το αρχείο δεν είναι πλέον προσπελάσιμο στο Keepass2Android. Είτε διαγράφτηκε ή ανακλήθηκαν τα δικαιώματα πρόσβασης. Δοκιμάστε να ξανανοίξετε το αρχείο, πχ με Αλλαγή βάσης δεδομένων. Προ-φόρτωση αρχείου βάσης δεδομένων Ξεκίνημα φόρτωσης στο παρασκήνιο ή λήψη του αρχείου της βάσης δεδομένων κατά την εισαγωγή του συνθηματικού. Συγχρονισμός μετά το QuickUnlock @@ -714,14 +716,19 @@ Ειδοποιήσεις Ειδοποίηση για απλοποιημένη πρόσβαση στην τρέχουσα καταχώριση. Κλείσιμο της βάσης δεδομένων μετά από 3 ανεπιτυχείς προσπάθειες βιομετρικού ξεκλειδώματος. - Προσοχή! Ο βιομετρικός έλεγχος ταυτότητας μπορεί να ακυρωθεί από το Android, π.χ. μετά την προσθήκη ενός νέου δακτυλικού αποτυπώματος στις ρυθμίσεις της συσκευής σας. Βεβαιωθείτε ότι ξέρετε πάντα πώς να ξεκλειδώσετε με τον κύριο κωδικό πρόσβασης! + Προσοχή! Ο βιομετρικός έλεγχος ταυτότητας μπορεί να ακυρωθεί από το Android, π.χ. μετά την προσθήκη ενός νέου δακτυλικού αποτυπώματος στις ρυθμίσεις της συσκευής σας. Βεβαιωθείτε ότι ξέρετε πάντα πώς να ξεκλειδώσετε με το κύριο συνθηματικό! + + Βελτιωμένη εκτίμηση της ποιότητας του συνθηματικού που λαμβάνει υπόψη τα πιο δημοφιλή συνθηματικά + Αποκλεισμός του QuickUnlock βασισμένου στο συνθηματικό (για λόγους ασφαλείας), εάν η συσκευή δεν έχει ενεργοποιημένο το κλείδωμα οθόνης. + Ενημερώστε τις ρυθμίσεις ασφαλείας δικτύου για να απενεργοποιήσετε τη μεταφορά ορατού κειμένου. + Αναβαθμίστηκε από Xamarin Android σε .ΝΕΤ 8 Αναβαθμίστηκε στοχεύοντας το SDK 34 Αναβαθμίστηκε σε διεπαφή χρήστη Material 3 Βελτιώστε την αυτόματη συμπλήρωση για να εργαστείτε με Compose Apps Διόρθωση ονόματος host στην αυτόματη συμπλήρωση και αναζήτηση - Διόρθωση προβλήματος με τη γεννήτρια κωδικού πρόσβασης + Διόρθωση προβλήματος με τη γεννήτρια συνθηματικών Αναβαθμίστηκε το OneDrive SDK στην έκδοση 5.68 @@ -747,12 +754,12 @@ Βελτίωση της υλοποίησης FTP και SFTP Προσθήκη πρόσβασης σε πλήρες pCloud Επιτρέπει την επιλογή γλώσσας συστήματος στο μενού της γλώσσας - Διόρθωση προβλήματος με την απομνημόνευση Keyfile + ερώτηση για τον τύπο του κωδικού πρόσβασης + Διόρθωση προβλήματος με την απομνημόνευση Keyfile + ερώτηση για το συνθηματικό Διόρθωση σφάλματος για απότομα κλεισίματα εφαρμογής και μη αναμενόμενες αποσυνδέσεις Μετάβαση σε νέα υλοποίηση SFTP, υποστηρίζοντας σύγχρονους αλγόριθμους δημόσιου κλειδιού όπως rsa-sha2-256 - Μαρκάρισμα κωδικών πρόσβασης ως ευαίσθητοι κατά την αντιγραφή στο πρόχειρο (Android 13) + Μαρκάρισμα των συνθηματικών ως ευαίσθητα κατά την αντιγραφή στο πρόχειρο (Android 13) Βελτιώσεις Autofill @@ -773,7 +780,7 @@ Προστέθηκε υποστήριξη για τη μορφή αρχείου KDBX 4.1 που εισήχθη στο KeePass 2.48 Προστέθηκε ο διάλογος ρύθμισης ρυθμίσεων TOTP για τις καταχωρίσεις - Βελτιωμένη γεννήτρια κωδικού πρόσβασης: Προστέθηκε υποστήριξη συνθηματικής φράσης, περισσότερες επιλογές, προφίλ και εκτίμηση ισχύος κωδικού πρόσβασης + Βελτιωμένη γεννήτρια συνθηματικών: Προστέθηκε υποστήριξη συνθηματικής φράσης, περισσότερες επιλογές, προφίλ και εκτίμηση ισχύος κωδικού πρόσβασης Βελτιώσεις στην αυτόματη συμπλήρωση (σταθερό αναδυόμενο παράθυρο δεν εμφανίζεται στο Chrome, καλύτερη υποστήριξη υποτομέα) Βελτιώσεις στην υλοποίηση του OneDrive: δεν υπάρχει πλέον όριο μεγέθους, ούτε περιττές αιτήσεις ελέγχου ταυτότητας Προστέθηκε επιλογή για να επιλέξετε το φωτεινό/σκούρο θέμα από τις ρυθμίσεις του συστήματος, συμπεριλαμβανομένων των νυχτερινών πλάνων, απαιτεί Android 10+ @@ -883,7 +890,7 @@ Δεν σχετίζεται το web domain %1$s με την εφαρμογή %2$s Το Keepass2Android ανίχνευσε βιομετρικό εξοπλισμό. Θέλετε να ενεργοποιήσετε βιομετρικό ξεκλείδωμα για αυτή τη βάση δεδομένων; Να επιτρέπονται οι ειδοποιήσεις - Το Keepass2Android μπορεί να εμφανίσει ειδοποιήσεις με κουμπιά για να αντιγράψετε τιμές, όπως κωδικούς πρόσβασης και TOTP στο πρόχειρο, ή για να εμφανιστεί το ενσωματωμένο πληκτρολόγιο. Αυτό είναι χρήσιμο για να μεταφέρετε τιμές σε άλλες εφαρμογές, χωρίς να μεταβείτε σε Keepass2Android επανειλημμένα. Θέλετε να ενεργοποιήσετε αυτές τις ειδοποιήσεις; + Το Keepass2Android μπορεί να εμφανίσει ειδοποιήσεις με κουμπιά για να αντιγράψετε τιμές, όπως συνθηματικά και TOTP στο πρόχειρο, ή για να εμφανιστεί το ενσωματωμένο πληκτρολόγιο. Αυτό είναι χρήσιμο για να μεταφέρετε τιμές σε άλλες εφαρμογές, χωρίς να μεταβείτε σε Keepass2Android επανειλημμένα. Θέλετε να ενεργοποιήσετε αυτές τις ειδοποιήσεις; Να επιτρέπονται οι ειδοποιήσεις Απενεργοποιήστε αυτό το χαρακτηριστικό Όχι τώρα @@ -909,4 +916,8 @@ Εναλλαγή πίσω όταν πατήσετε αποστολή / λήψη / ολοκλήρωση Η σάρωση QR κώδικα απαιτεί Google Play Services. Παρακαλώ εγκαταστήστε ή ενημερώστε τις Google Play Services στη συσκευή σας. Ρυθμίσεις πληκτρολογίου Android + Σημείωση: Έχετε ενεργοποιήσει στις Ρυθμίσεις - Εφαρμογή - Πρόσβαση στα συνθηματικά - Εναλλαγή πληκτρολογίου - Αυτόματη εναλλαγή πληκτρολογίου, αλλά φαίνεται ότι δεν έχει ρυθμιστεί σωστά. + Σημείωση: Έχετε ενεργοποιήσει στις Ρυθμίσεις - Εφαρμογή - Πρόσβαση στα συνθηματικά - Λειτουργία αυτόματης συμπλήρωσης - Αυτόματη συμπλήρωση για καταχωρίσεις TOTP. Αυτό μπορεί να προκαλέσει την εμφάνιση αυτού του παραθύρου όταν ανοίγετε μια καταχώριση με TOTP. + Σημείωση: Έχετε ενεργοποιήσει στις Ρυθμίσεις - Εφαρμογή - Ασφάλεια - Χρήση του ενσωματωμένου στο Keepass2Android πληκτρολογίου. Αυτό μπορεί να προκαλέσει την εμφάνιση αυτού του παραθύρου όταν ανοίγετε την εφαρμογή ή επεξεργάζεστε μια καταχώριση. + Σημείωση: Έχετε ενεργοποιήσει στις Ρυθμίσεις - Εφαρμογή - Πρόσβαση στα συνθηματικά - Εναλλαγή πληκτρολογίου - Εναλλαγή πληκτρολογίου. Αυτό μπορεί να προκαλέσει την εμφάνιση αυτού του παραθύρου κατά την αναζήτηση μιας καταχώρισης από το πρόγραμμα περιήγησης. diff --git a/src/keepass2android-app/Resources/values-pt-rBR/strings.xml b/src/keepass2android-app/Resources/values-pt-rBR/strings.xml index 0f52392e..bc8b05b7 100644 --- a/src/keepass2android-app/Resources/values-pt-rBR/strings.xml +++ b/src/keepass2android-app/Resources/values-pt-rBR/strings.xml @@ -93,6 +93,8 @@ Desabilitar o Desbloqueio Biométrico Habilitar o Desbloqueio de Biométrico completo Habilitar o Desbloqueio de Biométrico para o QuickUnlock + Desbloqueio rápido baseado em senha não disponível + O desbloqueio rápido usando uma parte da sua senha está bloqueado porque o bloqueio da tela não está ativado no seu dispositivo. Esse comportamento é para protegê -lo caso alguém o veja entrando na sua chave de desbloqueio rápido. Desbloqueio por impressão digital falhou: Chave para desencriptação foi invalidada pelo sistema Android. Isto costuma acontecer se for adicionada uma nova impressão digital ao sistema ou se os parâmetros de segurança forem alterados. Desbloqueio da base de dados falhado: Chave composta inválida. O desbloqueio por impressão digital foi desativado porque aparentemente a chave mestra arquivada não é válida. Por favor, reative o Desbloquear com Impressão Digital para a nova senha mestre. @@ -321,6 +323,7 @@ Digite o código QuickUnlock: Desbloqueio Rápido! Fechar banco de dados + Ativar bloqueio de tela Habilitar Desbloqueio Rápido por padrão Define se o Desbloqueio Rápido está habilitado por padrão ou não. Proteger a exibição da base de dados @@ -722,6 +725,11 @@ Notificação para simplificar o acesso à entrada selecionada. Fechar banco de dados após três tentativas de desbloqueio biométrico falhadas. Alerta! Autenticação biométrica pode ser invalidada pelo Android, por exemplo: depois de adicionar uma nova digital nas configurações do seu dispositivo. Esteja certo de sempre saber como desbloquear com sua senha mestra! + + Estimativa aprimorada da qualidade da senha, considerando a maioria das senhas populares. + Bloqueia o desbloqueio rápido baseado em senha (por motivos de segurança) se o dispositivo não tiver uma trava de tela ativada. + Atualize a configuração de segurança de rede para desativar a transferência de texto não criptografado. + Atualizado de Xamarin Android para .NET 8 Atualizado para o Target SDK 34 diff --git a/src/keepass2android-app/Resources/values/config.xml b/src/keepass2android-app/Resources/values/config.xml index e5c3a97c..db3582ec 100644 --- a/src/keepass2android-app/Resources/values/config.xml +++ b/src/keepass2android-app/Resources/values/config.xml @@ -209,6 +209,7 @@ ShowUnlockedNotification true + 65536 PreloadDatabaseEnabled true diff --git a/src/keepass2android-app/Resources/values/strings.xml b/src/keepass2android-app/Resources/values/strings.xml index 6c4f3c87..9ee59c17 100644 --- a/src/keepass2android-app/Resources/values/strings.xml +++ b/src/keepass2android-app/Resources/values/strings.xml @@ -507,7 +507,12 @@ Tell me more! No, I don\'t like it that much Enter WebDav login data: + Enter Samba login data: URL of folder or file (ex: mycloud.me.com/webdav/) + URL of folder or file (ex: 192.168.1.10/share/folder/) + Samba user\'s domain + Samba username + Enter OwnCloud login data: OwnCloud URL (ex: owncloud.me.com) Enter Nextcloud login data: @@ -545,6 +550,7 @@ FTP HTTP (WebDav) HTTPS (WebDav) + Samba (Windows Share) OwnCloud Nextcloud Dropbox @@ -728,7 +734,17 @@ Entry notifications Notification to simplify access to the currently selected entry. Close database after three failed biometric unlock attempts. + If you want to access files from your personal Windows computer, use the computer name or local IP address as host URL; enter the computer name as domain and the Windows login (often the same as your Microsoft account) as username and password. 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! + Chunk size for WebDav upload + Size of chunks when uploading to WebDav servers in bytes. Use 0 to disable chunked upload. + + + WebDav improvements: Bug fix for listing folders; support for chunked uploads and transactions + Added support for Samba/Windows network shares + Updated FluentFTP and enabled support for GnuTLS stream, supporting TLS 1.3 and solving other FTPS issues + + Improved password quality estimation by considering most popular passwords. diff --git a/src/keepass2android-app/Resources/xml/pref_app_file_handling.xml b/src/keepass2android-app/Resources/xml/pref_app_file_handling.xml index 98b85e90..196ab621 100644 --- a/src/keepass2android-app/Resources/xml/pref_app_file_handling.xml +++ b/src/keepass2android-app/Resources/xml/pref_app_file_handling.xml @@ -45,6 +45,14 @@ android:title="@string/UseFileTransactions_title" android:key="@string/UseFileTransactions_key" /> + + + \ No newline at end of file diff --git a/src/keepass2android-app/app/App.cs b/src/keepass2android-app/app/App.cs index db78a794..ea795680 100644 --- a/src/keepass2android-app/app/App.cs +++ b/src/keepass2android-app/app/App.cs @@ -46,9 +46,11 @@ using keepass2android; using keepass2android.Utils; using KeePassLib.Interfaces; using KeePassLib.Utility; + using Message = keepass2android.Utils.Message; #if !NoNet #if !EXCLUDE_JAVAFILESTORAGE +using Kp2aBusinessLogic.Io; using Android.Gms.Common; using Keepass2android.Javafilestorage; using GoogleDriveFileStorage = keepass2android.Io.GoogleDriveFileStorage; @@ -836,8 +838,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 +848,9 @@ 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 SmbFileStorage(), + new PCloudFileStorage(LocaleManager.LocalizedAppContext, this), new PCloudFileStorageAll(LocaleManager.LocalizedAppContext, this), new MegaFileStorage(App.Context), //new LegacyWebDavStorage(this), @@ -1333,6 +1336,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 +1473,7 @@ namespace keepass2android { Kp2aLog.LogUnexpectedError(e.Exception); } - - } + } }