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