Inline Search with suggestions

This commit is contained in:
AlexVallat 2013-06-19 18:44:35 +01:00
parent 4f49b073d8
commit 30cbc16051
11 changed files with 435 additions and 37 deletions

View File

@ -35,6 +35,11 @@ namespace KeePassLib
/// </summary>
public sealed class PwGroup : ITimeLogger, IStructureItem, IDeepCloneable<PwGroup>
{
private const int SearchContextStringMaxLength = 50; // Note, doesn't include elipsis, if added
public const string SearchContextUuid = "Uuid";
public const string SearchContextParentGroup = "Parent Group";
public const string SearchContextTags = "Tags";
public const bool DefaultAutoTypeEnabled = true;
public const bool DefaultSearchingEnabled = true;
@ -706,6 +711,18 @@ namespace KeePassLib
/// be stored.</param>
public void SearchEntries(SearchParameters sp, PwObjectList<PwEntry> listStorage,
IStatusLogger slStatus)
{
}
/// <summary>
/// Search this group and all subgroups for entries.
/// </summary>
/// <param name="sp">Specifies the search method.</param>
/// <param name="listStorage">Entry list in which the search results will
/// be stored.</param>
/// <param name="resultContexts">Dictionary that will be populated with text fragments indicating the context of why each entry (keyed by Uuid) was returned</param>
public void SearchEntries(SearchParameters sp, PwObjectList<PwEntry> listStorage, IDictionary<PwUuid, String> resultContexts,
IStatusLogger slStatus)
{
if(sp == null) { Debug.Assert(false); return; }
if(listStorage == null) { Debug.Assert(false); return; }
@ -716,7 +733,7 @@ namespace KeePassLib
if((lTerms.Count <= 1) || sp.RegularExpression)
{
if(slStatus != null) uTotalEntries = GetEntriesCount(true);
SearchEntriesSingle(sp, listStorage, slStatus, ref uCurEntries,
SearchEntriesSingle(sp, listStorage, resultContexts , slStatus, ref uCurEntries,
uTotalEntries);
return;
}
@ -748,7 +765,7 @@ namespace KeePassLib
bNegate = (sp.SearchString.Length > 0);
}
if(!pg.SearchEntriesSingle(sp, pgNew.Entries, slStatus,
if(!pg.SearchEntriesSingle(sp, pgNew.Entries, resultContexts, slStatus,
ref uCurEntries, uTotalEntries))
{
pg = null;
@ -773,7 +790,7 @@ namespace KeePassLib
}
private bool SearchEntriesSingle(SearchParameters spIn,
PwObjectList<PwEntry> listStorage, IStatusLogger slStatus,
PwObjectList<PwEntry> listStorage, IDictionary<PwUuid, String> resultContexts, IStatusLogger slStatus,
ref ulong uCurEntries, ulong uTotalEntries)
{
SearchParameters sp = spIn.Clone();
@ -856,42 +873,42 @@ namespace KeePassLib
if(strKey == PwDefs.TitleField)
{
if(bTitle) SearchEvalAdd(sp, kvp.Value.ReadString(),
rx, pe, listStorage);
rx, pe, listStorage, resultContexts, strKey);
}
else if(strKey == PwDefs.UserNameField)
{
if(bUserName) SearchEvalAdd(sp, kvp.Value.ReadString(),
rx, pe, listStorage);
rx, pe, listStorage, resultContexts, strKey);
}
else if(strKey == PwDefs.PasswordField)
{
if(bPassword) SearchEvalAdd(sp, kvp.Value.ReadString(),
rx, pe, listStorage);
rx, pe, listStorage, resultContexts, strKey);
}
else if(strKey == PwDefs.UrlField)
{
if(bUrl) SearchEvalAdd(sp, kvp.Value.ReadString(),
rx, pe, listStorage);
rx, pe, listStorage, resultContexts, strKey);
}
else if(strKey == PwDefs.NotesField)
{
if(bNotes) SearchEvalAdd(sp, kvp.Value.ReadString(),
rx, pe, listStorage);
rx, pe, listStorage, resultContexts, strKey);
}
else if(bOther)
SearchEvalAdd(sp, kvp.Value.ReadString(),
rx, pe, listStorage);
rx, pe, listStorage, resultContexts, strKey);
// An entry can match only once => break if we have added it
if(listStorage.UCount > uInitialResults) break;
}
if(bUuids && (listStorage.UCount == uInitialResults))
SearchEvalAdd(sp, pe.Uuid.ToHexString(), rx, pe, listStorage);
SearchEvalAdd(sp, pe.Uuid.ToHexString(), rx, pe, listStorage, resultContexts, SearchContextUuid);
if(bGroupName && (listStorage.UCount == uInitialResults) &&
(pe.ParentGroup != null))
SearchEvalAdd(sp, pe.ParentGroup.Name, rx, pe, listStorage);
SearchEvalAdd(sp, pe.ParentGroup.Name, rx, pe, listStorage, resultContexts, SearchContextParentGroup);
if(bTags)
{
@ -899,7 +916,7 @@ namespace KeePassLib
{
if(listStorage.UCount != uInitialResults) break; // Match
SearchEvalAdd(sp, strTag, rx, pe, listStorage);
SearchEvalAdd(sp, strTag, rx, pe, listStorage, resultContexts, SearchContextTags);
}
}
@ -913,14 +930,22 @@ namespace KeePassLib
}
private static void SearchEvalAdd(SearchParameters sp, string strDataField,
Regex rx, PwEntry pe, PwObjectList<PwEntry> lResults)
Regex rx, PwEntry pe, PwObjectList<PwEntry> lResults, IDictionary<PwUuid, String> resultContexts, string contextFieldName)
{
bool bMatch = false;
int matchPos;
if (rx == null)
bMatch = (strDataField.IndexOf(sp.SearchString,
sp.ComparisonMode) >= 0);
else bMatch = rx.IsMatch(strDataField);
{
matchPos = strDataField.IndexOf(sp.SearchString, sp.ComparisonMode);
bMatch = matchPos >= 0;
}
else
{
var match = rx.Match(strDataField);
bMatch = match.Success;
matchPos = match.Index;
}
if(!bMatch && (sp.DataTransformationFn != null))
{
@ -928,13 +953,36 @@ namespace KeePassLib
if(!object.ReferenceEquals(strCmp, strDataField))
{
if (rx == null)
bMatch = (strCmp.IndexOf(sp.SearchString,
sp.ComparisonMode) >= 0);
else bMatch = rx.IsMatch(strCmp);
{
matchPos = strCmp.IndexOf(sp.SearchString, sp.ComparisonMode);
bMatch = matchPos >= 0;
}
else
{
var match = rx.Match(strCmp);
bMatch = match.Success;
matchPos = match.Index;
}
}
}
if(bMatch) lResults.Add(pe);
if (bMatch)
{
lResults.Add(pe);
if (resultContexts != null)
{
// Trim the value if necessary
var contextString = strDataField;
if (contextString.Length > SearchContextStringMaxLength)
{
// Start 10% before actual data, and don't run over
var startPos = Math.Min(matchPos - (SearchContextStringMaxLength / 10), contextString.Length - SearchContextStringMaxLength);
contextString = "… " + contextString.Substring(startPos, SearchContextStringMaxLength) + ((startPos + SearchContextStringMaxLength < contextString.Length) ? " …" : null);
}
resultContexts[pe.Uuid] = contextFieldName + ": " + contextString;
}
}
}
public List<string> BuildEntryTagsList()

