Add Android shared vault import flow

This commit is contained in:
Joe Julian
2026-04-06 16:48:04 -07:00
parent af44810a1c
commit 05345559ee
3 changed files with 157 additions and 0 deletions
+66
View File
@@ -304,6 +304,7 @@ type ui struct {
editEntry widget.Clickable
cancelEdit widget.Clickable
pickVaultPath widget.Clickable
importSharedVault widget.Clickable
pickKeyFile widget.Clickable
pickSyncLocalPath widget.Clickable
clearVaultSelection widget.Clickable
@@ -758,6 +759,10 @@ func supportsDesktopFilePicker(goos string) bool {
return !strings.EqualFold(strings.TrimSpace(goos), "android")
}
func supportsSharedVaultImport(goos string) bool {
return strings.EqualFold(strings.TrimSpace(goos), "android")
}
func (u *ui) selectedAttachmentItems() []attachmentItem {
item, ok := u.selectedEntry()
if !ok || len(item.Attachments) == 0 {
@@ -1418,6 +1423,29 @@ func (u *ui) startChooseSyncLocalSourceAction() {
})
}
func (u *ui) startImportSharedVaultAction() {
if !supportsSharedVaultImport(runtime.GOOS) || u.fileExplorer == nil {
return
}
u.runBackgroundAction("import shared vault", func() (func() error, error) {
file, err := u.fileExplorer.ChooseFile(".kdbx")
if err != nil {
if errors.Is(err, explorer.ErrUserDecline) {
return func() error { return nil }, nil
}
return nil, err
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
return nil, err
}
return func() error {
return u.importSharedVaultBytesAction("shared-vault.kdbx", content)
}, nil
})
}
func (u *ui) advancedSyncToAction() error {
switch u.syncSourceMode {
case syncSourceRemote:
@@ -1452,6 +1480,38 @@ func (u *ui) saveAsTargetPath() string {
return u.defaultSaveAsPath
}
func (u *ui) importedVaultDestination(name string) string {
baseTarget := u.saveAsTargetPath()
baseDir := filepath.Dir(baseTarget)
baseName := filepath.Base(strings.TrimSpace(name))
switch {
case baseName == "" || baseName == "." || baseName == string(filepath.Separator):
return baseTarget
case strings.HasSuffix(strings.ToLower(baseName), ".kdbx"):
return filepath.Join(baseDir, baseName)
default:
return baseTarget
}
}
func (u *ui) importSharedVaultBytesAction(name string, content []byte) error {
target := u.importedVaultDestination(name)
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
return err
}
if err := os.WriteFile(target, append([]byte(nil), content...), 0o600); err != nil {
return err
}
u.lifecycleMode = "local"
u.vaultPath.SetText(target)
u.noteRecentVault(target)
u.state.ErrorMessage = ""
u.state.StatusMessage = ""
u.requestMasterPassFocus = true
u.filter()
return nil
}
func (u *ui) noteRecentVault(path string) {
path = strings.TrimSpace(path)
if path == "" {
@@ -3490,6 +3550,12 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
}
u.runAction("choose vault path", func() error { return u.chooseExistingFileAction(&u.vaultPath) })
}
for u.importSharedVault.Clicked(gtx) {
if u.lifecycleBusy() {
continue
}
u.startImportSharedVaultAction()
}
for u.pickKeyFile.Clicked(gtx) {
if u.lifecycleBusy() {
continue
+79
View File
@@ -5653,6 +5653,74 @@ func TestDefaultStatePathsUsesProvidedStateDir(t *testing.T) {
}
}
func TestImportedVaultDestinationUsesIncomingFilenameInsideDefaultDirectory(t *testing.T) {
t.Parallel()
u := newUIWithSession("desktop", &session.Manager{}, statePaths{
DefaultSaveAsPath: filepath.Join(t.TempDir(), "vault.kdbx"),
})
got := u.importedVaultDestination("shared-home.kdbx")
want := filepath.Join(filepath.Dir(u.defaultSaveAsPath), "shared-home.kdbx")
if got != want {
t.Fatalf("importedVaultDestination() = %q, want %q", got, want)
}
}
func TestUIImportSharedVaultBytesActionCopiesVaultAndSelectsIt(t *testing.T) {
t.Parallel()
dir := t.TempDir()
paths := statePaths{
DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
RecentVaultsPath: filepath.Join(dir, "recent-vaults.json"),
RecentRemotesPath: filepath.Join(dir, "recent-remotes.json"),
UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
}
key := vault.MasterKey{Password: "correct horse battery staple"}
var encoded bytes.Buffer
if err := vault.SaveKDBXWithKey(&encoded, vault.Model{
Entries: []vault.Entry{
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
},
}, key); err != nil {
t.Fatalf("SaveKDBXWithKey() error = %v", err)
}
u := newUIWithState("phone", &session.Manager{}, paths)
u.lifecycleMode = "remote"
if err := u.importSharedVaultBytesAction("shared-home.kdbx", encoded.Bytes()); err != nil {
t.Fatalf("importSharedVaultBytesAction() error = %v", err)
}
wantPath := filepath.Join(dir, "shared-home.kdbx")
if got := u.vaultPath.Text(); got != wantPath {
t.Fatalf("vaultPath = %q, want %q", got, wantPath)
}
if got := u.lifecycleMode; got != "local" {
t.Fatalf("lifecycleMode = %q, want local", got)
}
if !u.hasSelectedLifecycleTarget() {
t.Fatal("hasSelectedLifecycleTarget() = false, want true after import")
}
if _, err := os.Stat(wantPath); err != nil {
t.Fatalf("Stat(imported vault) error = %v", err)
}
reopened := newUIWithState("phone", &session.Manager{}, paths)
reopened.vaultPath.SetText(wantPath)
reopened.masterPassword.SetText(key.Password)
if err := reopened.openVaultAction(); err != nil {
t.Fatalf("openVaultAction(imported) error = %v", err)
}
reopened.state.NavigateToPath([]string{"Root", "Internet"})
reopened.filter()
if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Vault Console"}) {
t.Fatalf("filteredTitles() = %v, want [Vault Console]", got)
}
}
func TestDefaultStatePathsUsesEnvironmentStateDirWhenFlagUnset(t *testing.T) {
base := filepath.Join(t.TempDir(), "keepassgo-state-env")
t.Setenv("KEEPASSGO_STATE_DIR", base)
@@ -5765,6 +5833,17 @@ func TestSupportsDesktopFilePicker(t *testing.T) {
}
}
func TestSupportsSharedVaultImport(t *testing.T) {
t.Parallel()
if got := supportsSharedVaultImport("android"); !got {
t.Fatal("supportsSharedVaultImport(android) = false, want true")
}
if got := supportsSharedVaultImport("linux"); got {
t.Fatal("supportsSharedVaultImport(linux) = true, want false")
}
}
func TestEnterOnLocalLifecycleScreenDefaultsToOpenVault(t *testing.T) {
t.Parallel()
+12
View File
@@ -192,6 +192,18 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
}
return u.recentVaultList(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showLocalChooser || busy || !supportsSharedVaultImport(runtime.GOOS) {
return layout.Dimensions{}
}
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showLocalChooser || busy || !supportsSharedVaultImport(runtime.GOOS) {
return layout.Dimensions{}
}
return tonedButton(gtx, u.theme, &u.importSharedVault, "Import Shared Vault")
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
if !showLocalChooser {
return layout.Dimensions{}