diff --git a/android/application_snippets.xml b/android/application_snippets.xml
index d2f6caf..5e1a60d 100644
--- a/android/application_snippets.xml
+++ b/android/application_snippets.xml
@@ -35,6 +35,11 @@
android:name="org.julianfamily.keepassgo.SharedVaultImportActivity"
android:exported="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar">
+
+
+
+
+
@@ -42,6 +47,13 @@
+
+
+
+
+
+
+
diff --git a/androidsrc/org/julianfamily/keepassgo/SharedVaultImportActivity.java b/androidsrc/org/julianfamily/keepassgo/SharedVaultImportActivity.java
index 778b50c..c4eb884 100644
--- a/androidsrc/org/julianfamily/keepassgo/SharedVaultImportActivity.java
+++ b/androidsrc/org/julianfamily/keepassgo/SharedVaultImportActivity.java
@@ -20,6 +20,9 @@ import java.util.ArrayList;
public final class SharedVaultImportActivity extends Activity {
private static final String TAG = "KeePassGOImport";
private static final String DEFAULT_NAME = "shared-vault.kdbx";
+ private static final String PENDING_SHARED_VAULT = "pending-shared-vault.kdbx";
+ private static final String PENDING_SHARED_VAULT_NAME = "pending-shared-vault-name.txt";
+ private static final String PENDING_SHARED_LOOKUP = "pending-shared-lookup.txt";
@Override
protected void onCreate(Bundle state) {
@@ -40,6 +43,16 @@ public final class SharedVaultImportActivity extends Activity {
private void handleIntent(Intent intent) {
logIntent(intent);
+ String sharedLookup = resolveSharedLookup(intent);
+ if (!sharedLookup.isEmpty()) {
+ try {
+ persistPendingLookup(sharedLookup);
+ Log.i(TAG, "queued shared lookup target");
+ } catch (IOException | RuntimeException err) {
+ Log.e(TAG, "failed to queue shared lookup target", err);
+ }
+ return;
+ }
Uri uri = resolveSharedUri(intent);
if (uri == null) {
Log.i(TAG, "no shared vault URI on intent");
@@ -86,12 +99,35 @@ public final class SharedVaultImportActivity extends Activity {
return null;
}
+ private String resolveSharedLookup(Intent intent) {
+ if (intent == null) {
+ return "";
+ }
+ String action = intent.getAction();
+ if (Intent.ACTION_SEND.equals(action) && "text/plain".equalsIgnoreCase(intent.getType())) {
+ CharSequence extraText = intent.getCharSequenceExtra(Intent.EXTRA_TEXT);
+ if (extraText != null) {
+ return extraText.toString().trim();
+ }
+ }
+ if (Intent.ACTION_VIEW.equals(action)) {
+ Uri data = intent.getData();
+ if (data != null) {
+ String scheme = data.getScheme();
+ if ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme)) {
+ return data.toString();
+ }
+ }
+ }
+ return "";
+ }
+
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");
+ File pendingFile = new File(dir, PENDING_SHARED_VAULT);
try (InputStream in = openSharedInputStream(uri)) {
if (in == null) {
throw new IOException("failed to open shared vault stream");
@@ -105,12 +141,23 @@ public final class SharedVaultImportActivity extends Activity {
}
}
- File nameFile = new File(dir, "pending-shared-vault-name.txt");
+ File nameFile = new File(dir, PENDING_SHARED_VAULT_NAME);
try (FileOutputStream out = new FileOutputStream(nameFile, false)) {
out.write(resolveDisplayName(uri).getBytes(StandardCharsets.UTF_8));
}
}
+ private void persistPendingLookup(String lookup) 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_LOOKUP);
+ try (FileOutputStream out = new FileOutputStream(pendingFile, false)) {
+ out.write(lookup.getBytes(StandardCharsets.UTF_8));
+ }
+ }
+
private InputStream openSharedInputStream(Uri uri) throws IOException {
if ("file".equalsIgnoreCase(uri.getScheme())) {
String path = uri.getPath();
diff --git a/docs/android-autofill.md b/docs/android-autofill.md
index 2e7780f..f3829e6 100644
--- a/docs/android-autofill.md
+++ b/docs/android-autofill.md
@@ -36,3 +36,24 @@ Expected behavior:
package as `androidapp://`.
- The fallback path can therefore fill supported apps that never expose a
browser-style URL bar.
+
+## Share-Driven Lookup
+
+User story:
+
+- When Android shares a login URL or a text snippet containing a login URL into
+ KeePassGO, the app should open into a credential lookup flow instead of only
+ supporting shared `.kdbx` imports.
+- If the vault is already open, the shared target should immediately narrow the
+ entries view.
+- If the vault is not open yet, the shared target should survive startup and
+ apply as soon as the vault is unlocked.
+
+Expected behavior:
+
+- Android share intents can queue a pending lookup target in addition to shared
+ vault file imports.
+- KeePassGO normalizes the shared value into a search query that users can
+ immediately act on.
+- The pending lookup is consumed once and does not keep reappearing on later
+ launches.
diff --git a/internal/appui/app.go b/internal/appui/app.go
index 170ace8..eb67464 100644
--- a/internal/appui/app.go
+++ b/internal/appui/app.go
@@ -140,6 +140,7 @@ type statePaths struct {
AutofillCachePath string
PendingSharedVaultPath string
PendingSharedVaultNamePath string
+ PendingSharedLookupPath string
}
type recentVaultRecord struct {
@@ -474,6 +475,8 @@ type ui struct {
autofillCachePath string
pendingSharedVaultPath string
pendingSharedVaultNamePath string
+ pendingSharedLookupPath string
+ pendingSharedLookupQuery string
editingEntry bool
syncDefaultSourceMode syncSourceMode
syncDefaultDirection syncDirection
@@ -656,6 +659,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
autofillCachePath: paths.AutofillCachePath,
pendingSharedVaultPath: paths.PendingSharedVaultPath,
pendingSharedVaultNamePath: paths.PendingSharedVaultNamePath,
+ pendingSharedLookupPath: paths.PendingSharedLookupPath,
recentVaultGroups: map[string][]string{},
recentVaultUsedAt: map[string]time.Time{},
lifecycleAdvancedHidden: true,
@@ -704,6 +708,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
u.showStatusMessage("Some saved remote sign-ins came from an older KeePassGO build. Reopen those remotes and save them in the vault to migrate them.")
}
u.consumePendingSharedVaultImport()
+ u.consumePendingSharedLookup()
u.restoreStartupLifecycleTarget()
u.requestMasterPassFocus = u.hasSelectedLifecycleTarget()
u.loadUIPreferences()
@@ -785,6 +790,7 @@ func defaultStatePaths(stateDir string) statePaths {
AutofillCachePath: filepath.Join(baseDir, "autofill-cache.json"),
PendingSharedVaultPath: filepath.Join(baseDir, "pending-shared-vault.kdbx"),
PendingSharedVaultNamePath: filepath.Join(baseDir, "pending-shared-vault-name.txt"),
+ PendingSharedLookupPath: filepath.Join(baseDir, "pending-shared-lookup.txt"),
}
}
diff --git a/internal/appui/lifecycle_actions.go b/internal/appui/lifecycle_actions.go
index 9fc4617..d065275 100644
--- a/internal/appui/lifecycle_actions.go
+++ b/internal/appui/lifecycle_actions.go
@@ -4,8 +4,10 @@ import (
"errors"
"fmt"
"io"
+ "net/url"
"os"
"path/filepath"
+ "regexp"
"runtime"
"strings"
@@ -17,6 +19,8 @@ import (
"git.julianfamily.org/keepassgo/internal/webdav"
)
+var pendingSharedLookupURLPattern = regexp.MustCompile(`https?://[^\s<>"']+`)
+
func (u *ui) createVaultAction() error {
key, err := u.currentMasterKey()
defer u.clearMasterPassword()
@@ -78,6 +82,7 @@ func (u *ui) openVaultAction() error {
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
+ u.applyPendingSharedLookup()
u.applyPendingLifecycleOpenIntent()
return nil
}
@@ -120,6 +125,7 @@ func (u *ui) startOpenVaultAction() {
u.loadSecuritySettingsFromSession()
u.editingEntry = false
u.filter()
+ u.applyPendingSharedLookup()
u.applyPendingLifecycleOpenIntent()
return nil
}, nil
@@ -741,6 +747,49 @@ func (u *ui) consumePendingSharedVaultImport() {
}
}
+func normalizePendingSharedLookupQuery(raw string) string {
+ value := strings.TrimSpace(raw)
+ if value == "" {
+ return ""
+ }
+ if match := pendingSharedLookupURLPattern.FindString(value); match != "" {
+ value = match
+ }
+ if parsed, err := url.Parse(value); err == nil && strings.TrimSpace(parsed.Hostname()) != "" {
+ return strings.ToLower(strings.TrimSpace(parsed.Hostname()))
+ }
+ return value
+}
+
+func (u *ui) consumePendingSharedLookup() {
+ path := strings.TrimSpace(u.pendingSharedLookupPath)
+ if path == "" {
+ return
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ if !errors.Is(err, os.ErrNotExist) {
+ u.state.ErrorMessage = fmt.Sprintf("shared lookup: %v", err)
+ }
+ return
+ }
+ _ = os.Remove(path)
+ u.pendingSharedLookupQuery = normalizePendingSharedLookupQuery(string(data))
+ u.applyPendingSharedLookup()
+}
+
+func (u *ui) applyPendingSharedLookup() {
+ query := strings.TrimSpace(u.pendingSharedLookupQuery)
+ status, ok := u.state.Session.(sessionStatus)
+ if query == "" || !ok || !status.HasVault() || status.IsLocked() {
+ return
+ }
+ u.pendingSharedLookupQuery = ""
+ u.state.Section = appstate.SectionEntries
+ u.search.SetText(query)
+ u.filter()
+}
+
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/internal/appui/main_test.go b/internal/appui/main_test.go
index 66ce854..9082337 100644
--- a/internal/appui/main_test.go
+++ b/internal/appui/main_test.go
@@ -8390,6 +8390,9 @@ func TestDefaultStatePathsUsesProvidedStateDir(t *testing.T) {
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"))
}
+ if got := paths.PendingSharedLookupPath; got != filepath.Join(base, "pending-shared-lookup.txt") {
+ t.Fatalf("PendingSharedLookupPath = %q, want %q", got, filepath.Join(base, "pending-shared-lookup.txt"))
+ }
}
func TestImportedVaultDestinationUsesIncomingFilenameInsideDefaultDirectory(t *testing.T) {
@@ -8520,6 +8523,95 @@ func TestUIConsumesPendingSharedVaultImportOnStartup(t *testing.T) {
}
}
+func TestUIConsumesPendingSharedLookupOnStartupWhenVaultIsAlreadyOpen(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"),
+ PendingSharedLookupPath: filepath.Join(dir, "pending-shared-lookup.txt"),
+ }
+ if err := os.WriteFile(paths.PendingSharedLookupPath, []byte("https://bellagio.example.invalid/login\n"), 0o600); err != nil {
+ t.Fatalf("WriteFile(PendingSharedLookupPath) error = %v", err)
+ }
+
+ u := newUIWithSession("phone", &uiSession{model: vault.Model{
+ Entries: []vault.Entry{
+ {ID: "bellagio-login", Title: "Bellagio", URL: "https://bellagio.example.invalid/login", Path: []string{"Crew", "Internet"}},
+ {ID: "vault-console", Title: "Vault Console", URL: "https://vault.example.invalid", Path: []string{"Crew", "Internet"}},
+ },
+ }}, paths)
+
+ if got := u.search.Text(); got != "bellagio.example.invalid" {
+ t.Fatalf("search after pending shared lookup = %q, want %q", got, "bellagio.example.invalid")
+ }
+ if got := u.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) {
+ t.Fatalf("filteredTitles() after pending shared lookup = %v, want [Bellagio]", got)
+ }
+ if _, err := os.Stat(paths.PendingSharedLookupPath); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("Stat(PendingSharedLookupPath) error = %v, want not exist", err)
+ }
+}
+
+func TestNormalizePendingSharedLookupQueryExtractsURLFromTextSnippet(t *testing.T) {
+ t.Parallel()
+
+ raw := "Meet the crew at https://bellagio.example.invalid/login before the vault opens."
+ if got := normalizePendingSharedLookupQuery(raw); got != "bellagio.example.invalid" {
+ t.Fatalf("normalizePendingSharedLookupQuery() = %q, want %q", got, "bellagio.example.invalid")
+ }
+}
+
+func TestUIAppliesPendingSharedLookupAfterOpeningVault(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"),
+ PendingSharedLookupPath: filepath.Join(dir, "pending-shared-lookup.txt"),
+ }
+ if err := os.WriteFile(paths.PendingSharedLookupPath, []byte("https://bellagio.example.invalid/login\n"), 0o600); err != nil {
+ t.Fatalf("WriteFile(PendingSharedLookupPath) error = %v", err)
+ }
+
+ key := vault.MasterKey{Password: "correct horse battery staple"}
+ vaultPath := filepath.Join(dir, "bellagio.kdbx")
+ var encoded bytes.Buffer
+ if err := vault.SaveKDBXWithKey(&encoded, vault.Model{
+ Entries: []vault.Entry{
+ {ID: "bellagio-login", Title: "Bellagio", URL: "https://bellagio.example.invalid/login", Path: []string{"Crew", "Internet"}},
+ {ID: "vault-console", Title: "Vault Console", URL: "https://vault.example.invalid", Path: []string{"Crew", "Internet"}},
+ },
+ }, key); err != nil {
+ t.Fatalf("SaveKDBXWithKey() error = %v", err)
+ }
+ if err := os.WriteFile(vaultPath, encoded.Bytes(), 0o600); err != nil {
+ t.Fatalf("WriteFile(vaultPath) error = %v", err)
+ }
+
+ u := newUIWithState("phone", &session.Manager{}, paths)
+ if got := u.search.Text(); got != "" {
+ t.Fatalf("search before open with pending shared lookup = %q, want empty", got)
+ }
+ u.vaultPath.SetText(vaultPath)
+ u.masterPassword.SetText(key.Password)
+ if err := u.openVaultAction(); err != nil {
+ t.Fatalf("openVaultAction() with pending shared lookup error = %v", err)
+ }
+ if got := u.search.Text(); got != "bellagio.example.invalid" {
+ t.Fatalf("search after open with pending shared lookup = %q, want %q", got, "bellagio.example.invalid")
+ }
+ if got := u.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) {
+ t.Fatalf("filteredTitles() after open with pending shared lookup = %v, want [Bellagio]", got)
+ }
+}
+
func TestUICurrentShareableVaultPathUsesSelectedVaultPath(t *testing.T) {
t.Parallel()