diff --git a/.gitignore b/.gitignore
index edfb7c624..031b22990 100644
--- a/.gitignore
+++ b/.gitignore
@@ -178,3 +178,5 @@ src/java/Keepass2AndroidPluginSDK2/build/generated/mockable-Google-Inc.-Google-A
/src/MegaTest
*.dtbcache.json
/src/keepass2android-app/AndroidManifest.xml
+
+logs/
diff --git a/src/KeePass.sln b/src/KeePass.sln
index 4123c9ff1..6dd3ee257 100644
--- a/src/KeePass.sln
+++ b/src/KeePass.sln
@@ -31,20 +31,27 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Kp2aAutofillParser.Tests",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DropboxBinding", "DropboxBinding\DropboxBinding.csproj", "{2FE6E335-E834-4F86-AB83-2C5D225DA929}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kp2aS3PathCodec", "Kp2aS3PathCodec\Kp2aS3PathCodec.csproj", "{307AF317-ABDD-4BF7-BFF4-9107335118A2}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kp2aS3PathCodec.Tests", "Kp2aS3PathCodec.Tests\Kp2aS3PathCodec.Tests.csproj", "{C2291A1D-FC3C-4A5A-B3D5-CDED96D8D51B}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|Mixed Platforms = Debug|Mixed Platforms
Debug|Win32 = Debug|Win32
Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|Mixed Platforms = Release|Mixed Platforms
Release|Win32 = Release|Win32
Release|x64 = Release|x64
+ Release|x86 = Release|x86
ReleaseNoNet|Any CPU = ReleaseNoNet|Any CPU
ReleaseNoNet|Mixed Platforms = ReleaseNoNet|Mixed Platforms
ReleaseNoNet|Win32 = ReleaseNoNet|Win32
ReleaseNoNet|x64 = ReleaseNoNet|x64
+ ReleaseNoNet|x86 = ReleaseNoNet|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{1DF9DA08-D2FE-4227-BD53-761CD3F6CA42}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
@@ -55,6 +62,8 @@ Global
{1DF9DA08-D2FE-4227-BD53-761CD3F6CA42}.Debug|Win32.Build.0 = Debug|Any CPU
{1DF9DA08-D2FE-4227-BD53-761CD3F6CA42}.Debug|x64.ActiveCfg = Debug|Any CPU
{1DF9DA08-D2FE-4227-BD53-761CD3F6CA42}.Debug|x64.Build.0 = Debug|Any CPU
+ {1DF9DA08-D2FE-4227-BD53-761CD3F6CA42}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {1DF9DA08-D2FE-4227-BD53-761CD3F6CA42}.Debug|x86.Build.0 = Debug|Any CPU
{1DF9DA08-D2FE-4227-BD53-761CD3F6CA42}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1DF9DA08-D2FE-4227-BD53-761CD3F6CA42}.Release|Any CPU.Build.0 = Release|Any CPU
{1DF9DA08-D2FE-4227-BD53-761CD3F6CA42}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
@@ -63,6 +72,8 @@ Global
{1DF9DA08-D2FE-4227-BD53-761CD3F6CA42}.Release|Win32.Build.0 = Release|Any CPU
{1DF9DA08-D2FE-4227-BD53-761CD3F6CA42}.Release|x64.ActiveCfg = Release|Any CPU
{1DF9DA08-D2FE-4227-BD53-761CD3F6CA42}.Release|x64.Build.0 = Release|Any CPU
+ {1DF9DA08-D2FE-4227-BD53-761CD3F6CA42}.Release|x86.ActiveCfg = Release|Any CPU
+ {1DF9DA08-D2FE-4227-BD53-761CD3F6CA42}.Release|x86.Build.0 = Release|Any CPU
{1DF9DA08-D2FE-4227-BD53-761CD3F6CA42}.ReleaseNoNet|Any CPU.ActiveCfg = Release|Any CPU
{1DF9DA08-D2FE-4227-BD53-761CD3F6CA42}.ReleaseNoNet|Any CPU.Build.0 = Release|Any CPU
{1DF9DA08-D2FE-4227-BD53-761CD3F6CA42}.ReleaseNoNet|Mixed Platforms.ActiveCfg = Release|Any CPU
@@ -71,6 +82,8 @@ Global
{1DF9DA08-D2FE-4227-BD53-761CD3F6CA42}.ReleaseNoNet|Win32.Build.0 = Release|Any CPU
{1DF9DA08-D2FE-4227-BD53-761CD3F6CA42}.ReleaseNoNet|x64.ActiveCfg = Release|Any CPU
{1DF9DA08-D2FE-4227-BD53-761CD3F6CA42}.ReleaseNoNet|x64.Build.0 = Release|Any CPU
+ {1DF9DA08-D2FE-4227-BD53-761CD3F6CA42}.ReleaseNoNet|x86.ActiveCfg = ReleaseNoNet|Any CPU
+ {1DF9DA08-D2FE-4227-BD53-761CD3F6CA42}.ReleaseNoNet|x86.Build.0 = ReleaseNoNet|Any CPU
{69D491AA-3A31-4467-8216-3641A4F157BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{69D491AA-3A31-4467-8216-3641A4F157BE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{69D491AA-3A31-4467-8216-3641A4F157BE}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
@@ -79,6 +92,8 @@ Global
{69D491AA-3A31-4467-8216-3641A4F157BE}.Debug|Win32.Build.0 = Debug|Any CPU
{69D491AA-3A31-4467-8216-3641A4F157BE}.Debug|x64.ActiveCfg = Debug|Any CPU
{69D491AA-3A31-4467-8216-3641A4F157BE}.Debug|x64.Build.0 = Debug|Any CPU
+ {69D491AA-3A31-4467-8216-3641A4F157BE}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {69D491AA-3A31-4467-8216-3641A4F157BE}.Debug|x86.Build.0 = Debug|Any CPU
{69D491AA-3A31-4467-8216-3641A4F157BE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{69D491AA-3A31-4467-8216-3641A4F157BE}.Release|Any CPU.Build.0 = Release|Any CPU
{69D491AA-3A31-4467-8216-3641A4F157BE}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
@@ -87,6 +102,8 @@ Global
{69D491AA-3A31-4467-8216-3641A4F157BE}.Release|Win32.Build.0 = Release|Any CPU
{69D491AA-3A31-4467-8216-3641A4F157BE}.Release|x64.ActiveCfg = Release|Any CPU
{69D491AA-3A31-4467-8216-3641A4F157BE}.Release|x64.Build.0 = Release|Any CPU
+ {69D491AA-3A31-4467-8216-3641A4F157BE}.Release|x86.ActiveCfg = Release|Any CPU
+ {69D491AA-3A31-4467-8216-3641A4F157BE}.Release|x86.Build.0 = Release|Any CPU
{69D491AA-3A31-4467-8216-3641A4F157BE}.ReleaseNoNet|Any CPU.ActiveCfg = Release|Any CPU
{69D491AA-3A31-4467-8216-3641A4F157BE}.ReleaseNoNet|Any CPU.Build.0 = Release|Any CPU
{69D491AA-3A31-4467-8216-3641A4F157BE}.ReleaseNoNet|Mixed Platforms.ActiveCfg = Release|Any CPU
@@ -95,6 +112,8 @@ Global
{69D491AA-3A31-4467-8216-3641A4F157BE}.ReleaseNoNet|Win32.Build.0 = Release|Any CPU
{69D491AA-3A31-4467-8216-3641A4F157BE}.ReleaseNoNet|x64.ActiveCfg = Release|Any CPU
{69D491AA-3A31-4467-8216-3641A4F157BE}.ReleaseNoNet|x64.Build.0 = Release|Any CPU
+ {69D491AA-3A31-4467-8216-3641A4F157BE}.ReleaseNoNet|x86.ActiveCfg = ReleaseNoNet|Any CPU
+ {69D491AA-3A31-4467-8216-3641A4F157BE}.ReleaseNoNet|x86.Build.0 = ReleaseNoNet|Any CPU
{6E1DCFE1-D86D-480F-BE02-BBE8FD4601F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6E1DCFE1-D86D-480F-BE02-BBE8FD4601F0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6E1DCFE1-D86D-480F-BE02-BBE8FD4601F0}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
@@ -103,6 +122,8 @@ Global
{6E1DCFE1-D86D-480F-BE02-BBE8FD4601F0}.Debug|Win32.Build.0 = Debug|Any CPU
{6E1DCFE1-D86D-480F-BE02-BBE8FD4601F0}.Debug|x64.ActiveCfg = Debug|Any CPU
{6E1DCFE1-D86D-480F-BE02-BBE8FD4601F0}.Debug|x64.Build.0 = Debug|Any CPU
+ {6E1DCFE1-D86D-480F-BE02-BBE8FD4601F0}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {6E1DCFE1-D86D-480F-BE02-BBE8FD4601F0}.Debug|x86.Build.0 = Debug|Any CPU
{6E1DCFE1-D86D-480F-BE02-BBE8FD4601F0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6E1DCFE1-D86D-480F-BE02-BBE8FD4601F0}.Release|Any CPU.Build.0 = Release|Any CPU
{6E1DCFE1-D86D-480F-BE02-BBE8FD4601F0}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
@@ -111,6 +132,8 @@ Global
{6E1DCFE1-D86D-480F-BE02-BBE8FD4601F0}.Release|Win32.Build.0 = Release|Any CPU
{6E1DCFE1-D86D-480F-BE02-BBE8FD4601F0}.Release|x64.ActiveCfg = Release|Any CPU
{6E1DCFE1-D86D-480F-BE02-BBE8FD4601F0}.Release|x64.Build.0 = Release|Any CPU
+ {6E1DCFE1-D86D-480F-BE02-BBE8FD4601F0}.Release|x86.ActiveCfg = Release|Any CPU
+ {6E1DCFE1-D86D-480F-BE02-BBE8FD4601F0}.Release|x86.Build.0 = Release|Any CPU
{6E1DCFE1-D86D-480F-BE02-BBE8FD4601F0}.ReleaseNoNet|Any CPU.ActiveCfg = Release|Any CPU
{6E1DCFE1-D86D-480F-BE02-BBE8FD4601F0}.ReleaseNoNet|Any CPU.Build.0 = Release|Any CPU
{6E1DCFE1-D86D-480F-BE02-BBE8FD4601F0}.ReleaseNoNet|Mixed Platforms.ActiveCfg = Release|Any CPU
@@ -119,6 +142,8 @@ Global
{6E1DCFE1-D86D-480F-BE02-BBE8FD4601F0}.ReleaseNoNet|Win32.Build.0 = Release|Any CPU
{6E1DCFE1-D86D-480F-BE02-BBE8FD4601F0}.ReleaseNoNet|x64.ActiveCfg = Release|Any CPU
{6E1DCFE1-D86D-480F-BE02-BBE8FD4601F0}.ReleaseNoNet|x64.Build.0 = Release|Any CPU
+ {6E1DCFE1-D86D-480F-BE02-BBE8FD4601F0}.ReleaseNoNet|x86.ActiveCfg = ReleaseNoNet|Any CPU
+ {6E1DCFE1-D86D-480F-BE02-BBE8FD4601F0}.ReleaseNoNet|x86.Build.0 = ReleaseNoNet|Any CPU
{BF542E42-E7A9-4C71-AA3B-DAC0F959F0D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BF542E42-E7A9-4C71-AA3B-DAC0F959F0D5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BF542E42-E7A9-4C71-AA3B-DAC0F959F0D5}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
@@ -127,6 +152,8 @@ Global
{BF542E42-E7A9-4C71-AA3B-DAC0F959F0D5}.Debug|Win32.Build.0 = Debug|Any CPU
{BF542E42-E7A9-4C71-AA3B-DAC0F959F0D5}.Debug|x64.ActiveCfg = Debug|Any CPU
{BF542E42-E7A9-4C71-AA3B-DAC0F959F0D5}.Debug|x64.Build.0 = Debug|Any CPU
+ {BF542E42-E7A9-4C71-AA3B-DAC0F959F0D5}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {BF542E42-E7A9-4C71-AA3B-DAC0F959F0D5}.Debug|x86.Build.0 = Debug|Any CPU
{BF542E42-E7A9-4C71-AA3B-DAC0F959F0D5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BF542E42-E7A9-4C71-AA3B-DAC0F959F0D5}.Release|Any CPU.Build.0 = Release|Any CPU
{BF542E42-E7A9-4C71-AA3B-DAC0F959F0D5}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
@@ -135,6 +162,8 @@ Global
{BF542E42-E7A9-4C71-AA3B-DAC0F959F0D5}.Release|Win32.Build.0 = Release|Any CPU
{BF542E42-E7A9-4C71-AA3B-DAC0F959F0D5}.Release|x64.ActiveCfg = Release|Any CPU
{BF542E42-E7A9-4C71-AA3B-DAC0F959F0D5}.Release|x64.Build.0 = Release|Any CPU
+ {BF542E42-E7A9-4C71-AA3B-DAC0F959F0D5}.Release|x86.ActiveCfg = Release|Any CPU
+ {BF542E42-E7A9-4C71-AA3B-DAC0F959F0D5}.Release|x86.Build.0 = Release|Any CPU
{BF542E42-E7A9-4C71-AA3B-DAC0F959F0D5}.ReleaseNoNet|Any CPU.ActiveCfg = Release|Any CPU
{BF542E42-E7A9-4C71-AA3B-DAC0F959F0D5}.ReleaseNoNet|Any CPU.Build.0 = Release|Any CPU
{BF542E42-E7A9-4C71-AA3B-DAC0F959F0D5}.ReleaseNoNet|Mixed Platforms.ActiveCfg = Release|Any CPU
@@ -143,6 +172,8 @@ Global
{BF542E42-E7A9-4C71-AA3B-DAC0F959F0D5}.ReleaseNoNet|Win32.Build.0 = Release|Any CPU
{BF542E42-E7A9-4C71-AA3B-DAC0F959F0D5}.ReleaseNoNet|x64.ActiveCfg = Release|Any CPU
{BF542E42-E7A9-4C71-AA3B-DAC0F959F0D5}.ReleaseNoNet|x64.Build.0 = Release|Any CPU
+ {BF542E42-E7A9-4C71-AA3B-DAC0F959F0D5}.ReleaseNoNet|x86.ActiveCfg = ReleaseNoNet|Any CPU
+ {BF542E42-E7A9-4C71-AA3B-DAC0F959F0D5}.ReleaseNoNet|x86.Build.0 = ReleaseNoNet|Any CPU
{C3A88E81-0809-49A4-A4EC-DF71A6200F41}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C3A88E81-0809-49A4-A4EC-DF71A6200F41}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C3A88E81-0809-49A4-A4EC-DF71A6200F41}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
@@ -151,6 +182,8 @@ Global
{C3A88E81-0809-49A4-A4EC-DF71A6200F41}.Debug|Win32.Build.0 = Debug|Any CPU
{C3A88E81-0809-49A4-A4EC-DF71A6200F41}.Debug|x64.ActiveCfg = Debug|Any CPU
{C3A88E81-0809-49A4-A4EC-DF71A6200F41}.Debug|x64.Build.0 = Debug|Any CPU
+ {C3A88E81-0809-49A4-A4EC-DF71A6200F41}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {C3A88E81-0809-49A4-A4EC-DF71A6200F41}.Debug|x86.Build.0 = Debug|Any CPU
{C3A88E81-0809-49A4-A4EC-DF71A6200F41}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C3A88E81-0809-49A4-A4EC-DF71A6200F41}.Release|Any CPU.Build.0 = Release|Any CPU
{C3A88E81-0809-49A4-A4EC-DF71A6200F41}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
@@ -159,6 +192,8 @@ Global
{C3A88E81-0809-49A4-A4EC-DF71A6200F41}.Release|Win32.Build.0 = Release|Any CPU
{C3A88E81-0809-49A4-A4EC-DF71A6200F41}.Release|x64.ActiveCfg = Release|Any CPU
{C3A88E81-0809-49A4-A4EC-DF71A6200F41}.Release|x64.Build.0 = Release|Any CPU
+ {C3A88E81-0809-49A4-A4EC-DF71A6200F41}.Release|x86.ActiveCfg = Release|Any CPU
+ {C3A88E81-0809-49A4-A4EC-DF71A6200F41}.Release|x86.Build.0 = Release|Any CPU
{C3A88E81-0809-49A4-A4EC-DF71A6200F41}.ReleaseNoNet|Any CPU.ActiveCfg = Release|Any CPU
{C3A88E81-0809-49A4-A4EC-DF71A6200F41}.ReleaseNoNet|Any CPU.Build.0 = Release|Any CPU
{C3A88E81-0809-49A4-A4EC-DF71A6200F41}.ReleaseNoNet|Mixed Platforms.ActiveCfg = Release|Any CPU
@@ -167,6 +202,8 @@ Global
{C3A88E81-0809-49A4-A4EC-DF71A6200F41}.ReleaseNoNet|Win32.Build.0 = Release|Any CPU
{C3A88E81-0809-49A4-A4EC-DF71A6200F41}.ReleaseNoNet|x64.ActiveCfg = Release|Any CPU
{C3A88E81-0809-49A4-A4EC-DF71A6200F41}.ReleaseNoNet|x64.Build.0 = Release|Any CPU
+ {C3A88E81-0809-49A4-A4EC-DF71A6200F41}.ReleaseNoNet|x86.ActiveCfg = ReleaseNoNet|Any CPU
+ {C3A88E81-0809-49A4-A4EC-DF71A6200F41}.ReleaseNoNet|x86.Build.0 = ReleaseNoNet|Any CPU
{4F2E6B45-2C9F-4DF6-A9DC-9F81DC8681BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4F2E6B45-2C9F-4DF6-A9DC-9F81DC8681BC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4F2E6B45-2C9F-4DF6-A9DC-9F81DC8681BC}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
@@ -175,6 +212,8 @@ Global
{4F2E6B45-2C9F-4DF6-A9DC-9F81DC8681BC}.Debug|Win32.Build.0 = Debug|Any CPU
{4F2E6B45-2C9F-4DF6-A9DC-9F81DC8681BC}.Debug|x64.ActiveCfg = Debug|Any CPU
{4F2E6B45-2C9F-4DF6-A9DC-9F81DC8681BC}.Debug|x64.Build.0 = Debug|Any CPU
+ {4F2E6B45-2C9F-4DF6-A9DC-9F81DC8681BC}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {4F2E6B45-2C9F-4DF6-A9DC-9F81DC8681BC}.Debug|x86.Build.0 = Debug|Any CPU
{4F2E6B45-2C9F-4DF6-A9DC-9F81DC8681BC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4F2E6B45-2C9F-4DF6-A9DC-9F81DC8681BC}.Release|Any CPU.Build.0 = Release|Any CPU
{4F2E6B45-2C9F-4DF6-A9DC-9F81DC8681BC}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
@@ -183,6 +222,8 @@ Global
{4F2E6B45-2C9F-4DF6-A9DC-9F81DC8681BC}.Release|Win32.Build.0 = Release|Any CPU
{4F2E6B45-2C9F-4DF6-A9DC-9F81DC8681BC}.Release|x64.ActiveCfg = Release|Any CPU
{4F2E6B45-2C9F-4DF6-A9DC-9F81DC8681BC}.Release|x64.Build.0 = Release|Any CPU
+ {4F2E6B45-2C9F-4DF6-A9DC-9F81DC8681BC}.Release|x86.ActiveCfg = Release|Any CPU
+ {4F2E6B45-2C9F-4DF6-A9DC-9F81DC8681BC}.Release|x86.Build.0 = Release|Any CPU
{4F2E6B45-2C9F-4DF6-A9DC-9F81DC8681BC}.ReleaseNoNet|Any CPU.ActiveCfg = Release|Any CPU
{4F2E6B45-2C9F-4DF6-A9DC-9F81DC8681BC}.ReleaseNoNet|Any CPU.Build.0 = Release|Any CPU
{4F2E6B45-2C9F-4DF6-A9DC-9F81DC8681BC}.ReleaseNoNet|Mixed Platforms.ActiveCfg = Release|Any CPU
@@ -191,6 +232,8 @@ Global
{4F2E6B45-2C9F-4DF6-A9DC-9F81DC8681BC}.ReleaseNoNet|Win32.Build.0 = Release|Any CPU
{4F2E6B45-2C9F-4DF6-A9DC-9F81DC8681BC}.ReleaseNoNet|x64.ActiveCfg = Release|Any CPU
{4F2E6B45-2C9F-4DF6-A9DC-9F81DC8681BC}.ReleaseNoNet|x64.Build.0 = Release|Any CPU
+ {4F2E6B45-2C9F-4DF6-A9DC-9F81DC8681BC}.ReleaseNoNet|x86.ActiveCfg = ReleaseNoNet|Any CPU
+ {4F2E6B45-2C9F-4DF6-A9DC-9F81DC8681BC}.ReleaseNoNet|x86.Build.0 = ReleaseNoNet|Any CPU
{862D7A27-4211-4258-A4B0-741B4C0A73CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{862D7A27-4211-4258-A4B0-741B4C0A73CF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{862D7A27-4211-4258-A4B0-741B4C0A73CF}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
@@ -199,6 +242,8 @@ Global
{862D7A27-4211-4258-A4B0-741B4C0A73CF}.Debug|Win32.Build.0 = Debug|Any CPU
{862D7A27-4211-4258-A4B0-741B4C0A73CF}.Debug|x64.ActiveCfg = Debug|Any CPU
{862D7A27-4211-4258-A4B0-741B4C0A73CF}.Debug|x64.Build.0 = Debug|Any CPU
+ {862D7A27-4211-4258-A4B0-741B4C0A73CF}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {862D7A27-4211-4258-A4B0-741B4C0A73CF}.Debug|x86.Build.0 = Debug|Any CPU
{862D7A27-4211-4258-A4B0-741B4C0A73CF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{862D7A27-4211-4258-A4B0-741B4C0A73CF}.Release|Any CPU.Build.0 = Release|Any CPU
{862D7A27-4211-4258-A4B0-741B4C0A73CF}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
@@ -207,6 +252,8 @@ Global
{862D7A27-4211-4258-A4B0-741B4C0A73CF}.Release|Win32.Build.0 = Release|Any CPU
{862D7A27-4211-4258-A4B0-741B4C0A73CF}.Release|x64.ActiveCfg = Release|Any CPU
{862D7A27-4211-4258-A4B0-741B4C0A73CF}.Release|x64.Build.0 = Release|Any CPU
+ {862D7A27-4211-4258-A4B0-741B4C0A73CF}.Release|x86.ActiveCfg = Release|Any CPU
+ {862D7A27-4211-4258-A4B0-741B4C0A73CF}.Release|x86.Build.0 = Release|Any CPU
{862D7A27-4211-4258-A4B0-741B4C0A73CF}.ReleaseNoNet|Any CPU.ActiveCfg = Release|Any CPU
{862D7A27-4211-4258-A4B0-741B4C0A73CF}.ReleaseNoNet|Any CPU.Build.0 = Release|Any CPU
{862D7A27-4211-4258-A4B0-741B4C0A73CF}.ReleaseNoNet|Mixed Platforms.ActiveCfg = Release|Any CPU
@@ -215,6 +262,8 @@ Global
{862D7A27-4211-4258-A4B0-741B4C0A73CF}.ReleaseNoNet|Win32.Build.0 = Release|Any CPU
{862D7A27-4211-4258-A4B0-741B4C0A73CF}.ReleaseNoNet|x64.ActiveCfg = Release|Any CPU
{862D7A27-4211-4258-A4B0-741B4C0A73CF}.ReleaseNoNet|x64.Build.0 = Release|Any CPU
+ {862D7A27-4211-4258-A4B0-741B4C0A73CF}.ReleaseNoNet|x86.ActiveCfg = ReleaseNoNet|Any CPU
+ {862D7A27-4211-4258-A4B0-741B4C0A73CF}.ReleaseNoNet|x86.Build.0 = ReleaseNoNet|Any CPU
{2D5E3AEF-6EB2-4737-9ACB-921A22928682}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2D5E3AEF-6EB2-4737-9ACB-921A22928682}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2D5E3AEF-6EB2-4737-9ACB-921A22928682}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
@@ -223,6 +272,8 @@ Global
{2D5E3AEF-6EB2-4737-9ACB-921A22928682}.Debug|Win32.Build.0 = Debug|Any CPU
{2D5E3AEF-6EB2-4737-9ACB-921A22928682}.Debug|x64.ActiveCfg = Debug|Any CPU
{2D5E3AEF-6EB2-4737-9ACB-921A22928682}.Debug|x64.Build.0 = Debug|Any CPU
+ {2D5E3AEF-6EB2-4737-9ACB-921A22928682}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {2D5E3AEF-6EB2-4737-9ACB-921A22928682}.Debug|x86.Build.0 = Debug|Any CPU
{2D5E3AEF-6EB2-4737-9ACB-921A22928682}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2D5E3AEF-6EB2-4737-9ACB-921A22928682}.Release|Any CPU.Build.0 = Release|Any CPU
{2D5E3AEF-6EB2-4737-9ACB-921A22928682}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
@@ -231,6 +282,8 @@ Global
{2D5E3AEF-6EB2-4737-9ACB-921A22928682}.Release|Win32.Build.0 = Release|Any CPU
{2D5E3AEF-6EB2-4737-9ACB-921A22928682}.Release|x64.ActiveCfg = Release|Any CPU
{2D5E3AEF-6EB2-4737-9ACB-921A22928682}.Release|x64.Build.0 = Release|Any CPU
+ {2D5E3AEF-6EB2-4737-9ACB-921A22928682}.Release|x86.ActiveCfg = Release|Any CPU
+ {2D5E3AEF-6EB2-4737-9ACB-921A22928682}.Release|x86.Build.0 = Release|Any CPU
{2D5E3AEF-6EB2-4737-9ACB-921A22928682}.ReleaseNoNet|Any CPU.ActiveCfg = Release|Any CPU
{2D5E3AEF-6EB2-4737-9ACB-921A22928682}.ReleaseNoNet|Any CPU.Build.0 = Release|Any CPU
{2D5E3AEF-6EB2-4737-9ACB-921A22928682}.ReleaseNoNet|Mixed Platforms.ActiveCfg = Release|Any CPU
@@ -239,6 +292,8 @@ Global
{2D5E3AEF-6EB2-4737-9ACB-921A22928682}.ReleaseNoNet|Win32.Build.0 = Release|Any CPU
{2D5E3AEF-6EB2-4737-9ACB-921A22928682}.ReleaseNoNet|x64.ActiveCfg = Release|Any CPU
{2D5E3AEF-6EB2-4737-9ACB-921A22928682}.ReleaseNoNet|x64.Build.0 = Release|Any CPU
+ {2D5E3AEF-6EB2-4737-9ACB-921A22928682}.ReleaseNoNet|x86.ActiveCfg = ReleaseNoNet|Any CPU
+ {2D5E3AEF-6EB2-4737-9ACB-921A22928682}.ReleaseNoNet|x86.Build.0 = ReleaseNoNet|Any CPU
{0CCDA3EB-8A24-4A42-BC91-A4DD254504E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0CCDA3EB-8A24-4A42-BC91-A4DD254504E7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0CCDA3EB-8A24-4A42-BC91-A4DD254504E7}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
@@ -251,6 +306,8 @@ Global
{0CCDA3EB-8A24-4A42-BC91-A4DD254504E7}.Debug|x64.ActiveCfg = Debug|Any CPU
{0CCDA3EB-8A24-4A42-BC91-A4DD254504E7}.Debug|x64.Build.0 = Debug|Any CPU
{0CCDA3EB-8A24-4A42-BC91-A4DD254504E7}.Debug|x64.Deploy.0 = Debug|Any CPU
+ {0CCDA3EB-8A24-4A42-BC91-A4DD254504E7}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {0CCDA3EB-8A24-4A42-BC91-A4DD254504E7}.Debug|x86.Build.0 = Debug|Any CPU
{0CCDA3EB-8A24-4A42-BC91-A4DD254504E7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0CCDA3EB-8A24-4A42-BC91-A4DD254504E7}.Release|Any CPU.Build.0 = Release|Any CPU
{0CCDA3EB-8A24-4A42-BC91-A4DD254504E7}.Release|Any CPU.Deploy.0 = Release|Any CPU
@@ -263,6 +320,8 @@ Global
{0CCDA3EB-8A24-4A42-BC91-A4DD254504E7}.Release|x64.ActiveCfg = Release|Any CPU
{0CCDA3EB-8A24-4A42-BC91-A4DD254504E7}.Release|x64.Build.0 = Release|Any CPU
{0CCDA3EB-8A24-4A42-BC91-A4DD254504E7}.Release|x64.Deploy.0 = Release|Any CPU
+ {0CCDA3EB-8A24-4A42-BC91-A4DD254504E7}.Release|x86.ActiveCfg = Release|Any CPU
+ {0CCDA3EB-8A24-4A42-BC91-A4DD254504E7}.Release|x86.Build.0 = Release|Any CPU
{0CCDA3EB-8A24-4A42-BC91-A4DD254504E7}.ReleaseNoNet|Any CPU.ActiveCfg = Release|Any CPU
{0CCDA3EB-8A24-4A42-BC91-A4DD254504E7}.ReleaseNoNet|Any CPU.Build.0 = Release|Any CPU
{0CCDA3EB-8A24-4A42-BC91-A4DD254504E7}.ReleaseNoNet|Any CPU.Deploy.0 = Release|Any CPU
@@ -275,6 +334,8 @@ Global
{0CCDA3EB-8A24-4A42-BC91-A4DD254504E7}.ReleaseNoNet|x64.ActiveCfg = Release|Any CPU
{0CCDA3EB-8A24-4A42-BC91-A4DD254504E7}.ReleaseNoNet|x64.Build.0 = Release|Any CPU
{0CCDA3EB-8A24-4A42-BC91-A4DD254504E7}.ReleaseNoNet|x64.Deploy.0 = Release|Any CPU
+ {0CCDA3EB-8A24-4A42-BC91-A4DD254504E7}.ReleaseNoNet|x86.ActiveCfg = ReleaseNoNet|Any CPU
+ {0CCDA3EB-8A24-4A42-BC91-A4DD254504E7}.ReleaseNoNet|x86.Build.0 = ReleaseNoNet|Any CPU
{C80CA4D0-C2A1-44FF-94DD-4097BDC58AF1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C80CA4D0-C2A1-44FF-94DD-4097BDC58AF1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C80CA4D0-C2A1-44FF-94DD-4097BDC58AF1}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
@@ -283,6 +344,8 @@ Global
{C80CA4D0-C2A1-44FF-94DD-4097BDC58AF1}.Debug|Win32.Build.0 = Debug|Any CPU
{C80CA4D0-C2A1-44FF-94DD-4097BDC58AF1}.Debug|x64.ActiveCfg = Debug|Any CPU
{C80CA4D0-C2A1-44FF-94DD-4097BDC58AF1}.Debug|x64.Build.0 = Debug|Any CPU
+ {C80CA4D0-C2A1-44FF-94DD-4097BDC58AF1}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {C80CA4D0-C2A1-44FF-94DD-4097BDC58AF1}.Debug|x86.Build.0 = Debug|Any CPU
{C80CA4D0-C2A1-44FF-94DD-4097BDC58AF1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C80CA4D0-C2A1-44FF-94DD-4097BDC58AF1}.Release|Any CPU.Build.0 = Release|Any CPU
{C80CA4D0-C2A1-44FF-94DD-4097BDC58AF1}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
@@ -291,6 +354,8 @@ Global
{C80CA4D0-C2A1-44FF-94DD-4097BDC58AF1}.Release|Win32.Build.0 = Release|Any CPU
{C80CA4D0-C2A1-44FF-94DD-4097BDC58AF1}.Release|x64.ActiveCfg = Release|Any CPU
{C80CA4D0-C2A1-44FF-94DD-4097BDC58AF1}.Release|x64.Build.0 = Release|Any CPU
+ {C80CA4D0-C2A1-44FF-94DD-4097BDC58AF1}.Release|x86.ActiveCfg = Release|Any CPU
+ {C80CA4D0-C2A1-44FF-94DD-4097BDC58AF1}.Release|x86.Build.0 = Release|Any CPU
{C80CA4D0-C2A1-44FF-94DD-4097BDC58AF1}.ReleaseNoNet|Any CPU.ActiveCfg = Release|Any CPU
{C80CA4D0-C2A1-44FF-94DD-4097BDC58AF1}.ReleaseNoNet|Any CPU.Build.0 = Release|Any CPU
{C80CA4D0-C2A1-44FF-94DD-4097BDC58AF1}.ReleaseNoNet|Mixed Platforms.ActiveCfg = Release|Any CPU
@@ -299,6 +364,8 @@ Global
{C80CA4D0-C2A1-44FF-94DD-4097BDC58AF1}.ReleaseNoNet|Win32.Build.0 = Release|Any CPU
{C80CA4D0-C2A1-44FF-94DD-4097BDC58AF1}.ReleaseNoNet|x64.ActiveCfg = Release|Any CPU
{C80CA4D0-C2A1-44FF-94DD-4097BDC58AF1}.ReleaseNoNet|x64.Build.0 = Release|Any CPU
+ {C80CA4D0-C2A1-44FF-94DD-4097BDC58AF1}.ReleaseNoNet|x86.ActiveCfg = ReleaseNoNet|Any CPU
+ {C80CA4D0-C2A1-44FF-94DD-4097BDC58AF1}.ReleaseNoNet|x86.Build.0 = ReleaseNoNet|Any CPU
{B8CFEA61-5165-477A-BBED-6C711107522E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B8CFEA61-5165-477A-BBED-6C711107522E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B8CFEA61-5165-477A-BBED-6C711107522E}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
@@ -307,6 +374,8 @@ Global
{B8CFEA61-5165-477A-BBED-6C711107522E}.Debug|Win32.Build.0 = Debug|Any CPU
{B8CFEA61-5165-477A-BBED-6C711107522E}.Debug|x64.ActiveCfg = Debug|Any CPU
{B8CFEA61-5165-477A-BBED-6C711107522E}.Debug|x64.Build.0 = Debug|Any CPU
+ {B8CFEA61-5165-477A-BBED-6C711107522E}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {B8CFEA61-5165-477A-BBED-6C711107522E}.Debug|x86.Build.0 = Debug|Any CPU
{B8CFEA61-5165-477A-BBED-6C711107522E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B8CFEA61-5165-477A-BBED-6C711107522E}.Release|Any CPU.Build.0 = Release|Any CPU
{B8CFEA61-5165-477A-BBED-6C711107522E}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
@@ -315,6 +384,8 @@ Global
{B8CFEA61-5165-477A-BBED-6C711107522E}.Release|Win32.Build.0 = Release|Any CPU
{B8CFEA61-5165-477A-BBED-6C711107522E}.Release|x64.ActiveCfg = Release|Any CPU
{B8CFEA61-5165-477A-BBED-6C711107522E}.Release|x64.Build.0 = Release|Any CPU
+ {B8CFEA61-5165-477A-BBED-6C711107522E}.Release|x86.ActiveCfg = Release|Any CPU
+ {B8CFEA61-5165-477A-BBED-6C711107522E}.Release|x86.Build.0 = Release|Any CPU
{B8CFEA61-5165-477A-BBED-6C711107522E}.ReleaseNoNet|Any CPU.ActiveCfg = Release|Any CPU
{B8CFEA61-5165-477A-BBED-6C711107522E}.ReleaseNoNet|Any CPU.Build.0 = Release|Any CPU
{B8CFEA61-5165-477A-BBED-6C711107522E}.ReleaseNoNet|Mixed Platforms.ActiveCfg = Release|Any CPU
@@ -323,6 +394,8 @@ Global
{B8CFEA61-5165-477A-BBED-6C711107522E}.ReleaseNoNet|Win32.Build.0 = Release|Any CPU
{B8CFEA61-5165-477A-BBED-6C711107522E}.ReleaseNoNet|x64.ActiveCfg = Release|Any CPU
{B8CFEA61-5165-477A-BBED-6C711107522E}.ReleaseNoNet|x64.Build.0 = Release|Any CPU
+ {B8CFEA61-5165-477A-BBED-6C711107522E}.ReleaseNoNet|x86.ActiveCfg = ReleaseNoNet|Any CPU
+ {B8CFEA61-5165-477A-BBED-6C711107522E}.ReleaseNoNet|x86.Build.0 = ReleaseNoNet|Any CPU
{D5F03D6D-B233-4716-93B5-18C2D965B5EC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D5F03D6D-B233-4716-93B5-18C2D965B5EC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D5F03D6D-B233-4716-93B5-18C2D965B5EC}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
@@ -331,6 +404,8 @@ Global
{D5F03D6D-B233-4716-93B5-18C2D965B5EC}.Debug|Win32.Build.0 = Debug|Any CPU
{D5F03D6D-B233-4716-93B5-18C2D965B5EC}.Debug|x64.ActiveCfg = Debug|Any CPU
{D5F03D6D-B233-4716-93B5-18C2D965B5EC}.Debug|x64.Build.0 = Debug|Any CPU
+ {D5F03D6D-B233-4716-93B5-18C2D965B5EC}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {D5F03D6D-B233-4716-93B5-18C2D965B5EC}.Debug|x86.Build.0 = Debug|Any CPU
{D5F03D6D-B233-4716-93B5-18C2D965B5EC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D5F03D6D-B233-4716-93B5-18C2D965B5EC}.Release|Any CPU.Build.0 = Release|Any CPU
{D5F03D6D-B233-4716-93B5-18C2D965B5EC}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
@@ -339,6 +414,8 @@ Global
{D5F03D6D-B233-4716-93B5-18C2D965B5EC}.Release|Win32.Build.0 = Release|Any CPU
{D5F03D6D-B233-4716-93B5-18C2D965B5EC}.Release|x64.ActiveCfg = Release|Any CPU
{D5F03D6D-B233-4716-93B5-18C2D965B5EC}.Release|x64.Build.0 = Release|Any CPU
+ {D5F03D6D-B233-4716-93B5-18C2D965B5EC}.Release|x86.ActiveCfg = Release|Any CPU
+ {D5F03D6D-B233-4716-93B5-18C2D965B5EC}.Release|x86.Build.0 = Release|Any CPU
{D5F03D6D-B233-4716-93B5-18C2D965B5EC}.ReleaseNoNet|Any CPU.ActiveCfg = Release|Any CPU
{D5F03D6D-B233-4716-93B5-18C2D965B5EC}.ReleaseNoNet|Any CPU.Build.0 = Release|Any CPU
{D5F03D6D-B233-4716-93B5-18C2D965B5EC}.ReleaseNoNet|Mixed Platforms.ActiveCfg = Release|Any CPU
@@ -347,6 +424,8 @@ Global
{D5F03D6D-B233-4716-93B5-18C2D965B5EC}.ReleaseNoNet|Win32.Build.0 = Release|Any CPU
{D5F03D6D-B233-4716-93B5-18C2D965B5EC}.ReleaseNoNet|x64.ActiveCfg = Release|Any CPU
{D5F03D6D-B233-4716-93B5-18C2D965B5EC}.ReleaseNoNet|x64.Build.0 = Release|Any CPU
+ {D5F03D6D-B233-4716-93B5-18C2D965B5EC}.ReleaseNoNet|x86.ActiveCfg = ReleaseNoNet|Any CPU
+ {D5F03D6D-B233-4716-93B5-18C2D965B5EC}.ReleaseNoNet|x86.Build.0 = ReleaseNoNet|Any CPU
{F5A2A8F9-C084-498F-9603-9D927BA5C626}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F5A2A8F9-C084-498F-9603-9D927BA5C626}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F5A2A8F9-C084-498F-9603-9D927BA5C626}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
@@ -355,6 +434,8 @@ Global
{F5A2A8F9-C084-498F-9603-9D927BA5C626}.Debug|Win32.Build.0 = Debug|Any CPU
{F5A2A8F9-C084-498F-9603-9D927BA5C626}.Debug|x64.ActiveCfg = Debug|Any CPU
{F5A2A8F9-C084-498F-9603-9D927BA5C626}.Debug|x64.Build.0 = Debug|Any CPU
+ {F5A2A8F9-C084-498F-9603-9D927BA5C626}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {F5A2A8F9-C084-498F-9603-9D927BA5C626}.Debug|x86.Build.0 = Debug|Any CPU
{F5A2A8F9-C084-498F-9603-9D927BA5C626}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F5A2A8F9-C084-498F-9603-9D927BA5C626}.Release|Any CPU.Build.0 = Release|Any CPU
{F5A2A8F9-C084-498F-9603-9D927BA5C626}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
@@ -363,6 +444,8 @@ Global
{F5A2A8F9-C084-498F-9603-9D927BA5C626}.Release|Win32.Build.0 = Release|Any CPU
{F5A2A8F9-C084-498F-9603-9D927BA5C626}.Release|x64.ActiveCfg = Release|Any CPU
{F5A2A8F9-C084-498F-9603-9D927BA5C626}.Release|x64.Build.0 = Release|Any CPU
+ {F5A2A8F9-C084-498F-9603-9D927BA5C626}.Release|x86.ActiveCfg = Release|Any CPU
+ {F5A2A8F9-C084-498F-9603-9D927BA5C626}.Release|x86.Build.0 = Release|Any CPU
{F5A2A8F9-C084-498F-9603-9D927BA5C626}.ReleaseNoNet|Any CPU.ActiveCfg = Release|Any CPU
{F5A2A8F9-C084-498F-9603-9D927BA5C626}.ReleaseNoNet|Any CPU.Build.0 = Release|Any CPU
{F5A2A8F9-C084-498F-9603-9D927BA5C626}.ReleaseNoNet|Mixed Platforms.ActiveCfg = Release|Any CPU
@@ -371,6 +454,8 @@ Global
{F5A2A8F9-C084-498F-9603-9D927BA5C626}.ReleaseNoNet|Win32.Build.0 = Release|Any CPU
{F5A2A8F9-C084-498F-9603-9D927BA5C626}.ReleaseNoNet|x64.ActiveCfg = Release|Any CPU
{F5A2A8F9-C084-498F-9603-9D927BA5C626}.ReleaseNoNet|x64.Build.0 = Release|Any CPU
+ {F5A2A8F9-C084-498F-9603-9D927BA5C626}.ReleaseNoNet|x86.ActiveCfg = ReleaseNoNet|Any CPU
+ {F5A2A8F9-C084-498F-9603-9D927BA5C626}.ReleaseNoNet|x86.Build.0 = ReleaseNoNet|Any CPU
{2FE6E335-E834-4F86-AB83-2C5D225DA929}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2FE6E335-E834-4F86-AB83-2C5D225DA929}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2FE6E335-E834-4F86-AB83-2C5D225DA929}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
@@ -379,6 +464,8 @@ Global
{2FE6E335-E834-4F86-AB83-2C5D225DA929}.Debug|Win32.Build.0 = Debug|Any CPU
{2FE6E335-E834-4F86-AB83-2C5D225DA929}.Debug|x64.ActiveCfg = Debug|Any CPU
{2FE6E335-E834-4F86-AB83-2C5D225DA929}.Debug|x64.Build.0 = Debug|Any CPU
+ {2FE6E335-E834-4F86-AB83-2C5D225DA929}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {2FE6E335-E834-4F86-AB83-2C5D225DA929}.Debug|x86.Build.0 = Debug|Any CPU
{2FE6E335-E834-4F86-AB83-2C5D225DA929}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2FE6E335-E834-4F86-AB83-2C5D225DA929}.Release|Any CPU.Build.0 = Release|Any CPU
{2FE6E335-E834-4F86-AB83-2C5D225DA929}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
@@ -387,6 +474,8 @@ Global
{2FE6E335-E834-4F86-AB83-2C5D225DA929}.Release|Win32.Build.0 = Release|Any CPU
{2FE6E335-E834-4F86-AB83-2C5D225DA929}.Release|x64.ActiveCfg = Release|Any CPU
{2FE6E335-E834-4F86-AB83-2C5D225DA929}.Release|x64.Build.0 = Release|Any CPU
+ {2FE6E335-E834-4F86-AB83-2C5D225DA929}.Release|x86.ActiveCfg = Release|Any CPU
+ {2FE6E335-E834-4F86-AB83-2C5D225DA929}.Release|x86.Build.0 = Release|Any CPU
{2FE6E335-E834-4F86-AB83-2C5D225DA929}.ReleaseNoNet|Any CPU.ActiveCfg = Release|Any CPU
{2FE6E335-E834-4F86-AB83-2C5D225DA929}.ReleaseNoNet|Any CPU.Build.0 = Release|Any CPU
{2FE6E335-E834-4F86-AB83-2C5D225DA929}.ReleaseNoNet|Mixed Platforms.ActiveCfg = Release|Any CPU
@@ -395,6 +484,68 @@ Global
{2FE6E335-E834-4F86-AB83-2C5D225DA929}.ReleaseNoNet|Win32.Build.0 = Release|Any CPU
{2FE6E335-E834-4F86-AB83-2C5D225DA929}.ReleaseNoNet|x64.ActiveCfg = Release|Any CPU
{2FE6E335-E834-4F86-AB83-2C5D225DA929}.ReleaseNoNet|x64.Build.0 = Release|Any CPU
+ {2FE6E335-E834-4F86-AB83-2C5D225DA929}.ReleaseNoNet|x86.ActiveCfg = ReleaseNoNet|Any CPU
+ {2FE6E335-E834-4F86-AB83-2C5D225DA929}.ReleaseNoNet|x86.Build.0 = ReleaseNoNet|Any CPU
+ {307AF317-ABDD-4BF7-BFF4-9107335118A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {307AF317-ABDD-4BF7-BFF4-9107335118A2}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {307AF317-ABDD-4BF7-BFF4-9107335118A2}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
+ {307AF317-ABDD-4BF7-BFF4-9107335118A2}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU
+ {307AF317-ABDD-4BF7-BFF4-9107335118A2}.Debug|Win32.ActiveCfg = Debug|Any CPU
+ {307AF317-ABDD-4BF7-BFF4-9107335118A2}.Debug|Win32.Build.0 = Debug|Any CPU
+ {307AF317-ABDD-4BF7-BFF4-9107335118A2}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {307AF317-ABDD-4BF7-BFF4-9107335118A2}.Debug|x64.Build.0 = Debug|Any CPU
+ {307AF317-ABDD-4BF7-BFF4-9107335118A2}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {307AF317-ABDD-4BF7-BFF4-9107335118A2}.Debug|x86.Build.0 = Debug|Any CPU
+ {307AF317-ABDD-4BF7-BFF4-9107335118A2}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {307AF317-ABDD-4BF7-BFF4-9107335118A2}.Release|Any CPU.Build.0 = Release|Any CPU
+ {307AF317-ABDD-4BF7-BFF4-9107335118A2}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
+ {307AF317-ABDD-4BF7-BFF4-9107335118A2}.Release|Mixed Platforms.Build.0 = Release|Any CPU
+ {307AF317-ABDD-4BF7-BFF4-9107335118A2}.Release|Win32.ActiveCfg = Release|Any CPU
+ {307AF317-ABDD-4BF7-BFF4-9107335118A2}.Release|Win32.Build.0 = Release|Any CPU
+ {307AF317-ABDD-4BF7-BFF4-9107335118A2}.Release|x64.ActiveCfg = Release|Any CPU
+ {307AF317-ABDD-4BF7-BFF4-9107335118A2}.Release|x64.Build.0 = Release|Any CPU
+ {307AF317-ABDD-4BF7-BFF4-9107335118A2}.Release|x86.ActiveCfg = Release|Any CPU
+ {307AF317-ABDD-4BF7-BFF4-9107335118A2}.Release|x86.Build.0 = Release|Any CPU
+ {307AF317-ABDD-4BF7-BFF4-9107335118A2}.ReleaseNoNet|Any CPU.ActiveCfg = Debug|Any CPU
+ {307AF317-ABDD-4BF7-BFF4-9107335118A2}.ReleaseNoNet|Any CPU.Build.0 = Debug|Any CPU
+ {307AF317-ABDD-4BF7-BFF4-9107335118A2}.ReleaseNoNet|Mixed Platforms.ActiveCfg = Debug|Any CPU
+ {307AF317-ABDD-4BF7-BFF4-9107335118A2}.ReleaseNoNet|Mixed Platforms.Build.0 = Debug|Any CPU
+ {307AF317-ABDD-4BF7-BFF4-9107335118A2}.ReleaseNoNet|Win32.ActiveCfg = Debug|Any CPU
+ {307AF317-ABDD-4BF7-BFF4-9107335118A2}.ReleaseNoNet|Win32.Build.0 = Debug|Any CPU
+ {307AF317-ABDD-4BF7-BFF4-9107335118A2}.ReleaseNoNet|x64.ActiveCfg = Debug|Any CPU
+ {307AF317-ABDD-4BF7-BFF4-9107335118A2}.ReleaseNoNet|x64.Build.0 = Debug|Any CPU
+ {307AF317-ABDD-4BF7-BFF4-9107335118A2}.ReleaseNoNet|x86.ActiveCfg = Debug|Any CPU
+ {307AF317-ABDD-4BF7-BFF4-9107335118A2}.ReleaseNoNet|x86.Build.0 = Debug|Any CPU
+ {C2291A1D-FC3C-4A5A-B3D5-CDED96D8D51B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C2291A1D-FC3C-4A5A-B3D5-CDED96D8D51B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C2291A1D-FC3C-4A5A-B3D5-CDED96D8D51B}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
+ {C2291A1D-FC3C-4A5A-B3D5-CDED96D8D51B}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU
+ {C2291A1D-FC3C-4A5A-B3D5-CDED96D8D51B}.Debug|Win32.ActiveCfg = Debug|Any CPU
+ {C2291A1D-FC3C-4A5A-B3D5-CDED96D8D51B}.Debug|Win32.Build.0 = Debug|Any CPU
+ {C2291A1D-FC3C-4A5A-B3D5-CDED96D8D51B}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {C2291A1D-FC3C-4A5A-B3D5-CDED96D8D51B}.Debug|x64.Build.0 = Debug|Any CPU
+ {C2291A1D-FC3C-4A5A-B3D5-CDED96D8D51B}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {C2291A1D-FC3C-4A5A-B3D5-CDED96D8D51B}.Debug|x86.Build.0 = Debug|Any CPU
+ {C2291A1D-FC3C-4A5A-B3D5-CDED96D8D51B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C2291A1D-FC3C-4A5A-B3D5-CDED96D8D51B}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C2291A1D-FC3C-4A5A-B3D5-CDED96D8D51B}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
+ {C2291A1D-FC3C-4A5A-B3D5-CDED96D8D51B}.Release|Mixed Platforms.Build.0 = Release|Any CPU
+ {C2291A1D-FC3C-4A5A-B3D5-CDED96D8D51B}.Release|Win32.ActiveCfg = Release|Any CPU
+ {C2291A1D-FC3C-4A5A-B3D5-CDED96D8D51B}.Release|Win32.Build.0 = Release|Any CPU
+ {C2291A1D-FC3C-4A5A-B3D5-CDED96D8D51B}.Release|x64.ActiveCfg = Release|Any CPU
+ {C2291A1D-FC3C-4A5A-B3D5-CDED96D8D51B}.Release|x64.Build.0 = Release|Any CPU
+ {C2291A1D-FC3C-4A5A-B3D5-CDED96D8D51B}.Release|x86.ActiveCfg = Release|Any CPU
+ {C2291A1D-FC3C-4A5A-B3D5-CDED96D8D51B}.Release|x86.Build.0 = Release|Any CPU
+ {C2291A1D-FC3C-4A5A-B3D5-CDED96D8D51B}.ReleaseNoNet|Any CPU.ActiveCfg = Debug|Any CPU
+ {C2291A1D-FC3C-4A5A-B3D5-CDED96D8D51B}.ReleaseNoNet|Any CPU.Build.0 = Debug|Any CPU
+ {C2291A1D-FC3C-4A5A-B3D5-CDED96D8D51B}.ReleaseNoNet|Mixed Platforms.ActiveCfg = Debug|Any CPU
+ {C2291A1D-FC3C-4A5A-B3D5-CDED96D8D51B}.ReleaseNoNet|Mixed Platforms.Build.0 = Debug|Any CPU
+ {C2291A1D-FC3C-4A5A-B3D5-CDED96D8D51B}.ReleaseNoNet|Win32.ActiveCfg = Debug|Any CPU
+ {C2291A1D-FC3C-4A5A-B3D5-CDED96D8D51B}.ReleaseNoNet|Win32.Build.0 = Debug|Any CPU
+ {C2291A1D-FC3C-4A5A-B3D5-CDED96D8D51B}.ReleaseNoNet|x64.ActiveCfg = Debug|Any CPU
+ {C2291A1D-FC3C-4A5A-B3D5-CDED96D8D51B}.ReleaseNoNet|x64.Build.0 = Debug|Any CPU
+ {C2291A1D-FC3C-4A5A-B3D5-CDED96D8D51B}.ReleaseNoNet|x86.ActiveCfg = Debug|Any CPU
+ {C2291A1D-FC3C-4A5A-B3D5-CDED96D8D51B}.ReleaseNoNet|x86.Build.0 = Debug|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/src/Kp2aBusinessLogic/Io/S3FileStorage.cs b/src/Kp2aBusinessLogic/Io/S3FileStorage.cs
new file mode 100644
index 000000000..2f54e7112
--- /dev/null
+++ b/src/Kp2aBusinessLogic/Io/S3FileStorage.cs
@@ -0,0 +1,534 @@
+// 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
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Keepass2Android is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Keepass2Android. If not, see .
+
+#if !NoNet
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.IO;
+using System.Net;
+using Amazon;
+using Amazon.Runtime;
+using Amazon.S3;
+using Amazon.S3.Model;
+using Android.Content;
+using Android.OS;
+using KeePass.Util;
+using KeePassLib.Serialization;
+using KeePassLib.Utility;
+
+namespace keepass2android.Io
+{
+ ///
+ /// IFileStorage implementation for Amazon S3 and S3-compatible object stores
+ /// (Wasabi, Backblaze B2, Cloudflare R2, MinIO, ...).
+ ///
+ ///
+ /// Like the FTP/SMB storages, all connection data (provider, region/endpoint,
+ /// bucket, access key, secret key) is encoded into the IOConnectionInfo.Path so
+ /// the recent-files store works without extra plumbing. The path format is:
+ ///
+ /// s3://SET<accessKey>:<secret>:<provider>:<region>:<endpointOrAccount>#<bucket>/<objectKey>
+ ///
+ /// The five settings tokens after the "SET" marker are each URL-encoded so that a
+ /// ':' '#' or '/' inside a value cannot break parsing; the first '#' separates the
+ /// (encoded) settings segment from the raw "<bucket>/<objectKey>" location.
+ /// Because the path embeds the secret key, never log or display it raw (see
+ /// for the redacted form shown to the user).
+ ///
+ public class S3FileStorage : IFileStorage
+ {
+ public const string ProtocolId = "s3";
+
+ ///
+ /// Holds the credentials and endpoint configuration for an S3 connection. The actual
+ /// encode/decode of the path format lives in the platform-agnostic, unit-tested
+ /// ; this is a thin Android-side wrapper mapping to/from an
+ /// .
+ ///
+ public struct ConnectionSettings
+ {
+ public string AccessKey { get; set; }
+ public string SecretKey { get; set; }
+ public S3Provider Provider { get; set; }
+
+ /// region (AWS/Wasabi/B2). For R2 it is unused ("auto"); for Custom it is the optional SigV4 signing region.
+ public string Region { get; set; }
+
+ /// custom endpoint URL (Custom/MinIO) or the account id (Cloudflare R2). Empty otherwise.
+ public string EndpointOrAccount { get; set; }
+
+ public static ConnectionSettings FromIoc(IOConnectionInfo ioc)
+ {
+ S3PathCodec.ParsePath(ioc.Path, out string settings, out _, out _);
+ S3PathCodec.ParseSettings(settings, out string accessKey, out string secretKey,
+ out S3Provider provider, out string region, out string endpointOrAccount);
+ return new ConnectionSettings()
+ {
+ AccessKey = accessKey,
+ SecretKey = secretKey,
+ Provider = provider,
+ Region = region,
+ EndpointOrAccount = endpointOrAccount
+ };
+ }
+
+ ///
+ /// Serializes into the settings segment of an s3:// path. Deliberately NOT a ToString()
+ /// override: the output contains the secret key, and we don't want an accidental log to leak it.
+ ///
+ public string Serialize()
+ {
+ return S3PathCodec.SerializeSettings(AccessKey, SecretKey, Provider, Region, EndpointOrAccount);
+ }
+ }
+
+ private readonly ICertificateValidationHandler _app;
+
+ ///
+ /// Last known ETag per "bucket/key", captured on read and refreshed on write,
+ /// used for conditional (If-Match) writes to avoid clobbering concurrent updates.
+ ///
+ private readonly ConcurrentDictionary _lastKnownETags = new();
+
+ public S3FileStorage(Context context, ICertificateValidationHandler app)
+ {
+ _app = app;
+ }
+
+ public IEnumerable SupportedProtocols
+ {
+ get { yield return ProtocolId; }
+ }
+
+ public bool UserShouldBackup
+ {
+ get { return true; }
+ }
+
+ #region path <-> bucket/key helpers
+
+ ///
+ /// Exposes the bucket and (fully qualified) object key. Also used to prefill the
+ /// credentials dialog when editing an existing connection. Parsing lives in
+ /// .
+ ///
+ public static void GetBucketAndObjectKey(IOConnectionInfo ioc, out string bucket, out string objectKey)
+ {
+ S3PathCodec.ParsePath(ioc.Path, out _, out bucket, out objectKey);
+ }
+
+ private static string ETagCacheKey(string bucket, string key)
+ {
+ return bucket + "/" + key;
+ }
+
+ #endregion
+
+ private AmazonS3Client GetClient(IOConnectionInfo ioc)
+ {
+ var settings = ConnectionSettings.FromIoc(ioc);
+ var config = new AmazonS3Config();
+ switch (settings.Provider)
+ {
+ case S3Provider.Aws:
+ config.RegionEndpoint = RegionEndpoint.GetBySystemName(settings.Region);
+ break;
+ case S3Provider.Wasabi:
+ config.ServiceURL = "https://s3." + settings.Region + ".wasabisys.com";
+ config.AuthenticationRegion = settings.Region;
+ break;
+ case S3Provider.BackblazeB2:
+ config.ServiceURL = "https://s3." + settings.Region + ".backblazeb2.com";
+ config.AuthenticationRegion = settings.Region;
+ break;
+ case S3Provider.CloudflareR2:
+ config.ServiceURL = "https://" + settings.EndpointOrAccount + ".r2.cloudflarestorage.com";
+ config.AuthenticationRegion = "auto";
+ break;
+ case S3Provider.Custom:
+ config.ServiceURL = settings.EndpointOrAccount;
+ config.ForcePathStyle = true;
+ if (!string.IsNullOrEmpty(settings.Region))
+ config.AuthenticationRegion = settings.Region;
+ break;
+ }
+
+ return new AmazonS3Client(new BasicAWSCredentials(settings.AccessKey, settings.SecretKey), config);
+ }
+
+ ///
+ /// Maps an AmazonS3Exception from a read/list/delete call to a clearer, user-facing
+ /// exception (these messages can be shown to the user, so they avoid raw AWS text):
+ /// 404 -> FileNotFoundException; 403 -> an explanation of the missing-vs-forbidden
+ /// ambiguity that arises when the policy omits s3:ListBucket. The write path handles its
+ /// own 403 in , where the actionable permission is s3:PutObject.
+ ///
+ private static Exception ConvertException(Exception exception)
+ {
+ if (exception is AmazonS3Exception s3Ex)
+ {
+ if (s3Ex.StatusCode == HttpStatusCode.NotFound)
+ return new FileNotFoundException("The database was not found at this S3 location.", exception);
+
+ //A 403 here is ambiguous: when the IAM policy omits s3:ListBucket (recommended least
+ //privilege), S3 returns "403 AccessDenied" both for an object that does not exist and
+ //for one the key may not read. We surface a clear message rather than the raw
+ //"not authorized: s3:ListBucket" text, which would otherwise mislead the user into
+ //granting s3:ListBucket.
+ if (s3Ex.StatusCode == HttpStatusCode.Forbidden)
+ return new IOException(
+ "Access denied, or the database does not exist. The access key may not be allowed to read this " +
+ "object (s3:GetObject), or the object key may be wrong. Note: without s3:ListBucket, S3 reports a " +
+ "missing object and a forbidden object identically, so check the object key first.", exception);
+ }
+ return exception;
+ }
+
+ public bool CheckForFileChangeFast(IOConnectionInfo ioc, string previousFileVersion)
+ {
+ if (string.IsNullOrEmpty(previousFileVersion))
+ return false;
+ string? current = GetCurrentFileVersionFast(ioc);
+ //treat a deleted/unreadable file as "changed"
+ return current != previousFileVersion;
+ }
+
+ public string GetCurrentFileVersionFast(IOConnectionInfo ioc)
+ {
+ try
+ {
+ GetBucketAndObjectKey(ioc, out string bucket, out string key);
+ using (var client = GetClient(ioc))
+ {
+ var meta = client.GetObjectMetadataAsync(bucket, key).GetAwaiter().GetResult();
+ _lastKnownETags[ETagCacheKey(bucket, key)] = meta.ETag;
+ return meta.ETag;
+ }
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+
+ public Stream OpenFileForRead(IOConnectionInfo ioc)
+ {
+ try
+ {
+ GetBucketAndObjectKey(ioc, out string bucket, out string key);
+ using (var client = GetClient(ioc))
+ using (var response = client.GetObjectAsync(bucket, key).GetAwaiter().GetResult())
+ {
+ var memStream = new MemoryStream();
+ response.ResponseStream.CopyTo(memStream);
+ memStream.Seek(0, SeekOrigin.Begin);
+ _lastKnownETags[ETagCacheKey(bucket, key)] = response.ETag;
+ return memStream;
+ }
+ }
+ catch (AmazonS3Exception ex)
+ {
+ throw ConvertException(ex);
+ }
+ }
+
+ public IWriteTransaction OpenWriteTransaction(IOConnectionInfo ioc, bool useFileTransaction)
+ {
+ //S3 PutObject is atomic per object, so we never need a temp-file/rename transaction.
+ return new S3WriteTransaction(ioc, this);
+ }
+
+ ///
+ /// Uploads the given bytes to the object referenced by ioc. When the base ETag is known
+ /// (captured on the preceding read), the write is conditional (If-Match), so a concurrent
+ /// server-side change is detected (412 -> "changed on the server") instead of being
+ /// silently overwritten.
+ /// Gotcha: if the provider does not support conditional writes it returns 501 and we retry
+ /// WITHOUT the precondition — in that (rare) case the concurrent-overwrite protection is
+ /// lost and a racing change could be clobbered. Amazon S3 and the major S3-compatibles
+ /// support If-Match, so this only affects older/limited servers.
+ ///
+ internal void UploadFile(IOConnectionInfo ioc, byte[] content)
+ {
+ GetBucketAndObjectKey(ioc, out string bucket, out string key);
+ string cacheKey = ETagCacheKey(bucket, key);
+ _lastKnownETags.TryGetValue(cacheKey, out string? lastKnownETag);
+
+ using (var client = GetClient(ioc))
+ {
+ PutObjectResponse response;
+ try
+ {
+ response = PutObject(client, bucket, key, content, lastKnownETag);
+ }
+ catch (AmazonS3Exception ex) when (ex.StatusCode == HttpStatusCode.NotImplemented && lastKnownETag != null)
+ {
+ //provider does not support conditional writes: retry without the precondition
+ //(this drops the concurrent-overwrite protection — see the method summary)
+ response = PutObject(client, bucket, key, content, null);
+ }
+ catch (AmazonS3Exception ex) when (ex.StatusCode == HttpStatusCode.PreconditionFailed)
+ {
+ throw new IOException("The file was changed on the server since it was loaded.", ex);
+ }
+ catch (AmazonS3Exception ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
+ {
+ //write-specific message: the actionable permission on a save is s3:PutObject
+ //(the shared ConvertException talks about s3:GetObject, which would mislead here)
+ throw new IOException(
+ "Could not save: access denied. The access key may not be allowed to write this object " +
+ "(s3:PutObject), or the bucket / object key may be wrong.", ex);
+ }
+ _lastKnownETags[cacheKey] = response.ETag;
+ }
+ }
+
+ private static PutObjectResponse PutObject(AmazonS3Client client, string bucket, string key, byte[] content, string? lastKnownETag)
+ {
+ var request = new PutObjectRequest
+ {
+ BucketName = bucket,
+ Key = key,
+ InputStream = new MemoryStream(content)
+ };
+ //conditional write: only overwrite if the object still matches the ETag we last saw
+ if (!string.IsNullOrEmpty(lastKnownETag))
+ request.IfMatch = lastKnownETag;
+ return client.PutObjectAsync(request).GetAwaiter().GetResult();
+ }
+
+ ///
+ /// Deletes the single object referenced by ioc. This is an
+ /// contract method; KP2A calls it for its own housekeeping (e.g. removing a local
+ /// cached/backup copy, or cleaning up the just-created object when a "create database"
+ /// save fails — see CreateDatabaseActivity), not as a user-facing "delete my database"
+ /// action. It only ever targets the exact object key, never a prefix/bucket.
+ ///
+ public void Delete(IOConnectionInfo ioc)
+ {
+ try
+ {
+ GetBucketAndObjectKey(ioc, out string bucket, out string key);
+ using (var client = GetClient(ioc))
+ {
+ client.DeleteObjectAsync(bucket, key).GetAwaiter().GetResult();
+ }
+ }
+ catch (AmazonS3Exception ex)
+ {
+ throw ConvertException(ex);
+ }
+ }
+
+ public string GetFilenameWithoutPathAndExt(IOConnectionInfo ioc)
+ {
+ return UrlUtil.StripExtension(UrlUtil.GetFileName(GetObjectKeyForName(ioc)));
+ }
+
+ public string GetFileExtension(IOConnectionInfo ioc)
+ {
+ return UrlUtil.GetExtension(GetObjectKeyForName(ioc));
+ }
+
+ private static string GetObjectKeyForName(IOConnectionInfo ioc)
+ {
+ GetBucketAndObjectKey(ioc, out _, out string key);
+ //a "folder" key ends in '/'; trim it so GetFileName/StripExtension see the last segment
+ return key.TrimEnd('/');
+ }
+
+ public bool RequiresCredentials(IOConnectionInfo ioc)
+ {
+ return false;
+ }
+
+ public void CreateDirectory(IOConnectionInfo ioc, string newDirName)
+ {
+ //No-op: S3 has no real directories (a "folder" is just a key prefix). The direct-object
+ //flow never browses or creates folders, so there is nothing to do here. Creating a
+ //zero-byte marker object would also require an extra PutObject the user didn't ask for.
+ }
+
+ ///
+ /// Not supported by the S3 backend. The backend opens a fully qualified object directly and
+ /// never browses, which is precisely what lets a least-privilege user skip s3:ListBucket.
+ /// Browsing/listing is used by other storage backends through the file chooser
+ /// (FileChooserFileProvider.ListContents), but that flow never reaches S3, so rather than
+ /// keep a dead, ListBucket-requiring code path we fail loudly here.
+ ///
+ public IEnumerable ListContents(IOConnectionInfo ioc)
+ {
+ throw new NotSupportedException(
+ "Browsing is not supported for S3; open the database object directly. " +
+ "(Listing would require the s3:ListBucket permission, which this backend avoids.)");
+ }
+
+ public FileDescription GetFileDescription(IOConnectionInfo ioc)
+ {
+ try
+ {
+ GetBucketAndObjectKey(ioc, out string bucket, out string key);
+ using (var client = GetClient(ioc))
+ {
+ var meta = client.GetObjectMetadataAsync(bucket, key).GetAwaiter().GetResult();
+ return new FileDescription()
+ {
+ CanRead = true,
+ CanWrite = true,
+ Path = ioc.Path,
+ LastModified = meta.LastModified,
+ SizeInBytes = meta.ContentLength,
+ //trim a trailing '/' so a "folder"-style key yields its last segment as the display name
+ DisplayName = UrlUtil.GetFileName(key.TrimEnd('/')),
+ //in S3 a key ending in '/' is still a regular object, not a directory; the backend
+ //never represents directories, so this is always a file
+ IsDirectory = false
+ };
+ }
+ }
+ catch (AmazonS3Exception ex)
+ {
+ throw ConvertException(ex);
+ }
+ }
+
+ 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)
+ {
+ }
+
+ ///
+ /// Redacted, user-facing label for the connection: "s3://bucket/key (Provider)".
+ /// Deliberately contains only the bucket, object key and provider — NOT the access/secret
+ /// key — so it is safe to show or log (unlike the raw ioc.Path, which embeds the secret).
+ ///
+ public string GetDisplayName(IOConnectionInfo ioc)
+ {
+ var settings = ConnectionSettings.FromIoc(ioc);
+ GetBucketAndObjectKey(ioc, out string bucket, out string key);
+ return ProtocolId + "://" + bucket + "/" + key + " (" + settings.Provider + ")";
+ }
+
+ public string CreateFilePath(string parent, string newFilename)
+ {
+ if (!parent.EndsWith("/"))
+ parent += "/";
+ return parent + newFilename;
+ }
+
+ public IOConnectionInfo GetParentPath(IOConnectionInfo ioc)
+ {
+ return IoUtil.GetParentPath(ioc);
+ }
+
+ public IOConnectionInfo GetFilePath(IOConnectionInfo folderPath, string filename)
+ {
+ IOConnectionInfo res = folderPath.CloneDeep();
+ if (!res.Path.EndsWith("/"))
+ res.Path += "/";
+ res.Path += filename;
+ return res;
+ }
+
+ public bool IsPermanentLocation(IOConnectionInfo ioc)
+ {
+ return true;
+ }
+
+ public bool IsReadOnly(IOConnectionInfo ioc, OptionalOut reason = null)
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Buffers the database in memory and uploads it with a single (atomic) PutObject on commit.
+ /// Not thread-safe and not intended to be: like the other IWriteTransaction implementations,
+ /// a transaction is created, written and committed by a single save operation on one thread.
+ /// The only shared state across operations is S3FileStorage._lastKnownETags, which is a
+ /// ConcurrentDictionary.
+ ///
+ public class S3WriteTransaction : IWriteTransaction
+ {
+ private readonly IOConnectionInfo _ioc;
+ private readonly S3FileStorage _fileStorage;
+ private MemoryStream _stream;
+
+ public S3WriteTransaction(IOConnectionInfo ioc, S3FileStorage fileStorage)
+ {
+ _ioc = ioc;
+ _fileStorage = fileStorage;
+ }
+
+ public void Dispose()
+ {
+ if (_stream != null)
+ _stream.Dispose();
+ _stream = null;
+ }
+
+ public Stream OpenFile()
+ {
+ _stream = new MemoryStream();
+ return _stream;
+ }
+
+ public void CommitWrite()
+ {
+ //MemoryStream.ToArray() is valid even after the stream has been closed.
+ _fileStorage.UploadFile(_ioc, _stream.ToArray());
+ }
+ }
+}
+#endif
diff --git a/src/Kp2aBusinessLogic/Kp2aBusinessLogic.csproj b/src/Kp2aBusinessLogic/Kp2aBusinessLogic.csproj
index 969ec79a9..46aa04e38 100644
--- a/src/Kp2aBusinessLogic/Kp2aBusinessLogic.csproj
+++ b/src/Kp2aBusinessLogic/Kp2aBusinessLogic.csproj
@@ -10,6 +10,7 @@
+
@@ -23,6 +24,7 @@
+
diff --git a/src/Kp2aBusinessLogic/database/edit/AddTemplateEntries.cs b/src/Kp2aBusinessLogic/database/edit/AddTemplateEntries.cs
index af143b44e..d9f91eca5 100644
--- a/src/Kp2aBusinessLogic/database/edit/AddTemplateEntries.cs
+++ b/src/Kp2aBusinessLogic/database/edit/AddTemplateEntries.cs
@@ -343,7 +343,7 @@ public PwGroup AddTemplates(out List addedEntries)
templateGroup = new PwGroup(true, true, _app.GetResourceString(UiStringKey.TemplateGroupName), PwIcon.Folder);
_app.CurrentDb.KpDatabase.RootGroup.AddGroup(templateGroup, true);
_app.CurrentDb.KpDatabase.EntryTemplatesGroup = templateGroup.Uuid;
- _app.CurrentDb.KpDatabase.EntryTemplatesGroupChanged = DateTime.Now;
+ _app.CurrentDb.KpDatabase.EntryTemplatesGroupChanged = DateTime.UtcNow;
_app.DirtyGroups.Add(_app.CurrentDb.KpDatabase.RootGroup);
_app.CurrentDb.GroupsById[templateGroup.Uuid] = templateGroup;
_app.CurrentDb.Elements.Add(templateGroup);
diff --git a/src/Kp2aBusinessLogic/database/edit/DeleteRunnable.cs b/src/Kp2aBusinessLogic/database/edit/DeleteRunnable.cs
index f91e37eb3..4132e6b08 100644
--- a/src/Kp2aBusinessLogic/database/edit/DeleteRunnable.cs
+++ b/src/Kp2aBusinessLogic/database/edit/DeleteRunnable.cs
@@ -283,7 +283,7 @@ protected bool DoDeleteGroup(PwGroup pg, List touchedGroups, List ((Dialog)sender).Dismiss())
.Show();
-
}
catch (Exception)
{
@@ -146,6 +152,11 @@ public static void DisplayMessage(Context ctx, string message, bool makeDialog)
else
Toast.MakeText(ctx ?? Application.Context, message, ToastLength.Long).Show();
}
+
+ if (Looper.MyLooper() == Looper.MainLooper)
+ ShowUi();
+ else
+ new Handler(Looper.MainLooper).Post(ShowUi);
}
}
}
diff --git a/src/Kp2aBusinessLogic/database/edit/SetPassword.cs b/src/Kp2aBusinessLogic/database/edit/SetPassword.cs
index 39ba87caa..6faecbaea 100644
--- a/src/Kp2aBusinessLogic/database/edit/SetPassword.cs
+++ b/src/Kp2aBusinessLogic/database/edit/SetPassword.cs
@@ -74,7 +74,7 @@ public override void Run()
DateTime previousMasterKeyChanged = pm.MasterKeyChanged;
CompositeKey previousKey = pm.MasterKey;
- pm.MasterKeyChanged = DateTime.Now;
+ pm.MasterKeyChanged = DateTime.UtcNow;
pm.MasterKey = newKey;
// Save Database
diff --git a/src/Kp2aS3PathCodec.Tests/Kp2aS3PathCodec.Tests.csproj b/src/Kp2aS3PathCodec.Tests/Kp2aS3PathCodec.Tests.csproj
new file mode 100644
index 000000000..31d853032
--- /dev/null
+++ b/src/Kp2aS3PathCodec.Tests/Kp2aS3PathCodec.Tests.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Kp2aS3PathCodec.Tests/S3PathCodecTests.cs b/src/Kp2aS3PathCodec.Tests/S3PathCodecTests.cs
new file mode 100644
index 000000000..f9e626064
--- /dev/null
+++ b/src/Kp2aS3PathCodec.Tests/S3PathCodecTests.cs
@@ -0,0 +1,148 @@
+// 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
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Keepass2Android is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Keepass2Android. If not, see .
+
+using System;
+using keepass2android.Io;
+
+namespace Kp2aS3PathCodec.Tests
+{
+ public class S3PathCodecTests
+ {
+ // ---- settings round-trip (the encode/decode that worried the reviewer) ----
+
+ [Theory]
+ // plain
+ [InlineData("AKIAEXAMPLE", "simplesecret", S3Provider.Aws, "us-east-1", "")]
+ // a real-shaped AWS secret: base64 contains '+' and '/' and may end with '='
+ [InlineData("AKIA/IDX+1", "wJalrXUtnFEMI/K7MDENG+bPxRfiCYz+a/b=", S3Provider.Aws, "eu-west-3", "")]
+ // values containing the path delimiters ':' '#' '/' must survive
+ [InlineData("ak:with#weird/chars", "sk:with#weird/chars+and space", S3Provider.Custom, "us-east-1", "https://minio.example.com:9000")]
+ // empty/awkward values
+ [InlineData("", "", S3Provider.CloudflareR2, "", "abc123account")]
+ public void Settings_RoundTrip(string accessKey, string secretKey, S3Provider provider, string region, string endpointOrAccount)
+ {
+ string segment = S3PathCodec.SerializeSettings(accessKey, secretKey, provider, region, endpointOrAccount);
+
+ S3PathCodec.ParseSettings(segment, out string ak, out string sk, out S3Provider prov, out string reg, out string ep);
+
+ Assert.Equal(accessKey, ak);
+ Assert.Equal(secretKey, sk); // '+' must come back as '+', not as a space
+ Assert.Equal(provider, prov);
+ Assert.Equal(region, reg);
+ Assert.Equal(endpointOrAccount, ep);
+ }
+
+ [Fact]
+ public void Settings_PlusInSecret_IsNotDecodedToSpace()
+ {
+ string segment = S3PathCodec.SerializeSettings("ak", "a+b+c", S3Provider.Aws, "us-east-1", "");
+ // the literal '+' must have been percent-encoded, not left bare
+ Assert.Contains("%2B", segment, StringComparison.OrdinalIgnoreCase);
+ S3PathCodec.ParseSettings(segment, out _, out string sk, out _, out _, out _);
+ Assert.Equal("a+b+c", sk);
+ }
+
+ [Fact]
+ public void ParseSettings_MissingMarker_Throws()
+ {
+ Assert.Throws(() => S3PathCodec.ParseSettings("not-a-set-segment", out _, out _, out _, out _, out _));
+ }
+
+ // ---- path parsing ----
+
+ [Fact]
+ public void ParsePath_SplitsSettingsBucketAndKey()
+ {
+ string settings = S3PathCodec.SerializeSettings("ak", "sk", S3Provider.Aws, "us-east-1", "");
+ string path = S3PathCodec.BuildPath(settings, "my-bucket", "folder/db.kdbx");
+
+ S3PathCodec.ParsePath(path, out string parsedSettings, out string bucket, out string key);
+
+ Assert.Equal(settings, parsedSettings);
+ Assert.Equal("my-bucket", bucket);
+ Assert.Equal("folder/db.kdbx", key);
+ }
+
+ [Fact]
+ public void ParsePath_BucketWithoutKey_YieldsEmptyKey()
+ {
+ string settings = S3PathCodec.SerializeSettings("ak", "sk", S3Provider.Aws, "us-east-1", "");
+ S3PathCodec.ParsePath("s3://" + settings + "#only-bucket", out _, out string bucket, out string key);
+ Assert.Equal("only-bucket", bucket);
+ Assert.Equal("", key);
+ }
+
+ [Fact]
+ public void ParsePath_MissingHash_Throws()
+ {
+ Assert.Throws(() => S3PathCodec.ParsePath("s3://SETnohashhere", out _, out _, out _));
+ }
+
+ // ---- full round-trip: dialog values -> path -> back ----
+
+ [Fact]
+ public void BuildFullPath_RoundTripsThroughParse()
+ {
+ string path = S3PathCodec.BuildFullPath(S3Provider.Custom, "us-east-1",
+ "https://minio.example.com:9000", "bucket", "AK:1", "S+K/2=", "/leading/slash/db.kdbx");
+
+ S3PathCodec.ParsePath(path, out string settings, out string bucket, out string key);
+ S3PathCodec.ParseSettings(settings, out string ak, out string sk, out S3Provider prov, out string reg, out string ep);
+
+ Assert.Equal("bucket", bucket);
+ Assert.Equal("leading/slash/db.kdbx", key); // BuildFullPath trims a leading '/'
+ Assert.Equal("AK:1", ak);
+ Assert.Equal("S+K/2=", sk);
+ Assert.Equal(S3Provider.Custom, prov);
+ Assert.Equal("us-east-1", reg);
+ Assert.Equal("https://minio.example.com:9000", ep);
+ }
+
+ // ---- preview URL per provider / addressing style ----
+
+ [Theory]
+ [InlineData(S3Provider.Aws, "us-west-2", "", "mybucket", "db.kdbx", "https://mybucket.s3.us-west-2.amazonaws.com/db.kdbx")]
+ [InlineData(S3Provider.Aws, "", "", "mybucket", "db.kdbx", "https://mybucket.s3.amazonaws.com/db.kdbx")]
+ [InlineData(S3Provider.Wasabi, "eu-central-1", "", "b", "k.kdbx", "https://b.s3.eu-central-1.wasabisys.com/k.kdbx")]
+ [InlineData(S3Provider.BackblazeB2, "us-west-004", "", "b", "k.kdbx", "https://b.s3.us-west-004.backblazeb2.com/k.kdbx")]
+ [InlineData(S3Provider.CloudflareR2, "", "acct123", "b", "k.kdbx", "https://acct123.r2.cloudflarestorage.com/b/k.kdbx")]
+ [InlineData(S3Provider.Custom, "", "https://minio.example.com:9000", "b", "k.kdbx", "https://minio.example.com:9000/b/k.kdbx")]
+ public void BuildPreviewUrl_MatchesAddressingStyle(S3Provider provider, string region, string endpointOrAccount,
+ string bucket, string objectKey, string expected)
+ {
+ Assert.Equal(expected, S3PathCodec.BuildPreviewUrl(provider, region, endpointOrAccount, bucket, objectKey));
+ }
+
+ [Fact]
+ public void BuildPreviewUrl_TrimsLeadingSlashOnKey()
+ {
+ Assert.Equal("https://b.s3.amazonaws.com/folder/k.kdbx",
+ S3PathCodec.BuildPreviewUrl(S3Provider.Aws, "", "", "b", "/folder/k.kdbx"));
+ }
+
+ // ---- bucket-in-key detection ----
+
+ [Theory]
+ [InlineData("mybucket", "mybucket/db.kdbx", true)] // repeated bucket prefix
+ [InlineData("mybucket", "/mybucket/db.kdbx", true)] // leading slash still detected
+ [InlineData("mybucket", "db.kdbx", false)]
+ [InlineData("mybucket", "mybucketX/db.kdbx", false)] // prefix but not a path segment
+ [InlineData("", "anything/db.kdbx", false)] // no bucket -> no warning
+ public void KeyRepeatsBucket_DetectsDuplication(string bucket, string objectKey, bool expected)
+ {
+ Assert.Equal(expected, S3PathCodec.KeyRepeatsBucket(bucket, objectKey));
+ }
+ }
+}
diff --git a/src/Kp2aS3PathCodec/Kp2aS3PathCodec.csproj b/src/Kp2aS3PathCodec/Kp2aS3PathCodec.csproj
new file mode 100644
index 000000000..bfa77a111
--- /dev/null
+++ b/src/Kp2aS3PathCodec/Kp2aS3PathCodec.csproj
@@ -0,0 +1,7 @@
+
+
+ net8.0
+ enable
+ enable
+
+
diff --git a/src/Kp2aS3PathCodec/S3PathCodec.cs b/src/Kp2aS3PathCodec/S3PathCodec.cs
new file mode 100644
index 000000000..2d1dde29a
--- /dev/null
+++ b/src/Kp2aS3PathCodec/S3PathCodec.cs
@@ -0,0 +1,193 @@
+// 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
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Keepass2Android is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Keepass2Android. If not, see .
+
+using System;
+using System.Net;
+
+namespace keepass2android.Io
+{
+ /// S3 / S3-compatible provider. The integer values are persisted in the path, so don't reorder.
+ public enum S3Provider
+ {
+ Aws = 0,
+ Wasabi = 1,
+ BackblazeB2 = 2,
+ CloudflareR2 = 3,
+ Custom = 4
+ }
+
+ ///
+ /// Pure, platform-agnostic codec for the s3:// path format used by the S3 backend. This is the
+ /// most bug-prone part of the feature (string surgery + URL-encoding), so it lives in its own
+ /// library with no Android/KeePassLib dependencies and is unit-tested (Kp2aS3PathCodec.Tests).
+ ///
+ ///
+ /// Path format:
+ ///
+ /// s3://SET<accessKey>:<secret>:<provider>:<region>:<endpointOrAccount>#<bucket>/<objectKey>
+ ///
+ /// The five settings tokens after the "SET" marker are each URL-encoded so a ':' '#' or '/'
+ /// inside a value can't break parsing; the first '#' separates the encoded settings segment
+ /// from the raw "<bucket>/<objectKey>" location.
+ ///
+ public static class S3PathCodec
+ {
+ public const string ProtocolId = "s3";
+ public const string SettingsPrefix = "SET";
+ public const string SettingsPostFix = "#";
+ public const char Separator = ':';
+
+ ///
+ /// Serializes the connection settings into the (URL-encoded) "SET..." settings segment.
+ /// Each token is URL-encoded so a ':' '#' or '/' inside a value can't break parsing.
+ ///
+ public static string SerializeSettings(string? accessKey, string? secretKey, S3Provider provider,
+ string? region, string? endpointOrAccount)
+ {
+ return SettingsPrefix +
+ WebUtility.UrlEncode(accessKey ?? "") + Separator +
+ WebUtility.UrlEncode(secretKey ?? "") + Separator +
+ (int)provider + Separator +
+ WebUtility.UrlEncode(region ?? "") + Separator +
+ WebUtility.UrlEncode(endpointOrAccount ?? "");
+ }
+
+ /// Parses a "SET..." settings segment back into its fields (inverse of ).
+ public static void ParseSettings(string settingsSegment, out string accessKey, out string secretKey,
+ out S3Provider provider, out string region, out string endpointOrAccount)
+ {
+ if (settingsSegment == null || !settingsSegment.StartsWith(SettingsPrefix, StringComparison.Ordinal))
+ throw new FormatException("unexpected settings in S3 path (missing '" + SettingsPrefix + "' marker)");
+ string body = settingsSegment.Substring(SettingsPrefix.Length);
+ string[] tokens = body.Split(Separator);
+ if (tokens.Length < 5)
+ throw new FormatException("unexpected settings in S3 path (expected 5 tokens, got " + tokens.Length + ")");
+ accessKey = WebUtility.UrlDecode(tokens[0]);
+ secretKey = WebUtility.UrlDecode(tokens[1]);
+ provider = (S3Provider)int.Parse(tokens[2]);
+ region = WebUtility.UrlDecode(tokens[3]);
+ endpointOrAccount = WebUtility.UrlDecode(tokens[4]);
+ }
+
+ ///
+ /// Splits an s3:// path into its (still URL-encoded) settings segment, bucket and object key.
+ /// This is the single path parser; the malformed-path guard lives here in one place.
+ ///
+ public static void ParsePath(string path, out string settings, out string bucket, out string key)
+ {
+ int schemeSeparatorIndex = path.IndexOf("://", StringComparison.Ordinal);
+ string rest = path.Substring(schemeSeparatorIndex + 3);
+ int settingsSeparatorIndex = rest.IndexOf(SettingsPostFix, StringComparison.Ordinal);
+ if (settingsSeparatorIndex < 0)
+ throw new FormatException("unexpected S3 path (missing '" + SettingsPostFix + "' settings separator)");
+ settings = rest.Substring(0, settingsSeparatorIndex);
+ string afterSettings = rest.Substring(settingsSeparatorIndex + 1);
+ int firstSlashIndex = afterSettings.IndexOf('/');
+ if (firstSlashIndex < 0)
+ {
+ bucket = afterSettings;
+ key = "";
+ }
+ else
+ {
+ bucket = afterSettings.Substring(0, firstSlashIndex);
+ key = afterSettings.Substring(firstSlashIndex + 1);
+ }
+ }
+
+ /// Assembles a full s3:// path from a (serialized) settings segment, bucket and key.
+ public static string BuildPath(string settings, string bucket, string key)
+ {
+ return ProtocolId + "://" + settings + SettingsPostFix + bucket + "/" + key;
+ }
+
+ ///
+ /// Assembles a full s3:// path from the values entered in the credentials dialog.
+ /// is the fully qualified object key inside the bucket
+ /// (S3 has no directories), e.g. "passwords.kdbx" or "folder/passwords.kdbx".
+ ///
+ public static string BuildFullPath(S3Provider provider, string? region, string? endpointOrAccount,
+ string bucket, string? accessKey, string? secretKey, string? objectKey)
+ {
+ string settings = SerializeSettings(accessKey, secretKey, provider, region ?? "", endpointOrAccount ?? "");
+ string key = (objectKey ?? "").TrimStart('/');
+ return BuildPath(settings, bucket, key);
+ }
+
+ ///
+ /// Builds a human-readable https URL preview of the object the current dialog values point to.
+ /// The URL shape follows the addressing style each provider uses (matching ForcePathStyle in
+ /// the S3 client): AWS/Wasabi/B2 use virtual-hosted style (bucket as a sub-domain of the
+ /// regional host); R2 and Custom/MinIO use path style (bucket as the first path segment under
+ /// the endpoint host). Placeholders in angle brackets are shown for values not yet entered.
+ ///
+ public static string BuildPreviewUrl(S3Provider provider, string? region, string? endpointOrAccount,
+ string? bucket, string? objectKey)
+ {
+ region = (region ?? "").Trim();
+ endpointOrAccount = (endpointOrAccount ?? "").Trim();
+ bucket = (bucket ?? "").Trim();
+ objectKey = (objectKey ?? "").Trim().TrimStart('/');
+ string bucketOrPlaceholder = bucket.Length == 0 ? "" : bucket;
+
+ switch (provider)
+ {
+ //AWS/Wasabi/B2: virtual-hosted style -> https://./
+ case S3Provider.Aws:
+ {
+ string host = region.Length == 0 ? "s3.amazonaws.com" : "s3." + region + ".amazonaws.com";
+ return "https://" + bucketOrPlaceholder + "." + host + "/" + objectKey;
+ }
+ case S3Provider.Wasabi:
+ {
+ string r = region.Length == 0 ? "" : region;
+ return "https://" + bucketOrPlaceholder + ".s3." + r + ".wasabisys.com/" + objectKey;
+ }
+ case S3Provider.BackblazeB2:
+ {
+ string r = region.Length == 0 ? "" : region;
+ return "https://" + bucketOrPlaceholder + ".s3." + r + ".backblazeb2.com/" + objectKey;
+ }
+ //R2: path style under the account host -> https://.r2.cloudflarestorage.com//
+ case S3Provider.CloudflareR2:
+ {
+ string acct = endpointOrAccount.Length == 0 ? "" : endpointOrAccount;
+ return "https://" + acct + ".r2.cloudflarestorage.com/" + bucketOrPlaceholder + "/" + objectKey;
+ }
+ //Custom/MinIO: path style under the user's endpoint -> // (ForcePathStyle)
+ case S3Provider.Custom:
+ {
+ string ep = endpointOrAccount.TrimEnd('/');
+ if (ep.Length == 0) ep = "";
+ return ep + "/" + bucketOrPlaceholder + "/" + objectKey;
+ }
+ default:
+ return "";
+ }
+ }
+
+ ///
+ /// True if the object key starts with "<bucket>/" — a common mistake where the user repeats
+ /// the bucket name in the key, producing a "bucket/bucket/..." path. Comparison is ordinal.
+ ///
+ public static bool KeyRepeatsBucket(string? bucket, string? objectKey)
+ {
+ if (string.IsNullOrEmpty(bucket))
+ return false;
+ string trimmedKey = (objectKey ?? "").TrimStart('/');
+ return trimmedKey.StartsWith(bucket + "/", StringComparison.Ordinal);
+ }
+ }
+}
diff --git a/src/keepass2android-app/CreateDatabaseActivity.cs b/src/keepass2android-app/CreateDatabaseActivity.cs
index e1eb0de54..cef12e403 100644
--- a/src/keepass2android-app/CreateDatabaseActivity.cs
+++ b/src/keepass2android-app/CreateDatabaseActivity.cs
@@ -453,7 +453,9 @@ public override void Run()
}
else
{
- DisplayMessage(_activity);
+ //show the (possibly long) save error in a dismissible dialog rather than a
+ //short-lived toast that truncates it (makeDialog: true)
+ DisplayMessage(_activity, Message, true);
try
{
App.Kp2a.GetFileStorage(_ioc).Delete(_ioc);
diff --git a/src/keepass2android-app/FileSelectHelper.cs b/src/keepass2android-app/FileSelectHelper.cs
index 4f8d06d2d..4183af14d 100644
--- a/src/keepass2android-app/FileSelectHelper.cs
+++ b/src/keepass2android-app/FileSelectHelper.cs
@@ -484,6 +484,130 @@ private void ShowFtpDialog(Activity activity, Util.FileSelectedHandler onStartBr
}
+ private void ShowS3Dialog(Activity activity, Util.FileSelectedHandler onSelectFile, Action onCancel, string defaultPath)
+ {
+#if !NoNet
+ MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(activity);
+ View dlgContents = activity.LayoutInflater.Inflate(Resource.Layout.s3credentials, null);
+
+ var providerSpinner = dlgContents.FindViewById(Resource.Id.s3_provider);
+ var regionView = dlgContents.FindViewById(Resource.Id.s3_region);
+ var endpointView = dlgContents.FindViewById(Resource.Id.s3_endpoint);
+ var accountView = dlgContents.FindViewById(Resource.Id.s3_account_id);
+ var bucketView = dlgContents.FindViewById(Resource.Id.s3_bucket);
+ var accessKeyView = dlgContents.FindViewById(Resource.Id.s3_access_key);
+ var secretKeyView = dlgContents.FindViewById(Resource.Id.s3_secret_key);
+ var objectKeyView = dlgContents.FindViewById(Resource.Id.s3_object_key);
+ var previewView = dlgContents.FindViewById(Resource.Id.s3_url_preview);
+
+ void UpdateFieldVisibility(int position)
+ {
+ bool isR2 = position == (int)S3Provider.CloudflareR2;
+ bool isCustom = position == (int)S3Provider.Custom;
+ regionView.Visibility = isR2 ? ViewStates.Gone : ViewStates.Visible;
+ endpointView.Visibility = isCustom ? ViewStates.Visible : ViewStates.Gone;
+ accountView.Visibility = isR2 ? ViewStates.Visible : ViewStates.Gone;
+ //for a custom endpoint the region is only the SigV4 signing region (optional), not an
+ //AWS region; relabel so users don't think they must enter an AWS-style region code
+ regionView.Hint = activity.GetString(isCustom
+ ? Resource.String.hint_s3_signing_region
+ : Resource.String.hint_s3_region);
+ }
+
+ //Single source of truth for reading the dialog fields, so the live preview and the
+ //path actually saved can never drift apart. region/endpointOrAccount are derived from
+ //the selected provider (region field for AWS/Wasabi/B2; endpoint for Custom; account
+ //for R2, whose region is fixed to "auto").
+ (S3Provider provider, string region, string endpointOrAccount, string bucket,
+ string accessKey, string secretKey, string objectKey) ReadDialogValues()
+ {
+ var provider = (S3Provider)providerSpinner.SelectedItemPosition;
+ string region = provider == S3Provider.CloudflareR2 ? "" : regionView.Text;
+ string endpointOrAccount = "";
+ if (provider == S3Provider.Custom)
+ endpointOrAccount = endpointView.Text;
+ else if (provider == S3Provider.CloudflareR2)
+ endpointOrAccount = accountView.Text;
+ return (provider, region, endpointOrAccount, bucketView.Text, accessKeyView.Text,
+ secretKeyView.Text, objectKeyView.Text);
+ }
+
+ void UpdatePreview()
+ {
+ var v = ReadDialogValues();
+ previewView.Text = S3PathCodec.BuildPreviewUrl(v.provider, v.region, v.endpointOrAccount,
+ v.bucket, v.objectKey);
+ }
+
+ providerSpinner.ItemSelected += (sender, args) => { UpdateFieldVisibility(args.Position); UpdatePreview(); };
+
+ EventHandler onFieldChanged = (sender, args) => UpdatePreview();
+ regionView.TextChanged += onFieldChanged;
+ endpointView.TextChanged += onFieldChanged;
+ accountView.TextChanged += onFieldChanged;
+ bucketView.TextChanged += onFieldChanged;
+ objectKeyView.TextChanged += onFieldChanged;
+
+ if (!defaultPath.EndsWith(_schemeSeparator))
+ {
+ var ioc = IOConnectionInfo.FromPath(defaultPath);
+ var connection = S3FileStorage.ConnectionSettings.FromIoc(ioc);
+ S3FileStorage.GetBucketAndObjectKey(ioc, out string bucket, out string objectKey);
+
+ providerSpinner.SetSelection((int)connection.Provider);
+ regionView.Text = connection.Region;
+ if (connection.Provider == S3Provider.Custom)
+ endpointView.Text = connection.EndpointOrAccount;
+ else if (connection.Provider == S3Provider.CloudflareR2)
+ accountView.Text = connection.EndpointOrAccount;
+ bucketView.Text = bucket;
+ accessKeyView.Text = connection.AccessKey;
+ secretKeyView.Text = connection.SecretKey;
+ objectKeyView.Text = objectKey;
+ }
+ UpdateFieldVisibility(providerSpinner.SelectedItemPosition);
+ UpdatePreview();
+
+ builder.SetView(dlgContents);
+ builder.SetPositiveButton(Android.Resource.String.Ok,
+ (sender, args) =>
+ {
+ var v = ReadDialogValues();
+ string s3Path = S3PathCodec.BuildFullPath(v.provider, v.region,
+ v.endpointOrAccount, v.bucket, v.accessKey, v.secretKey, v.objectKey);
+
+ //The object key is the path INSIDE the bucket. If it repeats the bucket name
+ //the database ends up at "bucket/bucket/..." (a common mistake). Warn and let
+ //the user go back and fix it; don't silently strip it (could be intentional).
+ string trimmedKey = (v.objectKey ?? "").TrimStart('/');
+ if (S3PathCodec.KeyRepeatsBucket(v.bucket, v.objectKey))
+ {
+ new MaterialAlertDialogBuilder(activity)
+ .SetMessage(activity.GetString(Resource.String.s3_bucket_in_key_warning, v.bucket, trimmedKey))
+ .SetCancelable(false)
+ .SetPositiveButton(Resource.String.Continue, (s, e) => onSelectFile(s3Path))
+ .SetNegativeButton(Resource.String.s3_fix_object_key,
+ (s, e) => ShowS3Dialog(activity, onSelectFile, onCancel, s3Path))
+ .Show();
+ return;
+ }
+
+ //S3 has no real directories: open the fully qualified object directly
+ //instead of browsing/listing the bucket (which would require s3:ListBucket).
+ onSelectFile(s3Path);
+ });
+ EventHandler evtH = new EventHandler((sender, e) => onCancel());
+
+ builder.SetNegativeButton(Android.Resource.String.Cancel, evtH);
+ builder.SetTitle(activity.GetString(Resource.String.enter_s3_login_title));
+ builder.SetCancelable(false);
+ Dialog dialog = builder.Create();
+
+ dialog.Show();
+#endif
+ }
+
+
private void ShowMegaDialog(Activity activity, Util.FileSelectedHandler onStartBrowse, Action onCancel, string defaultPath)
{
#if !NoNet
@@ -552,6 +676,8 @@ public void PerformManualFileSelect(string defaultPath)
ShowSftpDialog(_activity, ReturnFileOrStartFileChooser, ReturnCancel, defaultPath);
else if ((defaultPath.StartsWith("ftp://")) || (defaultPath.StartsWith("ftps://")))
ShowFtpDialog(_activity, ReturnFileOrStartFileChooser, ReturnCancel, defaultPath);
+ else if (defaultPath.StartsWith("s3://"))
+ ShowS3Dialog(_activity, ReturnFileDirectly, ReturnCancel, defaultPath);
else if ((defaultPath.StartsWith("http://")) || (defaultPath.StartsWith("https://")))
ShowHttpDialog(_activity, ReturnFileOrStartFileChooser, ReturnCancel, defaultPath);
else if ((defaultPath.StartsWith("smb://")))
@@ -633,6 +759,17 @@ private bool ReturnFileOrStartFileChooser(string filename)
return true;
}
+ ///
+ /// Selects the given fully qualified file directly, without the file-chooser/browse
+ /// step. Used by storages where browsing is undesirable, e.g. S3 (where listing would
+ /// require an extra s3:ListBucket permission and S3 has no real directories anyway).
+ ///
+ private bool ReturnFileDirectly(string filename)
+ {
+ IocSelected(null, IOConnectionInfo.FromPath(filename));
+ return true;
+ }
+
private void ReturnCancel()
{
if (OnCancel != null)
@@ -795,6 +932,7 @@ public static bool CanEditIoc(IOConnectionInfo ioc)
return ioc.Path.StartsWith("http")
|| ioc.Path.StartsWith("ftp")
|| ioc.Path.StartsWith("sftp")
+ || ioc.Path.StartsWith("s3")
|| ioc.Path.StartsWith("mega");
}
diff --git a/src/keepass2android-app/Resources/drawable/ic_storage_s3.xml b/src/keepass2android-app/Resources/drawable/ic_storage_s3.xml
new file mode 100644
index 000000000..d2abcd346
--- /dev/null
+++ b/src/keepass2android-app/Resources/drawable/ic_storage_s3.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/keepass2android-app/Resources/layout/s3credentials.xml b/src/keepass2android-app/Resources/layout/s3credentials.xml
new file mode 100644
index 000000000..95d4e5f0d
--- /dev/null
+++ b/src/keepass2android-app/Resources/layout/s3credentials.xml
@@ -0,0 +1,111 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/keepass2android-app/Resources/values/strings.xml b/src/keepass2android-app/Resources/values/strings.xml
index f583fd3f4..e45d522af 100644
--- a/src/keepass2android-app/Resources/values/strings.xml
+++ b/src/keepass2android-app/Resources/values/strings.xml
@@ -595,6 +595,28 @@
MEGA
Please note: Keepass2Android must download the list of all files in your Mega account to work properly. For this reason, accessing accounts with many files might be slow.
System file picker
+ S3 / S3-compatible
+ Store your database in an Amazon S3 (or S3-compatible) bucket. You enter the full object key (path) of the database directly, so the app never lists the bucket: a least-privilege IAM user only needs s3:GetObject and s3:PutObject on that one object (add s3:DeleteObject only if needed). For non-AWS providers (Wasabi, Backblaze B2, Cloudflare R2 or a custom/MinIO endpoint) choose the matching provider. Note: the access key and secret key are stored on this device together with the file location, just like FTP or WebDav credentials.
+ Enter S3 connection data:
+ Region (ex: us-east-1)
+ Signing region (optional)
+ Endpoint URL (ex: https://minio.example.com)
+ Cloudflare account ID
+ Bucket name
+ Access key ID
+ Secret access key
+ Database object key (full path in bucket)
+ e.g. passwords.kdbx or folder/passwords.kdbx
+ Resulting URL:
+ The object key starts with the bucket name \"%1$s\". Your database would be stored at \"%1$s/%2$s\" — with the bucket name repeated. The object key is the path inside the bucket and should not include the bucket name. Continue anyway?
+ Edit object key
+
+ - Amazon S3
+ - Wasabi
+ - Backblaze B2
+ - Cloudflare R2
+ - Custom (S3-compatible)
+
File access initialization
Database location
You can store your database locally on your Android device or in the cloud (non-Offline version only). Keepass2Android makes the database available even if you are offline. As the database is securely encrypted with AES 256 bit encryption, nobody will be able to access your passwords except you. We recommend to select Dropbox: It\'s accessible on all your devices and even provides backups of previous file versions.
diff --git a/src/keepass2android-app/app/App.cs b/src/keepass2android-app/app/App.cs
index 2ebd5f7c7..20ae311d1 100644
--- a/src/keepass2android-app/app/App.cs
+++ b/src/keepass2android-app/app/App.cs
@@ -1007,6 +1007,7 @@ public IEnumerable FileStorages
new SftpFileStorage(LocaleManager.LocalizedAppContext, this, IsFtpDebugEnabled()),
new NetFtpFileStorage(LocaleManager.LocalizedAppContext, this, IsFtpDebugEnabled),
new WebDavFileStorage(this, WebDavChunkedUploadSize, App.Context),
+ new S3FileStorage(LocaleManager.LocalizedAppContext, this),
new SmbFileStorage(),
new PCloudFileStorage(LocaleManager.LocalizedAppContext, this),
new PCloudFileStorageAll(LocaleManager.LocalizedAppContext, this),