View File

@ -125,7 +125,7 @@ namespace keepass2android
entryId = new KeePassLib.PwUuid(MemUtil.HexStringToByteArray(uuidBytes));
State.parentGroup = null;
if (entryId == PwUuid.Zero)
if (entryId.EqualsValue(PwUuid.Zero))
{
String groupId = i.GetStringExtra(KEY_PARENT);

View File

@ -198,6 +198,11 @@ namespace keepass2android
MenuInflater inflater = MenuInflater;
inflater.Inflate(Resource.Menu.group, menu);
var searchManager = (SearchManager)GetSystemService(Context.SearchService);
var searchView = (SearchView)menu.FindItem(Resource.Id.menu_search).ActionView;
searchView.SetSearchableInfo(searchManager.GetSearchableInfo(ComponentName));
return true;
}
@ -243,6 +248,7 @@ namespace keepass2android
return true;
case Resource.Id.menu_search:
case Resource.Id.menu_search_advanced:
OnSearchRequested();
return true;

View File

@ -20,6 +20,12 @@
android:icon="@android:drawable/ic_menu_search"
android:title="@string/menu_search"
android:showAsAction="ifRoom"
android:actionViewClass="android.widget.SearchView"
/>
<item android:id="@+id/menu_search_advanced"
android:icon="@android:drawable/ic_menu_search"
android:title="@string/menu_search_advanced"
android:showAsAction="never"
/>
<item android:id="@+id/menu_lock"
android:icon="@android:drawable/ic_lock_lock"

