diff --git a/android/application_snippets.xml b/android/application_snippets.xml index 0990299..a938cb3 100644 --- a/android/application_snippets.xml +++ b/android/application_snippets.xml @@ -22,3 +22,21 @@ android:name="android.accessibilityservice" android:resource="@xml/keepassgo_accessibility_service" /> + + + + + + + + + + + + + + + diff --git a/android/keepassgo-android.jar b/android/keepassgo-android.jar index 89509e5..e0a50ff 100644 Binary files a/android/keepassgo-android.jar and b/android/keepassgo-android.jar differ diff --git a/androidsrc/org/julianfamily/keepassgo/SharedVaultImportActivity.java b/androidsrc/org/julianfamily/keepassgo/SharedVaultImportActivity.java new file mode 100644 index 0000000..27268f6 --- /dev/null +++ b/androidsrc/org/julianfamily/keepassgo/SharedVaultImportActivity.java @@ -0,0 +1,122 @@ +package org.julianfamily.keepassgo; + +import android.app.Activity; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.provider.OpenableColumns; +import android.util.Log; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +public final class SharedVaultImportActivity extends Activity { + private static final String TAG = "KeePassGOImport"; + private static final String DEFAULT_NAME = "shared-vault.kdbx"; + + @Override + protected void onCreate(Bundle state) { + super.onCreate(state); + handleIntent(getIntent()); + launchMainActivity(); + finish(); + } + + private void handleIntent(Intent intent) { + Uri uri = resolveSharedUri(intent); + if (uri == null) { + Log.i(TAG, "no shared vault URI on intent"); + return; + } + try { + persistPendingImport(uri); + Log.i(TAG, "queued shared vault import from " + uri); + } catch (IOException err) { + Log.e(TAG, "failed to queue shared vault import", err); + } + } + + private Uri resolveSharedUri(Intent intent) { + if (intent == null) { + return null; + } + String action = intent.getAction(); + if (Intent.ACTION_SEND.equals(action)) { + return intent.getParcelableExtra(Intent.EXTRA_STREAM); + } + if (Intent.ACTION_VIEW.equals(action)) { + return intent.getData(); + } + return null; + } + + private void persistPendingImport(Uri uri) throws IOException { + File dir = new File(getFilesDir(), "keepassgo"); + if (!dir.exists() && !dir.mkdirs()) { + throw new IOException("failed to create " + dir.getAbsolutePath()); + } + File pendingFile = new File(dir, "pending-shared-vault.kdbx"); + try (InputStream in = getContentResolver().openInputStream(uri)) { + if (in == null) { + throw new IOException("failed to open shared vault stream"); + } + try (FileOutputStream out = new FileOutputStream(pendingFile, false)) { + byte[] buffer = new byte[8192]; + int count; + while ((count = in.read(buffer)) >= 0) { + out.write(buffer, 0, count); + } + } + } + + File nameFile = new File(dir, "pending-shared-vault-name.txt"); + try (FileOutputStream out = new FileOutputStream(nameFile, false)) { + out.write(resolveDisplayName(uri).getBytes(StandardCharsets.UTF_8)); + } + } + + private String resolveDisplayName(Uri uri) { + String displayName = queryDisplayName(uri); + if (!displayName.isEmpty()) { + return displayName; + } + String lastSegment = uri.getLastPathSegment(); + if (lastSegment != null && !lastSegment.trim().isEmpty()) { + return lastSegment.trim(); + } + return DEFAULT_NAME; + } + + private String queryDisplayName(Uri uri) { + Cursor cursor = null; + try { + cursor = getContentResolver().query(uri, new String[]{OpenableColumns.DISPLAY_NAME}, null, null, null); + if (cursor != null && cursor.moveToFirst()) { + int index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + if (index >= 0) { + String value = cursor.getString(index); + if (value != null) { + return value.trim(); + } + } + } + } catch (RuntimeException err) { + Log.w(TAG, "failed to query display name", err); + } finally { + if (cursor != null) { + cursor.close(); + } + } + return ""; + } + + private void launchMainActivity() { + Intent launch = new Intent(); + launch.setClassName(this, "org.gioui.GioActivity"); + startActivity(launch); + } +} diff --git a/main.go b/main.go index 4c29a42..7b5a1e2 100644 --- a/main.go +++ b/main.go @@ -137,12 +137,14 @@ type attachmentItem struct { } type statePaths struct { - DefaultSaveAsPath string - RecentVaultsPath string - RecentRemotesPath string - SettingsPath string - UIPreferencesPath string - AutofillCachePath string + DefaultSaveAsPath string + RecentVaultsPath string + RecentRemotesPath string + SettingsPath string + UIPreferencesPath string + AutofillCachePath string + PendingSharedVaultPath string + PendingSharedVaultNamePath string } type recentVaultRecord struct { @@ -452,6 +454,8 @@ type ui struct { uiPreferencesPath string recentRemotesPath string autofillCachePath string + pendingSharedVaultPath string + pendingSharedVaultNamePath string editingEntry bool syncDefaultSourceMode syncSourceMode syncDefaultDirection syncDirection @@ -615,6 +619,8 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) uiPreferencesPath: paths.UIPreferencesPath, recentRemotesPath: paths.RecentRemotesPath, autofillCachePath: paths.AutofillCachePath, + pendingSharedVaultPath: paths.PendingSharedVaultPath, + pendingSharedVaultNamePath: paths.PendingSharedVaultNamePath, recentVaultGroups: map[string][]string{}, recentVaultUsedAt: map[string]time.Time{}, lifecycleAdvancedHidden: true, @@ -655,6 +661,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths) u.setCustomFieldRows(nil) u.loadRecentVaults() u.loadRecentRemotes() + u.consumePendingSharedVaultImport() u.restoreStartupLifecycleTarget() u.requestMasterPassFocus = u.hasSelectedLifecycleTarget() u.loadUIPreferences() @@ -725,12 +732,14 @@ func defaultStatePaths(stateDir string) statePaths { baseDir = filepath.Join(configDir, "keepassgo") } return statePaths{ - DefaultSaveAsPath: filepath.Join(baseDir, "vault.kdbx"), - RecentVaultsPath: filepath.Join(baseDir, "recent-vaults.json"), - RecentRemotesPath: filepath.Join(baseDir, "recent-remotes.json"), - SettingsPath: filepath.Join(baseDir, "settings.json"), - UIPreferencesPath: filepath.Join(baseDir, "ui-prefs.json"), - AutofillCachePath: filepath.Join(baseDir, "autofill-cache.json"), + DefaultSaveAsPath: filepath.Join(baseDir, "vault.kdbx"), + RecentVaultsPath: filepath.Join(baseDir, "recent-vaults.json"), + RecentRemotesPath: filepath.Join(baseDir, "recent-remotes.json"), + SettingsPath: filepath.Join(baseDir, "settings.json"), + UIPreferencesPath: filepath.Join(baseDir, "ui-prefs.json"), + AutofillCachePath: filepath.Join(baseDir, "autofill-cache.json"), + PendingSharedVaultPath: filepath.Join(baseDir, "pending-shared-vault.kdbx"), + PendingSharedVaultNamePath: filepath.Join(baseDir, "pending-shared-vault-name.txt"), } } @@ -1494,6 +1503,36 @@ func (u *ui) importedVaultDestination(name string) string { } } +func (u *ui) consumePendingSharedVaultImport() { + path := strings.TrimSpace(u.pendingSharedVaultPath) + if path == "" { + return + } + content, err := os.ReadFile(path) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err) + } + return + } + name := "shared-vault.kdbx" + if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" { + if rawName, err := os.ReadFile(namePath); err == nil { + if trimmed := strings.TrimSpace(string(rawName)); trimmed != "" { + name = trimmed + } + } + } + if err := u.importSharedVaultBytesAction(name, content); err != nil { + u.state.ErrorMessage = fmt.Sprintf("import shared vault: %v", err) + return + } + _ = os.Remove(path) + if namePath := strings.TrimSpace(u.pendingSharedVaultNamePath); namePath != "" { + _ = os.Remove(namePath) + } +} + func (u *ui) importSharedVaultBytesAction(name string, content []byte) error { target := u.importedVaultDestination(name) if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { diff --git a/main_test.go b/main_test.go index 2ad555b..8d2919c 100644 --- a/main_test.go +++ b/main_test.go @@ -5651,6 +5651,12 @@ func TestDefaultStatePathsUsesProvidedStateDir(t *testing.T) { if got := paths.AutofillCachePath; got != filepath.Join(base, "autofill-cache.json") { t.Fatalf("AutofillCachePath = %q, want %q", got, filepath.Join(base, "autofill-cache.json")) } + if got := paths.PendingSharedVaultPath; got != filepath.Join(base, "pending-shared-vault.kdbx") { + t.Fatalf("PendingSharedVaultPath = %q, want %q", got, filepath.Join(base, "pending-shared-vault.kdbx")) + } + if got := paths.PendingSharedVaultNamePath; got != filepath.Join(base, "pending-shared-vault-name.txt") { + t.Fatalf("PendingSharedVaultNamePath = %q, want %q", got, filepath.Join(base, "pending-shared-vault-name.txt")) + } } func TestImportedVaultDestinationUsesIncomingFilenameInsideDefaultDirectory(t *testing.T) { @@ -5721,6 +5727,66 @@ func TestUIImportSharedVaultBytesActionCopiesVaultAndSelectsIt(t *testing.T) { } } +func TestUIConsumesPendingSharedVaultImportOnStartup(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"), + PendingSharedVaultPath: filepath.Join(dir, "pending-shared-vault.kdbx"), + PendingSharedVaultNamePath: filepath.Join(dir, "pending-shared-vault-name.txt"), + } + 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: "Bellagio", Path: []string{"Crew", "Internet"}}, + }, + }, key); err != nil { + t.Fatalf("SaveKDBXWithKey() error = %v", err) + } + if err := os.WriteFile(paths.PendingSharedVaultPath, encoded.Bytes(), 0o600); err != nil { + t.Fatalf("WriteFile(PendingSharedVaultPath) error = %v", err) + } + if err := os.WriteFile(paths.PendingSharedVaultNamePath, []byte("crew-shared.kdbx\n"), 0o600); err != nil { + t.Fatalf("WriteFile(PendingSharedVaultNamePath) error = %v", err) + } + + u := newUIWithState("phone", &session.Manager{}, paths) + + wantPath := filepath.Join(dir, "crew-shared.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 pending import") + } + if _, err := os.Stat(paths.PendingSharedVaultPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("Stat(PendingSharedVaultPath) error = %v, want not exist", err) + } + if _, err := os.Stat(paths.PendingSharedVaultNamePath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("Stat(PendingSharedVaultNamePath) error = %v, want not exist", 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{"Crew", "Internet"}) + reopened.filter() + if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) { + t.Fatalf("filteredTitles() = %v, want [Bellagio]", got) + } +} + func TestDefaultStatePathsUsesEnvironmentStateDirWhenFlagUnset(t *testing.T) { base := filepath.Join(t.TempDir(), "keepassgo-state-env") t.Setenv("KEEPASSGO_STATE_DIR", base)