From 1f74cd6eed017173087101df8917c16fe8a29f7e Mon Sep 17 00:00:00 2001 From: serge Date: Wed, 14 Oct 2026 12:05:35 +0800 Subject: [PATCH] Plugin task to update an entry found by a key field UpdateEntryThenCloseTask finds the entry by a key field of the caller's choice holding a value the caller generated, typically a random string, so the user may change the title, URL or anything else. The entry opens in the editor with the new field values filled in and the changed fields named. Nothing is written until the user saves. A field passed as JSON null is removed. If no entry has the key, the user is told, or, with UpdateEntry_CreateIfMissing, the entry is created with the fields and the key field as with CreateEntryThenCloseTask. Kp2aControl.getUpdateEntryIntent builds the intent. --- .../pluginsdk/Kp2aControl.java | 38 +++++ src/keepass2android-app/EntryEditActivity.cs | 17 +- .../Resources/values/strings.xml | 2 + src/keepass2android-app/app/AppTask.cs | 158 ++++++++++++++++++ 4 files changed, 214 insertions(+), 1 deletion(-) diff --git a/src/java/Keepass2AndroidPluginSDK2/app/src/main/java/keepass2android/pluginsdk/Kp2aControl.java b/src/java/Keepass2AndroidPluginSDK2/app/src/main/java/keepass2android/pluginsdk/Kp2aControl.java index cb77db06c..93c4ac0ea 100644 --- a/src/java/Keepass2AndroidPluginSDK2/app/src/main/java/keepass2android/pluginsdk/Kp2aControl.java +++ b/src/java/Keepass2AndroidPluginSDK2/app/src/main/java/keepass2android/pluginsdk/Kp2aControl.java @@ -58,6 +58,44 @@ public static Intent getAddEntryIntent(String outputData, ArrayList prot } + /** + * Creates and returns an intent to launch Keepass2Android for updating the entry whose field keyField is exactly + * keyValue. The key is a field of the app's choice holding a value the app generated when it created the entry, + * typically a random string, so the user may freely change the title, URL or anything else. + * The entry opens in the editor with the new values filled in; nothing is changed until the user saves. Fields not + * passed stay as they are; a field mapped to null is removed. + * @param keyField Name of the field that identifies the entry, e.g. "MyApp id". + * @param keyValue Exact value of that field. + * @param fields Key/Value pairs of the field values. See KeepassDefs for standard keys. + * @param protectedFields List of keys of the protected fields. + * @param createIfMissing If true and no entry has the key, a new entry is created with the fields and the key field instead. + * @return Intent to start Keepass2Android. + */ + public static Intent getUpdateEntryIntent(String keyField, String keyValue, HashMap fields, ArrayList protectedFields, boolean createIfMissing) + { + JSONObject data = new JSONObject(); + try + { + for (java.util.Map.Entry f : fields.entrySet()) + data.put(f.getKey(), f.getValue() == null ? JSONObject.NULL : f.getValue()); + } + catch (JSONException e) + { + throw new IllegalArgumentException(e); + } + Intent startKp2aIntent = new Intent(Strings.ACTION_START_WITH_TASK); + startKp2aIntent.addCategory(Intent.CATEGORY_DEFAULT); + startKp2aIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + startKp2aIntent.putExtra("KP2A_APPTASK", "UpdateEntryThenCloseTask"); + startKp2aIntent.putExtra("UpdateEntry_KeyField", keyField); + startKp2aIntent.putExtra("UpdateEntry_KeyValue", keyValue); + startKp2aIntent.putExtra("UpdateEntry_CreateIfMissing", String.valueOf(createIfMissing)); //KP2A expects a StringExtra + startKp2aIntent.putExtra(Strings.EXTRA_ENTRY_OUTPUT_DATA, data.toString()); + if (protectedFields != null) + startKp2aIntent.putStringArrayListExtra(Strings.EXTRA_PROTECTED_FIELDS_LIST, protectedFields); + return startKp2aIntent; + } + /** * Creates an intent to open a Password Entry matching searchText * @param searchText queryString diff --git a/src/keepass2android-app/EntryEditActivity.cs b/src/keepass2android-app/EntryEditActivity.cs index 9aba9ae6b..ddf30958f 100644 --- a/src/keepass2android-app/EntryEditActivity.cs +++ b/src/keepass2android-app/EntryEditActivity.cs @@ -747,6 +747,16 @@ protected override void OnCreate(Bundle savedInstanceState) } State.Entry = State.EntryInDatabase.CloneDeep(); + if (!State.IsNew) + { + // a task may bring new values for an existing entry: they go into the copy, the database changes only on save + var changed = AppTask.PrepareEditEntry(State.Entry); + if (changed.Count > 0) + { + State.EntryModified = true; + App.Kp2a.ShowMessage(this, GetString(Resource.String.UpdateEntryFieldsChanged, string.Join(", ", changed)), MessageSeverity.Info); + } + } if (State.Entry.ParentGroup != null && State.Entry.ParentGroup.Name.Equals("AutoOpen", StrUtil.CaseIgnoreCmp)) State.EditMode = new AutoOpenEdit(State.Entry); else if (KpEntryTemplatedEdit.IsTemplated(db, State.Entry)) @@ -1094,7 +1104,12 @@ void SaveEntry() } else { - runnable = new UpdateEntry(App.Kp2a, initialEntry, newEntry, closeOrShowError); + ActionOnOperationFinished afterUpdateEntry = new ActionInContextInstanceOnOperationFinished(ContextInstanceId, App.Kp2a, (success, message, context) => + { + if (success && context is EntryEditActivity entryEditActivity) + AppTask.AfterUpdateEntry(entryEditActivity, newEntry); + }, closeOrShowError); + runnable = new UpdateEntry(App.Kp2a, initialEntry, newEntry, afterUpdateEntry); } BlockingOperationStarter pt = new BlockingOperationStarter(App.Kp2a, runnable); pt.Run(); diff --git a/src/keepass2android-app/Resources/values/strings.xml b/src/keepass2android-app/Resources/values/strings.xml index 25467a0d7..7947ac374 100644 --- a/src/keepass2android-app/Resources/values/strings.xml +++ b/src/keepass2android-app/Resources/values/strings.xml @@ -438,6 +438,8 @@ Activate built-in keyboard Copied TOTP to clipboard + Fields changed by the request (saved only when you save): %1$s + No entry has the requested value in the field %1$s. Nothing was changed. KP2A keyboard notification Make full entry accessible through the KP2A keyboard (recommended). diff --git a/src/keepass2android-app/app/AppTask.cs b/src/keepass2android-app/app/AppTask.cs index b7a5e7e96..e225f3b85 100644 --- a/src/keepass2android-app/app/AppTask.cs +++ b/src/keepass2android-app/app/AppTask.cs @@ -222,6 +222,19 @@ public virtual void PrepareNewEntry(PwEntry newEntry) } + /// + /// An existing entry is opened for editing: a task may change the copy being edited before it is shown. Nothing is + /// written until the user saves. Returns the names of the fields it changed (empty if none). + /// + public virtual IList PrepareEditEntry(PwEntry entryCopy) + { + return Array.Empty(); + } + + public virtual void AfterUpdateEntry(EntryEditActivity entryEditActivity, PwEntry entry) + { + } + public const String AppTaskKey = "KP2A_APPTASK"; /// @@ -851,6 +864,151 @@ public override void CompleteOnCreateEntryActivity(EntryActivity activity, Threa } + /// + /// Another app asks to change fields of an existing entry. The entry is found by a key field: a field of the app's + /// choice (for example "MyApp id") holding a value the app generated when it created the entry, typically a random + /// string, so the user may freely change the title, URL or anything else. The entry opens in the editor with the new + /// values filled in and the changed fields named; nothing is written until the user saves, the same consent as for + /// creating an entry. Fields that are not passed stay as they are; a field passed as JSON null is removed. If no entry + /// has the key, the user is told and nothing changes, or, with , a new entry is created + /// with the fields and the key field instead. + /// Extras: and (the key field and its exact value), the JSON + /// key/value map in , the protected field names in . + /// + public class UpdateEntryThenCloseTask : AppTask + { + public const String KeyFieldKey = "UpdateEntry_KeyField"; + public const String KeyValueKey = "UpdateEntry_KeyValue"; + public const String AllFieldsKey = Keepass2android.Pluginsdk.Strings.ExtraEntryOutputData; + public const String ProtectedFieldsListKey = Keepass2android.Pluginsdk.Strings.ExtraProtectedFieldsList; + + /// "true": if no entry has the key, a new one is created with the fields instead. + public const String CreateIfMissingKey = "UpdateEntry_CreateIfMissing"; + + public string KeyField { get; set; } + public string KeyValue { get; set; } + public string AllFields { get; set; } + public IList ProtectedFieldsList { get; set; } + public bool CreateIfMissing { get; set; } + + public override bool CanActivateSearchViewOnStart + { + get { return false; } + set { } + } + + public override void Setup(Bundle b) + { + KeyField = b.GetString(KeyFieldKey); + KeyValue = b.GetString(KeyValueKey); + AllFields = b.GetString(AllFieldsKey); + ProtectedFieldsList = b.GetStringArrayList(ProtectedFieldsListKey); + CreateIfMissing = GetBoolFromBundle(b, CreateIfMissingKey, false); + } + + public override IEnumerable Extras + { + get + { + yield return new StringExtra { Key = CreateIfMissingKey, Value = CreateIfMissing.ToString() }; + if (KeyField != null) + yield return new StringExtra { Key = KeyFieldKey, Value = KeyField }; + if (KeyValue != null) + yield return new StringExtra { Key = KeyValueKey, Value = KeyValue }; + if (AllFields != null) + yield return new StringExtra { Key = AllFieldsKey, Value = AllFields }; + if (ProtectedFieldsList != null) + yield return new StringArrayListExtra { Key = ProtectedFieldsListKey, Value = ProtectedFieldsList }; + } + } + + /// + /// The entry whose field is exactly , outside the recycle bin, in any open + /// database. + /// + PwEntry FindEntry() + { + if (string.IsNullOrEmpty(KeyField) || string.IsNullOrEmpty(KeyValue)) return null; + foreach (var db in App.Kp2a.OpenDatabases) + { + var kp = db.KpDatabase; + var bin = kp.RecycleBinEnabled ? kp.RootGroup.FindGroup(kp.RecycleBinUuid, true) : null; + foreach (var e in db.EntriesById.Values) + { + if (e.Strings.ReadSafe(KeyField) != KeyValue) continue; + if (bin != null && e.IsContainedIn(bin)) continue; + App.Kp2a.CurrentDb = db; + return e; + } + } + return null; + } + + public override void LaunchFirstGroupActivity(Activity act) + { + var entry = FindEntry(); + if (entry != null) + { + EntryEditActivity.Launch(act, entry, this); + return; + } + if (!CreateIfMissing) + { + App.Kp2a.ShowMessage(act, act.GetString(Resource.String.UpdateEntryNotFound, KeyField), MessageSeverity.Warning); + act.Finish(); + return; + } + // no such entry yet: it is created with the same fields (the ones to remove left out) and the key field; the user + // picks its group and saves, as for any new entry + var fields = AllFields == null ? new Org.Json.JSONObject() : new Org.Json.JSONObject(AllFields); + var removed = new List(); + for (var iter = fields.Keys(); iter.HasNext;) + { + string key = iter.Next().ToString(); + if (fields.IsNull(key)) removed.Add(key); + } + foreach (var key in removed) fields.Remove(key); + if (!string.IsNullOrEmpty(KeyField)) fields.Put(KeyField, KeyValue ?? ""); + new CreateEntryThenCloseTask { AllFields = fields.ToString(), ProtectedFieldsList = ProtectedFieldsList, ShowUserNotifications = ActivationCondition.Never } + .LaunchFirstGroupActivity(act); + } + + public override IList PrepareEditEntry(PwEntry entryCopy) + { + var changed = new List(); + if (AllFields == null) return changed; + var allFields = new Org.Json.JSONObject(AllFields); + for (var iter = allFields.Keys(); iter.HasNext;) + { + string key = iter.Next().ToString(); + if (allFields.IsNull(key)) + { + // JSON null: the field goes; the standard fields stay, emptied + if (!entryCopy.Strings.Exists(key)) continue; + if (PwDefs.IsStandardField(key)) entryCopy.Strings.Set(key, new ProtectedString(entryCopy.Strings.GetSafe(key).IsProtected, "")); + else entryCopy.Strings.Remove(key); + changed.Add(key); + continue; + } + string value = allFields.Get(key).ToString(); + if (entryCopy.Strings.ReadSafe(key) == value) continue; + bool isProtected = ((ProtectedFieldsList != null) && (ProtectedFieldsList.Contains(key))) + || (key == PwDefs.PasswordField); + entryCopy.Strings.Set(key, new ProtectedString(isProtected, value)); + changed.Add(key); + } + return changed; + } + + public override void AfterUpdateEntry(EntryEditActivity entryEditActivity, PwEntry entry) + { + EntryActivity.Launch(entryEditActivity, entry, -1, + new SelectEntryTask() { ShowUserNotifications = ActivationCondition.Never, ActivateKeyboard = ActivationCondition.Never }, + ActivityFlags.ForwardResult); + } + } + + /// /// Navigate to a folder and open a Task (appear in SearchResult) ///