diff --git a/src/KeePassLib2Android/PwGroup.cs b/src/KeePassLib2Android/PwGroup.cs index b5b20c25..fa0e2802 100644 --- a/src/KeePassLib2Android/PwGroup.cs +++ b/src/KeePassLib2Android/PwGroup.cs @@ -35,6 +35,11 @@ namespace KeePassLib /// public sealed class PwGroup : ITimeLogger, IStructureItem, IDeepCloneable { + 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. public void SearchEntries(SearchParameters sp, PwObjectList listStorage, IStatusLogger slStatus) + { + } + + /// + /// Search this group and all subgroups for entries. + /// + /// Specifies the search method. + /// Entry list in which the search results will + /// be stored. + /// Dictionary that will be populated with text fragments indicating the context of why each entry (keyed by Uuid) was returned + public void SearchEntries(SearchParameters sp, PwObjectList listStorage, IDictionary 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 listStorage, IStatusLogger slStatus, + PwObjectList listStorage, IDictionary 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,28 +930,59 @@ namespace KeePassLib } private static void SearchEvalAdd(SearchParameters sp, string strDataField, - Regex rx, PwEntry pe, PwObjectList lResults) + Regex rx, PwEntry pe, PwObjectList lResults, IDictionary resultContexts, string contextFieldName) { bool bMatch = false; + int matchPos; - if(rx == null) - bMatch = (strDataField.IndexOf(sp.SearchString, - sp.ComparisonMode) >= 0); - else bMatch = rx.IsMatch(strDataField); + if (rx == null) + { + 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)) { string strCmp = sp.DataTransformationFn(strDataField, pe); if(!object.ReferenceEquals(strCmp, strDataField)) { - if(rx == null) - bMatch = (strCmp.IndexOf(sp.SearchString, - sp.ComparisonMode) >= 0); - else bMatch = rx.IsMatch(strCmp); + if (rx == null) + { + 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 BuildEntryTagsList() diff --git a/src/keepass2android/EntryEditActivity.cs b/src/keepass2android/EntryEditActivity.cs index 46600af5..e6ca71b3 100644 --- a/src/keepass2android/EntryEditActivity.cs +++ b/src/keepass2android/EntryEditActivity.cs @@ -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); diff --git a/src/keepass2android/GroupBaseActivity.cs b/src/keepass2android/GroupBaseActivity.cs index 87eeaf60..c7fceb7f 100644 --- a/src/keepass2android/GroupBaseActivity.cs +++ b/src/keepass2android/GroupBaseActivity.cs @@ -197,7 +197,12 @@ 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; } @@ -241,8 +246,9 @@ namespace keepass2android SetResult(KeePass.EXIT_LOCK); Finish(); return true; - + case Resource.Id.menu_search: + case Resource.Id.menu_search_advanced: OnSearchRequested(); return true; diff --git a/src/keepass2android/Resources/menu-v11/group.xml b/src/keepass2android/Resources/menu-v11/group.xml index 67ab22b0..ccaff7c9 100644 --- a/src/keepass2android/Resources/menu-v11/group.xml +++ b/src/keepass2android/Resources/menu-v11/group.xml @@ -20,6 +20,12 @@ android:icon="@android:drawable/ic_menu_search" android:title="@string/menu_search" android:showAsAction="ifRoom" + android:actionViewClass="android.widget.SearchView" + /> + User Name Extra string fields File attachments + Notes The ArcFour stream cipher is not supported. Keepass2Android cannot handle this uri. Error creating group. @@ -129,6 +130,7 @@ Open Rename Search + Advanced Search Go to URL Minus Never diff --git a/src/keepass2android/Resources/xml/searchable.xml b/src/keepass2android/Resources/xml/searchable.xml index 48b9492a..1730f33a 100644 --- a/src/keepass2android/Resources/xml/searchable.xml +++ b/src/keepass2android/Resources/xml/searchable.xml @@ -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" /> \ No newline at end of file diff --git a/src/keepass2android/icons/DrawableFactory.cs b/src/keepass2android/icons/DrawableFactory.cs index 0f7d3fe9..4825e153 100644 --- a/src/keepass2android/icons/DrawableFactory.cs +++ b/src/keepass2android/icons/DrawableFactory.cs @@ -59,13 +59,13 @@ 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); } } - + private static void initBlank (Resources res) { if (blank == null) { @@ -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; diff --git a/src/keepass2android/keepass2android.csproj b/src/keepass2android/keepass2android.csproj index ace5b061..2f291ec9 100644 --- a/src/keepass2android/keepass2android.csproj +++ b/src/keepass2android/keepass2android.csproj @@ -86,6 +86,7 @@ + diff --git a/src/keepass2android/search/SearchDbHelper.cs b/src/keepass2android/search/SearchDbHelper.cs index 41bea8db..f1888d24 100644 --- a/src/keepass2android/search/SearchDbHelper.cs +++ b/src/keepass2android/search/SearchDbHelper.cs @@ -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 resultContexts) { if(sp.RegularExpression) // Validate regular expression @@ -67,7 +67,7 @@ namespace keepass2android PwObjectList listResults = pgResults.Entries; - database.root.SearchEntries(sp, listResults, new NullStatusLogger()); + database.root.SearchEntries(sp, listResults, resultContexts, new NullStatusLogger()); return pgResults; diff --git a/src/keepass2android/search/SearchProvider.cs b/src/keepass2android/search/SearchProvider.cs new file mode 100644 index 00000000..858b113f --- /dev/null +++ b/src/keepass2android/search/SearchProvider.cs @@ -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 . + */ +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(); + 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 mResultContexts; + + public GroupCursor(PwGroup group, IDictionary 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 + } + + } +} + diff --git a/src/keepass2android/search/SearchResults.cs b/src/keepass2android/search/SearchResults.cs index deef04df..b393f451 100644 --- a/src/keepass2android/search/SearchResults.cs +++ b/src/keepass2android/search/SearchResults.cs @@ -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 @@ -46,20 +47,40 @@ 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) { try {