Compare commits

...

5 Commits

Author SHA1 Message Date
Joe Julian 51f2a0121a Fix extra string actions
ci / lint-test (pull_request) Successful in 5m26s
ci / build (pull_request) Successful in 6m28s
2026-04-28 21:31:40 -07:00
joejulian 11e883279d Merge pull request 'Bump release version to 0.8.2' (#13) from release/v0.8.2 into main
ci / lint-test (push) Successful in 4m36s
ci / build (push) Successful in 5m48s
2026-04-28 04:55:42 +00:00
Joe Julian e305a25802 Bump release version to 0.8.2
ci / lint-test (pull_request) Successful in 5m3s
ci / build (pull_request) Successful in 6m3s
ci / lint-test (push) Successful in 5m3s
ci / build (push) Successful in 5m28s
2026-04-27 21:35:23 -07:00
joejulian 8b4609c141 Merge pull request 'Scope Android autofill candidates' (#12) from bugfix/android-autofill-relevant-picker into main
ci / lint-test (push) Successful in 5m15s
ci / build (push) Successful in 6m41s
Reviewed-on: #12
2026-04-28 04:29:30 +00:00
Joe Julian d18627cda4 Scope Android autofill candidates
ci / lint-test (pull_request) Successful in 5m45s
ci / build (pull_request) Successful in 6m19s
2026-04-27 19:50:55 -07:00
14 changed files with 497 additions and 35 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ PATH := $(JAVA_HOME)/bin:$(ANDROID_SDK_ROOT)/cmdline-tools/latest/bin:$(ANDROID_
APK_BUILD_IMAGE ?= keepassgo/android-apk-build:java25
APP_ID ?= org.julianfamily.keepassgo
APK_OUT ?= build/keepassgo.apk
APK_VERSION ?= 0.1.0.1
APK_VERSION ?= 0.8.2.298
APP_VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
GO_LDFLAGS ?= -X git.julianfamily.org/keepassgo/internal/appui.appVersion=$(APP_VERSION)
APK_ARCH ?= arm64,amd64
@@ -10,9 +10,7 @@ import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
final class AutofillCacheStore {
private static final String TAG = "KeePassGOAutofill";
@@ -41,22 +39,26 @@ final class AutofillCacheStore {
}
static List<Entry> chooserCandidates(Context context, String rawTarget) {
List<Entry> entries = readEntries(context);
return chooserCandidatesFromEntries(readEntries(context), rawTarget);
}
static List<Entry> chooserCandidatesFromEntries(List<Entry> entries, String rawTarget) {
if (entries.isEmpty()) {
return entries;
}
Entry direct = fromMatcherEntry(AutofillTargetMatcher.findBestMatch(toMatcherEntries(entries), rawTarget));
if (direct != null) {
List<Entry> resolved = new ArrayList<>();
resolved.add(direct);
return resolved;
return fromMatcherEntries(AutofillTargetMatcher.chooserCandidates(toMatcherEntries(entries), rawTarget));
}
entries.sort(Comparator
.comparing((Entry entry) -> entry.title.toLowerCase(Locale.US))
.thenComparing(entry -> String.join("/", entry.path).toLowerCase(Locale.US))
.thenComparing(entry -> entry.id));
static List<Entry> relevantCandidates(Context context, String rawTarget) {
return relevantCandidatesFromEntries(readEntries(context), rawTarget);
}
static List<Entry> relevantCandidatesFromEntries(List<Entry> entries, String rawTarget) {
if (entries.isEmpty()) {
return entries;
}
return fromMatcherEntries(AutofillTargetMatcher.relevantCandidates(toMatcherEntries(entries), rawTarget));
}
private static List<AutofillTargetMatcher.Entry> toMatcherEntries(List<Entry> entries) {
List<AutofillTargetMatcher.Entry> converted = new ArrayList<>(entries.size());
@@ -82,6 +84,14 @@ final class AutofillCacheStore {
return new Entry(entry.id, entry.title, entry.username, entry.password, entry.host, entry.url, entry.targets, entry.path);
}
private static List<Entry> fromMatcherEntries(List<AutofillTargetMatcher.Entry> entries) {
List<Entry> converted = new ArrayList<>(entries.size());
for (AutofillTargetMatcher.Entry entry : entries) {
converted.add(fromMatcherEntry(entry));
}
return converted;
}
private static List<Entry> readEntries(Context context) {
File cacheFile = findCacheFile(context);
if (cacheFile == null) {
@@ -37,6 +37,17 @@ final class AutofillTargetMatcher {
}
static List<Entry> chooserCandidates(List<Entry> entries, String rawTarget) {
if (entries == null || entries.isEmpty()) {
return new ArrayList<>();
}
List<Entry> related = relevantCandidates(entries, rawTarget);
if (!related.isEmpty()) {
return related;
}
return sortEntries(entries);
}
static List<Entry> relevantCandidates(List<Entry> entries, String rawTarget) {
if (entries == null || entries.isEmpty()) {
return new ArrayList<>();
}
@@ -47,6 +58,9 @@ final class AutofillTargetMatcher {
return resolved;
}
NormalizedTarget target = normalize(rawTarget);
if (target.host.isEmpty()) {
return new ArrayList<>();
}
List<Entry> exactHost = new ArrayList<>();
List<Entry> parentHost = new ArrayList<>();
for (Entry entry : entries) {
@@ -64,7 +78,7 @@ final class AutofillTargetMatcher {
if (!parentHost.isEmpty()) {
return sortEntries(parentHost);
}
return sortEntries(entries);
return new ArrayList<>();
}
static NormalizedTarget normalize(String raw) {
@@ -68,18 +68,32 @@ public final class KeePassGOAutofillService extends AutofillService {
return;
}
AutofillCacheStore.Entry entry = findBoundOrBestMatch(target.matchTarget);
AutofillCacheStore.Entry entry = findBoundEntry(target.matchTarget);
if (entry == null) {
List<AutofillCacheStore.Entry> candidates = AutofillCacheStore.relevantCandidates(this, target.matchTarget);
if (candidates.isEmpty()) {
FillResponse chooser = chooserResponse(target, fields);
if (chooser == null) {
Log.i(TAG, "no autofill cache match");
callback.onSuccess(null);
return;
}
Log.i(TAG, "returning chooser dataset for " + target.matchTarget);
Log.i(TAG, "returning searchable chooser dataset for " + target.matchTarget);
callback.onSuccess(chooser);
return;
}
if (candidates.size() > 1) {
Log.i(TAG, "returning " + candidates.size() + " scoped autofill datasets for " + target.matchTarget);
callback.onSuccess(candidatesFillResponse(candidates, fields));
return;
}
entry = candidates.get(0);
if (entry == null) {
Log.i(TAG, "no autofill cache match");
callback.onSuccess(null);
return;
}
}
Log.i(TAG, "matched entry title=" + entry.title + " user=" + entry.username + " host=" + entry.host);
FillResponse response = directFillResponse(entry, fields);
@@ -97,7 +111,7 @@ public final class KeePassGOAutofillService extends AutofillService {
callback.onSuccess();
}
private AutofillCacheStore.Entry findBoundOrBestMatch(String matchTarget) {
private AutofillCacheStore.Entry findBoundEntry(String matchTarget) {
String entryID = AutofillBindingStore.entryIDForTarget(this, matchTarget);
if (!entryID.isEmpty()) {
AutofillCacheStore.Entry bound = AutofillCacheStore.findByID(this, entryID);
@@ -105,15 +119,30 @@ public final class KeePassGOAutofillService extends AutofillService {
return bound;
}
}
return AutofillCacheStore.findBestMatch(this, matchTarget);
return null;
}
private FillResponse directFillResponse(AutofillCacheStore.Entry entry, ParsedFields fields) {
return new FillResponse.Builder()
.addDataset(datasetForEntry(entry, fields))
.build();
}
private FillResponse candidatesFillResponse(List<AutofillCacheStore.Entry> entries, ParsedFields fields) {
FillResponse.Builder response = new FillResponse.Builder();
for (AutofillCacheStore.Entry entry : entries) {
response.addDataset(datasetForEntry(entry, fields));
}
return response.build();
}
private Dataset datasetForEntry(AutofillCacheStore.Entry entry, ParsedFields fields) {
RemoteViews presentation = new RemoteViews(getPackageName(), android.R.layout.simple_list_item_1);
presentation.setTextViewText(
android.R.id.text1,
entry.title + " (" + entry.username + ")"
);
String label = entry.title == null || entry.title.trim().isEmpty() ? "KeePassGO entry" : entry.title;
if (entry.username != null && !entry.username.trim().isEmpty()) {
label += " (" + entry.username + ")";
}
presentation.setTextViewText(android.R.id.text1, label);
Dataset.Builder dataset = new Dataset.Builder(presentation);
dataset.setId(entry.id);
@@ -121,10 +150,7 @@ public final class KeePassGOAutofillService extends AutofillService {
dataset.setValue(fields.usernameId, AutofillValue.forText(entry.username));
}
dataset.setValue(fields.passwordId, AutofillValue.forText(entry.password));
return new FillResponse.Builder()
.addDataset(dataset.build())
.build();
return dataset.build();
}
private FillResponse chooserResponse(ParsedTarget target, ParsedFields fields) {
@@ -10,6 +10,9 @@ public final class AutofillCacheStoreBehaviorTest {
testChooserCandidatesCollapseToExactAndroidAppMatch();
testChooserCandidatesStayScopedToExactHostMatches();
testChooserCandidatesStayScopedToParentHostMatches();
testCacheStoreChooserCandidatesStayScopedToExactHostMatches();
testCacheStoreChooserCandidatesFallBackToAllEntriesOnlyWhenUnrelated();
testCacheStoreRelevantCandidatesDoNotFallBackToAllEntries();
}
private static void testFindBestMatchUsesAndroidAppTargets() {
@@ -128,6 +131,103 @@ public final class AutofillCacheStoreBehaviorTest {
}
}
private static void testCacheStoreChooserCandidatesStayScopedToExactHostMatches() {
List<AutofillCacheStore.Entry> entries = new ArrayList<>();
entries.add(new AutofillCacheStore.Entry(
"bellagio-primary",
"Bellagio Primary",
"dannyocean",
"vault-code",
"bellagio.example.invalid",
"https://bellagio.example.invalid/login",
Arrays.asList("https://bellagio.example.invalid/login"),
Arrays.asList("Crew", "Internet")
));
entries.add(new AutofillCacheStore.Entry(
"bellagio-backup",
"Bellagio Backup",
"rustyryan",
"backup-code",
"bellagio.example.invalid",
"https://bellagio.example.invalid/admin",
Arrays.asList("https://bellagio.example.invalid/admin"),
Arrays.asList("Crew", "Internet")
));
entries.add(new AutofillCacheStore.Entry(
"night-fox-entry",
"Night Fox",
"nightfox",
"vault-code",
"gitlab.com",
"https://gitlab.com",
Arrays.asList("https://gitlab.com"),
Arrays.asList("Crew", "Internet")
));
List<AutofillCacheStore.Entry> got = AutofillCacheStore.chooserCandidatesFromEntries(entries, "https://bellagio.example.invalid/security");
if (got.size() != 2 || !containsStoreIDs(got, "bellagio-primary", "bellagio-backup")) {
throw new AssertionError("AutofillCacheStore chooser candidates = " + describeStore(got) + ", want only Bellagio entries");
}
}
private static void testCacheStoreChooserCandidatesFallBackToAllEntriesOnlyWhenUnrelated() {
List<AutofillCacheStore.Entry> entries = new ArrayList<>();
entries.add(new AutofillCacheStore.Entry(
"bellagio-primary",
"Bellagio Primary",
"dannyocean",
"vault-code",
"bellagio.example.invalid",
"https://bellagio.example.invalid/login",
Arrays.asList("https://bellagio.example.invalid/login"),
Arrays.asList("Crew", "Internet")
));
entries.add(new AutofillCacheStore.Entry(
"night-fox-entry",
"Night Fox",
"nightfox",
"vault-code",
"gitlab.com",
"https://gitlab.com",
Arrays.asList("https://gitlab.com"),
Arrays.asList("Crew", "Internet")
));
List<AutofillCacheStore.Entry> got = AutofillCacheStore.chooserCandidatesFromEntries(entries, "https://tessio.example.invalid/login");
if (got.size() != 2 || !containsStoreIDs(got, "bellagio-primary", "night-fox-entry")) {
throw new AssertionError("AutofillCacheStore unrelated chooser candidates = " + describeStore(got) + ", want full fallback list");
}
}
private static void testCacheStoreRelevantCandidatesDoNotFallBackToAllEntries() {
List<AutofillCacheStore.Entry> entries = new ArrayList<>();
entries.add(new AutofillCacheStore.Entry(
"bellagio-primary",
"Bellagio Primary",
"dannyocean",
"vault-code",
"bellagio.example.invalid",
"https://bellagio.example.invalid/login",
Arrays.asList("https://bellagio.example.invalid/login"),
Arrays.asList("Crew", "Internet")
));
entries.add(new AutofillCacheStore.Entry(
"night-fox-entry",
"Night Fox",
"nightfox",
"vault-code",
"gitlab.com",
"https://gitlab.com",
Arrays.asList("https://gitlab.com"),
Arrays.asList("Crew", "Internet")
));
List<AutofillCacheStore.Entry> got = AutofillCacheStore.relevantCandidatesFromEntries(entries, "https://tessio.example.invalid/login");
if (!got.isEmpty()) {
throw new AssertionError("AutofillCacheStore relevant unrelated candidates = " + describeStore(got) + ", want []");
}
}
private static String describe(AutofillTargetMatcher.Entry entry) {
if (entry == null) {
return "null";
@@ -150,4 +250,20 @@ public final class AutofillCacheStoreBehaviorTest {
}
return ids.containsAll(Arrays.asList(wantIDs)) && ids.size() == wantIDs.length;
}
private static String describeStore(List<AutofillCacheStore.Entry> entries) {
List<String> ids = new ArrayList<>();
for (AutofillCacheStore.Entry entry : entries) {
ids.add(entry.id);
}
return ids.toString();
}
private static boolean containsStoreIDs(List<AutofillCacheStore.Entry> entries, String... wantIDs) {
List<String> ids = new ArrayList<>();
for (AutofillCacheStore.Entry entry : entries) {
ids.add(entry.id);
}
return ids.containsAll(Arrays.asList(wantIDs)) && ids.size() == wantIDs.length;
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "KeePassGO Browser",
"version": "0.1.0",
"version": "0.8.2",
"description": "Fill credentials from KeePassGO on sign-in pages.",
"permissions": ["activeTab", "nativeMessaging", "storage", "tabs"],
"host_permissions": ["http://*/*", "https://*/*"],
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 2,
"name": "KeePassGO Browser",
"version": "0.1.0",
"version": "0.8.2",
"description": "Fill credentials from KeePassGO on sign-in pages.",
"icons": {
"16": "icons/icon-16.png",
+1 -1
View File
@@ -12,7 +12,7 @@ const (
DefaultJavaHome = "/usr/lib/jvm/java-25-openjdk"
DefaultAppID = "org.julianfamily.keepassgo"
DefaultAPKOut = "build/keepassgo.apk"
DefaultVersion = "0.1.0.1"
DefaultVersion = "0.8.2.298"
DefaultLdflags = "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=dev"
DefaultMinSDK = "28"
DefaultTargetSDK = "35"
+127 -1
View File
@@ -249,6 +249,7 @@ type ui struct {
entryFields widget.Editor
customFieldKeys []widget.Editor
customFieldValues []widget.Editor
copyCustomFields []widget.Clickable
historyIndex widget.Editor
groupName widget.Editor
groupParentPath widget.Editor
@@ -402,6 +403,8 @@ type ui struct {
vaultRemoteCredentialClicks []widget.Clickable
syncRemoteCredentialClicks []widget.Clickable
removeCustomFields []widget.Clickable
toggleCustomFields []widget.Clickable
revealedCustomFields map[string]bool
state appstate.State
visible []entry
currentPath []string
@@ -662,6 +665,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
pendingSharedLookupPath: paths.PendingSharedLookupPath,
recentVaultGroups: map[string][]string{},
recentVaultUsedAt: map[string]time.Time{},
revealedCustomFields: map[string]bool{},
lifecycleAdvancedHidden: true,
historyHidden: true,
statusBannerTTL: statusBannerDuration,
@@ -940,6 +944,7 @@ func (u *ui) handlePhoneBack() bool {
func (u *ui) resetPasswordPeek() {
u.showPassword = false
u.revealedCustomFields = map[string]bool{}
}
func (u *ui) childGroups() []string {
@@ -2153,6 +2158,12 @@ type detailViewMetrics struct {
cardGap unit.Dp
}
type extraStringView struct {
Key string
Value string
Revealed bool
}
func (u *ui) detailViewContent(gtx layout.Context, item entry) layout.Dimensions {
rows := u.detailViewRows(item)
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
@@ -2180,6 +2191,8 @@ func (u *ui) detailViewRows(item entry) []layout.Widget {
layout.Spacer{Height: unit.Dp(8)}.Layout,
u.detailNotesCard(item),
layout.Spacer{Height: metrics.cardGap}.Layout,
u.detailExtraStringsCard,
layout.Spacer{Height: metrics.cardGap}.Layout,
u.attachmentSummaryPanel,
layout.Spacer{Height: metrics.cardGap}.Layout,
u.historyPanel,
@@ -2339,6 +2352,115 @@ func (u *ui) detailNotesCard(item entry) layout.Widget {
}
}
func (u *ui) detailExtraStringsCard(gtx layout.Context) layout.Dimensions {
fields := u.detailExtraStrings()
u.ensureExtraStringClickables(len(fields))
if len(fields) == 0 {
return layout.Dimensions{}
}
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
children := []layout.FlexChild{
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), "EXTRA STRINGS")
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
}
for i, field := range fields {
index := i
item := field
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.detailExtraStringRow(gtx, index, item)
}))
if i < len(fields)-1 {
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
}
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
})
}
func (u *ui) detailExtraStringRow(gtx layout.Context, index int, field extraStringView) layout.Dimensions {
for u.toggleCustomFields[index].Clicked(gtx) {
u.toggleExtraStringReveal(field.Key)
}
for u.copyCustomFields[index].Clicked(gtx) {
key := field.Key
u.runAction("copy extra string", func() error { return u.copySelectedCustomFieldAction(key) })
}
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(12), strings.ToUpper(field.Key))
lbl.Color = mutedColor
return lbl.Layout(gtx)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
lbl := material.Label(u.theme, unit.Sp(15), field.Value)
return lbl.Layout(gtx)
}),
)
}),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
return u.inlinePasswordToggle(gtx, &u.toggleCustomFields[index], field.Revealed)
}),
layout.Rigid(layout.Spacer{Width: unit.Dp(4)}.Layout),
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
btn := material.IconButton(u.theme, &u.copyCustomFields[index], u.copyIcon, "Copy extra string")
btn.Background = color.NRGBA{R: 239, G: 236, B: 229, A: 255}
btn.Color = accentColor
btn.Size = unit.Dp(18)
btn.Inset = layout.UniformInset(unit.Dp(8))
return btn.Layout(gtx)
}),
)
}
func (u *ui) detailExtraStrings() []extraStringView {
item, ok := u.selectedEntry()
if !ok || len(item.Fields) == 0 {
return nil
}
keys := make([]string, 0, len(item.Fields))
for key := range item.Fields {
keys = append(keys, key)
}
slices.Sort(keys)
out := make([]extraStringView, 0, len(keys))
for _, key := range keys {
value := item.Fields[key]
revealed := u.revealedCustomFields[key]
if !revealed {
value = maskedSecretValue(value)
}
out = append(out, extraStringView{Key: key, Value: value, Revealed: revealed})
}
return out
}
func (u *ui) toggleExtraStringReveal(key string) {
if u.revealedCustomFields == nil {
u.revealedCustomFields = map[string]bool{}
}
u.revealedCustomFields[key] = !u.revealedCustomFields[key]
}
func (u *ui) ensureExtraStringClickables(count int) {
if len(u.copyCustomFields) < count {
clicks := make([]widget.Clickable, count)
copy(clicks, u.copyCustomFields)
u.copyCustomFields = clicks
}
if len(u.toggleCustomFields) < count {
clicks := make([]widget.Clickable, count)
copy(clicks, u.toggleCustomFields)
u.toggleCustomFields = clicks
}
}
func (u *ui) detailActionRow(gtx layout.Context) layout.Dimensions {
switch u.state.Section {
case appstate.SectionTemplates:
@@ -2996,7 +3118,11 @@ func (u *ui) detailPasswordValue() string {
if u.showPassword {
return item.Password
}
return strings.Repeat("•", max(8, len(item.Password)))
return maskedSecretValue(item.Password)
}
func maskedSecretValue(value string) string {
return strings.Repeat("•", max(8, len(value)))
}
func card(gtx layout.Context, w layout.Widget) layout.Dimensions {
+44
View File
@@ -82,6 +82,8 @@ func (u *ui) setCustomFieldRows(fields map[string]string) {
u.customFieldKeys = nil
u.customFieldValues = nil
u.removeCustomFields = nil
u.copyCustomFields = nil
u.toggleCustomFields = nil
if len(fields) == 0 {
u.appendCustomFieldRow("", "")
return
@@ -104,21 +106,53 @@ func (u *ui) appendCustomFieldRow(key, value string) {
u.customFieldKeys = append(u.customFieldKeys, keyEditor)
u.customFieldValues = append(u.customFieldValues, valueEditor)
u.removeCustomFields = append(u.removeCustomFields, widget.Clickable{})
u.copyCustomFields = append(u.copyCustomFields, widget.Clickable{})
u.toggleCustomFields = append(u.toggleCustomFields, widget.Clickable{})
}
func (u *ui) removeCustomFieldRow(index int) {
u.ensureCustomFieldRowControls()
if index < 0 || index >= len(u.customFieldKeys) {
return
}
u.customFieldKeys = append(u.customFieldKeys[:index], u.customFieldKeys[index+1:]...)
u.customFieldValues = append(u.customFieldValues[:index], u.customFieldValues[index+1:]...)
u.removeCustomFields = append(u.removeCustomFields[:index], u.removeCustomFields[index+1:]...)
u.copyCustomFields = append(u.copyCustomFields[:index], u.copyCustomFields[index+1:]...)
u.toggleCustomFields = append(u.toggleCustomFields[:index], u.toggleCustomFields[index+1:]...)
if len(u.customFieldKeys) == 0 {
u.appendCustomFieldRow("", "")
}
}
func (u *ui) ensureCustomFieldRowControls() {
if len(u.customFieldValues) < len(u.customFieldKeys) {
values := make([]widget.Editor, len(u.customFieldKeys))
copy(values, u.customFieldValues)
for i := len(u.customFieldValues); i < len(values); i++ {
values[i] = widget.Editor{SingleLine: true, Submit: false}
}
u.customFieldValues = values
}
if len(u.removeCustomFields) < len(u.customFieldKeys) {
clicks := make([]widget.Clickable, len(u.customFieldKeys))
copy(clicks, u.removeCustomFields)
u.removeCustomFields = clicks
}
if len(u.copyCustomFields) < len(u.customFieldKeys) {
clicks := make([]widget.Clickable, len(u.customFieldKeys))
copy(clicks, u.copyCustomFields)
u.copyCustomFields = clicks
}
if len(u.toggleCustomFields) < len(u.customFieldKeys) {
clicks := make([]widget.Clickable, len(u.customFieldKeys))
copy(clicks, u.toggleCustomFields)
u.toggleCustomFields = clicks
}
}
func (u *ui) currentCustomFields() (map[string]string, error) {
u.ensureCustomFieldRowControls()
fields := map[string]string{}
for i := range u.customFieldKeys {
key := strings.TrimSpace(u.customFieldKeys[i].Text())
@@ -399,6 +433,16 @@ func (u *ui) copySelectedFieldAction(target clipboard.Target) error {
return service.Copy(model, u.state.SelectedEntryID, target)
}
func (u *ui) copySelectedCustomFieldAction(key string) error {
model, err := u.state.Session.Current()
if err != nil {
return err
}
service := clipboard.Service{Writer: u.clipboardWriter}
return service.CopyCustomField(model, u.state.SelectedEntryID, key)
}
func (u *ui) generatePasswordAction() error {
profile, err := passwords.LookupDefaultProfile(u.passwordProfile.Text())
if err != nil {
+1
View File
@@ -667,6 +667,7 @@ func (u *ui) customFieldEditorPanel(gtx layout.Context) layout.Dimensions {
if len(u.customFieldKeys) == 0 {
u.setCustomFieldRows(nil)
}
u.ensureCustomFieldRowControls()
return sectionCard(gtx, u.theme, "CUSTOM FIELDS", "Add key/value pairs. Changes are only saved when you save the entry.", func(gtx layout.Context) layout.Dimensions {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
+85
View File
@@ -3830,6 +3830,21 @@ func TestUILoadSelectedEntryIntoEditorPopulatesStructuredCustomFields(t *testing
}
}
func TestUIRemoveCustomFieldRowToleratesMissingClickables(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{})
u.customFieldKeys = []widget.Editor{{SingleLine: true}}
u.customFieldValues = []widget.Editor{{SingleLine: true}}
u.removeCustomFields = nil
u.removeCustomFieldRow(0)
if len(u.customFieldKeys) != 1 || len(u.customFieldValues) != 1 || len(u.removeCustomFields) != 1 {
t.Fatalf("custom field rows after remove with missing clickables = %d/%d/%d, want one blank row", len(u.customFieldKeys), len(u.customFieldValues), len(u.removeCustomFields))
}
}
func TestUIEditingEntryPathMovesEntryBetweenGroups(t *testing.T) {
t.Parallel()
@@ -9126,6 +9141,76 @@ func TestUIPasswordRevealTogglesDisplayedPasswordAndLockResetsIt(t *testing.T) {
}
}
func TestUIExtraStringValuesAreMaskedUntilRevealed(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Path: []string{"Root", "Internet"},
Fields: map[string]string{
"OTPSeed": "green-light",
},
},
},
})
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
u.state.SelectedEntryID = "vault-console"
fields := u.detailExtraStrings()
if len(fields) != 1 {
t.Fatalf("len(detailExtraStrings()) = %d, want 1", len(fields))
}
if fields[0].Value != strings.Repeat("•", len("green-light")) {
t.Fatalf("detailExtraStrings()[0].Value hidden = %q, want masked value", fields[0].Value)
}
u.toggleExtraStringReveal("OTPSeed")
fields = u.detailExtraStrings()
if fields[0].Value != "green-light" {
t.Fatalf("detailExtraStrings()[0].Value revealed = %q, want green-light", fields[0].Value)
}
}
func TestUICopyExtraStringWritesClipboardWithoutLeakingStatus(t *testing.T) {
t.Parallel()
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Title: "Vault Console",
Path: []string{"Root", "Internet"},
Fields: map[string]string{
"OTPSeed": "green-light",
},
},
},
})
writer := &memoryClipboardWriter{}
u.clipboardWriter = writer
u.showEntriesSection()
u.state.NavigateToPath([]string{"Root", "Internet"})
u.filter()
u.state.SelectedEntryID = "vault-console"
u.runAction("copy extra string", func() error { return u.copySelectedCustomFieldAction("OTPSeed") })
if writer.content != "green-light" {
t.Fatalf("clipboard content = %q, want green-light", writer.content)
}
if u.state.StatusMessage != "copy extra string complete" {
t.Fatalf("state.StatusMessage = %q, want copy extra string complete", u.state.StatusMessage)
}
if strings.Contains(u.state.StatusMessage, "green-light") {
t.Fatalf("state.StatusMessage = %q, must not contain copied extra string value", u.state.StatusMessage)
}
}
func TestUIPasswordTogglePresentationMatchesVisibility(t *testing.T) {
t.Parallel()
+16
View File
@@ -45,6 +45,22 @@ func (s Service) Copy(model vault.Model, entryID string, target Target) error {
return nil
}
func (s Service) CopyCustomField(model vault.Model, entryID, key string) error {
entry, err := findEntry(model, entryID)
if err != nil {
return err
}
content, ok := entry.Fields[key]
if !ok {
return ErrUnsupportedTarget
}
if err := s.writer().WriteText(content); err != nil {
return writeError{err: err}
}
return nil
}
func (s Service) writer() Writer {
if s.Writer != nil {
return s.Writer
+24
View File
@@ -48,6 +48,30 @@ func TestServiceCopiesUsernamePasswordAndURL(t *testing.T) {
}
}
func TestServiceCopiesCustomField(t *testing.T) {
t.Parallel()
var writer memoryWriter
service := Service{Writer: &writer}
model := vault.Model{
Entries: []vault.Entry{
{
ID: "vault-console",
Fields: map[string]string{
"OTPSeed": "green-light",
},
},
},
}
if err := service.CopyCustomField(model, "vault-console", "OTPSeed"); err != nil {
t.Fatalf("CopyCustomField(vault-console, OTPSeed) error = %v", err)
}
if writer.content != "green-light" {
t.Fatalf("clipboard content = %q, want green-light", writer.content)
}
}
func TestServiceRejectsUnknownEntryAndUnsupportedTarget(t *testing.T) {
t.Parallel()