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()