Skip to content

Commit 9eadd0d

Browse files
committed
feat: support adding OTP to existing entries when scanning otpauth:// QR codes
Previously, scanning an otpauth:// QR code via the system camera could only create a new entry. This change introduces an OTP search results page (OtpEntryResults) that searches for matching entries based on the issuer/label from the URI, allowing users to either add OTP to an existing entry or create a new one. When the selected entry already has OTP configured, a confirmation dialog asks whether to overwrite it.
1 parent c1513bf commit 9eadd0d

8 files changed

Lines changed: 524 additions & 16 deletions

File tree

src/keepass2android-app/EntryActivity.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1585,6 +1585,35 @@ internal void AddUrlToEntry(string url, Action<EntryActivity> finishAction)
15851585

15861586
}
15871587

1588+
/// <summary>
1589+
/// Adds or overwrites the OTP configuration in the entry using the KeeWeb/KeePassXC "otp" field format.
1590+
/// </summary>
1591+
internal void AddOtpToEntry(string otpUri, Action<EntryActivity> finishAction)
1592+
{
1593+
PwEntry initialEntry = Entry.CloneDeep();
1594+
1595+
PwEntry newEntry = Entry;
1596+
newEntry.History = newEntry.History.CloneDeep();
1597+
newEntry.CreateBackup(null);
1598+
1599+
newEntry.Touch(true, false); // Touch *after* backup
1600+
1601+
// Set the "otp" field with the otpauth:// URI (KeeWeb/KeePassXC format)
1602+
newEntry.Strings.Set("otp", new ProtectedString(true, otpUri));
1603+
1604+
// Save the entry:
1605+
ActionOnOperationFinished closeOrShowError = new ActionInContextInstanceOnOperationFinished(ContextInstanceId, App.Kp2a, (success, message, context) =>
1606+
{
1607+
OnOperationFinishedHandler.DisplayMessage(this, message, true);
1608+
finishAction(context as EntryActivity);
1609+
});
1610+
1611+
OperationWithFinishHandler runnable = new UpdateEntry(App.Kp2a, initialEntry, newEntry, closeOrShowError);
1612+
1613+
BlockingOperationStarter pt = new BlockingOperationStarter(App.Kp2a, runnable);
1614+
pt.Run();
1615+
}
1616+
15881617
public bool GetVisibilityForProtectedView(TextView protectedView)
15891618
{
15901619
if (protectedView == null)
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
/*
2+
This file is part of Keepass2Android, Copyright 2013 Philipp Crocoll.
3+
4+
Keepass2Android is free software: you can redistribute it and/or modify
5+
it under the terms of the GNU General Public License as published by
6+
the Free Software Foundation, either version 3 of the License, or
7+
(at your option) any later version.
8+
9+
Keepass2Android is distributed in the hope that it will be useful,
10+
but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
GNU General Public License for more details.
13+
14+
You should have received a copy of the GNU General Public License
15+
along with Keepass2Android. If not, see <http://www.gnu.org/licenses/>.
16+
*/
17+
18+
using System;
19+
using System.Collections.Generic;
20+
using System.Linq;
21+
using Android.App;
22+
using Android.Content;
23+
using Android.Content.PM;
24+
using Android.OS;
25+
using Android.Runtime;
26+
using Android.Views;
27+
using Android.Widget;
28+
using KeePassLib;
29+
30+
namespace keepass2android
31+
{
32+
/// <summary>
33+
/// Activity to display search results when adding OTP to an existing entry.
34+
/// Similar to ShareUrlResults but specialized for otpauth:// URI handling.
35+
/// </summary>
36+
[Activity(Label = "@string/otp_find_entry", ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.Keyboard | ConfigChanges.KeyboardHidden, Theme = "@style/Kp2aTheme_ActionBar", Exported = false)]
37+
public class OtpEntryResults : GroupBaseActivity
38+
{
39+
public OtpEntryResults(IntPtr javaReference, JniHandleOwnership transfer)
40+
: base(javaReference, transfer)
41+
{
42+
}
43+
44+
public OtpEntryResults()
45+
{
46+
}
47+
48+
public static void Launch(Activity act, AddOtpToEntryTask task, ActivityLaunchMode launchMode)
49+
{
50+
Intent i = new Intent(act, typeof(OtpEntryResults));
51+
task.ToIntent(i);
52+
launchMode.Launch(act, i);
53+
}
54+
55+
public override bool IsSearchResult
56+
{
57+
get { return true; }
58+
}
59+
60+
protected override void OnCreate(Bundle savedInstanceState)
61+
{
62+
if (!App.Kp2a.DatabasesBackgroundModificationLock.TryEnterReadLock(TimeSpan.FromSeconds(5)))
63+
{
64+
App.Kp2a.ShowMessage(this, GetString(Resource.String.failed_to_access_database), MessageSeverity.Error);
65+
Finish();
66+
return;
67+
}
68+
69+
base.OnCreate(savedInstanceState);
70+
71+
SetResult(Result.Canceled);
72+
73+
UpdateBottomBarElementVisibility(Resource.Id.select_other_entry, true);
74+
UpdateBottomBarElementVisibility(Resource.Id.add_url_entry, true);
75+
76+
if (App.Kp2a.DatabaseIsUnlocked)
77+
{
78+
Query();
79+
}
80+
}
81+
82+
protected override void OnSaveInstanceState(Bundle outState)
83+
{
84+
base.OnSaveInstanceState(outState);
85+
AppTask.ToBundle(outState);
86+
}
87+
88+
private void Query()
89+
{
90+
try
91+
{
92+
var addOtpTask = AppTask as AddOtpToEntryTask;
93+
if (addOtpTask == null)
94+
{
95+
Finish();
96+
return;
97+
}
98+
99+
string searchText = AddOtpToEntryTask.GetSearchTextFromOtpUri(addOtpTask.OtpUri);
100+
Group = GetSearchResultsForOtp(searchText);
101+
}
102+
catch (Exception e)
103+
{
104+
App.Kp2a.ShowMessage(this, Util.GetErrorMessage(e), MessageSeverity.Error);
105+
SetResult(Result.Canceled);
106+
Finish();
107+
return;
108+
}
109+
110+
// If no results, show empty layout
111+
if (Group == null || !Group.Entries.Any())
112+
{
113+
SetContentView(Resource.Layout.otp_entry_results_empty);
114+
}
115+
116+
SetGroupTitle();
117+
118+
var listFragment = FragmentManager.FindFragmentById<GroupListFragment>(Resource.Id.list_fragment);
119+
if (listFragment != null)
120+
{
121+
listFragment.ListAdapter = new PwGroupListAdapter(this, Group);
122+
}
123+
124+
View selectOtherEntry = FindViewById(Resource.Id.select_other_entry);
125+
View createNewEntry = FindViewById(Resource.Id.add_url_entry);
126+
127+
var otpTask = AppTask as AddOtpToEntryTask;
128+
129+
selectOtherEntry.Visibility = ViewStates.Visible;
130+
selectOtherEntry.Click += (sender, e) =>
131+
{
132+
// Launch GroupActivity with the same AddOtpToEntryTask so user can browse/search
133+
GroupActivity.Launch(this, otpTask, new ActivityLaunchModeRequestCode(0));
134+
};
135+
136+
if (App.Kp2a.OpenDatabases.Any(db => db.CanWrite))
137+
{
138+
createNewEntry.Visibility = ViewStates.Visible;
139+
createNewEntry.Click += (sender, e) =>
140+
{
141+
// Create a new entry with OTP pre-filled (original behavior)
142+
GroupActivity.Launch(this,
143+
new CreateEntryThenCloseTask
144+
{
145+
AllFields = Newtonsoft.Json.JsonConvert.SerializeObject(
146+
new Dictionary<string, string> { { "otp", otpTask.OtpUri } })
147+
},
148+
new ActivityLaunchModeRequestCode(0));
149+
App.Kp2a.ShowMessage(this,
150+
GetString(Resource.String.select_group_then_add,
151+
new Java.Lang.Object[] { GetString(Resource.String.add_entry) }),
152+
MessageSeverity.Info);
153+
};
154+
}
155+
else
156+
{
157+
createNewEntry.Visibility = ViewStates.Gone;
158+
}
159+
160+
Util.MoveBottomBarButtons(Resource.Id.select_other_entry, Resource.Id.add_url_entry, Resource.Id.bottom_bar, this);
161+
}
162+
163+
/// <summary>
164+
/// Search for entries matching the OTP issuer/label text.
165+
/// </summary>
166+
public static PwGroup GetSearchResultsForOtp(string searchText)
167+
{
168+
PwGroup resultsGroup = null;
169+
foreach (var db in App.Kp2a.OpenDatabases)
170+
{
171+
PwGroup resultsForThisDb = null;
172+
173+
if (!string.IsNullOrEmpty(searchText))
174+
{
175+
resultsForThisDb = db.SearchForText(searchText);
176+
}
177+
178+
if (resultsForThisDb == null)
179+
{
180+
resultsForThisDb = new PwGroup(true, true) { Name = "Search Results" };
181+
}
182+
183+
if (resultsGroup == null)
184+
{
185+
resultsGroup = resultsForThisDb;
186+
}
187+
else
188+
{
189+
foreach (var entry in resultsForThisDb.Entries)
190+
{
191+
resultsGroup.AddEntry(entry, false, false);
192+
}
193+
}
194+
}
195+
196+
return resultsGroup;
197+
}
198+
199+
public override bool OnSearchRequested()
200+
{
201+
Intent i = new Intent(this, typeof(SearchActivity));
202+
this.AppTask.ToIntent(i);
203+
i.SetFlags(ActivityFlags.ForwardResult);
204+
StartActivity(i);
205+
return true;
206+
}
207+
208+
protected override int ContentResourceId
209+
{
210+
get { return Resource.Layout.otp_entry_results; }
211+
}
212+
213+
public override bool EntriesBelongToCurrentDatabaseOnly
214+
{
215+
get { return false; }
216+
}
217+
218+
public override ElementAndDatabaseId FullGroupId
219+
{
220+
get { return null; }
221+
}
222+
223+
protected override void OnDestroy()
224+
{
225+
base.OnDestroy();
226+
App.Kp2a.DatabasesBackgroundModificationLock.ExitReadLock();
227+
}
228+
}
229+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
3+
xmlns:app="http://schemas.android.com/apk/res-auto"
4+
android:fitsSystemWindows="true"
5+
android:layout_width="fill_parent"
6+
android:layout_height="fill_parent"
7+
android:background="?android:attr/colorBackground">
8+
<LinearLayout
9+
android:id="@+id/top"
10+
android:layout_width="match_parent"
11+
android:layout_height="0dp"
12+
android:orientation="horizontal" />
13+
<RelativeLayout
14+
android:id="@+id/bottom_bar"
15+
android:layout_width="match_parent"
16+
android:layout_height="wrap_content"
17+
android:orientation="vertical"
18+
android:layout_alignParentBottom="true"
19+
android:baselineAligned="false">
20+
<Button
21+
android:id="@+id/select_other_entry"
22+
android:layout_width="wrap_content"
23+
android:layout_height="wrap_content"
24+
android:layout_alignParentLeft="true"
25+
android:text="@string/otp_select_other_entry"
26+
style="@style/BottomBarButton" />
27+
<Button
28+
android:id="@+id/add_url_entry"
29+
android:layout_width="wrap_content"
30+
android:layout_height="wrap_content"
31+
android:layout_alignParentRight="true"
32+
android:text="@string/otp_create_new_entry"
33+
style="@style/BottomBarButton" />
34+
</RelativeLayout>
35+
<View
36+
android:id="@+id/divider2"
37+
android:layout_width="fill_parent"
38+
android:layout_height="1dp"
39+
android:layout_above="@id/bottom_bar"
40+
android:background="#b8b8b8" />
41+
<fragment
42+
android:name="keepass2android.GroupListFragment"
43+
android:id="@+id/list_fragment"
44+
android:layout_above="@id/divider2"
45+
android:layout_below="@id/top"
46+
47+
android:layout_width="match_parent"
48+
android:layout_height="match_parent" />
49+
50+
51+
</RelativeLayout>
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
2+
xmlns:app="http://schemas.android.com/apk/res-auto"
3+
android:fitsSystemWindows="true"
4+
android:layout_width="fill_parent"
5+
android:layout_height="fill_parent"
6+
android:background="?android:attr/colorBackground">
7+
<LinearLayout
8+
android:id="@+id/top"
9+
android:layout_width="match_parent"
10+
android:layout_height="0dp"
11+
android:orientation="horizontal" />
12+
<RelativeLayout
13+
android:id="@+id/bottom_bar"
14+
android:layout_width="match_parent"
15+
android:layout_height="wrap_content"
16+
android:orientation="vertical"
17+
android:layout_alignParentBottom="true"
18+
android:baselineAligned="false">
19+
<Button
20+
android:id="@+id/select_other_entry"
21+
android:layout_width="wrap_content"
22+
android:layout_height="wrap_content"
23+
android:layout_alignParentLeft="true"
24+
android:text="@string/otp_select_other_entry"
25+
style="@style/BottomBarButton" />
26+
<Button
27+
android:id="@+id/add_url_entry"
28+
android:layout_width="wrap_content"
29+
android:layout_height="wrap_content"
30+
android:layout_alignParentRight="true"
31+
android:text="@string/otp_create_new_entry"
32+
style="@style/BottomBarButton" />
33+
</RelativeLayout>
34+
<View
35+
android:id="@+id/divider2"
36+
android:layout_width="fill_parent"
37+
android:layout_height="1dp"
38+
android:layout_above="@id/bottom_bar"
39+
android:background="#b8b8b8" />
40+
41+
<TextView
42+
android:id="@+id/no_results"
43+
android:layout_width="fill_parent"
44+
android:layout_height="wrap_content"
45+
android:layout_margin="12dp"
46+
android:layout_below="@id/top"
47+
android:text="@string/no_results" />
48+
49+
<ListView
50+
android:id="@android:id/list"
51+
android:layout_width="fill_parent"
52+
android:layout_height="wrap_content"
53+
android:layout_above="@id/divider2"
54+
android:layout_below="@id/no_results"
55+
android:paddingRight="8dp"
56+
android:paddingLeft="8dp" />
57+
</RelativeLayout>

src/keepass2android-app/Resources/values-zh/strings.xml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1264,4 +1264,13 @@ Initial public release
12641264
<string name="pref_enable_periodic_background_sync_summary">如启用,应用会定期同步解锁的数据库。这遵守上面的网络连接设置。</string>
12651265
<string name="pref_periodic_background_sync_interval_title">定期后台同步间隔</string>
12661266
<string name="pref_periodic_background_sync_interval_summary">设置分钟为单位的后台同步间隔</string>
1267+
1268+
<string name="otp_find_entry">查找 OTP 对应的记录</string>
1269+
<string name="otp_select_other_entry">选择其他记录</string>
1270+
<string name="otp_create_new_entry">新建记录</string>
1271+
<string name="otp_add_to_entry_title">添加 OTP 到记录?</string>
1272+
<string name="otp_add_to_entry_text">将扫描到的 OTP 配置添加到所选记录中?</string>
1273+
<string name="otp_overwrite_title">OTP 已存在</string>
1274+
<string name="otp_overwrite_text">此记录已有 OTP 配置,是否覆盖?</string>
1275+
<string name="overwrite">覆盖</string>
12671276
</resources>

0 commit comments

Comments
 (0)