diff --git a/src/PluginHostTest/App.cs b/src/PluginHostTest/App.cs index 614158a7..a816d1bb 100644 --- a/src/PluginHostTest/App.cs +++ b/src/PluginHostTest/App.cs @@ -1,17 +1,74 @@ +using System; using KeePassLib; +using KeePassLib.Collections; using KeePassLib.Keys; +using KeePassLib.Security; using KeePassLib.Serialization; namespace keepass2android { + /// + /// Represents the strings which are output from a PwEntry. + /// + /// In contrast to the original PwEntry, this means that placeholders are replaced. Also, plugins may modify + /// or add fields. + public class PwEntryOutput + { + private readonly PwEntry _entry; + private readonly PwDatabase _db; + private readonly ProtectedStringDictionary _outputStrings = new ProtectedStringDictionary(); + + /// + /// Constructs the PwEntryOutput by replacing the placeholders + /// + public PwEntryOutput(PwEntry entry, PwDatabase db) + { + _entry = entry; + _db = db; + + foreach (var pair in entry.Strings) + { + _outputStrings.Set(pair.Key, new ProtectedString(entry.Strings.Get(pair.Key).IsProtected, GetStringAndReplacePlaceholders(pair.Key))); + } + } + + string GetStringAndReplacePlaceholders(string key) + { + String value = Entry.Strings.ReadSafe(key); + value = SprEngine.Compile(value, new SprContext(Entry, _db, SprCompileFlags.All)); + return value; + } + + + /// + /// Returns the ID of the entry + /// + public PwUuid Uuid + { + get { return Entry.Uuid; } + } + + /// + /// The output strings for the represented entry + /// + public ProtectedStringDictionary OutputStrings { get { return _outputStrings; } } + + public PwEntry Entry + { + get { return _entry; } + } + } public class App { + public class Kp2A { private static Db _mDb; public class Db { + public PwEntryOutput LastOpenedEntry { get; set; } + public void SetEntry(PwEntry e) { KpDatabase = new PwDatabase(); diff --git a/src/PluginHostTest/CopyToClipboardService.cs b/src/PluginHostTest/CopyToClipboardService.cs index de30b26e..f9a345ee 100644 --- a/src/PluginHostTest/CopyToClipboardService.cs +++ b/src/PluginHostTest/CopyToClipboardService.cs @@ -1,14 +1,35 @@ +using System; +using Android.App; using Android.Content; +using Android.OS; +using Android.Runtime; using Android.Widget; using KeePassLib.Security; namespace keepass2android { - internal class CopyToClipboardService + [Service] + public class CopyToClipboardService: Service { + public CopyToClipboardService() + { + + } + + public CopyToClipboardService(IntPtr javaReference, JniHandleOwnership transfer) + : base(javaReference, transfer) + { + } + + public static void CopyValueToClipboardWithTimeout(Context ctx, string text) { Toast.MakeText(ctx, text, ToastLength.Short).Show(); } + + public override IBinder OnBind(Intent intent) + { + return null; + } } } \ No newline at end of file diff --git a/src/PluginHostTest/EntryActivity.cs b/src/PluginHostTest/EntryActivity.cs index 5eff70f3..59ed0af6 100644 --- a/src/PluginHostTest/EntryActivity.cs +++ b/src/PluginHostTest/EntryActivity.cs @@ -113,6 +113,7 @@ namespace keepass2android } _activity.AddPluginAction(pluginPackage, intent.GetStringExtra(Strings.ExtraFieldId), + intent.GetStringExtra(Strings.ExtraActionId), intent.GetStringExtra(Strings.ExtraActionDisplayText), intent.GetIntExtra(Strings.ExtraActionIconResId, -1), intent.GetBundleExtra(Strings.ExtraActionData)); @@ -156,6 +157,7 @@ namespace keepass2android private void SetPluginField(string key, string value, bool isProtected) { + //update or add the string view: IStringView existingField; if (_stringViews.TryGetValue(key, out existingField)) { @@ -168,13 +170,47 @@ namespace keepass2android extraGroup.AddView(view.View); } + //update the Entry output in the App database and notify the CopyToClipboard service + App.Kp2A.GetDb().LastOpenedEntry.OutputStrings.Set(key, new ProtectedString(isProtected, value)); + Intent updateKeyboardIntent = new Intent(this, typeof(CopyToClipboardService)); + Intent.SetAction(Intents.UpdateKeyboard); + updateKeyboardIntent.PutExtra(KeyEntry, Entry.Uuid.ToHexString()); + StartService(updateKeyboardIntent); + + //notify plugins + NotifyPluginsOnModification(Strings.PrefixString+key); } - private void AddPluginAction(string pluginPackage, string fieldId, string displayText, int iconId, Bundle bundleExtra) + private void AddPluginAction(string pluginPackage, string fieldId, string popupItemId, string displayText, int iconId, Bundle bundleExtra) { if (fieldId != null) { - _popupMenuItems[fieldId].Add(new PluginPopupMenuItem(this, pluginPackage, fieldId, displayText, iconId, bundleExtra)); + try + { + //create a new popup item for the plugin action: + var newPopup = new PluginPopupMenuItem(this, pluginPackage, fieldId, popupItemId, displayText, iconId, bundleExtra); + //see if we already have a popup item for this field with the same item id + var popupsForField = _popupMenuItems[fieldId]; + var popupItemPos = popupsForField.FindIndex(0, + item => + (item is PluginPopupMenuItem) && + ((PluginPopupMenuItem)item).PopupItemId == popupItemId); + + //replace existing or add + if (popupItemPos >= 0) + { + popupsForField[popupItemPos] = newPopup; + } + else + { + popupsForField.Add(newPopup); + } + } + catch (Exception e) + { + Kp2aLog.Log(e.ToString()); + } + } else { @@ -185,6 +221,7 @@ namespace keepass2android i.SetPackage(pluginPackage); i.PutExtra(Strings.ExtraActionData, bundleExtra); i.PutExtra(Strings.ExtraSender, PackageName); + PluginHost.AddEntryToIntent(i, App.Kp2A.GetDb().LastOpenedEntry); var menuOption = new PluginMenuOption() { @@ -407,6 +444,8 @@ namespace keepass2android SetupEditButtons(); + App.Kp2A.GetDb().LastOpenedEntry = new PwEntryOutput(Entry, App.Kp2A.GetDb().KpDatabase); + RegisterReceiver(new PluginActionReceiver(this), new IntentFilter(Strings.ActionAddEntryAction)); RegisterReceiver(new PluginFieldReceiver(this), new IntentFilter(Strings.ActionSetEntryField)); @@ -419,7 +458,22 @@ namespace keepass2android Intent i = new Intent(Strings.ActionOpenEntry); i.PutExtra(Strings.ExtraSender, PackageName); - PluginHost.AddEntryToIntent(i, Entry); + AddEntryToIntent(i); + + + foreach (var plugin in new PluginDatabase(this).GetPluginsWithAcceptedScope(Strings.ScopeCurrentEntry)) + { + i.SetPackage(plugin); + SendBroadcast(i); + } + } + private void NotifyPluginsOnModification(string fieldId) + { + Intent i = new Intent(Strings.ActionEntryOutputModified); + i.PutExtra(Strings.ExtraSender, PackageName); + i.PutExtra(Strings.ExtraFieldId, fieldId); + AddEntryToIntent(i); + foreach (var plugin in new PluginDatabase(this).GetPluginsWithAcceptedScope(Strings.ScopeCurrentEntry)) { @@ -842,5 +896,10 @@ namespace keepass2android { Toast.MakeText(this, "opening file TODO", ToastLength.Short).Show(); } + + public void AddEntryToIntent(Intent intent) + { + PluginHost.AddEntryToIntent(intent, App.Kp2A.GetDb().LastOpenedEntry); + } } } \ No newline at end of file diff --git a/src/PluginHostTest/EntryActivityClasses/CopyToClipboardPopupMenuIcon.cs b/src/PluginHostTest/EntryActivityClasses/CopyToClipboardPopupMenuIcon.cs new file mode 100644 index 00000000..a1a4207d --- /dev/null +++ b/src/PluginHostTest/EntryActivityClasses/CopyToClipboardPopupMenuIcon.cs @@ -0,0 +1,41 @@ +using Android.Content; +using Android.Graphics.Drawables; +using PluginHostTest; + +namespace keepass2android +{ + /// + /// Reperesents the popup menu item in EntryActivity to copy a string to clipboard + /// + class CopyToClipboardPopupMenuIcon : IPopupMenuItem + { + private readonly Context _context; + private readonly IStringView _stringView; + + public CopyToClipboardPopupMenuIcon(Context context, IStringView stringView) + { + _context = context; + _stringView = stringView; + + } + + public Drawable Icon + { + get + { + return _context.Resources.GetDrawable(Resource.Drawable.ic_menu_copy_holo_light); + } + } + public string Text + { + //TODO localize + get { return "Copy to clipboard"; } + } + + + public void HandleClick() + { + CopyToClipboardService.CopyValueToClipboardWithTimeout(_context, _stringView.Text); + } + } +} \ No newline at end of file diff --git a/src/PluginHostTest/EntryActivityClasses/GotoUrlMenuItem.cs b/src/PluginHostTest/EntryActivityClasses/GotoUrlMenuItem.cs new file mode 100644 index 00000000..deacae1e --- /dev/null +++ b/src/PluginHostTest/EntryActivityClasses/GotoUrlMenuItem.cs @@ -0,0 +1,34 @@ +using Android.Graphics.Drawables; +using PluginHostTest; + +namespace keepass2android +{ + /// + /// Reperesents the popup menu item in EntryActivity to go to the URL in the field + /// + class GotoUrlMenuItem : IPopupMenuItem + { + private readonly EntryActivity _ctx; + + public GotoUrlMenuItem(EntryActivity ctx) + { + _ctx = ctx; + } + + public Drawable Icon + { + get { return _ctx.Resources.GetDrawable(Android.Resource.Drawable.IcMenuUpload); } + } + + public string Text + { + get { return _ctx.Resources.GetString(Resource.String.menu_url); } + } + + public void HandleClick() + { + //TODO + _ctx.GotoUrl(); + } + } +} \ No newline at end of file diff --git a/src/PluginHostTest/EntryActivityClasses/IPopupMenuItem.cs b/src/PluginHostTest/EntryActivityClasses/IPopupMenuItem.cs index c9a54f65..9cb8c149 100644 --- a/src/PluginHostTest/EntryActivityClasses/IPopupMenuItem.cs +++ b/src/PluginHostTest/EntryActivityClasses/IPopupMenuItem.cs @@ -1,11 +1,12 @@ using System; -using Android.Content; using Android.Graphics.Drawables; using KeePassLib; -using PluginHostTest; namespace keepass2android { + /// + /// Interface for popup menu items in EntryActivity + /// internal interface IPopupMenuItem { Drawable Icon { get; } @@ -13,100 +14,4 @@ namespace keepass2android void HandleClick(); } - - class GotoUrlMenuItem : IPopupMenuItem - { - private readonly EntryActivity _ctx; - - public GotoUrlMenuItem(EntryActivity ctx) - { - _ctx = ctx; - } - - public Drawable Icon - { - get { return _ctx.Resources.GetDrawable(Android.Resource.Drawable.IcMenuUpload); } - } - - public string Text - { - get { return _ctx.Resources.GetString(Resource.String.menu_url); } - } - - public void HandleClick() - { - //TODO - _ctx.GotoUrl(); - } - } - - class ToggleVisibilityPopupMenuItem : IPopupMenuItem - { - private readonly EntryActivity _activity; - - - public ToggleVisibilityPopupMenuItem(EntryActivity activity) - { - _activity = activity; - - } - - public Drawable Icon - { - get - { - //return new TextDrawable("\uF06E", _activity); - return _activity.Resources.GetDrawable(Resource.Drawable.ic_action_eye_open); - - } - } - public string Text - { - get - { - return _activity.Resources.GetString( - _activity._showPassword ? - Resource.String.menu_hide_password - : Resource.String.show_password); - } - } - - - public void HandleClick() - { - _activity.ToggleVisibility(); - } - } - - class CopyToClipboardPopupMenuIcon : IPopupMenuItem - { - private readonly Context _context; - private readonly IStringView _stringView; - - public CopyToClipboardPopupMenuIcon(Context context, IStringView stringView) - { - _context = context; - _stringView = stringView; - - } - - public Drawable Icon - { - get - { - return _context.Resources.GetDrawable(Resource.Drawable.ic_menu_copy_holo_light); - } - } - public string Text - { - //TODO localize - get { return "Copy to clipboard"; } - } - - - public void HandleClick() - { - CopyToClipboardService.CopyValueToClipboardWithTimeout(_context, _stringView.Text); - } - } } \ No newline at end of file diff --git a/src/PluginHostTest/EntryActivityClasses/OpenBinaryPopupItem.cs b/src/PluginHostTest/EntryActivityClasses/OpenBinaryPopupItem.cs index 1315e2fb..38c07867 100644 --- a/src/PluginHostTest/EntryActivityClasses/OpenBinaryPopupItem.cs +++ b/src/PluginHostTest/EntryActivityClasses/OpenBinaryPopupItem.cs @@ -3,6 +3,9 @@ using PluginHostTest; namespace keepass2android { + /// + /// Represents the popup menu item in EntryActivity to open the associated attachment + /// internal class OpenBinaryPopupItem : IPopupMenuItem { private readonly string _key; diff --git a/src/PluginHostTest/EntryActivityClasses/PluginPopupMenuItem.cs b/src/PluginHostTest/EntryActivityClasses/PluginPopupMenuItem.cs index 792f5bdd..779f02ad 100644 --- a/src/PluginHostTest/EntryActivityClasses/PluginPopupMenuItem.cs +++ b/src/PluginHostTest/EntryActivityClasses/PluginPopupMenuItem.cs @@ -5,20 +5,25 @@ using Keepass2android.Pluginsdk; namespace keepass2android { + /// + /// Represents a popup menu item in EntryActivity which was added by a plugin. The click will therefore broadcast to the plugin. + /// class PluginPopupMenuItem : IPopupMenuItem { - private readonly Context _ctx; + private readonly EntryActivity _activity; private readonly string _pluginPackage; private readonly string _fieldId; + private readonly string _popupItemId; private readonly string _displayText; private readonly int _iconId; private readonly Bundle _bundleExtra; - public PluginPopupMenuItem(Context ctx, string pluginPackage, string fieldId, string displayText, int iconId, Bundle bundleExtra) + public PluginPopupMenuItem(EntryActivity activity, string pluginPackage, string fieldId, string popupItemId, string displayText, int iconId, Bundle bundleExtra) { - _ctx = ctx; + _activity = activity; _pluginPackage = pluginPackage; _fieldId = fieldId; + _popupItemId = popupItemId; _displayText = displayText; _iconId = iconId; _bundleExtra = bundleExtra; @@ -26,22 +31,29 @@ namespace keepass2android public Drawable Icon { - get { return _ctx.PackageManager.GetResourcesForApplication(_pluginPackage).GetDrawable(_iconId); } + get { return _activity.PackageManager.GetResourcesForApplication(_pluginPackage).GetDrawable(_iconId); } } public string Text { get { return _displayText; } } + + public string PopupItemId + { + get { return _popupItemId; } + } + public void HandleClick() { Intent i = new Intent(Strings.ActionEntryActionSelected); i.SetPackage(_pluginPackage); i.PutExtra(Strings.ExtraActionData, _bundleExtra); i.PutExtra(Strings.ExtraFieldId, _fieldId); - i.PutExtra(Strings.ExtraSender, _ctx.PackageName); - PluginHost.AddEntryToIntent(i, Entry); + i.PutExtra(Strings.ExtraSender, _activity.PackageName); - _ctx.SendBroadcast(i); + _activity.AddEntryToIntent(i); + + _activity.SendBroadcast(i); } } } \ No newline at end of file diff --git a/src/PluginHostTest/EntryActivityClasses/ToggleVisibilityPopupMenuItem.cs b/src/PluginHostTest/EntryActivityClasses/ToggleVisibilityPopupMenuItem.cs new file mode 100644 index 00000000..54ff1890 --- /dev/null +++ b/src/PluginHostTest/EntryActivityClasses/ToggleVisibilityPopupMenuItem.cs @@ -0,0 +1,46 @@ +using Android.Graphics.Drawables; +using PluginHostTest; + +namespace keepass2android +{ + /// + /// Reperesents the popup menu item in EntryActivity to toggle visibility of all protected strings (e.g. Password) + /// + class ToggleVisibilityPopupMenuItem : IPopupMenuItem + { + private readonly EntryActivity _activity; + + + public ToggleVisibilityPopupMenuItem(EntryActivity activity) + { + _activity = activity; + + } + + public Drawable Icon + { + get + { + //return new TextDrawable("\uF06E", _activity); + return _activity.Resources.GetDrawable(Resource.Drawable.ic_action_eye_open); + + } + } + public string Text + { + get + { + return _activity.Resources.GetString( + _activity._showPassword ? + Resource.String.menu_hide_password + : Resource.String.show_password); + } + } + + + public void HandleClick() + { + _activity.ToggleVisibility(); + } + } +} \ No newline at end of file diff --git a/src/PluginHostTest/EntryActivityClasses/WriteBinaryToFilePopupItem.cs b/src/PluginHostTest/EntryActivityClasses/WriteBinaryToFilePopupItem.cs index 5d7f0c98..a90fc61b 100644 --- a/src/PluginHostTest/EntryActivityClasses/WriteBinaryToFilePopupItem.cs +++ b/src/PluginHostTest/EntryActivityClasses/WriteBinaryToFilePopupItem.cs @@ -3,6 +3,9 @@ using PluginHostTest; namespace keepass2android { + /// + /// Represents the popup menu item in EntryActivity to store the binary attachment on SD card + /// internal class WriteBinaryToFilePopupItem : IPopupMenuItem { private readonly string _key; diff --git a/src/PluginHostTest/PluginArrayAdapter.cs b/src/PluginHostTest/PluginArrayAdapter.cs index 5bdda435..953fa198 100644 --- a/src/PluginHostTest/PluginArrayAdapter.cs +++ b/src/PluginHostTest/PluginArrayAdapter.cs @@ -10,7 +10,9 @@ using PluginHostTest; namespace keepass2android { - + /// + /// Represents information about a plugin for display in the plugin list activity + /// public class PluginItem { private readonly string _package; diff --git a/src/PluginHostTest/PluginDatabase.cs b/src/PluginHostTest/PluginDatabase.cs index 9a54c5fe..5b24be28 100644 --- a/src/PluginHostTest/PluginDatabase.cs +++ b/src/PluginHostTest/PluginDatabase.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using Android.Content; +using Android.Content.PM; using Android.Util; using Keepass2android.Pluginsdk; @@ -32,16 +33,15 @@ namespace keepass2android var editor = prefs.Edit(); editor.PutString(_requesttoken, Guid.NewGuid().ToString()); editor.Commit(); - - var hostPrefs = GetHostPrefs(); - var plugins = hostPrefs.GetStringSet(_pluginlist, new List()); - if (!plugins.Contains(packageName)) - { - plugins.Add(packageName); - hostPrefs.Edit().PutStringSet(_pluginlist, plugins).Commit(); - } - } + var hostPrefs = GetHostPrefs(); + var plugins = hostPrefs.GetStringSet(_pluginlist, new List()); + if (!plugins.Contains(packageName)) + { + plugins.Add(packageName); + hostPrefs.Edit().PutStringSet(_pluginlist, plugins).Commit(); + } + return prefs; } @@ -63,7 +63,20 @@ namespace keepass2android public IEnumerable GetAllPluginPackages() { var hostPrefs = GetHostPrefs(); - return hostPrefs.GetStringSet(_pluginlist, new List()); + return hostPrefs.GetStringSet(_pluginlist, new List()).Where(IsPackageInstalled); + } + + public bool IsPackageInstalled(string targetPackage) + { + try + { + PackageInfo info = _ctx.PackageManager.GetPackageInfo(targetPackage, PackageInfoFlags.MetaData); + } + catch (PackageManager.NameNotFoundException e) + { + return false; + } + return true; } public bool IsEnabled(string pluginPackage) diff --git a/src/PluginHostTest/PluginHost.cs b/src/PluginHostTest/PluginHost.cs index b867313d..0c759e0e 100644 --- a/src/PluginHostTest/PluginHost.cs +++ b/src/PluginHostTest/PluginHost.cs @@ -13,6 +13,7 @@ using Android.Util; using Android.Views; using Android.Widget; using KeePassLib; +using KeePassLib.Collections; using KeePassLib.Serialization; using KeePassLib.Utility; using Keepass2android; @@ -142,7 +143,7 @@ namespace keepass2android return true; } - public static void AddEntryToIntent(Intent intent, PwEntry entry) + public static void AddEntryToIntent(Intent intent, PwEntryOutput entry) { /*//add the entry XML not yet implemented. What to do with attachments? @@ -151,22 +152,12 @@ namespace keepass2android string entryData = StrUtil.Utf8.GetString(memStream.ToArray()); intent.PutExtra(Strings.ExtraEntryData, entryData); */ - //add the compiled string array (placeholders replaced taking into account the db context) - Dictionary compiledFields = new Dictionary(); - foreach (var pair in entry.Strings) - { - String key = pair.Key; + //add the output string array (placeholders replaced taking into account the db context) + Dictionary outputFields = entry.OutputStrings.ToDictionary(pair => StrUtil.SafeXmlString(pair.Key), pair => pair.Value.ReadString()); - String value = entry.Strings.ReadSafe(key); - value = SprEngine.Compile(value, new SprContext(entry, App.Kp2A.GetDb().KpDatabase, SprCompileFlags.All)); - - compiledFields.Add(StrUtil.SafeXmlString(pair.Key), value); - - } - - JSONObject json = new JSONObject(compiledFields); + JSONObject json = new JSONObject(outputFields); var jsonStr = json.ToString(); - intent.PutExtra(Strings.ExtraCompiledEntryData, jsonStr); + intent.PutExtra(Strings.ExtraEntryOutputData, jsonStr); intent.PutExtra(Strings.ExtraEntryId, entry.Uuid.ToHexString()); diff --git a/src/PluginHostTest/PluginHostTest.csproj b/src/PluginHostTest/PluginHostTest.csproj index dca03023..5be6575b 100644 --- a/src/PluginHostTest/PluginHostTest.csproj +++ b/src/PluginHostTest/PluginHostTest.csproj @@ -60,11 +60,15 @@ + + + + diff --git a/src/java/Keepass2AndroidPluginSDK/src/keepass2android/pluginsdk/KeepassDefs.java b/src/java/Keepass2AndroidPluginSDK/src/keepass2android/pluginsdk/KeepassDefs.java new file mode 100644 index 00000000..7dd2e706 --- /dev/null +++ b/src/java/Keepass2AndroidPluginSDK/src/keepass2android/pluginsdk/KeepassDefs.java @@ -0,0 +1,48 @@ +package keepass2android.pluginsdk; + +public class KeepassDefs { + + /// + /// Default identifier string for the title field. Should not contain + /// spaces, tabs or other whitespace. + /// + public static String TitleField = "Title"; + + /// + /// Default identifier string for the user name field. Should not contain + /// spaces, tabs or other whitespace. + /// + public static String UserNameField = "UserName"; + + /// + /// Default identifier string for the password field. Should not contain + /// spaces, tabs or other whitespace. + /// + public static String PasswordField = "Password"; + + /// + /// Default identifier string for the URL field. Should not contain + /// spaces, tabs or other whitespace. + /// + public static String UrlField = "URL"; + + /// + /// Default identifier string for the notes field. Should not contain + /// spaces, tabs or other whitespace. + /// + public static String NotesField = "Notes"; + + + public static boolean IsStandardField(String strFieldName) + { + if(strFieldName == null) + return false; + if(strFieldName.equals(TitleField)) return true; + if(strFieldName.equals(UserNameField)) return true; + if(strFieldName.equals(PasswordField)) return true; + if(strFieldName.equals(UrlField)) return true; + if(strFieldName.equals(NotesField)) return true; + + return false; + } +} diff --git a/src/java/Keepass2AndroidPluginSDK/src/keepass2android/pluginsdk/PluginAccessException.java b/src/java/Keepass2AndroidPluginSDK/src/keepass2android/pluginsdk/PluginAccessException.java new file mode 100644 index 00000000..5f2a73ad --- /dev/null +++ b/src/java/Keepass2AndroidPluginSDK/src/keepass2android/pluginsdk/PluginAccessException.java @@ -0,0 +1,21 @@ +package keepass2android.pluginsdk; + +import java.util.ArrayList; + +public class PluginAccessException extends Exception { + + public PluginAccessException(String what) + { + super(what); + } + + public PluginAccessException(String hostPackage, ArrayList scopes) { + + } + + /** + * + */ + private static final long serialVersionUID = 1L; + +} diff --git a/src/java/Keepass2AndroidPluginSDK/src/keepass2android/pluginsdk/PluginActionBroadcastReceiver.java b/src/java/Keepass2AndroidPluginSDK/src/keepass2android/pluginsdk/PluginActionBroadcastReceiver.java new file mode 100644 index 00000000..47dd3d6b --- /dev/null +++ b/src/java/Keepass2AndroidPluginSDK/src/keepass2android/pluginsdk/PluginActionBroadcastReceiver.java @@ -0,0 +1,224 @@ +package keepass2android.pluginsdk; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; + +import org.json.JSONException; +import org.json.JSONObject; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.util.Log; + +public abstract class PluginActionBroadcastReceiver extends BroadcastReceiver { + + protected abstract class PluginActionBase + { + protected Context _context; + protected Intent _intent; + + public PluginActionBase(Context context, Intent intent) + { + _context = context; + _intent = intent; + } + + public String getHostPackage() { + return _intent.getStringExtra(Strings.EXTRA_SENDER); + } + + public Context getContext() + { + return _context; + } + + protected HashMap getEntryFieldsFromIntent() + { + HashMap res = new HashMap(); + try { + JSONObject json = new JSONObject(_intent.getStringExtra(Strings.EXTRA_ENTRY_OUTPUT_DATA)); + for(Iterator iter = json.keys();iter.hasNext();) { + String key = iter.next(); + String value = json.get(key).toString(); + Log.d("KP2APluginSDK", "received " + key+"/"+value); + res.put(key, value); + } + + } catch (JSONException e) { + e.printStackTrace(); + } + return res; + } + } + + protected class ActionSelected extends PluginActionBase + { + public ActionSelected(Context ctx, Intent intent) { + super(ctx, intent); + + } + + /** + * + * @return the Bundle associated with the action. This bundle can be set in OpenEntry.add(Entry)FieldAction + */ + public Bundle getActionData() + { + return _intent.getBundleExtra(Strings.EXTRA_ACTION_DATA); + } + + /** + * + * @return the field id which was selected. null if an entry action (in the options menu) was selected. + */ + public String getFieldId() + { + return _intent.getStringExtra(Strings.EXTRA_FIELD_ID); + } + + /** + * + * @return true if an entry action, i.e. an option from the options menu, was selected. False if an option + * in a popup menu for a certain field was selected. + */ + public boolean isEntryAction() + { + return getFieldId() == null; + } + + public HashMap getEntryFields() + { + return getEntryFieldsFromIntent(); + } + } + + protected class CloseEntryView extends PluginActionBase + { + public CloseEntryView(Context context, Intent intent) { + super(context, intent); + } + + public String getEntryId() + { + return _intent.getStringExtra(Strings.EXTRA_ENTRY_ID); + } + } + + protected class OpenEntry extends PluginActionBase + { + + public OpenEntry(Context context, Intent intent) + { + super(context, intent); + } + + public String getEntryId() + { + return _intent.getStringExtra(Strings.EXTRA_ENTRY_ID); + } + + public HashMap getEntryFields() + { + return getEntryFieldsFromIntent(); + } + + public void addEntryAction(String actionDisplayText, int actionIconResourceId, Bundle actionData) throws PluginAccessException + { + addEntryFieldAction(null, null, actionDisplayText, actionIconResourceId, actionData); + } + + public void addEntryFieldAction(String actionId, String fieldId, String actionDisplayText, int actionIconResourceId, Bundle actionData) throws PluginAccessException + { + Intent i = new Intent(Strings.ACTION_ADD_ENTRY_ACTION); + ArrayList scope = new ArrayList(); + scope.add(Strings.SCOPE_CURRENT_ENTRY); + i.putExtra(Strings.EXTRA_ACCESS_TOKEN, AccessManager.getAccessToken(_context, getHostPackage(), scope)); + i.setPackage(getHostPackage()); + i.putExtra(Strings.EXTRA_SENDER, _context.getPackageName()); + i.putExtra(Strings.EXTRA_ACTION_DATA, actionData); + i.putExtra(Strings.EXTRA_ACTION_DISPLAY_TEXT, actionDisplayText); + i.putExtra(Strings.EXTRA_ACTION_ICON_RES_ID, actionIconResourceId); + i.putExtra(Strings.EXTRA_ENTRY_ID, getEntryId()); + i.putExtra(Strings.EXTRA_FIELD_ID, fieldId); + i.putExtra(Strings.EXTRA_ACTION_ID, actionId); + + _context.sendBroadcast(i); + } + + public void setEntryField(String fieldId, String fieldValue, boolean isProtected) throws PluginAccessException + { + Intent i = new Intent(Strings.ACTION_SET_ENTRY_FIELD); + ArrayList scope = new ArrayList(); + scope.add(Strings.SCOPE_CURRENT_ENTRY); + i.putExtra(Strings.EXTRA_ACCESS_TOKEN, AccessManager.getAccessToken(_context, getHostPackage(), scope)); + i.setPackage(getHostPackage()); + i.putExtra(Strings.EXTRA_SENDER, _context.getPackageName()); + i.putExtra(Strings.EXTRA_FIELD_VALUE, fieldValue); + i.putExtra(Strings.EXTRA_ENTRY_ID, getEntryId()); + i.putExtra(Strings.EXTRA_FIELD_ID, fieldId); + i.putExtra(Strings.EXTRA_FIELD_PROTECTED, isProtected); + + _context.sendBroadcast(i); + } + + + } + + //EntryOutputModified is very similar to OpenEntry because it receives the same + //data (+ the field id which was modified) + protected class EntryOutputModified extends OpenEntry + { + + public EntryOutputModified(Context context, Intent intent) + { + super(context, intent); + } + + public String getModifiedFieldId() + { + return _intent.getStringExtra(Strings.EXTRA_FIELD_ID); + } + } + + @Override + public void onReceive(Context ctx, Intent intent) { + String action = intent.getAction(); + android.util.Log.d("KP2A.pluginsdk", "received broadcast in PluginActionBroadcastReceiver with action="+action); + if (action == null) + return; + if (action.equals(Strings.ACTION_OPEN_ENTRY)) + { + openEntry(new OpenEntry(ctx, intent)); + } + else if (action.equals(Strings.ACTION_CLOSE_ENTRY_VIEW)) + { + closeEntryView(new CloseEntryView(ctx, intent)); + } + else if (action.equals(Strings.ACTION_ENTRY_ACTION_SELECTED)) + { + actionSelected(new ActionSelected(ctx, intent)); + } + else if (action.equals(Strings.ACTION_ENTRY_OUTPUT_MODIFIED)) + { + entryOutputModified(new EntryOutputModified(ctx, intent)); + } + else + { + //TODO handle unexpected action + } + + + } + + protected void closeEntryView(CloseEntryView closeEntryView) {} + + protected void actionSelected(ActionSelected actionSelected) {} + + protected void openEntry(OpenEntry oe) {} + + protected void entryOutputModified(EntryOutputModified eom) {} + +} diff --git a/src/java/Keepass2AndroidPluginSDK/src/keepass2android/pluginsdk/Strings.java b/src/java/Keepass2AndroidPluginSDK/src/keepass2android/pluginsdk/Strings.java index 5ba3de3a..9cdd2d5e 100644 --- a/src/java/Keepass2AndroidPluginSDK/src/keepass2android/pluginsdk/Strings.java +++ b/src/java/Keepass2AndroidPluginSDK/src/keepass2android/pluginsdk/Strings.java @@ -52,6 +52,12 @@ public class Strings { */ public static final String ACTION_OPEN_ENTRY= "keepass2android.ACTION_OPEN_ENTRY"; + /** + * Action sent from KP2A to the plugin to indicate that an entry output field was modified/added. + * The Intent contains the full new entry data. + */ + public static final String ACTION_ENTRY_OUTPUT_MODIFIED= "keepass2android.ACTION_ENTRY_OUTPUT_MODIFIED"; + /** * Action sent from KP2A to the plugin to indicate that an entry activity was closed. */ @@ -69,9 +75,9 @@ public class Strings { //public static final String EXTRA_ENTRY_DATA = "keepass2android.EXTRA_ENTRY_DATA"; /** - * Json serialized list of fields, compiled using the database context (i.e. placeholders are replaced already) + * Json serialized list of fields, transformed using the database context (i.e. placeholders are replaced already) */ - public static final String EXTRA_COMPILED_ENTRY_DATA = "keepass2android.EXTRA_COMPILED_ENTRY_DATA"; + public static final String EXTRA_ENTRY_OUTPUT_DATA = "keepass2android.EXTRA_ENTRY_OUTPUT_DATA"; /** * Extra key for passing the access token (both ways) @@ -88,6 +94,12 @@ public class Strings { public static final String EXTRA_ACTION_ICON_RES_ID = "keepass2android.EXTRA_ACTION_ICON_RES_ID"; public static final String EXTRA_FIELD_ID = "keepass2android.EXTRA_FIELD_ID"; + + /** + * Used to pass an id for the action. Each actionId may occur only once per field, otherwise the previous + * action with same id is replaced by the new action. + */ + public static final String EXTRA_ACTION_ID = "keepass2android.EXTRA_ACTION_ID"; /** Extra for ACTION_ADD_ENTRY_ACTION and ACTION_ENTRY_ACTION_SELECTED to pass data specifying the action parameters.*/ public static final String EXTRA_ACTION_DATA = "keepass2android.EXTRA_ACTION_DATA"; @@ -110,5 +122,6 @@ public class Strings { public static final String PREFIX_STRING = "STRING_"; public static final String PREFIX_BINARY = "BINARY_"; + } diff --git a/src/java/PluginQR/.classpath b/src/java/PluginQR/.classpath new file mode 100644 index 00000000..7bc01d9a --- /dev/null +++ b/src/java/PluginQR/.classpath @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/java/PluginQR/.project b/src/java/PluginQR/.project new file mode 100644 index 00000000..c47434d1 --- /dev/null +++ b/src/java/PluginQR/.project @@ -0,0 +1,33 @@ + + + PluginQR + + + + + + com.android.ide.eclipse.adt.ResourceManagerBuilder + + + + + com.android.ide.eclipse.adt.PreCompilerBuilder + + + + + org.eclipse.jdt.core.javabuilder + + + + + com.android.ide.eclipse.adt.ApkBuilder + + + + + + com.android.ide.eclipse.adt.AndroidNature + org.eclipse.jdt.core.javanature + + diff --git a/src/java/PluginQR/.settings/org.eclipse.jdt.core.prefs b/src/java/PluginQR/.settings/org.eclipse.jdt.core.prefs new file mode 100644 index 00000000..b080d2dd --- /dev/null +++ b/src/java/PluginQR/.settings/org.eclipse.jdt.core.prefs @@ -0,0 +1,4 @@ +eclipse.preferences.version=1 +org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.6 +org.eclipse.jdt.core.compiler.compliance=1.6 +org.eclipse.jdt.core.compiler.source=1.6 diff --git a/src/java/PluginQR/AndroidManifest.xml b/src/java/PluginQR/AndroidManifest.xml new file mode 100644 index 00000000..60a5d22a --- /dev/null +++ b/src/java/PluginQR/AndroidManifest.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/java/PluginQR/gen/keepass2android/plugin/qr/BuildConfig.java b/src/java/PluginQR/gen/keepass2android/plugin/qr/BuildConfig.java new file mode 100644 index 00000000..71caf12e --- /dev/null +++ b/src/java/PluginQR/gen/keepass2android/plugin/qr/BuildConfig.java @@ -0,0 +1,6 @@ +/** Automatically generated file. DO NOT MODIFY */ +package keepass2android.plugin.qr; + +public final class BuildConfig { + public final static boolean DEBUG = true; +} \ No newline at end of file diff --git a/src/java/PluginQR/gen/keepass2android/plugin/qr/R.java b/src/java/PluginQR/gen/keepass2android/plugin/qr/R.java new file mode 100644 index 00000000..94fe01ca --- /dev/null +++ b/src/java/PluginQR/gen/keepass2android/plugin/qr/R.java @@ -0,0 +1,93 @@ +/* AUTO-GENERATED FILE. DO NOT MODIFY. + * + * This class was automatically generated by the + * aapt tool from the resource data it found. It + * should not be modified by hand. + */ + +package keepass2android.plugin.qr; + +public final class R { + public static final class attr { + } + public static final class dimen { + /** Default screen margins, per the Android Design guidelines. + + Example customization of dimensions originally defined in res/values/dimens.xml + (such as screen margins) for screens with more than 820dp of available width. This + would include 7" and 10" devices in landscape (~960dp and ~1280dp respectively). + + */ + public static final int activity_horizontal_margin=0x7f060000; + public static final int activity_vertical_margin=0x7f060001; + } + public static final class drawable { + public static final int ic_launcher=0x7f020000; + public static final int qrcode=0x7f020001; + } + public static final class id { + public static final int cbIncludeLabel=0x7f080003; + public static final int container=0x7f080000; + public static final int expanded_image=0x7f080005; + public static final int qrView=0x7f080002; + public static final int spinner=0x7f080001; + public static final int tvError=0x7f080004; + } + public static final class layout { + public static final int activity_qr=0x7f030000; + public static final int fragment_qr=0x7f030001; + } + public static final class menu { + public static final int qr=0x7f070000; + } + public static final class string { + public static final int action_settings=0x7f040002; + public static final int action_show_qr=0x7f040003; + public static final int all_fields=0x7f040005; + public static final int app_name=0x7f040000; + public static final int include_label=0x7f040004; + public static final int kp2aplugin_author=0x7f040008; + public static final int kp2aplugin_shortdesc=0x7f040007; + public static final int kp2aplugin_title=0x7f040006; + public static final int title_activity_qr=0x7f040001; + } + public static final class style { + /** + Base application theme, dependent on API level. This theme is replaced + by AppBaseTheme from res/values-vXX/styles.xml on newer devices. + + + Theme customizations available in newer API levels can go in + res/values-vXX/styles.xml, while customizations related to + backward-compatibility can go here. + + + Base application theme for API 11+. This theme completely replaces + AppBaseTheme from res/values/styles.xml on API 11+ devices. + + API 11 theme customizations can go here. + + Base application theme for API 14+. This theme completely replaces + AppBaseTheme from BOTH res/values/styles.xml and + res/values-v11/styles.xml on API 14+ devices. + + API 14 theme customizations can go here. + + Base application theme, dependent on API level. This theme is replaced + by AppBaseTheme from res/values-vXX/styles.xml on newer devices. + + + Theme customizations available in newer API levels can go in + res/values-vXX/styles.xml, while customizations related to + backward-compatibility can go here. + + */ + public static final int AppBaseTheme=0x7f050000; + /** Application theme. + All customizations that are NOT specific to a particular API-level can go here. + Application theme. + All customizations that are NOT specific to a particular API-level can go here. + */ + public static final int AppTheme=0x7f050001; + } +} diff --git a/src/java/PluginQR/gen/keepass2android/pluginsdk/R.java b/src/java/PluginQR/gen/keepass2android/pluginsdk/R.java new file mode 100644 index 00000000..9d578f27 --- /dev/null +++ b/src/java/PluginQR/gen/keepass2android/pluginsdk/R.java @@ -0,0 +1,20 @@ +/* AUTO-GENERATED FILE. DO NOT MODIFY. + * + * This class was automatically generated by the + * aapt tool from the resource data it found. It + * should not be modified by hand. + */ +package keepass2android.pluginsdk; + +public final class R { + public static final class drawable { + public static final int ic_launcher = 0x7f020000; + } + public static final class string { + public static final int app_name = 0x7f040000; + } + public static final class style { + public static final int AppBaseTheme = 0x7f050000; + public static final int AppTheme = 0x7f050001; + } +} diff --git a/src/java/PluginQR/libs/core.jar b/src/java/PluginQR/libs/core.jar new file mode 100644 index 00000000..c283af7e Binary files /dev/null and b/src/java/PluginQR/libs/core.jar differ diff --git a/src/java/PluginQR/proguard-project.txt b/src/java/PluginQR/proguard-project.txt new file mode 100644 index 00000000..f2fe1559 --- /dev/null +++ b/src/java/PluginQR/proguard-project.txt @@ -0,0 +1,20 @@ +# To enable ProGuard in your project, edit project.properties +# to define the proguard.config property as described in that file. +# +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in ${sdk.dir}/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the ProGuard +# include property in project.properties. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} diff --git a/src/java/PluginQR/project.properties b/src/java/PluginQR/project.properties new file mode 100644 index 00000000..b983a093 --- /dev/null +++ b/src/java/PluginQR/project.properties @@ -0,0 +1,15 @@ +# This file is automatically generated by Android Tools. +# Do not modify this file -- YOUR CHANGES WILL BE ERASED! +# +# This file must be checked in Version Control Systems. +# +# To customize properties used by the Ant build system edit +# "ant.properties", and override values to adapt the script to your +# project structure. +# +# To enable ProGuard to shrink and obfuscate your code, uncomment this (available properties: sdk.dir, user.home): +#proguard.config=${sdk.dir}/tools/proguard/proguard-android.txt:proguard-project.txt + +# Project target. +target=android-19 +android.library.reference.1=../Keepass2AndroidPluginSDK diff --git a/src/java/PluginQR/res/drawable-hdpi/qrcode.png b/src/java/PluginQR/res/drawable-hdpi/qrcode.png new file mode 100644 index 00000000..86da774f Binary files /dev/null and b/src/java/PluginQR/res/drawable-hdpi/qrcode.png differ diff --git a/src/java/PluginQR/res/drawable-xhdpi/qrcode.png b/src/java/PluginQR/res/drawable-xhdpi/qrcode.png new file mode 100644 index 00000000..d871b5be Binary files /dev/null and b/src/java/PluginQR/res/drawable-xhdpi/qrcode.png differ diff --git a/src/java/PluginQR/res/layout/activity_qr.xml b/src/java/PluginQR/res/layout/activity_qr.xml new file mode 100644 index 00000000..24ee86c8 --- /dev/null +++ b/src/java/PluginQR/res/layout/activity_qr.xml @@ -0,0 +1,8 @@ + + diff --git a/src/java/PluginQR/res/layout/fragment_qr.xml b/src/java/PluginQR/res/layout/fragment_qr.xml new file mode 100644 index 00000000..4e9b5fe4 --- /dev/null +++ b/src/java/PluginQR/res/layout/fragment_qr.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/java/PluginQR/res/menu/qr.xml b/src/java/PluginQR/res/menu/qr.xml new file mode 100644 index 00000000..30a0acf3 --- /dev/null +++ b/src/java/PluginQR/res/menu/qr.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/src/java/PluginQR/res/values-w820dp/dimens.xml b/src/java/PluginQR/res/values-w820dp/dimens.xml new file mode 100644 index 00000000..f3e70203 --- /dev/null +++ b/src/java/PluginQR/res/values-w820dp/dimens.xml @@ -0,0 +1,10 @@ + + + + 64dp + + diff --git a/src/java/PluginQR/res/values/dimens.xml b/src/java/PluginQR/res/values/dimens.xml new file mode 100644 index 00000000..55c1e590 --- /dev/null +++ b/src/java/PluginQR/res/values/dimens.xml @@ -0,0 +1,7 @@ + + + + 16dp + 16dp + + diff --git a/src/java/PluginQR/res/values/strings.xml b/src/java/PluginQR/res/values/strings.xml new file mode 100644 index 00000000..ec787df3 --- /dev/null +++ b/src/java/PluginQR/res/values/strings.xml @@ -0,0 +1,18 @@ + + + + QR Plugin for KP2A + QRActivity + Settings + + Show QR Code + Include field label + All fields + + QR Plugin + Displays password entries as QR code + Philipp Crocoll + + + + diff --git a/src/java/PluginQR/res/values/styles.xml b/src/java/PluginQR/res/values/styles.xml new file mode 100644 index 00000000..6ce89c7b --- /dev/null +++ b/src/java/PluginQR/res/values/styles.xml @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/src/java/PluginQR/src/keepass2android/plugin/qr/AccessReceiver.java b/src/java/PluginQR/src/keepass2android/plugin/qr/AccessReceiver.java new file mode 100644 index 00000000..d9db29c7 --- /dev/null +++ b/src/java/PluginQR/src/keepass2android/plugin/qr/AccessReceiver.java @@ -0,0 +1,17 @@ +package keepass2android.plugin.qr; + +import java.util.ArrayList; + +import keepass2android.pluginsdk.PluginAccessBroadcastReceiver; +import keepass2android.pluginsdk.Strings; + +public class AccessReceiver extends PluginAccessBroadcastReceiver { + + @Override + public ArrayList getScopes() { + ArrayList scopes = new ArrayList(); + scopes.add(Strings.SCOPE_CURRENT_ENTRY); + return scopes; + } + +} diff --git a/src/java/PluginQR/src/keepass2android/plugin/qr/ActionReceiver.java b/src/java/PluginQR/src/keepass2android/plugin/qr/ActionReceiver.java new file mode 100644 index 00000000..3b65fd30 --- /dev/null +++ b/src/java/PluginQR/src/keepass2android/plugin/qr/ActionReceiver.java @@ -0,0 +1,49 @@ +package keepass2android.plugin.qr; + +import org.json.JSONObject; + +import android.content.Intent; +import android.widget.Toast; +import keepass2android.pluginsdk.PluginAccessException; +import keepass2android.pluginsdk.PluginActionBroadcastReceiver; +import keepass2android.pluginsdk.Strings; + +public class ActionReceiver extends PluginActionBroadcastReceiver{ + @Override + protected void openEntry(OpenEntry oe) { + try { + oe.addEntryAction(oe.getContext().getString(R.string.action_show_qr), + R.drawable.qrcode, null); + + for (String field: oe.getEntryFields().keySet()) + { + oe.addEntryFieldAction("keepass2android.plugin.qr.show", Strings.PREFIX_STRING+field, oe.getContext().getString(R.string.action_show_qr), + R.drawable.qrcode, null); + } + } catch (PluginAccessException e) { + e.printStackTrace(); + } + } + + @Override + protected void actionSelected(ActionSelected actionSelected) { + Intent i = new Intent(actionSelected.getContext(), QRActivity.class); + i.putExtra(Strings.EXTRA_ENTRY_OUTPUT_DATA, new JSONObject(actionSelected.getEntryFields()).toString()); + i.putExtra(Strings.EXTRA_FIELD_ID, actionSelected.getFieldId()); + i.putExtra(Strings.EXTRA_SENDER, actionSelected.getHostPackage()); + i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + actionSelected.getContext().startActivity(i); + } + + @Override + protected void entryOutputModified(EntryOutputModified eom) { + try { + eom.addEntryFieldAction("keepass2android.plugin.qr.show", eom.getModifiedFieldId(), eom.getContext().getString(R.string.action_show_qr), + R.drawable.qrcode, null); + } catch (PluginAccessException e) { + e.printStackTrace(); + } + } + + +} diff --git a/src/java/PluginQR/src/keepass2android/plugin/qr/Contents.java b/src/java/PluginQR/src/keepass2android/plugin/qr/Contents.java new file mode 100644 index 00000000..be555e3c --- /dev/null +++ b/src/java/PluginQR/src/keepass2android/plugin/qr/Contents.java @@ -0,0 +1,74 @@ +// +// * Copyright (C) 2008 ZXing authors +// * +// * Licensed under the Apache License, Version 2.0 (the "License"); +// * you may not use this file except in compliance with the License. +// * You may obtain a copy of the License at +// * +// * http://www.apache.org/licenses/LICENSE-2.0 +// * +// * Unless required by applicable law or agreed to in writing, software +// * distributed under the License is distributed on an "AS IS" BASIS, +// * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// * See the License for the specific language governing permissions and +// * limitations under the License. +// +package keepass2android.plugin.qr; + +import android.provider.ContactsContract; + +public final class Contents { + private Contents() { + } + + public static final class Type { + + // Plain text. Use Intent.putExtra(DATA, string). This can be used for URLs too, but string + // must include "http://" or "https://". + public static final String TEXT = "TEXT_TYPE"; + + // An email type. Use Intent.putExtra(DATA, string) where string is the email address. + public static final String EMAIL = "EMAIL_TYPE"; + + // Use Intent.putExtra(DATA, string) where string is the phone number to call. + public static final String PHONE = "PHONE_TYPE"; + + // An SMS type. Use Intent.putExtra(DATA, string) where string is the number to SMS. + public static final String SMS = "SMS_TYPE"; + + public static final String CONTACT = "CONTACT_TYPE"; + + public static final String LOCATION = "LOCATION_TYPE"; + + private Type() { + } + } + + public static final String URL_KEY = "URL_KEY"; + + public static final String NOTE_KEY = "NOTE_KEY"; + + // When using Type.CONTACT, these arrays provide the keys for adding or retrieving multiple phone numbers and addresses. + public static final String[] PHONE_KEYS = { + ContactsContract.Intents.Insert.PHONE, ContactsContract.Intents.Insert.SECONDARY_PHONE, + ContactsContract.Intents.Insert.TERTIARY_PHONE + }; + + public static final String[] PHONE_TYPE_KEYS = { + ContactsContract.Intents.Insert.PHONE_TYPE, + ContactsContract.Intents.Insert.SECONDARY_PHONE_TYPE, + ContactsContract.Intents.Insert.TERTIARY_PHONE_TYPE + }; + + public static final String[] EMAIL_KEYS = { + ContactsContract.Intents.Insert.EMAIL, ContactsContract.Intents.Insert.SECONDARY_EMAIL, + ContactsContract.Intents.Insert.TERTIARY_EMAIL + }; + + public static final String[] EMAIL_TYPE_KEYS = { + ContactsContract.Intents.Insert.EMAIL_TYPE, + ContactsContract.Intents.Insert.SECONDARY_EMAIL_TYPE, + ContactsContract.Intents.Insert.TERTIARY_EMAIL_TYPE + }; +} + diff --git a/src/java/PluginQR/src/keepass2android/plugin/qr/QRActivity.java b/src/java/PluginQR/src/keepass2android/plugin/qr/QRActivity.java new file mode 100644 index 00000000..0f6484f4 --- /dev/null +++ b/src/java/PluginQR/src/keepass2android/plugin/qr/QRActivity.java @@ -0,0 +1,455 @@ +package keepass2android.plugin.qr; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; + +import org.json.JSONException; +import org.json.JSONObject; + +import keepass2android.pluginsdk.AccessManager; +import keepass2android.pluginsdk.KeepassDefs; +import keepass2android.pluginsdk.Strings; + +import com.google.zxing.BarcodeFormat; +import com.google.zxing.WriterException; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.app.Activity; +import android.app.ActionBar; +import android.app.Fragment; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager.NameNotFoundException; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Point; +import android.graphics.Rect; +import android.os.Bundle; +import android.text.TextUtils; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.TypedValue; +import android.view.Display; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.WindowManager; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.view.animation.DecelerateInterpolator; +import android.widget.Adapter; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemSelectedListener; +import android.widget.ArrayAdapter; +import android.widget.CheckBox; +import android.widget.CompoundButton; +import android.widget.CompoundButton.OnCheckedChangeListener; +import android.widget.ImageView; +import android.widget.Spinner; +import android.widget.TextView; +import android.os.Build; +import android.preference.Preference; +import android.preference.PreferenceManager; + +public class QRActivity extends Activity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if ((getIntent() != null) && (getIntent().getStringExtra(Strings.EXTRA_ENTRY_OUTPUT_DATA)!= null)) + Log.d("QR", getIntent().getStringExtra(Strings.EXTRA_ENTRY_OUTPUT_DATA)); + + setContentView(R.layout.activity_qr); + + if (savedInstanceState == null) { + getFragmentManager().beginTransaction() + .add(R.id.container, new PlaceholderFragment()).commit(); + } + + + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + + // Inflate the menu; this adds items to the action bar if it is present. + getMenuInflater().inflate(R.menu.qr, menu); + return true; + } + + /** + * A placeholder fragment containing a simple view. + */ + public static class PlaceholderFragment extends Fragment { + + // Hold a reference to the current animator, + // so that it can be canceled mid-way. + private Animator mCurrentAnimator; + + + private int mShortAnimationDuration; + + Bitmap mBitmap; + ImageView mImageView; + TextView mErrorView; + HashMap mEntryOutput; + ArrayList mFieldList = new ArrayList(); + Spinner mSpinner; + String mHostname; + + private CheckBox mCbIncludeLabel; + + + private Resources kp2aRes; + + public PlaceholderFragment() { + } + + protected HashMap getEntryFieldsFromIntent(Intent intent) + { + HashMap res = new HashMap(); + try { + JSONObject json = new JSONObject(intent.getStringExtra(Strings.EXTRA_ENTRY_OUTPUT_DATA)); + for(Iterator iter = json.keys();iter.hasNext();) { + String key = iter.next(); + String value = json.get(key).toString(); + res.put(key, value); + } + + } catch (JSONException e) { + e.printStackTrace(); + } catch (NullPointerException e) { + e.printStackTrace(); + } + return res; + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, + Bundle savedInstanceState) { + View rootView = inflater.inflate(R.layout.fragment_qr, container, + false); + + mSpinner = (Spinner) rootView.findViewById(R.id.spinner); + + mEntryOutput = getEntryFieldsFromIntent(getActivity().getIntent()); + + ArrayList spinnerItems = new ArrayList(); + spinnerItems.add(getActivity().getString(R.string.all_fields)); + mFieldList.add(null); //all fields + + try { + mHostname = getActivity().getIntent().getStringExtra(Strings.EXTRA_SENDER); + kp2aRes = getActivity().getPackageManager().getResourcesForApplication(mHostname); + } catch (NameNotFoundException e) { + // TODO Auto-generated catch block + e.printStackTrace(); + } + + addIfExists(KeepassDefs.UserNameField, "entry_user_name", spinnerItems); + addIfExists(KeepassDefs.UrlField, "entry_url", spinnerItems); + addIfExists(KeepassDefs.PasswordField, "entry_password", spinnerItems); + addIfExists(KeepassDefs.TitleField, "entry_title", spinnerItems); + addIfExists(KeepassDefs.NotesField, "entry_comment", spinnerItems); + + //add non-standard fields: + ArrayList allKeys = new ArrayList(mEntryOutput.keySet()); + Collections.sort(allKeys); + + for (String k: allKeys) + { + if (!KeepassDefs.IsStandardField(k)) + { + if (!TextUtils.isEmpty(mEntryOutput.get(k))) + mFieldList.add(k); + spinnerItems.add(k); + } + } + + mCbIncludeLabel = (CheckBox)rootView.findViewById(R.id.cbIncludeLabel); + + boolean includeLabel = PreferenceManager.getDefaultSharedPreferences(getActivity()).getBoolean("includeLabels", false); + mCbIncludeLabel.setChecked(includeLabel); + mCbIncludeLabel.setOnCheckedChangeListener(new OnCheckedChangeListener() { + + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + + updateQrCode(buildQrData(mFieldList.get( mSpinner.getSelectedItemPosition() ))); + } + }); + + ArrayAdapter adapter = new ArrayAdapter(getActivity(), android.R.layout.simple_spinner_item, spinnerItems); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + mSpinner.setAdapter(adapter); + + mImageView = ((ImageView)rootView.findViewById(R.id.qrView)); + mErrorView = ((TextView)rootView.findViewById(R.id.tvError)); + String fieldId = null; + + if (getActivity().getIntent() != null) + { + fieldId = getActivity().getIntent().getStringExtra(Strings.EXTRA_FIELD_ID); + if (fieldId != null) + { + fieldId = fieldId.substring(Strings.PREFIX_STRING.length()); + } + } + updateQrCode(buildQrData(fieldId)); + + mImageView.setOnClickListener(new OnClickListener() { + + @Override + public void onClick(View v) { + zoomImageFromThumb(); + } + }); + + mSpinner.setOnItemSelectedListener(new OnItemSelectedListener() { + + @Override + public void onItemSelected(AdapterView arg0, View arg1, + int arg2, long arg3) { + if (arg2 != 0) + mCbIncludeLabel.setVisibility(View.VISIBLE); + else + mCbIncludeLabel.setVisibility(View.GONE); + updateQrCode(buildQrData(mFieldList.get(arg2))); + } + + @Override + public void onNothingSelected(AdapterView arg0) { + + } + }); + + mSpinner.setSelection(mFieldList.indexOf(fieldId)); + + mShortAnimationDuration = getResources().getInteger( + android.R.integer.config_shortAnimTime); + + + + return rootView; + } + + private void addIfExists(String fieldKey, String resKey, + ArrayList spinnerItems) { + if (!TextUtils.isEmpty(mEntryOutput.get(fieldKey))) + { + mFieldList.add(fieldKey); + String displayString = fieldKey; + try + { + displayString = kp2aRes.getString(kp2aRes.getIdentifier(resKey, "string", mHostname)); + } + catch (Exception e) + { + e.printStackTrace(); + } + spinnerItems.add(displayString); + } + + + } + + private String buildQrData(String fieldId) { + String res = ""; + + if (fieldId == null) + { + res = "kp2a:\n"; + for (String k:mFieldList) + { + if (k == null) + continue; + res += QRCodeEncoder.escapeMECARD(k)+":"; + res += QRCodeEncoder.escapeMECARD(mEntryOutput.get(k))+";\n"; + } + } + else + { + if ((mCbIncludeLabel.isChecked())) + { + res = fieldId+": "; + + } + res += mEntryOutput.get(fieldId); + } + + return res; + } + + private void updateQrCode(String qrData) { + DisplayMetrics displayMetrics = new DisplayMetrics(); + WindowManager wm = (WindowManager) getActivity().getSystemService(Context.WINDOW_SERVICE); // the results will be higher than using the activity context object or the getWindowManager() shortcut + wm.getDefaultDisplay().getMetrics(displayMetrics); + int screenWidth = displayMetrics.widthPixels; + int screenHeight = displayMetrics.heightPixels; + + int qrCodeDimension = screenWidth > screenHeight ? screenHeight : screenWidth; + QRCodeEncoder qrCodeEncoder = new QRCodeEncoder(qrData, null, + Contents.Type.TEXT, BarcodeFormat.QR_CODE.toString(), qrCodeDimension); + + + + try { + mBitmap = qrCodeEncoder.encodeAsBitmap(); + mImageView.setImageBitmap(mBitmap); + mImageView.setVisibility(View.VISIBLE); + mErrorView.setVisibility(View.GONE); + } catch (WriterException e) { + e.printStackTrace(); + mErrorView.setText("Error: "+e.getMessage()); + mErrorView.setVisibility(View.VISIBLE); + mImageView.setVisibility(View.GONE); + } + } + + private void zoomImageFromThumb() { + // If there's an animation in progress, cancel it + // immediately and proceed with this one. + if (mCurrentAnimator != null) { + mCurrentAnimator.cancel(); + } + + // Load the high-resolution "zoomed-in" image. + final ImageView expandedImageView = (ImageView) getActivity().findViewById( + R.id.expanded_image); + expandedImageView.setImageBitmap(mBitmap); + + // Calculate the starting and ending bounds for the zoomed-in image. + // This step involves lots of math. Yay, math. + final Rect startBounds = new Rect(); + final Rect finalBounds = new Rect(); + final Point globalOffset = new Point(); + + // The start bounds are the global visible rectangle of the thumbnail, + // and the final bounds are the global visible rectangle of the container + // view. Also set the container view's offset as the origin for the + // bounds, since that's the origin for the positioning animation + // properties (X, Y). + mImageView.getGlobalVisibleRect(startBounds); + getActivity().findViewById(R.id.container) + .getGlobalVisibleRect(finalBounds, globalOffset); + startBounds.offset(-globalOffset.x, -globalOffset.y); + finalBounds.offset(-globalOffset.x, -globalOffset.y); + + // Adjust the start bounds to be the same aspect ratio as the final + // bounds using the "center crop" technique. This prevents undesirable + // stretching during the animation. Also calculate the start scaling + // factor (the end scaling factor is always 1.0). + float startScale; + if ((float) finalBounds.width() / finalBounds.height() + > (float) startBounds.width() / startBounds.height()) { + // Extend start bounds horizontally + startScale = (float) startBounds.height() / finalBounds.height(); + float startWidth = startScale * finalBounds.width(); + float deltaWidth = (startWidth - startBounds.width()) / 2; + startBounds.left -= deltaWidth; + startBounds.right += deltaWidth; + } else { + // Extend start bounds vertically + startScale = (float) startBounds.width() / finalBounds.width(); + float startHeight = startScale * finalBounds.height(); + float deltaHeight = (startHeight - startBounds.height()) / 2; + startBounds.top -= deltaHeight; + startBounds.bottom += deltaHeight; + } + + // Hide the thumbnail and show the zoomed-in view. When the animation + // begins, it will position the zoomed-in view in the place of the + // thumbnail. + mImageView.setAlpha(0f); + expandedImageView.setVisibility(View.VISIBLE); + + // Set the pivot point for SCALE_X and SCALE_Y transformations + // to the top-left corner of the zoomed-in view (the default + // is the center of the view). + expandedImageView.setPivotX(0f); + expandedImageView.setPivotY(0f); + + // Construct and run the parallel animation of the four translation and + // scale properties (X, Y, SCALE_X, and SCALE_Y). + AnimatorSet set = new AnimatorSet(); + set + .play(ObjectAnimator.ofFloat(expandedImageView, View.X, + startBounds.left, finalBounds.left)) + .with(ObjectAnimator.ofFloat(expandedImageView, View.Y, + startBounds.top, finalBounds.top)) + .with(ObjectAnimator.ofFloat(expandedImageView, View.SCALE_X, + startScale, 1f)).with(ObjectAnimator.ofFloat(expandedImageView, + View.SCALE_Y, startScale, 1f)); + set.setDuration(mShortAnimationDuration); + set.setInterpolator(new DecelerateInterpolator()); + set.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + mCurrentAnimator = null; + } + + @Override + public void onAnimationCancel(Animator animation) { + mCurrentAnimator = null; + } + }); + set.start(); + mCurrentAnimator = set; + + // Upon clicking the zoomed-in image, it should zoom back down + // to the original bounds and show the thumbnail instead of + // the expanded image. + final float startScaleFinal = startScale; + expandedImageView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + if (mCurrentAnimator != null) { + mCurrentAnimator.cancel(); + } + + // Animate the four positioning/sizing properties in parallel, + // back to their original values. + AnimatorSet set = new AnimatorSet(); + set.play(ObjectAnimator + .ofFloat(expandedImageView, View.X, startBounds.left)) + .with(ObjectAnimator + .ofFloat(expandedImageView, + View.Y,startBounds.top)) + .with(ObjectAnimator + .ofFloat(expandedImageView, + View.SCALE_X, startScaleFinal)) + .with(ObjectAnimator + .ofFloat(expandedImageView, + View.SCALE_Y, startScaleFinal)); + set.setDuration(mShortAnimationDuration); + set.setInterpolator(new DecelerateInterpolator()); + set.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + mImageView.setAlpha(1f); + expandedImageView.setVisibility(View.GONE); + mCurrentAnimator = null; + } + + @Override + public void onAnimationCancel(Animator animation) { + mImageView.setAlpha(1f); + expandedImageView.setVisibility(View.GONE); + mCurrentAnimator = null; + } + }); + set.start(); + mCurrentAnimator = set; + } + }); + } + } + +} diff --git a/src/java/PluginQR/src/keepass2android/plugin/qr/QRCodeEncoder.java b/src/java/PluginQR/src/keepass2android/plugin/qr/QRCodeEncoder.java new file mode 100644 index 00000000..ef0f7392 --- /dev/null +++ b/src/java/PluginQR/src/keepass2android/plugin/qr/QRCodeEncoder.java @@ -0,0 +1,252 @@ +/* + * Copyright (C) 2008 ZXing authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package keepass2android.plugin.qr; + +import android.provider.ContactsContract; +import android.graphics.Bitmap; +import android.os.Bundle; +import android.telephony.PhoneNumberUtils; + +import java.util.Collection; +import java.util.EnumMap; +import java.util.HashSet; +import java.util.Map; + +import com.google.zxing.BarcodeFormat; +import com.google.zxing.EncodeHintType; +import com.google.zxing.MultiFormatWriter; +import com.google.zxing.WriterException; +import com.google.zxing.common.BitMatrix; + +public final class QRCodeEncoder { + private static final int WHITE = 0xFFFFFFFF; + private static final int BLACK = 0xFF000000; + + private int dimension = Integer.MIN_VALUE; + private String contents = null; + private String displayContents = null; + private String title = null; + private BarcodeFormat format = null; + private boolean encoded = false; + + public QRCodeEncoder(String data, Bundle bundle, String type, String format, int dimension) { + this.dimension = dimension; + encoded = encodeContents(data, bundle, type, format); + } + + public String getContents() { + return contents; + } + + public String getDisplayContents() { + return displayContents; + } + + public String getTitle() { + return title; + } + + private boolean encodeContents(String data, Bundle bundle, String type, String formatString) { + // Default to QR_CODE if no format given. + format = null; + if (formatString != null) { + try { + format = BarcodeFormat.valueOf(formatString); + } catch (IllegalArgumentException iae) { + // Ignore it then + } + } + if (format == null || format == BarcodeFormat.QR_CODE) { + this.format = BarcodeFormat.QR_CODE; + encodeQRCodeContents(data, bundle, type); + } else if (data != null && data.length() > 0) { + contents = data; + displayContents = data; + title = "Text"; + } + return contents != null && contents.length() > 0; + } + + private void encodeQRCodeContents(String data, Bundle bundle, String type) { + if (type.equals(Contents.Type.TEXT)) { + if (data != null && data.length() > 0) { + contents = data; + displayContents = data; + title = "Text"; + } + } else if (type.equals(Contents.Type.EMAIL)) { + data = trim(data); + if (data != null) { + contents = "mailto:" + data; + displayContents = data; + title = "E-Mail"; + } + } else if (type.equals(Contents.Type.PHONE)) { + data = trim(data); + if (data != null) { + contents = "tel:" + data; + displayContents = PhoneNumberUtils.formatNumber(data); + title = "Phone"; + } + } else if (type.equals(Contents.Type.SMS)) { + data = trim(data); + if (data != null) { + contents = "sms:" + data; + displayContents = PhoneNumberUtils.formatNumber(data); + title = "SMS"; + } + } else if (type.equals(Contents.Type.CONTACT)) { + if (bundle != null) { + StringBuilder newContents = new StringBuilder(100); + StringBuilder newDisplayContents = new StringBuilder(100); + + newContents.append("MECARD:"); + + String name = trim(bundle.getString(ContactsContract.Intents.Insert.NAME)); + if (name != null) { + newContents.append("N:").append(escapeMECARD(name)).append(';'); + newDisplayContents.append(name); + } + + String address = trim(bundle.getString(ContactsContract.Intents.Insert.POSTAL)); + if (address != null) { + newContents.append("ADR:").append(escapeMECARD(address)).append(';'); + newDisplayContents.append('\n').append(address); + } + + Collection uniquePhones = new HashSet(Contents.PHONE_KEYS.length); + for (int x = 0; x < Contents.PHONE_KEYS.length; x++) { + String phone = trim(bundle.getString(Contents.PHONE_KEYS[x])); + if (phone != null) { + uniquePhones.add(phone); + } + } + for (String phone : uniquePhones) { + newContents.append("TEL:").append(escapeMECARD(phone)).append(';'); + newDisplayContents.append('\n').append(PhoneNumberUtils.formatNumber(phone)); + } + + Collection uniqueEmails = new HashSet(Contents.EMAIL_KEYS.length); + for (int x = 0; x < Contents.EMAIL_KEYS.length; x++) { + String email = trim(bundle.getString(Contents.EMAIL_KEYS[x])); + if (email != null) { + uniqueEmails.add(email); + } + } + for (String email : uniqueEmails) { + newContents.append("EMAIL:").append(escapeMECARD(email)).append(';'); + newDisplayContents.append('\n').append(email); + } + + String url = trim(bundle.getString(Contents.URL_KEY)); + if (url != null) { + // escapeMECARD(url) -> wrong escape e.g. http\://zxing.google.com + newContents.append("URL:").append(url).append(';'); + newDisplayContents.append('\n').append(url); + } + + String note = trim(bundle.getString(Contents.NOTE_KEY)); + if (note != null) { + newContents.append("NOTE:").append(escapeMECARD(note)).append(';'); + newDisplayContents.append('\n').append(note); + } + + // Make sure we've encoded at least one field. + if (newDisplayContents.length() > 0) { + newContents.append(';'); + contents = newContents.toString(); + displayContents = newDisplayContents.toString(); + title = "Contact"; + } else { + contents = null; + displayContents = null; + } + + } + } else if (type.equals(Contents.Type.LOCATION)) { + if (bundle != null) { + // These must use Bundle.getFloat(), not getDouble(), it's part of the API. + float latitude = bundle.getFloat("LAT", Float.MAX_VALUE); + float longitude = bundle.getFloat("LONG", Float.MAX_VALUE); + if (latitude != Float.MAX_VALUE && longitude != Float.MAX_VALUE) { + contents = "geo:" + latitude + ',' + longitude; + displayContents = latitude + "," + longitude; + title = "Location"; + } + } + } + } + + public Bitmap encodeAsBitmap() throws WriterException { + if (!encoded) return null; + + Map hints = null; + String encoding = guessAppropriateEncoding(contents); + hints = new EnumMap(EncodeHintType.class); + if (encoding != null) { + + hints.put(EncodeHintType.CHARACTER_SET, encoding); + } + hints.put(EncodeHintType.MARGIN, 2); /* default = 4 */ + + + MultiFormatWriter writer = new MultiFormatWriter(); + BitMatrix result = writer.encode(contents, format, dimension, dimension, hints); + int width = result.getWidth(); + int height = result.getHeight(); + int[] pixels = new int[width * height]; + // All are 0, or black, by default + for (int y = 0; y < height; y++) { + int offset = y * width; + for (int x = 0; x < width; x++) { + pixels[offset + x] = result.get(x, y) ? BLACK : WHITE; + } + } + + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + bitmap.setPixels(pixels, 0, width, 0, 0, width, height); + return bitmap; + } + + private static String guessAppropriateEncoding(CharSequence contents) { + // Very crude at the moment + for (int i = 0; i < contents.length(); i++) { + if (contents.charAt(i) > 0xFF) { return "UTF-8"; } + } + return null; + } + + private static String trim(String s) { + if (s == null) { return null; } + String result = s.trim(); + return result.length() == 0 ? null : result; + } + + public static String escapeMECARD(String input) { + if (input == null || (input.indexOf(':') < 0 && input.indexOf(';') < 0)) { return input; } + int length = input.length(); + StringBuilder result = new StringBuilder(length); + for (int i = 0; i < length; i++) { + char c = input.charAt(i); + if (c == ':' || c == ';') { + result.append('\\'); + } + result.append(c); + } + return result.toString(); + } +} +