View File

@ -61,6 +61,7 @@
<string name="entry_user_name">User Name</string>
<string name="entry_extra_strings">Extra string fields</string>
<string name="entry_binaries">File attachments</string>
<string name="entry_notes">Notes</string>
<string name="error_arc4">The ArcFour stream cipher is not supported.</string>
<string name="error_can_not_handle_uri">Keepass2Android cannot handle this uri.</string>
<string name="error_could_not_create_group">Error creating group.</string>
@ -129,6 +130,7 @@
<string name="menu_open">Open</string>
<string name="menu_rename">Rename</string>
<string name="menu_search">Search</string>
<string name="menu_search_advanced">Advanced Search</string>
<string name="menu_url">Go to URL</string>
<string name="minus">Minus</string>
<string name="never">Never</string>

View File

@ -21,4 +21,9 @@
android:label="@string/search_label"
android:hint="@string/search_hint"
android:searchMode="showSearchLabelAsBadge"
android:searchSuggestAuthority="keepass2android.search.SearchProvider"
android:searchSuggestSelection=" ?"
android:searchSuggestThreshold="2"
android:searchSuggestIntentAction="android.intent.action.VIEW"
android:searchSuggestIntentData="content://keepass2android.EntryActivity"
/>

View File

@ -59,7 +59,7 @@ namespace keepass2android
public Drawable getIconDrawable (Resources res, PwDatabase db, PwIcon icon, PwUuid customIconId)
{
if (customIconId != PwUuid.Zero) {
if (!customIconId.EqualsValue(PwUuid.Zero)) {
return getIconDrawable (res, db, customIconId);
} else {
return getIconDrawable (res, icon);
@ -92,7 +92,7 @@ namespace keepass2android
public Drawable getIconDrawable (Resources res, PwDatabase db, PwUuid icon)
{
initBlank (res);
if (icon == PwUuid.Zero) {
if (icon.EqualsValue(PwUuid.Zero)) {
return blank;
}
Drawable draw = null;

View File

@ -86,6 +86,7 @@
<Compile Include="Database.cs" />
<Compile Include="fileselect\FileSelectActivity.cs" />
<Compile Include="fileselect\FileDbHelper.cs" />
<Compile Include="search\SearchProvider.cs" />
<Compile Include="Utils\Util.cs" />
<Compile Include="Utils\Interaction.cs" />
<Compile Include="intents\Intents.cs" />

View File

@ -50,9 +50,9 @@ namespace keepass2android
SearchParameters sp = new SearchParameters();
sp.SearchString = str;
return search(database, sp);
return search(database, sp, null);
}
public PwGroup search(Database database, SearchParameters sp)
public PwGroup search(Database database, SearchParameters sp, IDictionary<PwUuid, String> resultContexts)
{
if(sp.RegularExpression) // Validate regular expression
@ -67,7 +67,7 @@ namespace keepass2android
PwObjectList<PwEntry> listResults = pgResults.Entries;
database.root.SearchEntries(sp, listResults, new NullStatusLogger());
database.root.SearchEntries(sp, listResults, resultContexts, new NullStatusLogger());
return pgResults;

View File

@ -0,0 +1,309 @@
/*
This file is part of Keepass2Android, Copyright 2013 Philipp Crocoll. This file is based on Keepassdroid, Copyright Brian Pellin.
Keepass2Android is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
Keepass2Android is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with Keepass2Android. If not, see <http://www.gnu.org/licenses/>.
*/
using System;
using System.Linq;
using Android.App;
using Android.Content;
using Android.Content.Res;
using Android.Database;
using Android.Graphics;
using Android.Graphics.Drawables;
using Android.OS;
using Android.Runtime;
using KeePassLib;
using KeePassLib.Utility;
using System.Threading;
using System.Collections.Generic;
namespace keepass2android.search
{
[ContentProvider(new [] { SearchProvider.Authority })]
public class SearchProvider : ContentProvider
{
private enum UriMatches
{
NoMatch = UriMatcher.NoMatch,
GetIcon,
GetSuggestions
}
public const string Authority = "keepass2android.search.SearchProvider";
private const string GetIconPathQuery = "get_icon";
private const string IconIdParameter = "IconId";
private const string CustomIconUuidParameter = "CustomIconUuid";
//public static readonly String AUTHORITY = "keepass2android.search.SearchProvider";
//public static readonly Android.Net.Uri CONTENT_URI = Android.Net.Uri.Parse("content://" + AUTHORITY + "/dictionary");
private Database mDb;
private static UriMatcher UriMatcher = BuildUriMatcher();
static UriMatcher BuildUriMatcher()
{
var matcher = new UriMatcher(UriMatcher.NoMatch);
// to get definitions...
matcher.AddURI(Authority, GetIconPathQuery, (int)UriMatches.GetIcon);
matcher.AddURI(Authority, SearchManager.SuggestUriPathQuery, (int)UriMatches.GetSuggestions);
return matcher;
}
public override bool OnCreate()
{
mDb = App.getDB();
return true;
}
public override Android.Database.ICursor Query(Android.Net.Uri uri, string[] projection, string selection, string[] selectionArgs, string sortOrder)
{
if (mDb.Open) // Can't show suggestions if the database is locked!
{
switch ((UriMatches)UriMatcher.Match(uri))
{
case UriMatches.GetSuggestions:
var searchString = selectionArgs[0];
if (!String.IsNullOrEmpty(searchString))
{
try
{
var resultsContexts = new Dictionary<PwUuid, String>();
var result = mDb.Search(new SearchParameters { SearchString = searchString }, resultsContexts );
return new GroupCursor(result, resultsContexts);
}
catch (Exception e)
{
System.Diagnostics.Debug.WriteLine("Failed to search for suggestions: " + e.Message);
}
}
break;
case UriMatches.GetIcon:
return null; // This will be handled by OpenAssetFile
default:
return null;
//throw new ArgumentException("Unknown Uri: " + uri, "uri");
}
}
return null;
}
public override ParcelFileDescriptor OpenFile(Android.Net.Uri uri, string mode)
{
switch ((UriMatches)UriMatcher.Match(uri))
{
case UriMatches.GetIcon:
var iconId = (PwIcon)Enum.Parse(typeof(PwIcon), uri.GetQueryParameter(IconIdParameter));
var customIconUuid = new PwUuid(MemUtil.HexStringToByteArray(uri.GetQueryParameter(CustomIconUuidParameter)));
var iconDrawable = mDb.drawFactory.getIconDrawable(App.Context.Resources, mDb.pm, iconId, customIconUuid) as BitmapDrawable;
if (iconDrawable != null)
{
var pipe = ParcelFileDescriptor.CreatePipe();
var outStream = new OutputStreamInvoker(new ParcelFileDescriptor.AutoCloseOutputStream(pipe[1]));
ThreadPool.QueueUserWorkItem(state =>
{
iconDrawable.Bitmap.Compress(Bitmap.CompressFormat.Png, 100, outStream);
outStream.Close();
});
return pipe[0];
}
// Couldn't get an icon for some reason.
return null;
default:
throw new ArgumentException("Unknown Uri: " + uri, "uri");
}
}
public override string GetType(Android.Net.Uri uri)
{
switch ((UriMatches)UriMatcher.Match(uri))
{
case UriMatches.GetSuggestions:
return SearchManager.SuggestMimeType;
case UriMatches.GetIcon:
return "image/png";
default:
throw new ArgumentException("Unknown Uri: " + uri, "uri");
}
}
#region Unimplemented
public override int Delete(Android.Net.Uri uri, string selection, string[] selectionArgs)
{
throw new NotImplementedException();
}
public override Android.Net.Uri Insert(Android.Net.Uri uri, ContentValues values)
{
throw new NotImplementedException();
}
public override int Update(Android.Net.Uri uri, ContentValues values, string selection, string[] selectionArgs)
{
throw new NotImplementedException();
}
#endregion
private class GroupCursor : AbstractCursor
{
private static readonly string[] ColumnNames = new[] { Android.Provider.BaseColumns.Id,
SearchManager.SuggestColumnText1,
SearchManager.SuggestColumnText2,
SearchManager.SuggestColumnIcon1,
SearchManager.SuggestColumnIntentDataId,
};
private readonly PwGroup mGroup;
private readonly IDictionary<PwUuid, String> mResultContexts;
public GroupCursor(PwGroup group, IDictionary<PwUuid, String> resultContexts)
{
System.Diagnostics.Debug.Assert(!group.Groups.Any(), "Expecting a flat list of groups");
mGroup = group;
mResultContexts = resultContexts;
}
public override int Count
{
get { return (int)Math.Min(mGroup.GetEntriesCount(false), int.MaxValue); }
}
public override string[] GetColumnNames()
{
return ColumnNames;
}
public override FieldType GetType(int column)
{
switch (column)
{
case 0: // _ID
return FieldType.Integer;
default:
return base.GetType(column); // Ends up as string
}
}
private PwEntry CurrentEntry
{
get
{
return mGroup.Entries.GetAt((uint)MPos);
}
}
public override long GetLong(int column)
{
switch (column)
{
case 0: // _ID
return MPos;
default:
throw new FormatException();
}
}
public override string GetString(int column)
{
switch (column)
{
case 0: // _ID
return MPos.ToString();
case 1: // SuggestColumnText1
return CurrentEntry.Strings.ReadSafe(PwDefs.TitleField);
case 2: // SuggestColumnText2
string context;
if (mResultContexts.TryGetValue(CurrentEntry.Uuid, out context))
{
context = Internationalise(context);
return context;
}
return null;
case 3: // SuggestColumnIcon1
var builder = new Android.Net.Uri.Builder();
builder.Scheme(ContentResolver.SchemeContent);
builder.Authority(Authority);
builder.Path(GetIconPathQuery);
builder.AppendQueryParameter(IconIdParameter, CurrentEntry.IconId.ToString());
builder.AppendQueryParameter(CustomIconUuidParameter, CurrentEntry.CustomIconUuid.ToHexString());
return builder.Build().ToString();
case 4: // SuggestColumnIntentDataId
return CurrentEntry.Uuid.ToHexString();
default:
return null;
}
}
private string Internationalise(string context)
{
// Some context names can be internationalised.
var splitPos = context.IndexOf(':');
var rawName = context.Substring(0, splitPos);
int intlResourceId = 0;
switch (rawName)
{
case PwDefs.TitleField:
// We will already be showing Title, so ignore it entirely so it doesn't double-appear
return null;
case PwDefs.UserNameField:
intlResourceId = Resource.String.entry_user_name;
break;
case PwDefs.UrlField:
intlResourceId = Resource.String.entry_url;
break;
case PwDefs.NotesField:
intlResourceId = Resource.String.entry_notes;
break;
case PwGroup.SearchContextTags:
intlResourceId = Resource.String.entry_tags;
break;
default:
// Other fields aren't part of the default SearchParameters, so we won't ever get them as context anyway
break;
}
if (intlResourceId > 0)
{
return App.Context.GetString(intlResourceId) + context.Substring(splitPos);
}
return context;
}
public override bool IsNull(int column)
{
return false;
}
#region Data types appearing in no columns
public override int GetInt(int column) { throw new FormatException(); }
public override double GetDouble(int column) { throw new FormatException(); }
public override float GetFloat(int column) { throw new FormatException(); }
public override short GetShort(int column) { throw new FormatException(); }
#endregion
}
}
}

View File

@ -27,10 +27,11 @@ using Android.Views;
using Android.Widget;
using keepass2android.view;
using KeePassLib;
using Android.Support.V4.App;
namespace keepass2android.search
{
[Activity (Label = "@string/app_name", Theme="@style/NoTitleBar")]
[Activity (Label = "@string/app_name", Theme="@style/NoTitleBar", LaunchMode=Android.Content.PM.LaunchMode.SingleTop)]
[MetaData("android.app.searchable",Resource="@xml/searchable")]
[IntentFilter(new[]{Intent.ActionSearch}, Categories=new[]{Intent.CategoryDefault})]
public class SearchResults : GroupBaseActivity
@ -47,18 +48,38 @@ namespace keepass2android.search
SetResult(KeePass.EXIT_NORMAL);
ProcessIntent(Intent);
}
protected override void OnNewIntent(Intent intent)
{
ProcessIntent(intent);
}
private void ProcessIntent(Intent intent)
{
mDb = App.getDB();
// Likely the app has been killed exit the activity
if ( ! mDb.Open ) {
if (!mDb.Open)
{
Finish();
}
query(getSearch(Intent));
if (intent.Action == Intent.ActionView)
{
var entryIntent = new Intent(this, typeof(EntryActivity));
entryIntent.PutExtra(EntryActivity.KEY_ENTRY, intent.Data.LastPathSegment);
Finish(); // Close this activity so that the entry activity is navigated to from the main activity, not this one.
StartActivity(entryIntent);
}
else
{
// Action may either by ActionSearch (from search widget) or null (if called from SearchActivity directly)
query(getSearch(intent));
}
}
private void query (SearchParameters searchParams)
{